Files
coder/coderd/rbac/authz_test.go
T
Jeremy RuppelandSteven Masley 51a9aa1bfc perf(coderd): batch authcheck permissions via rbac.Filter (#27309)
`POST /api/v2/authcheck` evaluated every check with a full policy
evaluation in a serial loop. A subject in many organizations (100+)
produced hundreds of full evaluations, taking seconds on a cold cache
(DEVEX-608).

Group the checks by `(action, resource type)` and authorize each group
with the existing `rbac.Filter`, which amortizes a single partial
evaluation across the group once it is large enough. Each check is
wrapped in a small value struct that carries its response key, so
`Filter`'s returned subset maps back to keys by reading a field rather
than relying on element identity.

`Filter` now takes an explicit `prepareThreshold`; existing callers pass
the new `rbac.DefaultFilterThreshold` (10), and `checkAuthorization`
passes 50, above the ~35-group crossover measured for this workload, so
subjects with few objects of a given type keep the per-object path and
cannot regress.

## Stacking

This is stacked on top of #27244. `Filter` runs `Prepare` (partial
evaluation), and those residuals are only compact once #27244's
set-membership residuals land. On plain `main` the existing O(N)
residual fanout means batching can regress at high org counts, so this
change should land with or after #27244.

<details>
<summary>Decision log</summary>

### Bottleneck

- `site/src/modules/permissions/organizations.ts` defines ~14 permission
checks per org; `organizationsPermissions()` flattens them across all
orgs into one `POST /api/v2/authcheck`. A 100-org request is ~1400
checks.
- `checkAuthorization` looped serially, calling `Authorizer.Authorize`
(full eval) once per check.
- The endpoint's `maxFetch = 10` only caps checks that carry a
`resource_id`, not total checks, so it does not bound this workload.

### Approach

- Group checks by `(action, resource type)` and run each group through
`rbac.Filter`, which does one partial evaluation (`Prepare`) and reuses
it across the group.
- Carry the response key as data in a small value struct implementing
`RBACObject()`, so allowed results map back to keys without pointer
identity:

  ```go
  type authorizeCheck struct {
      key    string
      object rbac.Object
  }
  func (c authorizeCheck) RBACObject() rbac.Object { return c.object }
  ```

- `Filter` takes a required `prepareThreshold int` (no functional
options). Generic callers pass `rbac.DefaultFilterThreshold = 10`;
`/authcheck` passes 50 because the measured crossover for this workload
is ~35 groups.

### Alternatives rejected

- **Bounded `errgroup` parallelism**: reduced wall time at high org
counts but not aggregate work (allocations flat). Discarded in favor of
reducing work via partial evaluation.
- **Symmetric-deny Rego simplification** (on the #27244 branch):
replacing the known-org deny-fold with symmetric `org := -1` /
`scope_org := -1` rules failed existing SQL-compile tests. A `-1`
known-org vote gated by `not org = -1` produces a negated membership
test over the unknown org id, which OPA emits as an unconvertible
support rule. #27244's fold (`member_allow - org_deny`, a positive
set-difference membership test) is therefore load-bearing, not
incidental.

</details>

---

Authored with Coder Agents.

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2026-08-06 09:18:46 -04:00

431 lines
14 KiB
Go

package rbac_test
import (
"context"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/testutil"
)
type benchmarkCase struct {
Name string
Actor rbac.Subject
}
// benchmarkUserCases builds a set of users with different roles and groups.
// The user id used as the subject and the orgs used to build the roles are
// returned.
func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.UUID) {
orgs = []uuid.UUID{
uuid.MustParse("bf7b72bd-a2b1-4ef2-962c-1d698e0483f6"),
uuid.MustParse("e4660c6f-b9de-422d-9578-cd888983a795"),
uuid.MustParse("fb13d477-06f4-42d9-b957-f6b89bd63515"),
}
user := uuid.MustParse("10d03e62-7703-4df5-a358-4f76577d4e2f")
// noiseGroups are just added to add noise to the authorize call. They
// never match an object's list of groups.
noiseGroups := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
benchCases := []benchmarkCase{
{
Name: "NoRoles",
Actor: rbac.Subject{
ID: user.String(),
Roles: rbac.RoleIdentifiers{},
Scope: rbac.ScopeAll,
},
},
{
Name: "Admin",
Actor: rbac.Subject{
// Give some extra roles that an admin might have
Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "OrgAdmin",
Actor: rbac.Subject{
Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "OrgMember",
Actor: rbac.Subject{
// Member of 2 orgs
Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "ManyRoles",
Actor: rbac.Subject{
// Admin of many orgs
Roles: rbac.RoleIdentifiers{
rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]),
rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]),
rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]),
rbac.RoleMember(),
},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "ManyRolesCachedSubject",
Actor: rbac.Subject{
// Admin of many orgs
Roles: rbac.RoleIdentifiers{
rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]),
rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]),
rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]),
rbac.RoleMember(),
},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
}.WithCachedASTValue(),
},
{
Name: "AdminWithScope",
Actor: rbac.Subject{
// Give some extra roles that an admin might have
Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeApplicationConnect,
Groups: noiseGroups,
},
},
{
// This test should only use static roles. AKA no org roles.
Name: "StaticRoles",
Actor: rbac.Subject{
// Give some extra roles that an admin might have
Roles: rbac.RoleIdentifiers{
rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember(),
rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(),
},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
// This test should only use static roles. AKA no org roles.
Name: "StaticRolesWithCache",
Actor: rbac.Subject{
// Give some extra roles that an admin might have
Roles: rbac.RoleIdentifiers{
rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember(),
rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(),
},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
}.WithCachedASTValue(),
},
}
return benchCases, users, orgs
}
// BenchmarkRBACAuthorize benchmarks the rbac.Authorize method.
//
// go test -run=^$ -bench '^BenchmarkRBACAuthorize$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACAuthorize(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
user,
uuid.MustParse("4ca78b1d-f2d2-4168-9d76-cd93b51c6c1e"),
uuid.MustParse("0632b012-49e0-4d70-a5b3-f4398f1dcd52"),
uuid.MustParse("70dbaa7a-ea9c-4f68-a781-97b08af8461d"),
)
// There is no caching that occurs because a fresh context is used for each
// call. And the context needs 'WithCacheCtx' to work.
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
// This benchmarks all the simple cases using just user permissions. Groups
// are added as noise, but do not do anything.
for _, c := range benchCases {
b.Run(c.Name, func(b *testing.B) {
objects := benchmarkSetup(orgs, users, b.N)
b.ResetTimer()
for i := 0; i < b.N; i++ {
allowed := authorizer.Authorize(context.Background(), c.Actor, policy.ActionRead, objects[b.N%len(objects)])
_ = allowed
}
})
}
}
// BenchmarkRBACAuthorizeGroups benchmarks the rbac.Authorize method and leverages
// groups for authorizing rather than the permissions/roles.
//
// go test -bench '^BenchmarkRBACAuthorizeGroups$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACAuthorizeGroups(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
user,
uuid.MustParse("4ca78b1d-f2d2-4168-9d76-cd93b51c6c1e"),
uuid.MustParse("0632b012-49e0-4d70-a5b3-f4398f1dcd52"),
uuid.MustParse("70dbaa7a-ea9c-4f68-a781-97b08af8461d"),
)
authorizer := rbac.NewAuthorizer(prometheus.NewRegistry())
// Same benchmark cases, but this time groups will be used to match.
// Some '*' permissions will still match, but using a fake action reduces
// the chance.
neverMatchAction := policy.Action("never-match-action")
for _, c := range benchCases {
b.Run(c.Name+"GroupACL", func(b *testing.B) {
userGroupAllow := uuid.NewString()
c.Actor.Groups = append(c.Actor.Groups, userGroupAllow)
c.Actor.Scope = rbac.ScopeAll
objects := benchmarkSetup(orgs, users, b.N, func(object rbac.Object) rbac.Object {
m := map[string][]policy.Action{
// Add the user's group
// Noise
uuid.NewString(): {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
uuid.NewString(): {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
uuid.NewString(): {policy.ActionCreate, policy.ActionRead},
uuid.NewString(): {policy.ActionCreate},
uuid.NewString(): {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
uuid.NewString(): {policy.ActionRead, policy.ActionUpdate},
}
for _, g := range c.Actor.Groups {
// Every group the user is in will be added, but it will not match the perms. This makes the
// authorizer look at many groups before finding the one that matches.
m[g] = []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
}
// This is the only group that will give permission.
m[userGroupAllow] = []policy.Action{neverMatchAction}
return object.WithGroupACL(m)
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
allowed := authorizer.Authorize(context.Background(), c.Actor, neverMatchAction, objects[b.N%len(objects)])
_ = allowed
}
})
}
}
// BenchmarkRBACFilter benchmarks the rbac.Filter method.
//
// go test -bench '^BenchmarkRBACFilter$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACFilter(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
user,
uuid.MustParse("4ca78b1d-f2d2-4168-9d76-cd93b51c6c1e"),
uuid.MustParse("0632b012-49e0-4d70-a5b3-f4398f1dcd52"),
uuid.MustParse("70dbaa7a-ea9c-4f68-a781-97b08af8461d"),
)
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
for _, c := range benchCases {
b.Run("PrepareOnly-"+c.Name, func(b *testing.B) {
obType := rbac.ResourceWorkspace.Type
for i := 0; i < b.N; i++ {
_, err := authorizer.Prepare(context.Background(), c.Actor, policy.ActionRead, obType)
require.NoError(b, err)
}
})
}
for _, c := range benchCases {
b.Run(c.Name, func(b *testing.B) {
objects := benchmarkSetup(orgs, users, b.N)
b.ResetTimer()
allowed, err := rbac.Filter(context.Background(), authorizer, c.Actor, policy.ActionRead, objects, rbac.DefaultFilterThreshold)
require.NoError(b, err)
_ = allowed
})
}
}
func benchmarkSetup(orgs []uuid.UUID, users []uuid.UUID, size int, opts ...func(object rbac.Object) rbac.Object) []rbac.Object {
// Create a "random" but deterministic set of objects.
aclList := map[string][]policy.Action{
uuid.NewString(): {policy.ActionRead, policy.ActionUpdate},
uuid.NewString(): {policy.ActionCreate},
}
objectList := make([]rbac.Object, size)
for i := range objectList {
objectList[i] = rbac.ResourceWorkspace.
WithID(uuid.New()).
InOrg(orgs[i%len(orgs)]).
WithOwner(users[i%len(users)].String()).
WithACLUserList(aclList).
WithGroupACL(aclList)
for _, opt := range opts {
objectList[i] = opt(objectList[i])
}
}
return objectList
}
// BenchmarkCacher benchmarks the performance of the cacher.
func BenchmarkCacher(b *testing.B) {
ctx := context.Background()
authz := rbac.Cacher(&coderdtest.FakeAuthorizer{})
rats := []int{1, 10, 100}
for _, rat := range rats {
b.Run(fmt.Sprintf("%v:1", rat), func(b *testing.B) {
b.ReportAllocs()
var (
subj rbac.Subject
obj rbac.Object
action policy.Action
)
for i := 0; i < b.N; i++ {
if i%rat == 0 {
// Cache miss
b.StopTimer()
subj, obj, action = coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
b.StartTimer()
}
_ = authz.Authorize(ctx, subj, action, obj)
}
})
}
}
func TestCache(t *testing.T) {
t.Parallel()
t.Run("NoCache", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
rec := &coderdtest.RecordingAuthorizer{
Wrapped: &coderdtest.FakeAuthorizer{},
}
subj, obj, action := coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
// Two identical calls
_ = rec.Authorize(ctx, subj, action, obj)
_ = rec.Authorize(ctx, subj, action, obj)
// Yields two calls to the wrapped Authorizer
rec.AssertActor(t, subj, rec.Pair(action, obj), rec.Pair(action, obj))
require.NoError(t, rec.AllAsserted(), "all assertions should have been made")
})
t.Run("Cache", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
rec := &coderdtest.RecordingAuthorizer{
Wrapped: &coderdtest.FakeAuthorizer{},
}
authz := rbac.Cacher(rec)
subj, obj, action := coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
// Two identical calls
_ = authz.Authorize(ctx, subj, action, obj)
_ = authz.Authorize(ctx, subj, action, obj)
// Yields only 1 call to the wrapped Authorizer for that subject
rec.AssertActor(t, subj, rec.Pair(action, obj))
require.NoError(t, rec.AllAsserted(), "all assertions should have been made")
})
t.Run("DontCacheTransientErrors", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
authOut = make(chan error, 1) // buffered to not block
authorizeFunc = func(ctx context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error {
// Just return what you're told.
return testutil.TryReceive(ctx, t, authOut)
}
ma = &rbac.MockAuthorizer{AuthorizeFunc: authorizeFunc}
rec = &coderdtest.RecordingAuthorizer{Wrapped: ma}
authz = rbac.Cacher(rec)
subj, obj, action = coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
)
// First call will result in a transient error. This should not be cached.
testutil.RequireSend(ctx, t, authOut, context.Canceled)
err := authz.Authorize(ctx, subj, action, obj)
assert.ErrorIs(t, err, context.Canceled)
// A subsequent call should still hit the authorizer.
testutil.RequireSend(ctx, t, authOut, nil)
err = authz.Authorize(ctx, subj, action, obj)
assert.NoError(t, err)
// This should be cached and not hit the wrapped authorizer again.
err = authz.Authorize(ctx, subj, action, obj)
assert.NoError(t, err)
// Let's change the subject.
subj, obj, action = coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
// A third will be a legit error
testutil.RequireSend(ctx, t, authOut, assert.AnError)
err = authz.Authorize(ctx, subj, action, obj)
assert.EqualError(t, err, assert.AnError.Error())
// This should be cached and not hit the wrapped authorizer again.
err = authz.Authorize(ctx, subj, action, obj)
assert.EqualError(t, err, assert.AnError.Error())
})
t.Run("MultipleSubjects", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
rec := &coderdtest.RecordingAuthorizer{
Wrapped: &coderdtest.FakeAuthorizer{},
}
authz := rbac.Cacher(rec)
subj1, obj1, action1 := coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
// Two identical calls
_ = authz.Authorize(ctx, subj1, action1, obj1)
_ = authz.Authorize(ctx, subj1, action1, obj1)
// Extra unique calls
var pairs []coderdtest.ActionObjectPair
subj2, obj2, action2 := coderdtest.RandomRBACSubject(), coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
_ = authz.Authorize(ctx, subj2, action2, obj2)
pairs = append(pairs, rec.Pair(action2, obj2))
obj3, action3 := coderdtest.RandomRBACObject(), coderdtest.RandomRBACAction()
_ = authz.Authorize(ctx, subj2, action3, obj3)
pairs = append(pairs, rec.Pair(action3, obj3))
// Extra identical call after some unique calls
_ = authz.Authorize(ctx, subj1, action1, obj1)
// Yields 3 calls, 1 for the first subject, 2 for the unique subjects
rec.AssertActor(t, subj1, rec.Pair(action1, obj1))
rec.AssertActor(t, subj2, pairs...)
require.NoError(t, rec.AllAsserted(), "all assertions should have been made")
})
}