Jeremy Ruppel 7e708b24ce perf(coderd/rbac): collapse org authorization to a set-membership test (#27244)
## 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.
2026-08-06 09:18:45 -04:00
2022-04-04 11:55:06 -05:00

Coder Logo Light Coder Logo Dark

Self-Hosted Cloud Development Environments and AI Agents

Coder Banner Light Coder Banner Dark

Quickstart | Docs | Why Coder | Premium

discord release godoc Go Report Card OpenSSF Best Practices OpenSSF Scorecard license

Coder is a self-hosted platform for cloud development environments and AI coding agents. Workspaces are defined with Terraform, connected through a secure Wireguard® tunnel, and automatically shut down when not used. Coder Agents runs a native AI coding agent whose loop executes in the control plane on your infrastructure, with no API keys in workspaces.

  • Define cloud development environments in Terraform
    • EC2 VMs, Kubernetes Pods, Docker Containers, etc.
  • Automatically shutdown idle resources to save on costs
  • Onboard developers in seconds instead of days
  • Delegate coding work to AI agents on your infrastructure
    • Bring any model (Anthropic, OpenAI, Google, Bedrock, self-hosted)
    • No LLM credentials in workspaces, user identity on every action
    • Centralized model governance, cost tracking, and audit logging

Coder platform showing templates and a running workspace

Quickstart

The most convenient way to try Coder is to install it on your local machine and experiment with provisioning cloud development environments using Docker (works on Linux, macOS, and Windows).

# First, install Coder
curl -L https://coder.com/install.sh | sh

# Start the Coder server (caches data in ~/.cache/coder)
coder server

# Navigate to http://localhost:3000 to create your initial user,
# create a Docker template and provision a workspace

Install

The easiest way to install Coder is to use the install script for Linux and macOS. For Windows, use the latest ..._installer.exe file from GitHub Releases.

curl -L https://coder.com/install.sh | sh

You can run the install script with --dry-run to see the commands that will be used to install without executing them. Run the install script with --help for additional flags.

See install for additional methods.

Once installed, you can start a production deployment with a single command:

# Automatically sets up an external access URL on *.try.coder.app
coder server

# Requires a PostgreSQL instance (version 13 or higher) and external access URL
coder server --postgres-url <url> --access-url <url>

Use coder --help to get a list of flags and environment variables. See the install guides for a complete tutorial.

Documentation

Browse the documentation or visit a specific section below:

  • Workspaces: Workspaces contain the IDEs, dependencies, and configuration information needed for software development
  • Templates: Templates are written in Terraform and describe the infrastructure for workspaces
  • Coder Agents: Delegate coding work to AI agents running on your self-hosted infrastructure
  • Administration: Learn how to operate Coder
  • Premium: Learn about paid features built for large teams
  • IDEs: Connect your existing editor to a workspace

Support

Feel free to open an issue if you have questions, run into bugs, or have a feature request.

Join our Discord to provide feedback on in-progress features and chat with the community using Coder!

Integrations

New integrations are always in progress. Open an issue to request one. Contributions are welcome in any official or community repository.

Official

Community

Contributing

New contributors are always welcome. If you are new to the Coder codebase, see the contribution guide to get started.

Hiring

Apply on the careers page if you are interested in joining the team.

S
Description
Provision remote development environments via Terraform
Readme AGPL-3.0
918 MiB
Languages
Go 74.9%
TypeScript 23.1%
Shell 0.8%
HCL 0.3%
PLpgSQL 0.3%
Other 0.4%