`POST /api/v2/authcheck` evaluated every check with a full policy
evaluation in a serial loop. A subject in many organizations (100+)
produced hundreds of full evaluations, taking seconds on a cold cache
(DEVEX-608).
Group the checks by `(action, resource type)` and authorize each group
with the existing `rbac.Filter`, which amortizes a single partial
evaluation across the group once it is large enough. Each check is
wrapped in a small value struct that carries its response key, so
`Filter`'s returned subset maps back to keys by reading a field rather
than relying on element identity.
`Filter` now takes an explicit `prepareThreshold`; existing callers pass
the new `rbac.DefaultFilterThreshold` (10), and `checkAuthorization`
passes 50, above the ~35-group crossover measured for this workload, so
subjects with few objects of a given type keep the per-object path and
cannot regress.
## Stacking
This is stacked on top of #27244. `Filter` runs `Prepare` (partial
evaluation), and those residuals are only compact once #27244's
set-membership residuals land. On plain `main` the existing O(N)
residual fanout means batching can regress at high org counts, so this
change should land with or after #27244.
<details>
<summary>Decision log</summary>
### Bottleneck
- `site/src/modules/permissions/organizations.ts` defines ~14 permission
checks per org; `organizationsPermissions()` flattens them across all
orgs into one `POST /api/v2/authcheck`. A 100-org request is ~1400
checks.
- `checkAuthorization` looped serially, calling `Authorizer.Authorize`
(full eval) once per check.
- The endpoint's `maxFetch = 10` only caps checks that carry a
`resource_id`, not total checks, so it does not bound this workload.
### Approach
- Group checks by `(action, resource type)` and run each group through
`rbac.Filter`, which does one partial evaluation (`Prepare`) and reuses
it across the group.
- Carry the response key as data in a small value struct implementing
`RBACObject()`, so allowed results map back to keys without pointer
identity:
```go
type authorizeCheck struct {
key string
object rbac.Object
}
func (c authorizeCheck) RBACObject() rbac.Object { return c.object }
```
- `Filter` takes a required `prepareThreshold int` (no functional
options). Generic callers pass `rbac.DefaultFilterThreshold = 10`;
`/authcheck` passes 50 because the measured crossover for this workload
is ~35 groups.
### Alternatives rejected
- **Bounded `errgroup` parallelism**: reduced wall time at high org
counts but not aggregate work (allocations flat). Discarded in favor of
reducing work via partial evaluation.
- **Symmetric-deny Rego simplification** (on the #27244 branch):
replacing the known-org deny-fold with symmetric `org := -1` /
`scope_org := -1` rules failed existing SQL-compile tests. A `-1`
known-org vote gated by `not org = -1` produces a negated membership
test over the unknown org id, which OPA emits as an unconvertible
support rule. #27244's fold (`member_allow - org_deny`, a positive
set-difference membership test) is therefore load-bearing, not
incidental.
</details>
---
Authored with Coder Agents.
---------
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
## Problem
Authorization for users who belong to many organizations is slow. On the
list <br>endpoints (`/api/v2/organizations`, `/users`, `/groups`) a user
in hundreds of <br>orgs saw multi-second page loads
<br>([DEVEX-608](https://linear.app/codercom/issue/DEVEX-608/performance-degrades-for-users-in-many-organizations-across-multiple)
<br>/ coder/coder#21890 / Pylon
[#2758](<https://github.com/coder/coder/issues/2758>)). This is
partial-evaluation bound: `rbac.Prepare` <br>scales with the number of
org-scoped roles the subject carries.
## Root cause
The known-org path in `check_org_permissions` indexed an N-entry vote
map by the <br>object's org id:
```rego
vote := allow_map[input.object.org_owner]
```
`input.object.org_owner` is unknown during partial evaluation. Indexing
a map by <br>an unknown key cannot reduce to a single expression, so OPA
emits one residual <br>query per org membership, and
`newPartialAuthorizer` then calls `PrepareForEval` <br>once per
residual, making `Prepare` O(N) in org count. The list endpoints
<br>intentionally use partial eval; the fan-out is in partial eval
itself.
## Change
Test the object's org id for membership in a set that is fully known at
<br>partial-evaluation time, so the query collapses to a single
<br>`organization_id = ANY(ARRAY[...])` residual instead of N residuals:
* The known-org clause only ever votes to allow, tested via
<br>`org_owner in org_ids_with_vote(role_org_votes, 1)`.
* Org-level denies are folded into the org-member level as a ground set
<br>difference (`member_allow - org_deny`), so the unknown org id
appears in only <br>one positive membership test and the decision never
branches on it.
* The per-org vote maps are computed once as memoized zero-arg rules
<br>(`role_org_votes`, `role_member_votes`, `scope_org_votes`,
<br>`scope_member_votes`) instead of through parametrized functions that
OPA <br>re-evaluates at every call site.
* `role_allow`/`scope_allow`, the `any_org` path, and full evaluation
are <br>unchanged in behavior.
Semantics are unchanged (see the equivalence argument below). The only
<br>representational change is that a denied known org's intermediate
`org` vote is <br>now `0` instead of `-1`, compensated by the set
difference and not observable in <br>the final `allow` decision.
## Results
Measured with `BenchmarkRBACManyOrgs` (added on `main` in
coder/coder#27270). Full tables: <br>[B/op and
allocs/op](<https://github.com/coder/coder/pull/27244#issuecomment-4984523720>).
* Residual queries: O(N) -> O(1).
* `Prepare` / `PrepareAndCompile` memory changes from < />quadratic
growth on `main` <br>(176 MiB, 7.08M allocs per op at 100 orgs) to
near-linear (6.5 MiB, 258k <br>allocs), a < />96% reduction at 100 orgs,
with similar wins in time.
* Memoizing the vote maps removed an early single-org regression: at 1
org <br>`Prepare` now allocates < />7% fewer bytes and < />9% fewer
objects than `main`.
* `Authorize` (full evaluation) memory is marginally higher (+1-8%,
largest at <br>1 org) and time-neutral. This is the inherent cost of the
set-membership form <br>that keeps partial evaluation from fanning out;
full evaluation builds an <br>allow set it would not otherwise need.
* `go test ./coderd/rbac/...` passes, including `TestAuthorizeDomain`
(full- vs <br>partial-eval equivalence) and the regosql suite.
A second, independent bottleneck remains (out of scope here): the vote
map is <br>still built in O(N^2) in `check_all_org_permissions`
<br>(`roles[_].by_org_id[org_id]` scans all roles per org). Fixing it
means <br>pre-merging roles' `by_org_id` into one org->perms map in the
OPA input, and is <br>tracked as a follow-up.
## Testing
* `OrgDenyBlocksMember` (`TestAuthorizeLevels`): an org-level deny
blocks a <br>member-allowed action on an owned in-org object, while a
clean org is allowed, <br>including an action-scoped deny.
* `ScopeOrgDenyBlocksMember` (`TestAuthorizeScope`): the same fold at
the scope <br>level.
* The shared harness covers full and partial evaluation and asserts the
partial <br>result compiles to SQL with zero support rules.
<details><summary>Decision log and equivalence argument</summary>
### Why not deny-via-set-membership
The first attempt expressed deny as a second set-membership clause (`:=
-1 if org_owner in deny_set`). That makes `org`/`scope_org`
multi-valued, and the `not org = -1` checks in
`role_allow`/`scope_allow` then cause OPA to emit a
`data.partial.__not__` support rule that regosql cannot compile
(`TestAuthorizeDomain/UserACLList` failed). It failed even when the deny
set was empty, purely because the `-1` clause exists.
### Why not deny-via-enumeration
A follow-up enumerated only the (usually empty) deny set. It compiled
and passed, but it branches on the unknown org id (one ground residual
per denied org), which violates the "do not branch on the unknown" rule
in `coderd/rbac/POLICY.md`.
### Final approach: allow-only + ground set difference
The known-org clause votes only to allow, and the org-level deny gate is
moved into the org-member level as `member_allow - org_deny`, a set
difference over fully-known sets. The unknown org id is used only in
positive `in` tests, so there is no enumeration, no negated membership,
and no branching on the unknown.
### Empty-set residual pruning
A naive set-membership left unsatisfiable residuals (`org_owner in
set()`) for levels with no matching permissions (e.g. the org level for
an org-member role, or scope-org for `ScopeAll`), each still costing a
`PrepareForEval`. Guarding each membership with a ground `count(...) >
0` lets OPA drop those branches, flattening the residual count across
org sizes.
### Memoized vote maps
Profiling the single-org path showed the cost was repeated function
evaluation: the parametrized helpers rebuilt the same vote map for the
org, member, and scope paths on every check. Hoisting the maps into
memoized zero-arg complete rules (which OPA evaluates once per query)
removed that overhead and eliminated the single-org `Prepare`
regression, while composition keeps the policy readable.
### Equivalence (known-org path, `site != -1`)
* A: original `org == 1` <=> `org_owner in org_allow` (unchanged).
* B: original `org != -1 and member == 1` <=> `org_owner not in org_deny
and org_owner in member_allow` <=> `org_owner in (member_allow -
org_deny)` = new `org_member == 1`.
The critical case (`org` denies, member allows): old blocks it via `not
org = -1`; new blocks it because `org_owner` is removed from
`member_allow - org_deny`. Same outcome. Deny-wins aggregation is intact
because `check_all_org_permissions` still nets an org to `-1` via
`to_vote`, landing it in `org_deny`.
</details>
---
This PR was generated by Coder Agents on behalf of @jeremyruppel.
Promotes the `minimum-implicit-member` experiment to GA and removes it.
## What changes
- The `minimum-implicit-member` experiment constant, its
`RoleOptions.MinimumImplicitMember` toggle, and the global
`rbac.MinimumImplicitMember()` accessor are deleted. The minimal-member
behavior is now the only behavior: `organization-member` and
`organization-service-account` carry only the floor (read-self records,
notifications, and similar) and grant **no workspace permissions**.
Workspace access lives exclusively on the
`organization-workspace-access` role.
- The experiment gate on customizing `default_org_member_roles` (`PATCH
/organizations/{org}`) is removed; the built-in-roles-only validation
remains.
- The dashboard's Default Roles section and the implied-roles display on
the members page are no longer experiment-gated.
- Admin docs: new "Default member roles" section in
`docs/admin/users/organizations.md`, cross-linked from
`groups-roles.md`.
## Why this is safe for existing deployments
Migration `000516` (shipped earlier) backfilled
`default_org_member_roles` with `['organization-workspace-access']` on
every organization. Members therefore keep exactly the effective
permissions they had with the experiment off; the workspace elevation
flows through the default role instead of being baked into
`organization-member`.
**Rollback caveat:** rolling back past this release restores the bundled
elevation, silently re-granting workspace access to members of
organizations that cleared their default roles.
## Review
Deep-review R1 findings are addressed in `chore: address deep-review
findings` (copy fixes, read-only Default Roles for viewers, removable
overlapping explicit grants, RBAC prose restoration, test
de-tautologizing, docs). Point-by-point disposition is in the PR
comments.
---
Generated by Coder Agents on behalf of @Emyrk.
<!-- Created by Coder Agents on behalf of @Emyrk. -->
Adds RBAC tests for a user holding both `organization-workspace-access`
and `organization-workspace-creation-ban`.
- Single org with both roles: the `any_org` workspace create check
returns **false**, since the ban's negative permission is the only
organization vote.
- Member of two orgs, banned in one, workspace-access in the other:
`any_org` create returns **true**, since the max vote across
organizations wins.
- Per-org checks confirm the ban denies create/delete only in the banned
org, and non-banned actions (read, update) remain allowed.
---
<sub>Coder Agents on behalf of @Emyrk.</sub>
Introduce a three-way workspace sharing setting (none, everyone,
service_accounts) replacing the boolean workspace_sharing_disabled.
In service_accounts mode, only service account-owned workspaces can be
shared while regular members' share permissions are removed. Adds a
new organization-service-account system role with per-org permissions
reconciled alongside the existing organization-member system role.
Related to:
https://linear.app/codercom/issue/PLAT-28/feat-service-accounts-sharing-mode-and-rbac-role
---------
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Co-authored-by: Kayla はな <mckayla@hey.com>
The removal of that permission from the role broke valid use cases (e.g.
a site owner user creating a workspace owned by a system account and
then trying to share it with another user).
The bulk of the PR is made up of the rollbacks of the previously
introduced test updates necessitated by the removal.
Related to: https://github.com/coder/internal/issues/1285
In preparation for adding the "member" permission level, which will also
be grouped by org ID, do a bit of a refactor to make room for it and the
existing "org" level to live in the same `map`
* chore: authz 'any_org' to return if at least 1 org has perms
Allows checking if a user can do an action in any organization,
rather than a specific one. Allows asking general questions on the
UI to determine which elements to show.
* more strict, add comments to policy
* add unit tests and extend to /authcheck api
* make field optional
* chore: create type for unique role names
Using `string` was confusing when something should be combined with
org context, and when not to. Naming this new name, "RoleIdentifier"
Organization member's table is already scoped to an organization.
Rolename should avoid having the org_id appended.
Wipes all existing organization role assignments, which should not be used anyway.
Removes our pseudo rbac resources like `WorkspaceApplicationConnect` in favor of additional verbs like `ssh`. This is to make more intuitive permissions for building custom roles.
The source of truth is now `policy.go`
Just moved `rbac.Action` -> `policy.Action`. This is for the stacked PR to not have circular dependencies when doing autogen. Without this, the autogen can produce broken golang code, which prevents the autogen from compiling.
So just avoiding circular dependencies. Doing this in it's own PR to reduce LoC diffs in the primary PR, since this has 0 functional changes.
* chore: add /v2 to import module path
go mod requires semantic versioning with versions greater than 1.x
This was a mechanical update by running:
```
go install github.com/marwan-at-work/mod/cmd/mod@latest
mod upgrade
```
Migrate generated files to import /v2
* Fix gen
I manually went through every single dbfake function and ensured it has
the correct lock type depending on whether it writes or only reads.
There were a surprising amount of methods that had the wrong lock type
(Lock when only reading, or RLock when writing (!!!)).
This also manually fixes every method that acquires a RLock and then
calls a method that also acquires it's own RLock to use noLock methods
instead. You cannot rely on acquiring a RLock twice in the same
goroutine as RWMutex prioritizes any waiting Lock calls.
I tried writing a ruleguard rule for this but because of limitations in
ruleguard it doesn't seem possible.
- rbac: export rbac.Permissions
- dbauthz: move GetDeploymentDAUs, GetTemplateDAUs,
GetTemplateAverageBuildTime from querier.go to system.go
and removes auth checks
- dbauthz: remove AsSystem(), add individual roles for
autostart, provisionerd, add restricted system role for
everything else
feat: Add initial AuthzQuerier implementation
- Adds package database/dbauthz that adds a database.Store implementation where each method goes through AuthZ checks
- Implements all database.Store methods on AuthzQuerier
- Updates and fixes unit tests where required
- Updates coderd initialization to use AuthzQuerier if codersdk.ExperimentAuthzQuerier is enabled
* feat: Add cachable authorizer to elimate duplicate rbac calls
Cache is context bound, so only prevents duplicate rbac calls in
the same request context.
* chore: Implement standard rbac.Subject to be reused everywhere
An rbac subject is created in multiple spots because of the way we
expand roles, scopes, etc. This difference in use creates a list
of arguments which is unwieldy.
Use of the expander interface lets us conform to a single subject
in every case
* chore: Authz should support non-named roles
Named roles are a construct for users to assign/interact with roles.
For authzlayer implementation, we need to create "system" users.
To enforce strict security, we are making specific roles with
the exact required permissions for the system action.
These new roles should not be available to the user. There is a
clear code divide with this implementation that allows a RoleNames
implemenation for users to user, and system users can create their
own implementation
* feat: Implement allow_list for scopes for resource specific permissions
Feature that adds an allow_list for scopes to specify particular resources.
This enables workspace agent tokens to use the same RBAC system as users.
- Add ID to compileSQL matchers
* Plumb through WithID on rbac objects
* Rename Scope -> ScopeName
* Update input.json with scope allow_list
Co-authored-by: Cian Johnston <cian@coder.com>
* chore: More complete tracing for RBAC functions
* Add input.json as example rbac input for rego cli
The input.json is required to play with the rego cli and debug
the policy without golang. It is good to have an example to run
the commands in the readme.md
* Add span events to capture authorize and prepared results
* chore: Add prometheus metrics to rbac authorizer
* chore: Rewrite rbac rego -> SQL clause
Previous code was challenging to read with edge cases
- bug: OrgAdmin could not make new groups
- Also refactor some function names
* feat: Convert rego queries into SQL clauses
* Fix postgres quotes to single quotes
* Ensure all test cases can compile into SQL clauses
* Do not export extra types
* Add custom query with rbac filter
* First draft of a custom authorized db call
* Add comments + tests
* Support better regex style matching for variables
* Handle jsonb arrays
* Remove auth call on workspaces
* Fix PG endpoints test
* Match psql implementation
* Add some comments
* Remove unused argument
* Add query name for tracking
* Handle nested types
This solves it without proper types in our AST.
Might bite the bullet and implement some better types
* Add comment
* Renaming function call to GetAuthorizedWorkspaces