From dade29c10a4167cd646b8c5b565093c710f23ad9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:05:26 -0600 Subject: [PATCH 1/6] chore: Allow RecordingAuthorizer to record multiple rbac authz calls Prior iteration only recorded the last call. This is required for more comprehensive testing --- coderd/coderdtest/authorize.go | 262 ++++++++++++++++++++++++++------ coderd/rbac/authz.go | 20 +++ coderd/rbac/object.go | 43 ++++++ coderd/rbac/object_test.go | 176 +++++++++++++++++++++ coderd/rbac/subject_test.go | 132 ++++++++++++++++ coderd/util/slice/slice.go | 20 +++ coderd/util/slice/slice_test.go | 30 ++++ 7 files changed, 633 insertions(+), 50 deletions(-) create mode 100644 coderd/rbac/object_test.go create mode 100644 coderd/rbac/subject_test.go diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 8f2e39458539c..49315c96be714 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -7,17 +7,17 @@ import ( "net/http" "strconv" "strings" + "sync" "testing" "time" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/rbac/regosql" "github.com/coder/coder/codersdk" @@ -443,7 +443,9 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) { // Always fail auth from this point forward - a.authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil) + a.authorizer.Wrapped = &FakeAuthorizer{ + AlwaysReturn: rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil), + } routeMissing := make(map[string]bool) for k, v := range assertRoute { @@ -483,7 +485,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck return nil } a.t.Run(name, func(t *testing.T) { - a.authorizer.reset() + a.authorizer.Reset() routeKey := strings.TrimRight(name, "/") routeAssertions, ok := assertRoute[routeKey] @@ -514,18 +516,19 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck assert.Equal(t, http.StatusForbidden, resp.StatusCode, "expect unauthorized") } } - if a.authorizer.Called != nil { + if a.authorizer.lastCall() != nil { + last := a.authorizer.lastCall() if routeAssertions.AssertAction != "" { - assert.Equal(t, routeAssertions.AssertAction, a.authorizer.Called.Action, "resource action") + assert.Equal(t, routeAssertions.AssertAction, last.Action, "resource action") } if routeAssertions.AssertObject.Type != "" { - assert.Equal(t, routeAssertions.AssertObject.Type, a.authorizer.Called.Object.Type, "resource type") + assert.Equal(t, routeAssertions.AssertObject.Type, last.Object.Type, "resource type") } if routeAssertions.AssertObject.Owner != "" { - assert.Equal(t, routeAssertions.AssertObject.Owner, a.authorizer.Called.Object.Owner, "resource owner") + assert.Equal(t, routeAssertions.AssertObject.Owner, last.Object.Owner, "resource owner") } if routeAssertions.AssertObject.OrgID != "" { - assert.Equal(t, routeAssertions.AssertObject.OrgID, a.authorizer.Called.Object.OrgID, "resource org") + assert.Equal(t, routeAssertions.AssertObject.OrgID, last.Object.OrgID, "resource org") } } } else { @@ -539,52 +542,222 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck } type authCall struct { - Subject rbac.Subject - Action rbac.Action - Object rbac.Object + Actor rbac.Subject + Action rbac.Action + Object rbac.Object + + asserted bool } +var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) + +// RecordingAuthorizer wraps any rbac.Authorizer and records all Authorize() +// calls made. This is useful for testing as these calls can later be asserted. type RecordingAuthorizer struct { - Called *authCall - AlwaysReturn error + sync.RWMutex + Called []authCall + Wrapped rbac.Authorizer } -var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) +type ActionObjectPair struct { + Action rbac.Action + Object rbac.Object +} -// AuthorizeSQL does not record the call. This matches the postgres behavior -// of not calling Authorize() -func (r *RecordingAuthorizer) AuthorizeSQL(_ context.Context, _ rbac.Subject, _ rbac.Action, _ rbac.Object) error { - return r.AlwaysReturn +// Pair is on the RecordingAuthorizer to be easy to find and keep the pkg +// interface smaller. +func (*RecordingAuthorizer) Pair(action rbac.Action, object rbac.Objecter) ActionObjectPair { + return ActionObjectPair{ + Action: action, + Object: object.RBACObject(), + } } -func (r *RecordingAuthorizer) Authorize(_ context.Context, subject rbac.Subject, action rbac.Action, object rbac.Object) error { - r.Called = &authCall{ - Subject: subject, - Action: action, - Object: object, +// AllAsserted returns an error if all calls to Authorize() have not been +// asserted and checked. This is useful for testing to ensure that all +// Authorize() calls are checked in the unit test. +func (r *RecordingAuthorizer) AllAsserted() error { + r.RLock() + defer r.RUnlock() + missed := []authCall{} + for _, c := range r.Called { + if !c.asserted { + missed = append(missed, c) + } + } + + if len(missed) > 0 { + return xerrors.Errorf("missed calls: %+v", missed) } - return r.AlwaysReturn + return nil } -func (r *RecordingAuthorizer) Prepare(_ context.Context, subject rbac.Subject, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { - return &fakePreparedAuthorizer{ - Original: r, - Subject: subject, - Action: action, - HardCodedSQLString: "true", +// AssertActor asserts in order. If the order of authz calls does not match, +// this will fail. +func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) { + r.RLock() + defer r.RUnlock() + ptr := 0 + for i, call := range r.Called { + if ptr == len(did) { + // Finished all assertions + return + } + if call.Actor.ID == actor.ID { + action, object := did[ptr].Action, did[ptr].Object + assert.Equalf(t, action, call.Action, "assert action %d", ptr) + assert.Equalf(t, object, call.Object, "assert object %d", ptr) + r.Called[i].asserted = true + ptr++ + } + } + + assert.Equalf(t, len(did), ptr, "assert actor: didn't find all actions, %d missing actions", len(did)-ptr) +} + +// UnorderedAssertActor is the same as AssertActor, except it doesn't care about +// order. It will assert the first call that matches the actor and pair. +// It will not assert the same call twice, so if there is a duplicate assertion, +// the pair will need to be passed in twice. +func (r *RecordingAuthorizer) UnorderedAssertActor(t *testing.T, actor rbac.Subject, dids ...ActionObjectPair) { + r.RLock() + defer r.RUnlock() + for _, did := range dids { + found := false + InnerCalledLoop: + for i, c := range r.Called { + if c.asserted { + // Do not assert an already asserted call. + continue + } + + if c.Action != did.Action || c.Object.Equal(did.Object) || c.Actor.Equal(actor) { + continue + } + + r.Called[i].asserted = true + found = true + break InnerCalledLoop + } + require.Truef(t, found, "did not find call for %s %s", did.Action, did.Object.Type) + } +} + +// recordAuthorize is the internal method that records the Authorize() call. +func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action rbac.Action, object rbac.Object) { + r.Lock() + defer r.Unlock() + r.Called = append(r.Called, authCall{ + Actor: subject, + Action: action, + Object: object, + }) +} + +func (r *RecordingAuthorizer) Authorize(ctx context.Context, subject rbac.Subject, action rbac.Action, object rbac.Object) error { + r.recordAuthorize(subject, action, object) + if r.Wrapped == nil { + panic("Developer error: RecordingAuthorizer.Wrapped is nil") + } + return r.Wrapped.Authorize(ctx, subject, action, object) +} + +func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action rbac.Action, objectType string) (rbac.PreparedAuthorized, error) { + r.RLock() + defer r.RUnlock() + if r.Wrapped == nil { + panic("Developer error: RecordingAuthorizer.Wrapped is nil") + } + + prep, err := r.Wrapped.Prepare(ctx, subject, action, objectType) + if err != nil { + return nil, err + } + return &PreparedRecorder{ + rec: r, + prepped: prep, }, nil } -func (r *RecordingAuthorizer) reset() { +// Reset clears the recorded Authorize() calls. +func (r *RecordingAuthorizer) Reset() { + r.Lock() + defer r.Unlock() r.Called = nil } +// lastCall is implemented to support legacy tests. +// Deprecated +func (r *RecordingAuthorizer) lastCall() *authCall { + r.RLock() + defer r.RUnlock() + if len(r.Called) == 0 { + return nil + } + return &r.Called[len(r.Called)-1] +} + +// PreparedRecorder is the prepared version of the RecordingAuthorizer. +// It records the Authorize() calls to the original recorder. If the caller +// uses CompileToSQL, all recording stops. This is to support parity between +// memory and SQL backed dbs. +type PreparedRecorder struct { + rec *RecordingAuthorizer + prepped rbac.PreparedAuthorized + subject rbac.Subject + action rbac.Action + + rw sync.Mutex + usingSQL bool +} + +func (s *PreparedRecorder) Authorize(ctx context.Context, object rbac.Object) error { + s.rw.Lock() + defer s.rw.Unlock() + + if !s.usingSQL { + s.rec.recordAuthorize(s.subject, s.action, object) + } + return s.prepped.Authorize(ctx, object) +} +func (s *PreparedRecorder) CompileToSQL(ctx context.Context, cfg regosql.ConvertConfig) (string, error) { + s.rw.Lock() + defer s.rw.Unlock() + + s.usingSQL = true + return s.prepped.CompileToSQL(ctx, cfg) +} + +// FakeAuthorizer is an Authorizer that always returns the same error. +type FakeAuthorizer struct { + // AlwaysReturn is the error that will be returned by Authorize. + AlwaysReturn error +} + +var _ rbac.Authorizer = (*FakeAuthorizer)(nil) + +func (d *FakeAuthorizer) Authorize(_ context.Context, _ rbac.Subject, _ rbac.Action, _ rbac.Object) error { + return d.AlwaysReturn +} + +func (d *FakeAuthorizer) Prepare(_ context.Context, subject rbac.Subject, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { + return &fakePreparedAuthorizer{ + Original: d, + Subject: subject, + Action: action, + }, nil +} + +var _ rbac.PreparedAuthorized = (*fakePreparedAuthorizer)(nil) + +// fakePreparedAuthorizer is the prepared version of a FakeAuthorizer. It will +// return the same error as the original FakeAuthorizer. type fakePreparedAuthorizer struct { - Original *RecordingAuthorizer - Subject rbac.Subject - Action rbac.Action - HardCodedSQLString string - HardCodedRegoString string + sync.RWMutex + Original *FakeAuthorizer + Subject rbac.Subject + Action rbac.Action + ShouldCompileToSQL bool } func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { @@ -593,17 +766,6 @@ func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Obje // CompileToSQL returns a compiled version of the authorizer that will work for // in memory databases. This fake version will not work against a SQL database. -func (fakePreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { - return "", xerrors.New("not implemented") -} - -func (f *fakePreparedAuthorizer) Eval(object rbac.Object) bool { - return f.Original.AuthorizeSQL(context.Background(), f.Subject, f.Action, object) == nil -} - -func (f fakePreparedAuthorizer) RegoString() string { - if f.HardCodedRegoString != "" { - return f.HardCodedRegoString - } - panic("not implemented") +func (*fakePreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { + return "not a valid sql string", nil } diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index e11f419a78a82..a15270b59b485 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/coderd/rbac/regosql" "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/coderd/util/slice" ) // Subject is a struct that contains all the elements of a subject in an rbac @@ -26,6 +27,25 @@ type Subject struct { Scope ExpandableScope } +func (s Subject) Equal(b Subject) bool { + if s.ID != b.ID { + return false + } + + if !slice.SameElements(s.Groups, b.Groups) { + return false + } + + if !slice.SameElements(s.SafeRoleNames(), b.SafeRoleNames()) { + return false + } + + if s.SafeScopeName() != b.SafeScopeName() { + return false + } + return true +} + // SafeScopeName prevent nil pointer dereference. func (s Subject) SafeScopeName() string { if s.Scope == nil { diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 1ee606a33cfe5..8f71ee3419566 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -176,6 +176,49 @@ type Object struct { ACLGroupList map[string][]Action ` json:"acl_group_list"` } +func (z Object) Equal(b Object) bool { + if z.ID != b.ID { + return false + } + if z.Owner != b.Owner { + return false + } + if z.OrgID != b.OrgID { + return false + } + if z.Type != b.Type { + return false + } + + if !equalACLLists(z.ACLUserList, b.ACLUserList) { + return false + } + + if !equalACLLists(z.ACLGroupList, b.ACLGroupList) { + return false + } + + return true +} + +func equalACLLists(a, b map[string][]Action) bool { + if len(a) != len(b) { + return false + } + + for k, actions := range a { + if len(actions) != len(b[k]) { + return false + } + for i, a := range actions { + if a != b[k][i] { + return false + } + } + } + return true +} + func (z Object) RBACObject() Object { return z } diff --git a/coderd/rbac/object_test.go b/coderd/rbac/object_test.go new file mode 100644 index 0000000000000..386a1e98f5477 --- /dev/null +++ b/coderd/rbac/object_test.go @@ -0,0 +1,176 @@ +package rbac_test + +import ( + "testing" + + "github.com/coder/coder/coderd/rbac" +) + +func TestObjectEqual(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + A rbac.Object + B rbac.Object + Expected bool + }{ + { + Name: "Empty", + A: rbac.Object{}, + B: rbac.Object{}, + Expected: true, + }, + { + Name: "NilVs0", + A: rbac.Object{ + ACLGroupList: map[string][]rbac.Action{}, + ACLUserList: map[string][]rbac.Action{}, + }, + B: rbac.Object{}, + Expected: true, + }, + { + Name: "Same", + A: rbac.Object{ + ID: "id", + Owner: "owner", + OrgID: "orgID", + Type: "type", + ACLUserList: map[string][]rbac.Action{}, + ACLGroupList: map[string][]rbac.Action{}, + }, + B: rbac.Object{ + ID: "id", + Owner: "owner", + OrgID: "orgID", + Type: "type", + ACLUserList: map[string][]rbac.Action{}, + ACLGroupList: map[string][]rbac.Action{}, + }, + Expected: true, + }, + { + Name: "DifferentID", + A: rbac.Object{ + ID: "id", + }, + B: rbac.Object{ + ID: "id2", + }, + Expected: false, + }, + { + Name: "DifferentOwner", + A: rbac.Object{ + Owner: "owner", + }, + B: rbac.Object{ + Owner: "owner2", + }, + Expected: false, + }, + { + Name: "DifferentOrgID", + A: rbac.Object{ + OrgID: "orgID", + }, + B: rbac.Object{ + OrgID: "orgID2", + }, + Expected: false, + }, + { + Name: "DifferentType", + A: rbac.Object{ + Type: "type", + }, + B: rbac.Object{ + Type: "type2", + }, + Expected: false, + }, + { + Name: "DifferentACLUserList", + A: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead}, + }, + }, + B: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user2": {rbac.ActionRead}, + }, + }, + Expected: false, + }, + { + Name: "ACLUserDiff#Actions", + A: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead}, + }, + }, + B: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead, rbac.ActionUpdate}, + }, + }, + Expected: false, + }, + { + Name: "ACLUserDiffAction", + A: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead}, + }, + }, + B: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionUpdate}, + }, + }, + Expected: false, + }, + { + Name: "ACLUserDiff#Users", + A: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead}, + }, + }, + B: rbac.Object{ + ACLUserList: map[string][]rbac.Action{ + "user1": {rbac.ActionRead}, + "user2": {rbac.ActionRead}, + }, + }, + Expected: false, + }, + { + Name: "DifferentACLGroupList", + A: rbac.Object{ + ACLGroupList: map[string][]rbac.Action{ + "group1": {rbac.ActionRead}, + }, + }, + B: rbac.Object{ + ACLGroupList: map[string][]rbac.Action{ + "group2": {rbac.ActionRead}, + }, + }, + Expected: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + actual := tc.A.Equal(tc.B) + if actual != tc.Expected { + t.Errorf("expected %v, got %v", tc.Expected, actual) + } + }) + } +} diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go new file mode 100644 index 0000000000000..294200aff8f8c --- /dev/null +++ b/coderd/rbac/subject_test.go @@ -0,0 +1,132 @@ +package rbac_test + +import ( + "testing" + + "github.com/coder/coder/coderd/rbac" +) + +func TestSubjectEqual(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + A rbac.Subject + B rbac.Subject + Expected bool + }{ + { + Name: "Empty", + A: rbac.Subject{}, + B: rbac.Subject{}, + Expected: true, + }, + { + Name: "Same", + A: rbac.Subject{ + ID: "id", + Roles: rbac.RoleNames{rbac.RoleMember()}, + Groups: []string{"group"}, + Scope: rbac.ScopeAll, + }, + B: rbac.Subject{ + ID: "id", + Roles: rbac.RoleNames{rbac.RoleMember()}, + Groups: []string{"group"}, + Scope: rbac.ScopeAll, + }, + Expected: true, + }, + { + Name: "DifferentID", + A: rbac.Subject{ + ID: "id", + }, + B: rbac.Subject{ + ID: "id2", + }, + Expected: false, + }, + { + Name: "RolesNilVs0", + A: rbac.Subject{ + Roles: rbac.RoleNames{}, + }, + B: rbac.Subject{ + Roles: nil, + }, + Expected: true, + }, + { + Name: "GroupsNilVs0", + A: rbac.Subject{ + Groups: []string{}, + }, + B: rbac.Subject{ + Groups: nil, + }, + Expected: true, + }, + { + Name: "DifferentRoles", + A: rbac.Subject{ + Roles: rbac.RoleNames{rbac.RoleMember()}, + }, + B: rbac.Subject{ + Roles: rbac.RoleNames{rbac.RoleOwner()}, + }, + Expected: false, + }, + { + Name: "Different#Roles", + A: rbac.Subject{ + Roles: rbac.RoleNames{rbac.RoleMember()}, + }, + B: rbac.Subject{ + Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}, + }, + Expected: false, + }, + { + Name: "DifferentGroups", + A: rbac.Subject{ + Groups: []string{"group1"}, + }, + B: rbac.Subject{ + Groups: []string{"group2"}, + }, + Expected: false, + }, + { + Name: "Different#Groups", + A: rbac.Subject{ + Groups: []string{"group1"}, + }, + B: rbac.Subject{ + Groups: []string{"group1", "group2"}, + }, + Expected: false, + }, + { + Name: "DifferentScope", + A: rbac.Subject{ + Scope: rbac.ScopeAll, + }, + B: rbac.Subject{ + Scope: rbac.ScopeApplicationConnect, + }, + Expected: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + actual := tc.A.Equal(tc.B) + if actual != tc.Expected { + t.Errorf("expected %v, got %v", tc.Expected, actual) + } + }) + } +} diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 38c6592856a34..d13162cb4fa57 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -1,5 +1,25 @@ package slice +// New is a convenience method for creating []T. +func New[T any](items ...T) []T { + return items +} + +// SameElements returns true if the 2 lists have the same elements in any +// order. +func SameElements[T comparable](a []T, b []T) bool { + if len(a) != len(b) { + return false + } + + for _, element := range a { + if !Contains(b, element) { + return false + } + } + return true +} + func ContainsCompare[T any](haystack []T, needle T, equal func(a, b T) bool) bool { for _, hay := range haystack { if equal(needle, hay) { diff --git a/coderd/util/slice/slice_test.go b/coderd/util/slice/slice_test.go index 103b9603a272a..3759c718af40a 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -1,14 +1,44 @@ package slice_test import ( + "math/rand" "testing" + "github.com/stretchr/testify/assert" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/util/slice" ) +func TestSameElements(t *testing.T) { + t.Parallel() + + // True + testSameElements(t, []int{}) + testSameElements(t, []int{1, 2, 3}) + testSameElements(t, slice.New("a", "b", "c")) + testSameElements(t, slice.New(uuid.New(), uuid.New(), uuid.New())) + + // False + assert.False(t, slice.SameElements([]int{1, 2, 3}, []int{1, 2, 3, 4})) + assert.False(t, slice.SameElements([]int{1, 2, 3}, []int{1, 2})) + assert.False(t, slice.SameElements([]int{1, 2, 3}, []int{})) + assert.False(t, slice.SameElements([]int{}, []int{1, 2, 3})) + assert.False(t, slice.SameElements([]int{1, 2, 3}, []int{1, 2, 4})) + assert.False(t, slice.SameElements([]int{1}, []int{2})) +} + +func testSameElements[T comparable](t *testing.T, elements []T) { + cpy := make([]T, len(elements)) + copy(cpy, elements) + rand.Shuffle(len(cpy), func(i, j int) { + cpy[i], cpy[j] = cpy[j], cpy[i] + }) + assert.True(t, slice.SameElements(elements, cpy)) +} + func TestUnique(t *testing.T) { t.Parallel() From 52265c3f5517a847e0c9de353807960e89531de3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:29:21 -0600 Subject: [PATCH 2/6] Write authorize recorder tests --- coderd/coderdtest/authorize.go | 37 ++-------- coderd/coderdtest/authorize_test.go | 106 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 32 deletions(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 49315c96be714..23191675d2b24 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -615,34 +615,6 @@ func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did assert.Equalf(t, len(did), ptr, "assert actor: didn't find all actions, %d missing actions", len(did)-ptr) } -// UnorderedAssertActor is the same as AssertActor, except it doesn't care about -// order. It will assert the first call that matches the actor and pair. -// It will not assert the same call twice, so if there is a duplicate assertion, -// the pair will need to be passed in twice. -func (r *RecordingAuthorizer) UnorderedAssertActor(t *testing.T, actor rbac.Subject, dids ...ActionObjectPair) { - r.RLock() - defer r.RUnlock() - for _, did := range dids { - found := false - InnerCalledLoop: - for i, c := range r.Called { - if c.asserted { - // Do not assert an already asserted call. - continue - } - - if c.Action != did.Action || c.Object.Equal(did.Object) || c.Actor.Equal(actor) { - continue - } - - r.Called[i].asserted = true - found = true - break InnerCalledLoop - } - require.Truef(t, found, "did not find call for %s %s", did.Action, did.Object.Type) - } -} - // recordAuthorize is the internal method that records the Authorize() call. func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action rbac.Action, object rbac.Object) { r.Lock() @@ -676,6 +648,8 @@ func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, return &PreparedRecorder{ rec: r, prepped: prep, + subject: subject, + action: action, }, nil } @@ -754,10 +728,9 @@ var _ rbac.PreparedAuthorized = (*fakePreparedAuthorizer)(nil) // return the same error as the original FakeAuthorizer. type fakePreparedAuthorizer struct { sync.RWMutex - Original *FakeAuthorizer - Subject rbac.Subject - Action rbac.Action - ShouldCompileToSQL bool + Original *FakeAuthorizer + Subject rbac.Subject + Action rbac.Action } func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index d4db546454d7d..0ee1a34f584ef 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -4,7 +4,11 @@ import ( "context" "testing" + "github.com/moby/moby/pkg/namesgenerator" + "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" ) func TestAuthorizeAllEndpoints(t *testing.T) { @@ -20,3 +24,105 @@ func TestAuthorizeAllEndpoints(t *testing.T) { skipRoute, assertRoute := coderdtest.AGPLRoutes(a) a.Test(context.Background(), assertRoute, skipRoute) } + +func TestAuthzRecorder(t *testing.T) { + t.Parallel() + + t.Run("Authorize", func(t *testing.T) { + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{}, + } + sub := randomSubject() + pairs := fuzzAuthz(t, sub, rec, 10) + rec.AssertActor(t, sub, pairs...) + require.NoError(t, rec.AllAsserted(), "all assertions should have been made") + }) + + t.Run("Authorize2Subjects", func(t *testing.T) { + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{}, + } + a := randomSubject() + aPairs := fuzzAuthz(t, a, rec, 10) + + b := randomSubject() + bPairs := fuzzAuthz(t, b, rec, 10) + + rec.AssertActor(t, b, bPairs...) + rec.AssertActor(t, a, aPairs...) + require.NoError(t, rec.AllAsserted(), "all assertions should have been made") + }) + + t.Run("Authorize&Prepared", func(t *testing.T) { + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: &coderdtest.FakeAuthorizer{}, + } + a := randomSubject() + aPairs := fuzzAuthz(t, a, rec, 10) + + b := randomSubject() + + act, ot := randomAction(), randomObject().Type + prep, _ := rec.Prepare(context.Background(), b, act, ot) + bPairs := fuzzAuthzPrep(t, prep, 10, act, ot) + + rec.AssertActor(t, b, bPairs...) + rec.AssertActor(t, a, aPairs...) + require.NoError(t, rec.AllAsserted(), "all assertions should have been made") + }) +} + +// fuzzAuthzPrep has same action and object types for all calls. +func fuzzAuthzPrep(t *testing.T, prep rbac.PreparedAuthorized, n int, action rbac.Action, objectType string) []coderdtest.ActionObjectPair { + t.Helper() + pairs := make([]coderdtest.ActionObjectPair, 0, n) + + for i := 0; i < n; i++ { + obj := randomObject() + obj.Type = objectType + p := coderdtest.ActionObjectPair{Action: action, Object: obj} + _ = prep.Authorize(context.Background(), p.Object) + pairs = append(pairs, p) + } + return pairs +} + +func fuzzAuthz(t *testing.T, sub rbac.Subject, rec rbac.Authorizer, n int) []coderdtest.ActionObjectPair { + t.Helper() + pairs := make([]coderdtest.ActionObjectPair, 0, n) + + for i := 0; i < n; i++ { + p := coderdtest.ActionObjectPair{Action: randomAction(), Object: randomObject()} + _ = 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, + } +} From 8085abc62e71877d2f6b9bf35b65979efaa0bb3b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:31:22 -0600 Subject: [PATCH 3/6] import order --- coderd/util/slice/slice_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/util/slice/slice_test.go b/coderd/util/slice/slice_test.go index 3759c718af40a..b1670bfe4a143 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -4,9 +4,8 @@ import ( "math/rand" "testing" - "github.com/stretchr/testify/assert" - "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/util/slice" From 3edc6c0e929afca8ae464d10ca47f06e82d45c5a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:37:26 -0600 Subject: [PATCH 4/6] linting --- coderd/coderdtest/authorize_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index 0ee1a34f584ef..ec03de5093c70 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -62,9 +62,9 @@ func TestAuthzRecorder(t *testing.T) { b := randomSubject() - act, ot := randomAction(), randomObject().Type - prep, _ := rec.Prepare(context.Background(), b, act, ot) - bPairs := fuzzAuthzPrep(t, prep, 10, act, ot) + act, objTy := randomAction(), randomObject().Type + prep, _ := rec.Prepare(context.Background(), b, act, objTy) + bPairs := fuzzAuthzPrep(t, prep, 10, act, objTy) rec.AssertActor(t, b, bPairs...) rec.AssertActor(t, a, aPairs...) From f6ab7374ee7e8856474f65703b8e9b3208b73727 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:38:41 -0600 Subject: [PATCH 5/6] Always use a fake authorizer --- coderd/coderdtest/authorize_test.go | 2 +- enterprise/coderd/coderdenttest/coderdenttest_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index ec03de5093c70..f392c0260dfb6 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -16,7 +16,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ // Required for any subdomain-based proxy tests to pass. AppHostname: "*.test.coder.com", - Authorizer: &coderdtest.RecordingAuthorizer{}, + Authorizer: &coderdtest.RecordingAuthorizer{Wrapped: &coderdtest.FakeAuthorizer{}}, IncludeProvisionerDaemon: true, }) admin := coderdtest.CreateFirstUser(t, client) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index 59350e07d2940..8fdfbd0a8c9e2 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -27,7 +27,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { Options: &coderdtest.Options{ // Required for any subdomain-based proxy tests to pass. AppHostname: "*.test.coder.com", - Authorizer: &coderdtest.RecordingAuthorizer{}, + Authorizer: &coderdtest.RecordingAuthorizer{Wrapped: &coderdtest.FakeAuthorizer{}}, IncludeProvisionerDaemon: true, }, }) From 4923635b112048bdd368dc97be0e4b87dc740467 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 3 Feb 2023 12:49:38 -0600 Subject: [PATCH 6/6] Linting --- coderd/coderdtest/authorize_test.go | 6 ++++++ coderd/util/slice/slice_test.go | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index f392c0260dfb6..7d819a9d74c0f 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -29,6 +29,8 @@ func TestAuthzRecorder(t *testing.T) { t.Parallel() t.Run("Authorize", func(t *testing.T) { + t.Parallel() + rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } @@ -39,6 +41,8 @@ func TestAuthzRecorder(t *testing.T) { }) t.Run("Authorize2Subjects", func(t *testing.T) { + t.Parallel() + rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } @@ -54,6 +58,8 @@ func TestAuthzRecorder(t *testing.T) { }) t.Run("Authorize&Prepared", func(t *testing.T) { + t.Parallel() + rec := &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } diff --git a/coderd/util/slice/slice_test.go b/coderd/util/slice/slice_test.go index b1670bfe4a143..b21e0cc0b52a5 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -15,10 +15,10 @@ func TestSameElements(t *testing.T) { t.Parallel() // True - testSameElements(t, []int{}) - testSameElements(t, []int{1, 2, 3}) - testSameElements(t, slice.New("a", "b", "c")) - testSameElements(t, slice.New(uuid.New(), uuid.New(), uuid.New())) + assertSameElements(t, []int{}) + assertSameElements(t, []int{1, 2, 3}) + assertSameElements(t, slice.New("a", "b", "c")) + assertSameElements(t, slice.New(uuid.New(), uuid.New(), uuid.New())) // False assert.False(t, slice.SameElements([]int{1, 2, 3}, []int{1, 2, 3, 4})) @@ -29,7 +29,7 @@ func TestSameElements(t *testing.T) { assert.False(t, slice.SameElements([]int{1}, []int{2})) } -func testSameElements[T comparable](t *testing.T, elements []T) { +func assertSameElements[T comparable](t *testing.T, elements []T) { cpy := make([]T, len(elements)) copy(cpy, elements) rand.Shuffle(len(cpy), func(i, j int) {