diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 3fcbb63cb632c..d1d70545509f9 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -12,7 +12,10 @@ import ( "testing" "time" + "github.com/coder/coder/cryptorand" "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -543,9 +546,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck } type authCall struct { - Actor rbac.Subject - Action rbac.Action - Object rbac.Object + rbac.AuthCall asserted bool } @@ -621,9 +622,11 @@ func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action rbac. r.Lock() defer r.Unlock() r.Called = append(r.Called, authCall{ - Actor: subject, - Action: action, - Object: object, + AuthCall: rbac.AuthCall{ + Actor: subject, + Action: action, + Object: object, + }, }) } @@ -743,3 +746,67 @@ func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Obje func (*fakePreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { return "not a valid sql string", nil } + +// Random rbac helper funcs + +func RandomRBACAction() rbac.Action { + all := rbac.AllActions() + return all[must(cryptorand.Intn(len(all)))] +} + +func RandomRBACObject() rbac.Object { + return rbac.Object{ + ID: uuid.NewString(), + Owner: uuid.NewString(), + OrgID: uuid.NewString(), + Type: randomRBACType(), + ACLUserList: map[string][]rbac.Action{ + namesgenerator.GetRandomName(1): {RandomRBACAction()}, + }, + ACLGroupList: map[string][]rbac.Action{ + namesgenerator.GetRandomName(1): {RandomRBACAction()}, + }, + } +} + +func randomRBACType() string { + all := []string{ + rbac.ResourceWorkspace.Type, + rbac.ResourceWorkspaceExecution.Type, + rbac.ResourceWorkspaceApplicationConnect.Type, + rbac.ResourceAuditLog.Type, + rbac.ResourceTemplate.Type, + rbac.ResourceGroup.Type, + rbac.ResourceFile.Type, + rbac.ResourceProvisionerDaemon.Type, + rbac.ResourceOrganization.Type, + rbac.ResourceRoleAssignment.Type, + rbac.ResourceOrgRoleAssignment.Type, + rbac.ResourceAPIKey.Type, + rbac.ResourceUser.Type, + rbac.ResourceUserData.Type, + rbac.ResourceOrganizationMember.Type, + rbac.ResourceWildcard.Type, + rbac.ResourceLicense.Type, + rbac.ResourceDeploymentConfig.Type, + rbac.ResourceReplicas.Type, + rbac.ResourceDebugInfo.Type, + } + return all[must(cryptorand.Intn(len(all)))] +} + +func RandomRBACSubject() rbac.Subject { + return rbac.Subject{ + ID: uuid.NewString(), + Roles: rbac.RoleNames{rbac.RoleMember()}, + Groups: []string{namesgenerator.GetRandomName(1)}, + Scope: rbac.ScopeAll, + } +} + +func must[T any](value T, err error) T { + if err != nil { + panic(err) + } + return value +} diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index 7d819a9d74c0f..422c29ee63563 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/moby/moby/pkg/namesgenerator" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" @@ -34,7 +33,7 @@ func TestAuthzRecorder(t *testing.T) { rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } - sub := randomSubject() + sub := coderdtest.RandomRBACSubject() pairs := fuzzAuthz(t, sub, rec, 10) rec.AssertActor(t, sub, pairs...) require.NoError(t, rec.AllAsserted(), "all assertions should have been made") @@ -46,10 +45,10 @@ func TestAuthzRecorder(t *testing.T) { rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } - a := randomSubject() + a := coderdtest.RandomRBACSubject() aPairs := fuzzAuthz(t, a, rec, 10) - b := randomSubject() + b := coderdtest.RandomRBACSubject() bPairs := fuzzAuthz(t, b, rec, 10) rec.AssertActor(t, b, bPairs...) @@ -63,12 +62,12 @@ func TestAuthzRecorder(t *testing.T) { rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } - a := randomSubject() + a := coderdtest.RandomRBACSubject() aPairs := fuzzAuthz(t, a, rec, 10) - b := randomSubject() + b := coderdtest.RandomRBACSubject() - act, objTy := randomAction(), randomObject().Type + act, objTy := coderdtest.RandomRBACAction(), coderdtest.RandomRBACObject().Type prep, _ := rec.Prepare(context.Background(), b, act, objTy) bPairs := fuzzAuthzPrep(t, prep, 10, act, objTy) @@ -84,7 +83,7 @@ func fuzzAuthzPrep(t *testing.T, prep rbac.PreparedAuthorized, n int, action rba pairs := make([]coderdtest.ActionObjectPair, 0, n) for i := 0; i < n; i++ { - obj := randomObject() + obj := coderdtest.RandomRBACObject() obj.Type = objectType p := coderdtest.ActionObjectPair{Action: action, Object: obj} _ = prep.Authorize(context.Background(), p.Object) @@ -98,37 +97,9 @@ func fuzzAuthz(t *testing.T, sub rbac.Subject, rec rbac.Authorizer, n int) []cod pairs := make([]coderdtest.ActionObjectPair, 0, n) for i := 0; i < n; i++ { - p := coderdtest.ActionObjectPair{Action: randomAction(), Object: randomObject()} + p := coderdtest.ActionObjectPair{Action: coderdtest.RandomRBACAction(), Object: coderdtest.RandomRBACObject()} _ = rec.Authorize(context.Background(), sub, p.Action, p.Object) pairs = append(pairs, p) } return pairs } - -func randomAction() rbac.Action { - return rbac.Action(namesgenerator.GetRandomName(1)) -} - -func randomObject() rbac.Object { - return rbac.Object{ - ID: namesgenerator.GetRandomName(1), - Owner: namesgenerator.GetRandomName(1), - OrgID: namesgenerator.GetRandomName(1), - Type: namesgenerator.GetRandomName(1), - ACLUserList: map[string][]rbac.Action{ - namesgenerator.GetRandomName(1): {rbac.ActionRead}, - }, - ACLGroupList: map[string][]rbac.Action{ - namesgenerator.GetRandomName(1): {rbac.ActionRead}, - }, - } -} - -func randomSubject() rbac.Subject { - return rbac.Subject{ - ID: namesgenerator.GetRandomName(1), - Roles: rbac.RoleNames{rbac.RoleMember()}, - Groups: []string{namesgenerator.GetRandomName(1)}, - Scope: rbac.ScopeAll, - } -} diff --git a/coderd/rbac/action.go b/coderd/rbac/action.go index a05814b2ecd07..6aa6846c562db 100644 --- a/coderd/rbac/action.go +++ b/coderd/rbac/action.go @@ -9,3 +9,8 @@ const ( ActionUpdate Action = "update" ActionDelete Action = "delete" ) + +// AllActions is a helper function to return all the possible actions types. +func AllActions() []Action { + return []Action{ActionCreate, ActionRead, ActionUpdate, ActionDelete} +} diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 29195ad1792a0..91aca34e3cc46 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -222,16 +222,16 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "UserACLList", user, []authTestCase{ { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ - user.ID: allActions(), + user.ID: AllActions(), }), - actions: allActions(), + actions: AllActions(), allow: true, }, { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ user.ID: {WildcardSymbol}, }), - actions: allActions(), + actions: AllActions(), allow: true, }, { @@ -254,16 +254,16 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "GroupACLList", user, []authTestCase{ { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ - allUsersGroup: allActions(), + allUsersGroup: AllActions(), }), - actions: allActions(), + actions: AllActions(), allow: true, }, { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ allUsersGroup: {WildcardSymbol}, }), - actions: allActions(), + actions: AllActions(), allow: true, }, { @@ -285,27 +285,27 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "Member", user, []authTestCase{ // Org + me - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: allActions(), allow: true}, - {resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: AllActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner(user.ID), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.WithOwner(user.ID), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.All(), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.All(), actions: AllActions(), allow: false}, // Other org + me - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, // Other org + other user - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, // Other org + other us - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, }) user = Subject{ @@ -326,27 +326,27 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "DeletedMember", user, []authTestCase{ // Org + me - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(defOrg), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner(user.ID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner(user.ID), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.All(), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.All(), actions: AllActions(), allow: false}, // Other org + me - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, // Other org + other user - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, // Other org + other use - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, }) user = Subject{ @@ -360,27 +360,27 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "OrgAdmin", user, []authTestCase{ // Org + me - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: allActions(), allow: true}, - {resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: AllActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.WithOwner(user.ID), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.WithOwner(user.ID), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.All(), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.All(), actions: AllActions(), allow: false}, // Other org + me - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, // Other org + other user - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, // Other org + other use - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: AllActions(), allow: false}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: false}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: false}, }) user = Subject{ @@ -394,27 +394,27 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "SiteAdmin", user, []authTestCase{ // Org + me - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: allActions(), allow: true}, - {resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: AllActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.WithOwner(user.ID), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.WithOwner(user.ID), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.All(), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.All(), actions: AllActions(), allow: true}, // Other org + me - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: allActions(), allow: true}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.ID), actions: AllActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: true}, // Other org + other user - {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: true}, // Other org + other use - {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: true}, - {resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: AllActions(), allow: true}, + {resource: ResourceWorkspace.InOrg(unuseID), actions: AllActions(), allow: true}, - {resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true}, + {resource: ResourceWorkspace.WithOwner("not-me"), actions: AllActions(), allow: true}, }) user = Subject{ @@ -642,7 +642,7 @@ func TestAuthorizeLevels(t *testing.T) { testAuthorize(t, "AdminAlwaysAllow", user, cases(func(c authTestCase) authTestCase { - c.actions = allActions() + c.actions = AllActions() c.allow = true return c }, []authTestCase{ @@ -701,7 +701,7 @@ func TestAuthorizeLevels(t *testing.T) { testAuthorize(t, "OrgAllowAll", user, cases(func(c authTestCase) authTestCase { - c.actions = allActions() + c.actions = AllActions() return c }, []authTestCase{ // Org + me @@ -1034,10 +1034,6 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes } } -// allActions is a helper function to return all the possible actions types. -func allActions() []Action { - return []Action{ActionCreate, ActionRead, ActionUpdate, ActionDelete} -} func must[T any](value T, err error) T { if err != nil { diff --git a/coderd/rbac/cache.go b/coderd/rbac/cache.go new file mode 100644 index 0000000000000..7ee71bb6e32ab --- /dev/null +++ b/coderd/rbac/cache.go @@ -0,0 +1,107 @@ +package rbac + +import ( + "context" + "sync" +) + +type AuthCall struct { + Actor Subject + Action Action + Object Object +} + +type cachedCalls struct { + authz Authorizer +} + +// Cacher returns an Authorizer that can use a cache stored on a context +// to short circuit duplicate calls to the Authorizer. This is useful when +// multiple calls are made to the Authorizer for the same subject, action, and +// object. The cache is on each `ctx` and is not shared between requests. +// If no cache is found on the context, the Authorizer is called as normal. +func Cacher(authz Authorizer) Authorizer { + return &cachedCalls{authz: authz} +} + +func (c *cachedCalls) Authorize(ctx context.Context, subject Subject, action Action, object Object) error { + cache := cacheFromContext(ctx) + + resp, ok := cache.Load(subject, action, object) + if ok { + return resp + } + + err := c.authz.Authorize(ctx, subject, action, object) + cache.Save(subject, action, object, err) + return err +} + +// Prepare returns the underlying PreparedAuthorized. The cache does not apply +// to prepared authorizations. These should be using a SQL filter, and +// therefore the cache is not needed. +func (c *cachedCalls) Prepare(ctx context.Context, subject Subject, action Action, objectType string) (PreparedAuthorized, error) { + return c.authz.Prepare(ctx, subject, action, objectType) +} + +type cachedAuthCall struct { + AuthCall + Err error +} + +type authorizeCache struct { + sync.Mutex + // calls is a list of all calls made to the Authorizer. + // This list is cached per request context. The size of this list is expected + // to be incredibly small. Often 1 or 2 calls. + calls []cachedAuthCall +} + +//nolint:error-return,revive +func (c *authorizeCache) Load(subject Subject, action Action, object Object) (error, bool) { + if c == nil { + return nil, false + } + c.Lock() + defer c.Unlock() + + for _, call := range c.calls { + if call.Action == action && call.Object.Equal(object) && call.Actor.Equal(subject) { + return call.Err, true + } + } + return nil, false +} + +func (c *authorizeCache) Save(subject Subject, action Action, object Object, err error) { + if c == nil { + return + } + c.Lock() + defer c.Unlock() + + c.calls = append(c.calls, cachedAuthCall{ + AuthCall: AuthCall{ + Actor: subject, + Action: action, + Object: object, + }, + Err: err, + }) +} + +// cacheContextKey is a context key used to store the cache in the context. +type cacheContextKey struct{} + +// cacheFromContext returns the cache from the context. +// If there is no cache, a nil value is returned. +// The nil cache can still be called as a normal cache, but will not cache or +// return any values. +func cacheFromContext(ctx context.Context) *authorizeCache { + cache, _ := ctx.Value(cacheContextKey{}).(*authorizeCache) + return cache +} + +func WithCacheCtx(ctx context.Context) context.Context { + return context.WithValue(ctx, cacheContextKey{}, &authorizeCache{}) +} diff --git a/coderd/rbac/cache_test.go b/coderd/rbac/cache_test.go new file mode 100644 index 0000000000000..4670da307b756 --- /dev/null +++ b/coderd/rbac/cache_test.go @@ -0,0 +1,86 @@ +package rbac_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" +) + +func TestCacher(t *testing.T) { + t.Parallel() + + t.Run("EmptyCacheCtx", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil}, + } + 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 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("CacheCtx", func(t *testing.T) { + t.Parallel() + + ctx := rbac.WithCacheCtx(context.Background()) + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil}, + } + 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("MultipleSubjects", func(t *testing.T) { + t.Parallel() + + ctx := rbac.WithCacheCtx(context.Background()) + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil}, + } + 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") + }) +}