perf(coderd): batch authcheck permissions via rbac.Filter - #27309
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
| benchmark | main | #27244 | #27309 |
|---|---|---|---|
| Prepare/orgs=5 | 6.54m | -45.6% | -46.3% |
| Prepare/orgs=10 | 13.06m | -63.4% | -64.2% |
| Prepare/orgs=50 | 142.7m | -83.7% | -85.0% |
| Prepare/orgs=100 | 495.0m | -86.4% | -87.2% |
| PrepareAndCompile/orgs=50 | 143.1m | -83.2% | -84.9% |
| PrepareAndCompile/orgs=100 | 496.7m | -86.4% | -87.3% |
| Authorize/orgs=100 | 7.81m | +3.0% | ~ |
allocs/op (vs main)
| benchmark | main | #27244 | #27309 |
|---|---|---|---|
| Prepare/orgs=50 | 2,010.8k | -92.9% | -92.9% |
| Prepare/orgs=100 | 7,084.1k | -96.4% | -96.4% |
| PrepareAndCompile/orgs=100 | 7,094.6k | -96.3% | -96.3% |
| Authorize/orgs=100 | 82.2k | +1.3% | +1.3% |
Takeaways
- Partial evaluation at high org counts drops dramatically: ~-87% time and ~-96% allocations at 100 orgs. This is the fix for the list/SQL-filter fanout and the main DEVEX-608 win. It comes entirely from perf(coderd/rbac): collapse org authorization to a set-membership test #27244.
#27309vs#27244is within noise on every metric, confirming the batching change does not regress raw RBAC evaluation. Its endpoint-level benefit (grouping/authcheckchecks so a single partial evaluation is amortized across many objects) is not captured by this benchmark.- Full-eval
Authorizecarries a small, consistent regression from perf(coderd/rbac): collapse org authorization to a set-membership test #27244 (~+1-3% time, +1-8% allocs) from building the memoized vote maps. This path is not the DEVEX-608 hotspot, but flagging it for visibility.
Full benchstat output
goos: linux
goarch: amd64
pkg: github.com/coder/coder/v2/coderd/rbac
cpu: AMD EPYC 9575F 64-Core Processor
│ main │ pr27244 │ pr27309 │
│ sec/op │ sec/op vs base │ sec/op vs base │
RBACManyOrgs/Authorize/orgs=1-128 126.3µ ± 1% 137.3µ ± 6% +8.69% (p=0.002 n=6) 132.9µ ± 2% +5.21% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1-128 2.754m ± 1% 2.796m ± 1% ~ (p=0.093 n=6) 2.720m ± 1% -1.25% (p=0.015 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-128 2.805m ± 1% 2.838m ± 1% +1.16% (p=0.041 n=6) 2.810m ± 2% ~ (p=1.000 n=6)
RBACManyOrgs/Authorize/orgs=5-128 366.8µ ± 1% 377.6µ ± 4% +2.97% (p=0.002 n=6) 365.7µ ± 0% ~ (p=0.699 n=6)
RBACManyOrgs/Prepare/orgs=5-128 6.541m ± 2% 3.558m ± 1% -45.61% (p=0.002 n=6) 3.515m ± 2% -46.26% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-128 6.659m ± 1% 3.646m ± 2% -45.25% (p=0.002 n=6) 3.604m ± 1% -45.88% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-128 681.5µ ± 2% 695.9µ ± 2% +2.12% (p=0.026 n=6) 664.6µ ± 3% -2.48% (p=0.041 n=6)
RBACManyOrgs/Prepare/orgs=10-128 13.056m ± 1% 4.776m ± 1% -63.42% (p=0.002 n=6) 4.678m ± 5% -64.17% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-128 13.289m ± 2% 4.985m ± 1% -62.49% (p=0.002 n=6) 4.821m ± 2% -63.73% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-128 3.471m ± 2% 3.511m ± 3% ~ (p=0.180 n=6) 3.376m ± 1% -2.74% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-128 142.74m ± 2% 23.28m ± 2% -83.69% (p=0.002 n=6) 21.46m ± 1% -84.96% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-128 143.11m ± 1% 24.02m ± 3% -83.22% (p=0.002 n=6) 21.64m ± 3% -84.88% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-128 7.807m ± 1% 8.038m ± 4% +2.96% (p=0.004 n=6) 7.650m ± 4% ~ (p=0.065 n=6)
RBACManyOrgs/Prepare/orgs=100-128 495.00m ± 2% 67.21m ± 16% -86.42% (p=0.002 n=6) 63.14m ± 1% -87.24% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-128 496.72m ± 4% 67.43m ± 15% -86.43% (p=0.002 n=6) 63.21m ± 2% -87.28% (p=0.002 n=6)
geomean 9.119m 4.502m -50.63% 4.317m -52.66%
│ main │ pr27244 │ pr27309 │
│ B/op │ B/op vs base │ B/op vs base │
RBACManyOrgs/Authorize/orgs=1-128 59.17Ki ± 0% 63.83Ki ± 0% +7.88% (p=0.002 n=6) 63.84Ki ± 0% +7.89% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1-128 1087.7Ki ± 0% 1010.8Ki ± 0% -7.07% (p=0.002 n=6) 1010.4Ki ± 0% -7.10% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-128 1.088Mi ± 0% 1.014Mi ± 0% -6.87% (p=0.002 n=6) 1.013Mi ± 0% -6.91% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5-128 146.0Ki ± 0% 151.8Ki ± 0% +3.96% (p=0.002 n=6) 151.8Ki ± 0% +3.96% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5-128 2.447Mi ± 0% 1.203Mi ± 0% -50.84% (p=0.002 n=6) 1.203Mi ± 0% -50.85% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-128 2.504Mi ± 0% 1.247Mi ± 0% -50.18% (p=0.002 n=6) 1.247Mi ± 0% -50.19% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-128 255.4Ki ± 0% 263.0Ki ± 0% +2.97% (p=0.002 n=6) 263.0Ki ± 0% +2.98% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10-128 4.871Mi ± 0% 1.483Mi ± 0% -69.56% (p=0.002 n=6) 1.483Mi ± 0% -69.56% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-128 4.969Mi ± 0% 1.547Mi ± 0% -68.87% (p=0.002 n=6) 1.546Mi ± 0% -68.88% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-128 1.101Mi ± 0% 1.121Mi ± 0% +1.83% (p=0.002 n=6) 1.121Mi ± 0% +1.84% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-128 51.213Mi ± 0% 3.689Mi ± 0% -92.80% (p=0.002 n=6) 3.689Mi ± 0% -92.80% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-128 51.619Mi ± 0% 3.924Mi ± 0% -92.40% (p=0.002 n=6) 3.924Mi ± 0% -92.40% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-128 2.164Mi ± 0% 2.201Mi ± 0% +1.71% (p=0.002 n=6) 2.201Mi ± 0% +1.71% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100-128 176.691Mi ± 0% 6.458Mi ± 0% -96.35% (p=0.002 n=6) 6.460Mi ± 0% -96.34% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-128 177.518Mi ± 0% 6.912Mi ± 0% -96.11% (p=0.002 n=6) 6.908Mi ± 0% -96.11% (p=0.002 n=6)
geomean 3.333Mi 1.187Mi -64.40% 1.187Mi -64.40%
│ main │ pr27244 │ pr27309 │
│ allocs/op │ allocs/op vs base │ allocs/op vs base │
RBACManyOrgs/Authorize/orgs=1-128 1.727k ± 0% 1.857k ± 0% +7.53% (p=0.002 n=6) 1.857k ± 0% +7.53% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=1-128 32.79k ± 0% 29.80k ± 0% -9.12% (p=0.002 n=6) 29.78k ± 0% -9.19% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=1-128 33.31k ± 0% 30.32k ± 0% -8.97% (p=0.002 n=6) 30.30k ± 0% -9.03% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=5-128 4.986k ± 0% 5.155k ± 0% +3.39% (p=0.002 n=6) 5.155k ± 0% +3.39% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=5-128 83.10k ± 0% 39.08k ± 0% -52.97% (p=0.002 n=6) 39.06k ± 0% -52.99% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=5-128 84.04k ± 0% 39.70k ± 0% -52.76% (p=0.002 n=6) 39.68k ± 0% -52.78% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=10-128 9.058k ± 0% 9.278k ± 0% +2.43% (p=0.002 n=6) 9.278k ± 0% +2.43% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=10-128 173.98k ± 0% 50.75k ± 0% -70.83% (p=0.002 n=6) 50.73k ± 0% -70.84% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=10-128 175.42k ± 0% 51.46k ± 0% -70.66% (p=0.002 n=6) 51.45k ± 0% -70.67% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=50-128 41.56k ± 0% 42.15k ± 0% +1.41% (p=0.002 n=6) 42.15k ± 0% +1.41% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=50-128 2010.8k ± 0% 143.0k ± 0% -92.89% (p=0.002 n=6) 143.0k ± 0% -92.89% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=50-128 2016.2k ± 0% 144.5k ± 0% -92.83% (p=0.002 n=6) 144.5k ± 0% -92.83% (p=0.002 n=6)
RBACManyOrgs/Authorize/orgs=100-128 82.17k ± 0% 83.21k ± 0% +1.27% (p=0.002 n=6) 83.21k ± 0% +1.27% (p=0.002 n=6)
RBACManyOrgs/Prepare/orgs=100-128 7084.1k ± 0% 258.3k ± 0% -96.35% (p=0.002 n=6) 258.3k ± 0% -96.35% (p=0.002 n=6)
RBACManyOrgs/PrepareAndCompile/orgs=100-128 7094.6k ± 0% 260.9k ± 0% -96.32% (p=0.002 n=6) 260.8k ± 0% -96.32% (p=0.002 n=6)
geomean 118.4k 41.06k -65.33% 41.05k -65.34%
Authored with Coder Agents.
Endpoint-level benchmark (
|
| orgs | checks | sec/op | allocs/op |
|---|---|---|---|
| 1 | 15 | +8.3% | +4.6% |
| 5 | 75 | +16.9% (noisy, ±23%) | +1.6% |
| 10 | 150 | +6.0% | +1.0% |
| 50 | 750 | -33.7% | -50.2% |
| 100 | 1500 | -63.7% | -75.8% |
Large win at high org counts (100 orgs: 2.65s -> 0.96s, 15.7M -> 4.0M allocs). But below the group threshold (1/5/10 orgs) there is a small, real regression.
Cause and fix
The low-N overhead is redundant, role-count-scaled span attribute construction that ran an extra time per group on Filter's below-threshold fallback. It is fixed in the stacked #27310. With that change, low-N goes flat and the high-N win is preserved:
| orgs | this PR alone | with #27310 |
|---|---|---|
| 1 | +8.3% | +1.9% |
| 5 | +16.9% | ~ (no diff) |
| 10 | +6.0% | ~ (no diff) |
| 50 | -33.7% | -33.9% |
| 100 | -63.7% | -64.8% |
So this PR + #27310 together deliver the batching win with no common-case regression.
Full benchstat: this PR alone
=== #27309 alone (perobject vs Filter) ===
goos: linux
goarch: amd64
pkg: github.com/coder/coder/v2/coderd
cpu: AMD EPYC 9575F 64-Core Processor
│ old_per_object │ this_pr │
│ sec/op │ sec/op vs base │
AuthcheckGrouping/orgs=1-128 881.9µ ± 1% 955.2µ ± 10% +8.30% (p=0.000 n=8)
AuthcheckGrouping/orgs=5-128 6.972m ± 2% 8.147m ± 23% +16.85% (p=0.000 n=8)
AuthcheckGrouping/orgs=10-128 21.05m ± 7% 22.32m ± 7% +6.01% (p=0.015 n=8)
AuthcheckGrouping/orgs=50-128 510.8m ± 2% 338.6m ± 1% -33.72% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 2654.6m ± 1% 963.5m ± 1% -63.70% (p=0.000 n=8)
geomean 44.55m 35.53m -20.24%
│ old_per_object │ this_pr │
│ B/op │ B/op vs base │
AuthcheckGrouping/orgs=1-128 498.8Ki ± 0% 521.5Ki ± 0% +4.55% (p=0.000 n=8)
AuthcheckGrouping/orgs=5-128 3.276Mi ± 0% 3.328Mi ± 0% +1.59% (p=0.000 n=8)
AuthcheckGrouping/orgs=10-128 8.748Mi ± 0% 8.838Mi ± 0% +1.03% (p=0.000 n=8)
AuthcheckGrouping/orgs=50-128 130.13Mi ± 0% 64.81Mi ± 0% -50.20% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 476.5Mi ± 0% 115.4Mi ± 0% -75.79% (p=0.000 n=8)
geomean 15.40Mi 10.23Mi -33.57%
│ old_per_object │ this_pr │
│ allocs/op │ allocs/op vs base │
AuthcheckGrouping/orgs=1-128 12.54k ± 0% 12.78k ± 0% +1.92% (p=0.000 n=8)
AuthcheckGrouping/orgs=5-128 92.38k ± 0% 92.81k ± 0% +0.47% (p=0.000 n=8)
AuthcheckGrouping/orgs=10-128 258.8k ± 0% 259.5k ± 0% +0.25% (p=0.000 n=8)
AuthcheckGrouping/orgs=50-128 4.215M ± 0% 2.232M ± 0% -47.04% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 15.715M ± 0% 4.044M ± 0% -74.27% (p=0.000 n=8)
geomean 456.7k 308.1k -32.52%
Full benchstat: with #27310
=== with #27310 (perobject vs Filter) ===
goos: linux
goarch: amd64
pkg: github.com/coder/coder/v2/coderd
cpu: AMD EPYC 9575F 64-Core Processor
│ old_per_object │ with_27310 │
│ sec/op │ sec/op vs base │
AuthcheckGrouping/orgs=1-128 869.1µ ± 2% 885.7µ ± 5% +1.90% (p=0.038 n=8)
AuthcheckGrouping/orgs=5-128 6.969m ± 1% 6.835m ± 4% ~ (p=0.083 n=8)
AuthcheckGrouping/orgs=10-128 20.89m ± 4% 20.64m ± 3% ~ (p=0.130 n=8)
AuthcheckGrouping/orgs=50-128 509.4m ± 9% 336.7m ± 4% -33.90% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 2712.0m ± 3% 953.7m ± 1% -64.84% (p=0.000 n=8)
geomean 44.52m 33.17m -25.50%
│ old_per_object │ with_27310 │
│ B/op │ B/op vs base │
AuthcheckGrouping/orgs=1-128 485.9Ki ± 0% 496.9Ki ± 0% +2.27% (p=0.000 n=8)
AuthcheckGrouping/orgs=5-128 3.147Mi ± 0% 3.176Mi ± 0% +0.92% (p=0.000 n=8)
AuthcheckGrouping/orgs=10-128 8.332Mi ± 0% 8.380Mi ± 0% +0.58% (p=0.000 n=8)
AuthcheckGrouping/orgs=50-128 121.26Mi ± 0% 64.44Mi ± 0% -46.86% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 441.8Mi ± 0% 114.6Mi ± 0% -74.06% (p=0.000 n=8)
geomean 14.61Mi 9.906Mi -32.21%
│ old_per_object │ with_27310 │
│ allocs/op │ allocs/op vs base │
AuthcheckGrouping/orgs=1-128 12.42k ± 0% 12.53k ± 0% +0.84% (p=0.000 n=8)
AuthcheckGrouping/orgs=5-128 91.10k ± 0% 91.27k ± 0% +0.18% (p=0.000 n=8)
AuthcheckGrouping/orgs=10-128 254.8k ± 0% 255.0k ± 0% +0.09% (p=0.000 n=8)
AuthcheckGrouping/orgs=50-128 4.134M ± 0% 2.229M ± 0% -46.09% (p=0.000 n=8)
AuthcheckGrouping/orgs=100-128 15.404M ± 0% 4.037M ± 0% -73.79% (p=0.000 n=8)
geomean 449.5k 304.6k -32.24%
Authored with Coder Agents.
Emyrk
left a comment
There was a problem hiding this comment.
Batching breaks checks with any_org: true: partial evaluation denies AnyOrgOwner objects that full evaluation allows, and this PR routes them through Filter's prepared path once a group reaches 50.
The full-vs-partial divergence for AnyOrgOwner is pre-existing — authz_internal_test.go deliberately skips the equivalence assertion for AnyOrgOwner objects ("a partial eval does not make sense") — and no existing Filter caller is affected because DB-derived objects never set it. /authcheck is the only place user-supplied any_org objects exist, so this PR is what first exposes them to partial evaluation.
Concretely: a member checking workspace create, any_org: true, owner_id: me gets true for a single check but false when the same check appears 55 times in one request. Repro test in #27347 (targets this branch) — it passes on main and fails here.
Suggested fix: exclude AnyOrgOwner objects from grouping in checkAuthorization and authorize them per-object, since the per-object path is the only one with verified semantics for them.
Coder Agents on behalf of @Emyrk.
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.
bb38d95 to
0927e14
Compare
Batching /authcheck checks through rbac.Filter routed groups of 50 or more through a prepared partial query. Partial evaluation has no verified semantics for AnyOrgOwner objects (authz_internal_test.go skips the full-vs-partial equivalence assertion for them) and denies them, so a member's 'workspace create, any_org: true' check returned true as a single check but false once repeated past the batching threshold. Authorize AnyOrgOwner objects per-object with a full evaluation instead of grouping them. Add a regression case to TestCheckPermissions covering a single any_org check and the same check repeated past the threshold.
|
Fixed in 37eeb93. I added the regression to the existing @Emyrk could you re-review when you have a moment? (Coder Agents generated on behalf of @jeremyruppel.) |
…#27347) <!-- Authored with Coder Agents on behalf of @Emyrk --> Failing regression test for #27309: batching authcheck permissions through `rbac.Filter` breaks checks with `any_org: true`. - A member checking `workspace create, any_org: true, owner_id: me` gets `true` for a single check but `false` when the same check appears 55 times in one request, because the `(create, workspace)` group crosses the batching threshold and partial evaluation denies `AnyOrgOwner` objects that full evaluation allows. - The test passes on `main` and fails on `jeremy/devex-608-authcheck-batch`. Intended to merge together with a fix (e.g. excluding `AnyOrgOwner` objects from grouping). See the review on #27309 for the full analysis. <sub>Coder Agents on behalf of @Emyrk.</sub>
Emyrk
left a comment
There was a problem hiding this comment.
Second pass after the any_org fix: the fix is exactly right (per-object full eval for AnyOrgOwner before grouping) and I verified both regression tests pass on head c627014 locally — that resolves my earlier request-changes. Three remaining comments inline (Filter-error handling, prepareThreshold validation, a mixed-group test); none are blockers. Threshold=50 and the equivalence/maxFetch semantics reviewed with no comment.
Coder Agents on behalf of @Emyrk.
| } | ||
| allowed, err := rbac.Filter(ctx, api.Authorizer, auth, group.action, checks, authcheckFilterThreshold) | ||
| if err != nil { | ||
| continue |
There was a problem hiding this comment.
An error from Filter here is never a denial — per-object rejections are filtered inside Filter; only Prepare failures and context errors propagate. So rather than silently denying the whole group (dozens of checks, nothing in the logs, frontend quietly hides UI), return httpapi.InternalServerError: "couldn't evaluate" is the honest answer, not "not permitted". Short-circuit ctx.Err() != nil with a bare return first so client disconnects neither 500 nor log. Worth a test for the error path alongside.
Coder Agents on behalf of @Emyrk.
There was a problem hiding this comment.
Done in 0b622c9. A Filter error now returns httpapi.InternalServerError instead of denying the group, with a bare return first when ctx.Err() != nil so client disconnects neither 500 nor log.
On the error-path test: I did not add one. Reaching this branch requires a Prepare/context failure, which isn't inducible through the HTTP handler without injecting a failing authorizer into the API (no seam for that today). Happy to add one if you'd prefer I wire up that seam.
(Coder Agents on behalf of @jeremyruppel.)
Address review feedback on the authcheck batching: - rbac.Filter now rejects a non-positive prepareThreshold instead of silently forcing the Prepare path for every input, so misuse fails loudly on an authorization function. - checkAuthorization returns 500 on a Filter error rather than silently denying the whole group, since a Filter error signals an evaluation failure (Prepare or context), not a denial. Client cancellations short-circuit with a bare return. - Add a mixed-group test: one (read, workspace) group past the threshold combining resource_id-fetched objects and org-scoped objects, asserting correct per-key verdicts through the prepared query. - Add a Filter non-positive threshold regression case.
Satisfy the misspell linter (US spelling) that failed CI.
| if obj.AnyOrgOwner { | ||
| err := api.Authorizer.Authorize(ctx, auth, policy.Action(v.Action), obj) | ||
| response[k] = err == nil | ||
| continue | ||
| } |
…7310) `rbacTraceAttributes` materialized the subject's role names (one string allocation per role) and was passed into every `Filter`, `Authorize`, and `Prepare` span at creation time, so the O(roles) work ran even when no tracer was recording. It also called `SafeRoleNames()` twice. Replace it with `setRBACAttributes`, which attaches the same attributes *after* the span is created and only when `span.IsRecording()` is true, reading `SafeRoleNames()` once. Recorded spans are unchanged; untraced and unsampled calls skip the per-role work. This originated from #27309: once `/authcheck` checks are batched through `rbac.Filter`, each below-threshold group paid the role-attribute build for the `Filter` span *and* for every per-object `Authorize` span, so the redundant per-call work showed up as extra allocations per request. ## Benchmarks `AMD EPYC 9575F`, `benchstat`, no tracer configured (exercises the `IsRecording()==false` path). **`BenchmarkRBACManyOrgs`** (general RBAC eval), before vs after: wall time flat (geomean −0.04%), allocations strictly lower everywhere (geomean B/op −0.52%; `Authorize` −1.0 to −1.3% B/op), no regressions. **Authcheck path** (`BenchmarkAuthcheckGrouping`, #27309 vs #27310, back-to-back): this change is an **allocation reduction and is time-neutral**. On the endpoint (`Grouped`) path, per-request allocations drop ~4-5% B/op at common org counts (1-10); on the pure per-object path the reduction grows with org count (B/op −2.6% → −7.3% at 100 orgs). Wall time is flat within noise: low-org deltas sit inside this host's ±10-23% run-to-run variance, so no wall-time claim is made. Net: same speed, less garbage per request, which also lowers GC pressure under real concurrent load. <details> <summary>Decision log</summary> - The `Filter` span wraps the whole filtering routine (total latency + `num_objects`); it is the valuable span and is kept. The costly part was `rbacTraceAttributes`, not the span itself. - `rbacTraceAttributes` was O(roles): it allocated a string per role for the `subject_roles` attribute and called `SafeRoleNames()` twice. On `Filter`'s below-threshold fallback it ran once for the `Filter` span and again for each per-object `Authorize` span, so a group of N objects paid N+1 builds vs the old loop's N. Benchmarks confirm this as real per-call allocation; its wall-time cost is below the authcheck benchmark's noise floor. - Deferring attribute construction behind `IsRecording()` requires the span object, so the three callsites moved from `StartSpan(ctx, rbacTraceAttributes(...))` to `StartSpan(ctx)` then `setRBACAttributes(span, ...)`. No spans were removed or renamed; recorded output is identical. - Tradeoff: when a span is not recording, `subject_roles`/`num_subject_roles`/etc. are not computed. Unsampled spans emit nothing anyway, so there is no observable output change. </details> --- Authored with Coder Agents.

POST /api/v2/authcheckevaluated 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 existingrbac.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, soFilter's returned subset maps back to keys by reading a field rather than relying on element identity.Filternow takes an explicitprepareThreshold; existing callers pass the newrbac.DefaultFilterThreshold(10), andcheckAuthorizationpasses 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.
FilterrunsPrepare(partial evaluation), and those residuals are only compact once #27244's set-membership residuals land. On plainmainthe existing O(N) residual fanout means batching can regress at high org counts, so this change should land with or after #27244.Decision log
Bottleneck
site/src/modules/permissions/organizations.tsdefines ~14 permission checks per org;organizationsPermissions()flattens them across all orgs into onePOST /api/v2/authcheck. A 100-org request is ~1400 checks.checkAuthorizationlooped serially, callingAuthorizer.Authorize(full eval) once per check.maxFetch = 10only caps checks that carry aresource_id, not total checks, so it does not bound this workload.Approach
Group checks by
(action, resource type)and run each group throughrbac.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:Filtertakes a requiredprepareThreshold int(no functional options). Generic callers passrbac.DefaultFilterThreshold = 10;/authcheckpasses 50 because the measured crossover for this workload is ~35 groups.Alternatives rejected
errgroupparallelism: reduced wall time at high org counts but not aggregate work (allocations flat). Discarded in favor of reducing work via partial evaluation.org := -1/scope_org := -1rules failed existing SQL-compile tests. A-1known-org vote gated bynot org = -1produces a negated membership test over the unknown org id, which OPA emits as an unconvertible support rule. perf(coderd/rbac): collapse org authorization to a set-membership test #27244's fold (member_allow - org_deny, a positive set-difference membership test) is therefore load-bearing, not incidental.Authored with Coder Agents.