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

Skip to content

perf(coderd): batch authcheck permissions via rbac.Filter - #27309

Merged
jeremyruppel merged 5 commits into
jeremy/devex-608-org-setmembershipfrom
jeremy/devex-608-authcheck-batch
Aug 6, 2026
Merged

perf(coderd): batch authcheck permissions via rbac.Filter#27309
jeremyruppel merged 5 commits into
jeremy/devex-608-org-setmembershipfrom
jeremy/devex-608-authcheck-batch

Conversation

@jeremyruppel

@jeremyruppel jeremyruppel commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.

Decision log

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:

    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


Authored with Coder Agents.

@linear-code

linear-code Bot commented Jul 16, 2026

Copy link
Copy Markdown

DEVEX-608

jeremyruppel commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Copy link
Copy Markdown
Contributor Author

BenchmarkRBACManyOrgs

Ran BenchmarkRBACManyOrgs across main, #27244 (this PR's base), and this PR (#27309), comparing with benchstat.

go test -run '^$' -bench '^BenchmarkRBACManyOrgs$' -benchmem -benchtime 1s -count 6 ./coderd/rbac/

cpu: AMD EPYC 9575F 64-Core Processor (GOMAXPROCS=128). Same benchmark file on all three branches (verified identical).

What this benchmark covers

It exercises the shared RBAC evaluation paths (Authorize = full eval, Prepare = partial eval, PrepareAndCompile = partial + SQL compile). Those are what #27244 changes. This PR's batching change lives in the /authcheck handler and rbac.Filter, which this microbenchmark does not call, so the numbers here show two things: the partial-eval win from #27244, and that stacking this PR on top is neutral on the RBAC paths (no regression).

sec/op (vs main)

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.
  • #27309 vs #27244 is within noise on every metric, confirming the batching change does not regress raw RBAC evaluation. Its endpoint-level benefit (grouping /authcheck checks so a single partial evaluation is amortized across many objects) is not captured by this benchmark.
  • Full-eval Authorize carries 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.

Copy link
Copy Markdown
Contributor Author

Endpoint-level benchmark (/authcheck grouping)

BenchmarkRBACManyOrgs (posted earlier) measures shared RBAC eval paths but not this PR's grouping. This benchmark isolates the endpoint strategy directly: a subject in N organizations, the ~15 (action, resource type) checks per org from site/src/modules/permissions/organizations.ts, flattened into one request. It compares the pre-DEVEX-608 serial loop (Authorize per check) against this PR's batched rbac.Filter per (action, type) group.

AMD EPYC 9575F, benchstat, -benchmem -benchtime 200ms -count 8. Each op is one full authcheck request (15 groups x N objects). The benchmark itself is a local throwaway, not committed.

This PR alone (old per-object loop vs batched Filter)

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 Emyrk left a comment

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.

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.
@jeremyruppel
jeremyruppel force-pushed the jeremy/devex-608-authcheck-batch branch from bb38d95 to 0927e14 Compare July 27, 2026 13:15
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.

Copy link
Copy Markdown
Contributor Author

Fixed in 37eeb93. checkAuthorization now authorizes AnyOrgOwner objects per-object with a full evaluation and excludes them from the batched rbac.Filter grouping, since partial evaluation has no verified semantics for them.

I added the regression to the existing TestCheckPermissions (subtest CheckAuthorization/AnyOrg) rather than a separate file: a single workspace create, any_org: true check plus the same check repeated 55x, all expected true. Verified it fails without the fix and passes with it. Thanks for the catch and the repro in #27347.

@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 Emyrk left a comment

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.

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.

Comment thread coderd/rbac/authz.go
Comment thread coderd/authorize_test.go
Comment thread coderd/authorize.go Outdated
}
allowed, err := rbac.Filter(ctx, api.Authorizer, auth, group.action, checks, authcheckFilterThreshold)
if err != nil {
continue

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.

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.

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.

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.
@jeremyruppel
jeremyruppel requested a review from Emyrk July 27, 2026 19:27
@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 4, 2026
Comment thread coderd/authorize.go
Comment on lines +304 to +308
if obj.AnyOrgOwner {
err := api.Authorizer.Authorize(ctx, auth, policy.Action(v.Action), obj)
response[k] = err == nil
continue
}

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.

👍

@jeremyruppel
jeremyruppel merged commit 51a9aa1 into main Aug 6, 2026
23 of 25 checks passed
@jeremyruppel
jeremyruppel deleted the jeremy/devex-608-authcheck-batch branch August 6, 2026 13:18
jeremyruppel added a commit that referenced this pull request Aug 6, 2026
…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.
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.

2 participants