coder/coderd/rbac/authz_test.go

430 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/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.RoleNames{},
Scope: rbac.ScopeAll,
},
},
{
Name: "Admin",
Actor: rbac.Subject{
// Give some extra roles that an admin might have
Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "OrgAdmin",
Actor: rbac.Subject{
Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "OrgMember",
Actor: rbac.Subject{
// Member of 2 orgs
Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgMember(orgs[1]), rbac.RoleMember()},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "ManyRoles",
Actor: rbac.Subject{
// Admin of many orgs
Roles: rbac.RoleNames{
rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]),
rbac.RoleOrgMember(orgs[1]), rbac.RoleOrgAdmin(orgs[1]),
rbac.RoleOrgMember(orgs[2]), rbac.RoleOrgAdmin(orgs[2]),
rbac.RoleMember(),
},
ID: user.String(),
Scope: rbac.ScopeAll,
Groups: noiseGroups,
},
},
{
Name: "ManyRolesCachedSubject",
Actor: rbac.Subject{
// Admin of many orgs
Roles: rbac.RoleNames{
rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]),
rbac.RoleOrgMember(orgs[1]), rbac.RoleOrgAdmin(orgs[1]),
rbac.RoleOrgMember(orgs[2]), rbac.RoleOrgAdmin(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.RoleNames{rbac.RoleOrgMember(orgs[0]), "auditor", 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.RoleNames{
"auditor", 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.RoleNames{
"auditor", 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.NewCachingAuthorizer(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, rbac.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.NewCachingAuthorizer(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 := rbac.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][]rbac.Action{
// Add the user's group
// Noise
uuid.NewString(): {rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
uuid.NewString(): {rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate},
uuid.NewString(): {rbac.ActionCreate, rbac.ActionRead},
uuid.NewString(): {rbac.ActionCreate},
uuid.NewString(): {rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
uuid.NewString(): {rbac.ActionRead, rbac.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] = []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}
}
// This is the only group that will give permission.
m[userGroupAllow] = []rbac.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.NewCachingAuthorizer(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, rbac.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, rbac.ActionRead, objects)
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][]rbac.Action{
uuid.NewString(): {rbac.ActionRead, rbac.ActionUpdate},
uuid.NewString(): {rbac.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{AlwaysReturn: nil})
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 rbac.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 TestCacher(t *testing.T) {
t.Parallel()
t.Run("NoCache", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
rec := &coderdtest.RecordingAuthorizer{
Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil},
}
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{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("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 rbac.Action, object rbac.Object) error {
// Just return what you're told.
return testutil.RequireRecvCtx(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.RequireSendCtx(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.RequireSendCtx(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.RequireSendCtx(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{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")
})
}