Thanks to visit codestin.com
Credit goes to github.com

Skip to content

perf(coderd/rbac): collapse org authorization to a set-membership test - #27244

Merged
jeremyruppel merged 5 commits into
mainfrom
jeremy/devex-608-org-setmembership
Aug 6, 2026
Merged

perf(coderd/rbac): collapse org authorization to a set-membership test#27244
jeremyruppel merged 5 commits into
mainfrom
jeremy/devex-608-org-setmembership

Conversation

@jeremyruppel

@jeremyruppel jeremyruppel commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Authorization for users who belong to many organizations is slow. On the list
endpoints (/api/v2/organizations, /users, /groups) a user in hundreds of
orgs saw multi-second page loads
(DEVEX-608
/ #21890 / Pylon #2758). This is partial-evaluation bound: rbac.Prepare
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
object's org id:

vote := allow_map[input.object.org_owner]

input.object.org_owner is unknown during partial evaluation. Indexing a map by
an unknown key cannot reduce to a single expression, so OPA emits one residual
query per org membership, and newPartialAuthorizer then calls PrepareForEval
once per residual, making Prepare O(N) in org count. The list endpoints
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
partial-evaluation time, so the query collapses to a single
organization_id = ANY(ARRAY[...]) residual instead of N residuals:

  • The known-org clause only ever votes to allow, tested via
    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
    difference (member_allow - org_deny), so the unknown org id appears in only
    one positive membership test and the decision never branches on it.
  • The per-org vote maps are computed once as memoized zero-arg rules
    (role_org_votes, role_member_votes, scope_org_votes,
    scope_member_votes) instead of through parametrized functions that OPA
    re-evaluates at every call site.
  • role_allow/scope_allow, the any_org path, and full evaluation are
    unchanged in behavior.

Semantics are unchanged (see the equivalence argument below). The only
representational change is that a denied known org's intermediate org vote is
now 0 instead of -1, compensated by the set difference and not observable in
the final allow decision.

Results

Measured with BenchmarkRBACManyOrgs (added on main in #27270). Full tables:
B/op and allocs/op.

  • Residual queries: O(N) -> O(1).
  • Prepare / PrepareAndCompile memory changes from < />quadratic growth on main
    (176 MiB, 7.08M allocs per op at 100 orgs) to near-linear (6.5 MiB, 258k
    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
    Prepare now allocates < />7% fewer bytes and < />9% fewer objects than main.
  • Authorize (full evaluation) memory is marginally higher (+1-8%, largest at
    1 org) and time-neutral. This is the inherent cost of the set-membership form
    that keeps partial evaluation from fanning out; full evaluation builds an
    allow set it would not otherwise need.
  • go test ./coderd/rbac/... passes, including TestAuthorizeDomain (full- vs
    partial-eval equivalence) and the regosql suite.

A second, independent bottleneck remains (out of scope here): the vote map is
still built in O(N^2) in check_all_org_permissions
(roles[_].by_org_id[org_id] scans all roles per org). Fixing it means
pre-merging roles' by_org_id into one org->perms map in the OPA input, and is
tracked as a follow-up.

Testing

  • OrgDenyBlocksMember (TestAuthorizeLevels): an org-level deny blocks a
    member-allowed action on an owned in-org object, while a clean org is allowed,
    including an action-scoped deny.
  • ScopeOrgDenyBlocksMember (TestAuthorizeScope): the same fold at the scope
    level.
  • The shared harness covers full and partial evaluation and asserts the partial
    result compiles to SQL with zero support rules.
Decision log and equivalence argument

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.


This PR was generated by Coder Agents on behalf of @jeremyruppel.

@linear-code

linear-code Bot commented Jul 14, 2026

Copy link
Copy Markdown

DEVEX-608

@jeremyruppel jeremyruppel left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, surgical fix. Keeping the unknown org_owner in a single positive set-membership test (and folding org-deny into a ground set difference) is exactly the discipline POLICY.md prescribes, and it makes the O(N) partial-eval fan-out structurally impossible rather than merely unlikely. The not org = -1 gates were preserved rather than deleted, keeping the diff auditable, and the new test genuinely exercises both the full and partial/SQL paths.

Independent verification across reviewers found no over-authorization: the full/partial-eval equivalence holds across all nine {org, member} vote combinations, and the intermediate -1 -> 0 change is not observable outside rego (authz.go only queries data.authz.allow).

Findings are all P3 / non-blocking, across 4 inline comments: an untested scope-level deny fold, a now-implicit "known-org org vote is never -1" invariant (plus POLICY.md drift), a stale doc comment, and a few high-value test rows to add.

Two notes that don't map to changed lines:

  • P3 (Database Reviewer): the list endpoints this PR targets use StringVarMatcher("organization_id :: text", ...) (coderd/rbac/regosql/configs.go), so the residual compiles to organization_id :: text = ANY(ARRAY[...]). The left-side cast defeats a UUID index on organization_id, and the array is now larger. This is pre-existing (the old N-residual form had the same cast) and still a large net win, but it caps the DB-side ceiling for exactly these endpoints; worth a follow-up to evaluate a UUID matcher or expression index.
  • Resolved during cross-check: the Edge Case concern that org_member calling org_ids_with_vote twice compounds the O(N^2) map build is neutralized — the Performance Analyst verified OPA v1.18.1 memoizes ground-arg function calls via virtualCache, so check_all_org_permissions(roles, "org") runs once. The remaining O(N^2) build is the known, already-tracked follow-up.

Comment thread coderd/rbac/policy.rego
Comment thread coderd/rbac/policy.rego Outdated
Comment thread coderd/rbac/policy.rego
Comment thread coderd/rbac/authz_internal_test.go

Emyrk commented Jul 15, 2026

Copy link
Copy Markdown
Member

Ran some benchmarks on this branch vs main to check how the set-membership rewrite scales with org count. Benchmark source: #27270 (BenchmarkRBACManyOrgs — full-eval Authorize, partial-eval Prepare, and Prepare+CompileToSQL, subject with membership in N orgs, pre-expanded cached roles, no authz cache).

TL;DR: big wins at high org counts (Prepare -66% at 50 orgs, -74% at 100 orgs, allocs -88%), and full-eval Authorize does not regress. But Prepare regresses significantly at low org counts: +115% at 1 org, +45% at 5 orgs (with matching alloc increases). The crossover is between 5 and 10 orgs. Since single-org subjects are the common case, that regression is worth a look before merging — it may be cheap to shrink (e.g. the partial-eval query now produces more support/expression work per org-branch at small N).

sec/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 284.1µ 332.0µ +16.84% (p=0.004)
Authorize/orgs=5 868.7µ 898.5µ ~ (p=0.589)
Authorize/orgs=10 1.662m 1.622m ~ (p=0.132)
Authorize/orgs=50 7.809m 7.445m -4.67%
Authorize/orgs=100 16.53m 15.89m -3.88%
Prepare/orgs=1 6.760m 14.534m +115.00%
Prepare/orgs=5 13.55m 19.65m +45.02%
Prepare/orgs=10 27.95m 25.82m -7.62%
Prepare/orgs=50 303.6m 103.6m -65.89%
Prepare/orgs=100 1021.2m 261.8m -74.36%
PrepareAndCompile/orgs=1 6.368m 15.116m +137.39%
PrepareAndCompile/orgs=5 14.96m 20.54m +37.31%
PrepareAndCompile/orgs=10 30.70m 27.63m -10.01%
PrepareAndCompile/orgs=50 293.1m 107.5m -63.34%
PrepareAndCompile/orgs=100 1038.3m 265.6m -74.42%

Memory follows the same shape: Prepare/orgs=100 drops 176 MiB → 20.4 MiB per op (7.08M → 803k allocs), while Prepare/orgs=1 grows 1.06 MiB → 2.79 MiB (32.8k → 77.7k allocs).

Full benchstat output (sec/op, B/op, allocs/op)
goos: linux
goarch: amd64
pkg: github.com/coder/coder/v2/coderd/rbac
cpu: AMD EPYC 9454P 48-Core Processor               
                                           │ /tmp/bench-main-10s.txt │         /tmp/bench-pr-10s.txt         │
                                           │         sec/op          │    sec/op      vs base                │
RBACManyOrgs/Authorize/orgs=1-96                        284.1µ ± 11%    332.0µ ±  7%   +16.84% (p=0.004 n=6)
RBACManyOrgs/Prepare/orgs=1-96                          6.760m ±  2%   14.534m ±  6%  +115.00% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-96                6.368m ±  7%   15.116m ±  3%  +137.39% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5-96                        868.7µ ± 13%    898.5µ ± 15%         ~ (p=0.589 n=6)
RBACManyOrgs/Prepare/orgs=5-96                          13.55m ±  6%    19.65m ±  6%   +45.02% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-96                14.96m ± 10%    20.54m ±  4%   +37.31% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-96                       1.662m ± 10%    1.622m ±  4%         ~ (p=0.132 n=6)
RBACManyOrgs/Prepare/orgs=10-96                         27.95m ±  7%    25.82m ±  6%    -7.62% (p=0.015 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-96               30.70m ±  7%    27.63m ±  3%   -10.01% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-96                       7.809m ±  2%    7.445m ±  3%    -4.67% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-96                         303.6m ±  4%    103.6m ±  2%   -65.89% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-96               293.1m ±  3%    107.5m ±  1%   -63.34% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-96                      16.53m ± 15%    15.89m ±  2%    -3.88% (p=0.026 n=6)
RBACManyOrgs/Prepare/orgs=100-96                       1021.2m ±  3%    261.8m ±  1%   -74.36% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-96             1038.3m ±  7%    265.6m ±  1%   -74.42% (p=0.002 n=6)
geomean                                                 20.18m          16.98m         -15.87%

                                           │ /tmp/bench-main-10s.txt │        /tmp/bench-pr-10s.txt         │
                                           │          B/op           │     B/op      vs base                │
RBACManyOrgs/Authorize/orgs=1-96                        59.05Ki ± 0%   64.17Ki ± 0%    +8.66% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1-96                          1.059Mi ± 0%   2.787Mi ± 0%  +163.16% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-96                1.085Mi ± 0%   2.899Mi ± 0%  +167.14% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5-96                        145.7Ki ± 0%   151.9Ki ± 0%    +4.27% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5-96                          2.440Mi ± 0%   3.484Mi ± 0%   +42.80% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-96                2.497Mi ± 0%   3.644Mi ± 0%   +45.96% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-96                       254.9Ki ± 0%   262.9Ki ± 0%    +3.15% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10-96                         4.858Mi ± 0%   4.388Mi ± 0%    -9.67% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-96               4.955Mi ± 0%   4.621Mi ± 0%    -6.73% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-96                       1.099Mi ± 0%   1.119Mi ± 0%    +1.87% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-96                         51.11Mi ± 0%   11.51Mi ± 0%   -77.48% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-96               51.51Mi ± 0%   12.30Mi ± 0%   -76.12% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-96                      2.161Mi ± 0%   2.198Mi ± 0%    +1.72% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100-96                       176.39Mi ± 0%   20.44Mi ± 0%   -88.41% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-96             177.22Mi ± 0%   21.82Mi ± 0%   -87.69% (p=0.002 n=6)
geomean                                                 3.325Mi        2.469Mi        -25.76%

                                           │ /tmp/bench-main-10s.txt │        /tmp/bench-pr-10s.txt         │
                                           │        allocs/op        │  allocs/op    vs base                │
RBACManyOrgs/Authorize/orgs=1-96                         1.727k ± 0%    1.845k ± 0%    +6.83% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1-96                           32.77k ± 0%    77.72k ± 0%  +137.14% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-96                 33.29k ± 0%    79.85k ± 0%  +139.84% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5-96                         4.986k ± 0%    5.143k ± 0%    +3.15% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5-96                           83.06k ± 0%   107.19k ± 0%   +29.05% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-96                 84.00k ± 0%   109.59k ± 0%   +30.47% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-96                        9.058k ± 0%    9.266k ± 0%    +2.30% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10-96                          173.9k ± 0%    144.2k ± 0%   -17.09% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-96                175.4k ± 0%    146.9k ± 0%   -16.23% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-96                        41.56k ± 0%    42.13k ± 0%    +1.38% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-96                         2010.5k ± 0%    437.0k ± 0%   -78.27% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-96               2016.1k ± 0%    442.1k ± 0%   -78.07% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-96                       82.17k ± 0%    83.20k ± 0%    +1.25% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100-96                        7083.7k ± 0%    802.7k ± 0%   -88.67% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-96              7094.1k ± 0%    810.8k ± 0%   -88.57% (p=0.002 n=6)
geomean                                                  118.4k         82.88k        -30.00%

Methodology: go test -run '^$' -bench 'BenchmarkRBACManyOrgs' -benchmem -benchtime 10s -count 6 ./coderd/rbac/ on linux/amd64, AMD EPYC 9454P 48-Core. main at f958727887; PR branch at 8f4080e0e2 with the benchmark commit cherry-picked on top. Compared with benchstat.

Coder Agents on behalf of @Emyrk.

Emyrk commented Jul 15, 2026

Copy link
Copy Markdown
Member

Follow-up with the memory side of the comparison (same runs as above).

B/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 59.05Ki 64.17Ki +8.66%
Authorize/orgs=5 145.7Ki 151.9Ki +4.27%
Authorize/orgs=10 254.9Ki 262.9Ki +3.15%
Authorize/orgs=50 1.099Mi 1.119Mi +1.87%
Authorize/orgs=100 2.161Mi 2.198Mi +1.72%
Prepare/orgs=1 1.059Mi 2.787Mi +163.16%
Prepare/orgs=5 2.440Mi 3.484Mi +42.80%
Prepare/orgs=10 4.858Mi 4.388Mi -9.67%
Prepare/orgs=50 51.11Mi 11.51Mi -77.48%
Prepare/orgs=100 176.39Mi 20.44Mi -88.41%
PrepareAndCompile/orgs=1 1.085Mi 2.899Mi +167.14%
PrepareAndCompile/orgs=5 2.497Mi 3.644Mi +45.96%
PrepareAndCompile/orgs=10 4.955Mi 4.621Mi -6.73%
PrepareAndCompile/orgs=50 51.51Mi 12.30Mi -76.12%
PrepareAndCompile/orgs=100 177.22Mi 21.82Mi -87.69%

allocs/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 1.727k 1.845k +6.83%
Authorize/orgs=5 4.986k 5.143k +3.15%
Authorize/orgs=10 9.058k 9.266k +2.30%
Authorize/orgs=50 41.56k 42.13k +1.38%
Authorize/orgs=100 82.17k 83.20k +1.25%
Prepare/orgs=1 32.77k 77.72k +137.14%
Prepare/orgs=5 83.06k 107.19k +29.05%
Prepare/orgs=10 173.9k 144.2k -17.09%
Prepare/orgs=50 2010.5k 437.0k -78.27%
Prepare/orgs=100 7083.7k 802.7k -88.67%
PrepareAndCompile/orgs=1 33.29k 79.85k +139.84%
PrepareAndCompile/orgs=5 84.00k 109.59k +30.47%
PrepareAndCompile/orgs=10 175.4k 146.9k -16.23%
PrepareAndCompile/orgs=50 2016.1k 442.1k -78.07%
PrepareAndCompile/orgs=100 7094.1k 810.8k -88.57%

(All p=0.002, n=6.)

Same pattern as runtime: Prepare memory scales roughly quadratically with org count on main (176 MiB / 7.08M allocs per op at 100 orgs) and near-linearly on this PR, but at 1 org the PR allocates ~2.6x more bytes and ~2.4x more objects per Prepare. Authorize memory is only marginally higher (+1–9%) at every org count.

Coder Agents on behalf of @Emyrk.

Emyrk added a commit that referenced this pull request Jul 15, 2026
<!-- Authored with Coder Agents on behalf of @Emyrk -->
Adds `BenchmarkRBACManyOrgs` to measure `Authorize`, `Prepare` (partial
evaluation), and `Prepare`+`CompileToSQL` as a subject's org-membership
count grows (1, 5, 10, 50, 100 orgs).

- Written to evaluate the org set-membership rewrite in #27244, where
partial-eval cost scales with org count.
- Subject uses pre-expanded cached roles (`WithCachedASTValue`), member
+ per-org `organization-member` roles, `ScopeAll`; authorizer has no
cache so each iteration measures a real evaluation.

Results comparing `main` vs #27244 are posted on that PR.

<sub>Coder Agents on behalf of @Emyrk.</sub>
@jeremyruppel
jeremyruppel force-pushed the jeremy/devex-608-org-setmembership branch from 8f4080e to b4d0eb9 Compare July 15, 2026 19:08

Copy link
Copy Markdown
Contributor Author

Memory side of the comparison, re-run after the rebase (-benchmem -benchtime 2s -count 6).

B/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 59.16Ki 63.84Ki +7.90%
Authorize/orgs=5 146.0Ki 151.8Ki +3.97%
Authorize/orgs=10 255.3Ki 262.9Ki +2.97%
Authorize/orgs=50 1.101Mi 1.121Mi +1.84%
Authorize/orgs=100 2.164Mi 2.201Mi +1.70%
Prepare/orgs=1 1087.5Ki 1010.3Ki -7.10%
Prepare/orgs=5 2.447Mi 1.203Mi -50.85%
Prepare/orgs=10 4.871Mi 1.482Mi -69.57%
Prepare/orgs=50 51.215Mi 3.688Mi -92.80%
Prepare/orgs=100 176.699Mi 6.456Mi -96.35%
PrepareAndCompile/orgs=1 1.088Mi 1.013Mi -6.90%
PrepareAndCompile/orgs=5 2.504Mi 1.247Mi -50.19%
PrepareAndCompile/orgs=10 4.968Mi 1.546Mi -68.88%
PrepareAndCompile/orgs=50 51.619Mi 3.923Mi -92.40%
PrepareAndCompile/orgs=100 177.524Mi 6.906Mi -96.11%

allocs/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 1.727k 1.857k +7.53%
Authorize/orgs=5 4.985k 5.155k +3.41%
Authorize/orgs=10 9.058k 9.278k +2.43%
Authorize/orgs=50 41.56k 42.15k +1.41%
Authorize/orgs=100 82.17k 83.21k +1.26%
Prepare/orgs=1 32.79k 29.78k -9.19%
Prepare/orgs=5 83.10k 39.06k -53.00%
Prepare/orgs=10 173.97k 50.72k -70.84%
Prepare/orgs=50 2010.8k 143.0k -92.89%
Prepare/orgs=100 7084.1k 258.3k -96.35%
PrepareAndCompile/orgs=1 33.31k 30.31k -9.02%
PrepareAndCompile/orgs=5 84.04k 39.68k -52.78%
PrepareAndCompile/orgs=10 175.42k 51.44k -70.67%
PrepareAndCompile/orgs=50 2016.2k 144.5k -92.83%
PrepareAndCompile/orgs=100 7094.7k 260.8k -96.32%

(All p=0.002, n=6.)

After memoizing the org vote maps, the earlier single-org Prepare regression is gone: at 1 org this PR now allocates ~7% fewer bytes and ~9% fewer objects per Prepare than main, while retaining the near-linear scaling that turns main's ~quadratic growth (176 MiB / 7.08M allocs per op at 100 orgs) into 6.5 MiB / 258k. Authorize memory remains marginally higher (+1–8%), largest at 1 org, which is the inherent cost of the set-membership form used to keep partial evaluation from fanning out.

Coder Agents on behalf of @jeremyruppel.

Copy link
Copy Markdown
Contributor Author

Re-ran the runtime benchmarks on the rebased branch vs main after the memoization change. Source: #27270 (BenchmarkRBACManyOrgs — full-eval Authorize, partial-eval Prepare, and Prepare+CompileToSQL; subject with membership in N orgs, pre-expanded cached roles, no authz cache).

TL;DR: big wins at high org counts (Prepare -84% at 50 orgs, -87% at 100 orgs, allocs -96%), full-eval Authorize does not regress, and the earlier low-org Prepare regression is gone — at 1 org Prepare is now slightly faster than main (memoizing the org vote maps removed the per-call-site rebuild). Authorize memory is marginally higher (+1-8%, largest at 1 org), which is the inherent cost of the set-membership form.

sec/op, main → this PR:

Benchmark main PR vs base
Authorize/orgs=1 131.1µ 138.1µ +5.37%
Authorize/orgs=5 382.2µ 389.0µ +1.76%
Authorize/orgs=10 720.7µ 701.9µ ~ (p=0.093)
Authorize/orgs=50 3.622m 3.549m ~ (p=0.065)
Authorize/orgs=100 8.520m 7.943m -6.77%
Prepare/orgs=1 2.868m 2.762m -3.71%
Prepare/orgs=5 6.717m 3.620m -46.10%
Prepare/orgs=10 13.750m 4.900m -64.36%
Prepare/orgs=50 149.86m 23.48m -84.33%
Prepare/orgs=100 517.55m 64.82m -87.48%
PrepareAndCompile/orgs=1 2.965m 2.838m -4.28%
PrepareAndCompile/orgs=5 6.863m 3.699m -46.10%
PrepareAndCompile/orgs=10 13.962m 4.995m -64.22%
PrepareAndCompile/orgs=50 150.89m 23.40m -84.49%
PrepareAndCompile/orgs=100 520.97m 65.70m -87.39%

Memory follows the same shape: Prepare/orgs=100 drops 176.7 MiB → 6.46 MiB per op (7.08M → 258k allocs), and unlike the earlier revision Prepare/orgs=1 now shrinks 1.06 MiB → 1.01 MiB (32.8k → 29.8k allocs).

Full benchstat output (sec/op, B/op, allocs/op)
goos: linux
goarch: amd64
pkg: github.com/coder/coder/v2/coderd/rbac
cpu: AMD EPYC 9575F 64-Core Processor
                                        │ bt-main.txt  │             bt-pr.txt              │
                                        │    sec/op    │   sec/op     vs base               │
RBACManyOrgs/Authorize/orgs=1              131.1µ ± 2%   138.1µ ± 4%   +5.37% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1               2.868m ± 2%   2.762m ± 0%   -3.71% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1     2.965m ± 3%   2.838m ± 1%   -4.28% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5             382.2µ ± 1%   389.0µ ± 2%   +1.76% (p=0.041 n=6)
RBACManyOrgs/Prepare/orgs=5               6.717m ± 0%   3.620m ± 1%  -46.10% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5     6.863m ± 3%   3.699m ± 1%  -46.10% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10            720.7µ ± 3%   701.9µ ± 5%        ~ (p=0.093 n=6)
RBACManyOrgs/Prepare/orgs=10             13.750m ± 0%   4.900m ± 4%  -64.36% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10   13.962m ± 1%   4.995m ± 1%  -64.22% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50            3.622m ± 1%   3.549m ± 4%        ~ (p=0.065 n=6)
RBACManyOrgs/Prepare/orgs=50            149.86m ± 1%   23.48m ± 2%  -84.33% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50  150.89m ± 2%   23.40m ± 0%  -84.49% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100           8.520m ± 10%   7.943m ± 1%   -6.77% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100           517.55m ± 0%   64.82m ± 2%  -87.48% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100 520.97m ± 1%   65.70m ± 0%  -87.39% (p=0.002 n=6)
geomean                                   9.563m         4.505m       -52.89%

                                        │ bt-main.txt  │              bt-pr.txt               │
                                        │     B/op     │     B/op       vs base               │
RBACManyOrgs/Authorize/orgs=1             59.15Ki ± 0%    63.82Ki ± 0%   +7.90% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1              1086.9Ki ± 0%   1010.5Ki ± 0%   -7.03% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1     1.088Mi ± 0%    1.014Mi ± 0%   -6.80% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5             145.9Ki ± 0%    151.7Ki ± 0%   +3.97% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5               2.446Mi ± 0%    1.203Mi ± 0%  -50.81% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5     2.502Mi ± 0%    1.247Mi ± 0%  -50.16% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10            255.3Ki ± 0%    262.9Ki ± 0%   +2.98% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10              4.869Mi ± 0%    1.483Mi ± 0%  -69.55% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10    4.966Mi ± 0%    1.546Mi ± 0%  -68.86% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50            1.101Mi ± 0%    1.121Mi ± 0%   +1.84% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50             51.205Mi ± 0%    3.688Mi ± 0%  -92.80% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50   51.613Mi ± 0%    3.923Mi ± 0%  -92.40% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100           2.164Mi ± 0%    2.201Mi ± 0%   +1.71% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100           176.672Mi ± 0%    6.455Mi ± 0%  -96.35% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100 177.504Mi ± 0%    6.905Mi ± 0%  -96.11% (p=0.002 n=6)
geomean                                   3.332Mi         1.186Mi       -64.39%

                                        │ bt-main.txt  │             bt-pr.txt              │
                                        │  allocs/op   │  allocs/op   vs base               │
RBACManyOrgs/Authorize/orgs=1              1.727k ± 0%   1.857k ± 0%   +7.53% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1               32.77k ± 0%   29.80k ± 0%   -9.08% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1     33.29k ± 0%   30.32k ± 0%   -8.91% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5              4.985k ± 0%   5.155k ± 0%   +3.41% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5               83.06k ± 0%   39.08k ± 0%  -52.95% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5     84.00k ± 0%   39.70k ± 0%  -52.74% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10             9.058k ± 0%   9.278k ± 0%   +2.43% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10             173.92k ± 0%   50.75k ± 0%  -70.82% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10   175.37k ± 0%   51.46k ± 0%  -70.65% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50            41.56k ± 0%   42.15k ± 0%   +1.41% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50            2010.5k ± 0%   143.0k ± 0%  -92.89% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50  2016.0k ± 0%   144.6k ± 0%  -92.83% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100           82.17k ± 0%   83.21k ± 0%   +1.27% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100           7083.6k ± 0%   258.3k ± 0%  -96.35% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100 7094.2k ± 0%   260.8k ± 0%  -96.32% (p=0.002 n=6)
geomean                                   118.4k        41.06k       -65.32%

Coder Agents on behalf of @jeremyruppel.

The known-org authorization path indexed an N-entry vote map by the
object's org id, which is unknown during partial evaluation. Indexing by
an unknown forces OPA to emit one residual query per org membership, so
Prepare did O(N) work per request (~4s and up for users in hundreds of
orgs), driving slow list endpoints for many-org users.

Rewrite the known-org check to test the object's org id for membership in
a set that is fully known at partial-evaluation time, which collapses to a
single 'organization_id = ANY(ARRAY[...])' residual. The known-org clause
now only votes to allow; org-level denies are folded into the org-member
level as a set difference (member-allow minus org-deny), preserving exact
semantics while keeping the unknown org id out of any map index.

Add an OrgDenyBlocksMember case to TestAuthorizeLevels covering the
org-level deny gate on both the full- and partial-evaluation paths.
…asymmetry

Address review feedback on the org set-membership change:

- Expand OrgDenyBlocksMember with member-level deny-wins, org-level allow
  overriding member-level deny, and action-scoped org deny cases.
- Add ScopeOrgDenyBlocksMember to exercise the scope_org_member deny fold,
  which built-in scopes never reach.
- Document the known-org asymmetry in POLICY.md (known-org org level never
  votes -1; deny is folded into org_member) and annotate the now-vestigial
  not org = -1 gates.
- Refresh the stale check_all_org_permissions doc comment that described the
  removed org-id-keyed lookup.
…ression

The set-membership rewrite emitted an unsatisfiable `org_owner in set()`
residual for every org/scope org-level branch whose allow set was empty
(e.g. an org-member role with no org-level permission, or a site-wildcard
scope). OPA does not fold membership in an empty set to false, so each dead
branch still cost a PrepareForEval, inflating Prepare at low org counts
(+115% at 1 org) even though high org counts improved.

Guard each membership test with a ground `count(set) > 0`: when the set is
empty the guard is ground-false and partial evaluation drops the branch
instead of emitting a dead residual; when non-empty it is ground-true and
drops out, leaving just the membership residual. Residual count for a
workspace read is now a flat 5 regardless of org count (was 6 -> 505 on
main, 21 flat before this guard).

Benchmarks (this branch vs main): Prepare/orgs=1 +115% -> +10%,
Prepare/orgs=100 still -78% (allocs -90%).
Hoist the per-org vote maps into memoized zero-arg complete rules
(role_org_votes, role_member_votes, scope_org_votes,
scope_member_votes) instead of recomputing them through parametrized
functions at every call site. OPA evaluates and caches complete rules
once per query, while a function is re-evaluated on each call, so the
org, member, and scope paths previously rebuilt the same vote map on
every authorization check.

The known-org clauses now read from these maps. This removes the
single-org partial-evaluation regression (Prepare/Compile at orgs=1 go
from slower than main to faster) while keeping the large wins at high
org counts, and leaves the set-membership partial-eval shape unchanged.
@jeremyruppel
jeremyruppel force-pushed the jeremy/devex-608-org-setmembership branch from b4d0eb9 to 14b02e3 Compare July 16, 2026 19:20

jeremyruppel commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@cstyan

cstyan commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

closed my PR in favour of this one

this is more performant across the board by reducing the fanout for queries to a single query, while mine only reduces the O(n^2) map building to be linear

it may be possible to stack ours for an additional perf. improvement at 50+ orgs for Prepare, but currently my changes result in a significant memory usage increase for the relative speed up

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that the tests also pass against the policy from origin/main 👍 This gives me more signal that this does not change the core logic.

Comment thread coderd/rbac/policy.rego Outdated
The comment referenced role_member_allowed_orgs, which does not exist.
Describe the actual construct: the allowed set is a ground set difference
of the member-allow and org-deny vote sets.
@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 4, 2026
@jeremyruppel
jeremyruppel merged commit 7e708b2 into main Aug 6, 2026
25 checks passed
@jeremyruppel
jeremyruppel deleted the jeremy/devex-608-org-setmembership branch August 6, 2026 13:18
jeremyruppel added a commit that referenced this pull request Aug 6, 2026
`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 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants