chore: Minor rbac memory optimization (#7391)

* test: Add benchmark for static rbac roles
* static roles should only be allocated once
* A unit test that modifies the ast value should not mess with the globals
* Cache subject AST values to avoid reallocating slices
This commit is contained in:
Steven Masley 2023-05-03 14:42:24 -05:00 committed by GitHub
parent 2e9310b203
commit 3368b8b65f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 177 additions and 68 deletions

View File

@ -144,7 +144,8 @@ var (
}, },
}), }),
Scope: rbac.ScopeAll, Scope: rbac.ScopeAll,
} }.WithCachedASTValue()
subjectAutostart = rbac.Subject{ subjectAutostart = rbac.Subject{
ID: uuid.Nil.String(), ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{ Roles: rbac.Roles([]rbac.Role{
@ -161,7 +162,8 @@ var (
}, },
}), }),
Scope: rbac.ScopeAll, Scope: rbac.ScopeAll,
} }.WithCachedASTValue()
subjectSystemRestricted = rbac.Subject{ subjectSystemRestricted = rbac.Subject{
ID: uuid.Nil.String(), ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{ Roles: rbac.Roles([]rbac.Role{
@ -188,7 +190,7 @@ var (
}, },
}), }),
Scope: rbac.ScopeAll, Scope: rbac.ScopeAll,
} }.WithCachedASTValue()
) )
// AsProvisionerd returns a context with an actor that has permissions required // AsProvisionerd returns a context with an actor that has permissions required

View File

@ -379,7 +379,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
Roles: rbac.RoleNames(roles.Roles), Roles: rbac.RoleNames(roles.Roles),
Groups: roles.Groups, Groups: roles.Groups,
Scope: rbac.ScopeName(key.Scope), Scope: rbac.ScopeName(key.Scope),
}, }.WithCachedASTValue(),
} }
return &key, &authz, true return &key, &authz, true

View File

@ -110,5 +110,5 @@ func getAgentSubject(ctx context.Context, db database.Store, agent database.Work
Roles: rbac.RoleNames(roles.Roles), Roles: rbac.RoleNames(roles.Roles),
Groups: roles.Groups, Groups: roles.Groups,
Scope: rbac.WorkspaceAgentScope(workspace.ID, user.ID), Scope: rbac.WorkspaceAgentScope(workspace.ID, user.ID),
}, nil }.WithCachedASTValue(), nil
} }

View File

@ -65,6 +65,10 @@ func regoPartialInputValue(subject Subject, action Action, objectType string) (a
// regoValue returns the ast.Object representation of the subject. // regoValue returns the ast.Object representation of the subject.
func (s Subject) regoValue() (ast.Value, error) { func (s Subject) regoValue() (ast.Value, error) {
if s.cachedASTValue != nil {
return s.cachedASTValue, nil
}
subjRoles, err := s.Roles.Expand() subjRoles, err := s.Roles.Expand()
if err != nil { if err != nil {
return nil, xerrors.Errorf("expand roles: %w", err) return nil, xerrors.Errorf("expand roles: %w", err)
@ -133,7 +137,20 @@ func (z Object) regoValue() ast.Value {
) )
} }
// withCachedRegoValue returns a copy of the role with the cachedRegoValue.
// It does not mutate the underlying role.
// Avoid using this function if possible, it should only be used if the
// caller can guarantee the role is static and will never change.
func (role Role) withCachedRegoValue() Role {
tmp := role
tmp.cachedRegoValue = role.regoValue()
return tmp
}
func (role Role) regoValue() ast.Value { func (role Role) regoValue() ast.Value {
if role.cachedRegoValue != nil {
return role.cachedRegoValue
}
orgMap := ast.NewObject() orgMap := ast.NewObject()
for k, p := range role.Org { for k, p := range role.Org {
orgMap.Insert(ast.StringTerm(k), ast.NewTerm(regoSlice(p))) orgMap.Insert(ast.StringTerm(k), ast.NewTerm(regoSlice(p)))

View File

@ -49,6 +49,20 @@ type Subject struct {
Roles ExpandableRoles Roles ExpandableRoles
Groups []string Groups []string
Scope ExpandableScope Scope ExpandableScope
// cachedASTValue is the cached ast value for this subject.
cachedASTValue ast.Value
}
// WithCachedASTValue can be called if the subject is static. This will compute
// the ast value once and cache it for future calls.
func (s Subject) WithCachedASTValue() Subject {
tmp := s
v, err := tmp.regoValue()
if err == nil {
tmp.cachedASTValue = v
}
return tmp
} }
func (s Subject) Equal(b Subject) bool { func (s Subject) Equal(b Subject) bool {

View File

@ -86,6 +86,21 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U
Groups: noiseGroups, 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", Name: "AdminWithScope",
Actor: rbac.Subject{ Actor: rbac.Subject{
@ -96,13 +111,41 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U
Groups: noiseGroups, 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 return benchCases, users, orgs
} }
// BenchmarkRBACAuthorize benchmarks the rbac.Authorize method. // BenchmarkRBACAuthorize benchmarks the rbac.Authorize method.
// //
// go test -bench BenchmarkRBACAuthorize -benchmem -memprofile memprofile.out -cpuprofile profile.out // go test -run=^$ -bench BenchmarkRBACAuthorize -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACAuthorize(b *testing.B) { func BenchmarkRBACAuthorize(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases() benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{}, users := append([]uuid.UUID{},
@ -111,6 +154,9 @@ func BenchmarkRBACAuthorize(b *testing.B) {
uuid.MustParse("0632b012-49e0-4d70-a5b3-f4398f1dcd52"), uuid.MustParse("0632b012-49e0-4d70-a5b3-f4398f1dcd52"),
uuid.MustParse("70dbaa7a-ea9c-4f68-a781-97b08af8461d"), 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()) authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry())
// This benchmarks all the simple cases using just user permissions. Groups // This benchmarks all the simple cases using just user permissions. Groups
// are added as noise, but do not do anything. // are added as noise, but do not do anything.

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/open-policy-agent/opa/ast"
"golang.org/x/xerrors" "golang.org/x/xerrors"
) )
@ -128,86 +129,100 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
) )
} }
// Static roles that never change should be allocated in a closure.
// This is to ensure these data structures are only allocated once and not
// on every authorize call. 'withCachedRegoValue' can be used as well to
// preallocate the rego value that is used by the rego eval engine.
ownerRole := Role{
Name: owner,
DisplayName: "Owner",
Site: allPermsExcept(ownerAndAdminExceptions...),
Org: map[string][]Permission{},
User: []Permission{},
}.withCachedRegoValue()
memberRole := Role{
Name: member,
DisplayName: "",
Site: Permissions(map[string][]Action{
// All users can read all other users and know they exist.
ResourceUser.Type: {ActionRead},
ResourceRoleAssignment.Type: {ActionRead},
// All users can see the provisioner daemons.
ResourceProvisionerDaemon.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: allPermsExcept(),
}.withCachedRegoValue()
auditorRole := Role{
Name: auditor,
DisplayName: "Auditor",
Site: Permissions(map[string][]Action{
// Should be able to read all template details, even in orgs they
// are not in.
ResourceTemplate.Type: {ActionRead},
ResourceAuditLog.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
}.withCachedRegoValue()
templateAdminRole := Role{
Name: templateAdmin,
DisplayName: "Template Admin",
Site: Permissions(map[string][]Action{
ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// CRUD all files, even those they did not upload.
ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceWorkspace.Type: {ActionRead},
// CRUD to provisioner daemons for now.
ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// Needs to read all organizations since
ResourceOrganization.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
}.withCachedRegoValue()
userAdminRole := Role{
Name: userAdmin,
DisplayName: "User Admin",
Site: Permissions(map[string][]Action{
ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// Full perms to manage org members
ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
}),
Org: map[string][]Permission{},
User: []Permission{},
}.withCachedRegoValue()
builtInRoles = map[string]func(orgID string) Role{ builtInRoles = map[string]func(orgID string) Role{
// admin grants all actions to all resources. // admin grants all actions to all resources.
owner: func(_ string) Role { owner: func(_ string) Role {
return Role{ return ownerRole
Name: owner,
DisplayName: "Owner",
Site: allPermsExcept(ownerAndAdminExceptions...),
Org: map[string][]Permission{},
User: []Permission{},
}
}, },
// member grants all actions to all resources owned by the user // member grants all actions to all resources owned by the user
member: func(_ string) Role { member: func(_ string) Role {
return Role{ return memberRole
Name: member,
DisplayName: "",
Site: Permissions(map[string][]Action{
// All users can read all other users and know they exist.
ResourceUser.Type: {ActionRead},
ResourceRoleAssignment.Type: {ActionRead},
// All users can see the provisioner daemons.
ResourceProvisionerDaemon.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: allPermsExcept(),
}
}, },
// auditor provides all permissions required to effectively read and understand // auditor provides all permissions required to effectively read and understand
// audit log events. // audit log events.
// TODO: Finish the auditor as we add resources. // TODO: Finish the auditor as we add resources.
auditor: func(_ string) Role { auditor: func(_ string) Role {
return Role{ return auditorRole
Name: auditor,
DisplayName: "Auditor",
Site: Permissions(map[string][]Action{
// Should be able to read all template details, even in orgs they
// are not in.
ResourceTemplate.Type: {ActionRead},
ResourceAuditLog.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
}
}, },
templateAdmin: func(_ string) Role { templateAdmin: func(_ string) Role {
return Role{ return templateAdminRole
Name: templateAdmin,
DisplayName: "Template Admin",
Site: Permissions(map[string][]Action{
ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// CRUD all files, even those they did not upload.
ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceWorkspace.Type: {ActionRead},
// CRUD to provisioner daemons for now.
ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// Needs to read all organizations since
ResourceOrganization.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
}
}, },
userAdmin: func(_ string) Role { userAdmin: func(_ string) Role {
return Role{ return userAdminRole
Name: userAdmin,
DisplayName: "User Admin",
Site: Permissions(map[string][]Action{
ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// Full perms to manage org members
ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
}),
Org: map[string][]Permission{},
User: []Permission{},
}
}, },
// orgAdmin returns a role with all actions allows in a given // orgAdmin returns a role with all actions allows in a given
@ -333,6 +348,10 @@ type Role struct {
// roles. // roles.
Org map[string][]Permission `json:"org"` Org map[string][]Permission `json:"org"`
User []Permission `json:"user"` User []Permission `json:"user"`
// cachedRegoValue can be used to cache the rego value for this role.
// This is helpful for static roles that never change.
cachedRegoValue ast.Value
} }
type Roles []Role type Roles []Role

View File

@ -68,8 +68,19 @@ func BenchmarkRBACValueAllocation(b *testing.B) {
func TestRegoInputValue(t *testing.T) { func TestRegoInputValue(t *testing.T) {
t.Parallel() t.Parallel()
// Expand all roles and make sure we have a good copy.
// This is because these tests modify the roles, and we don't want to
// modify the original roles.
roles, err := RoleNames{RoleOrgMember(uuid.New()), RoleOrgAdmin(uuid.New()), RoleMember()}.Expand()
require.NoError(t, err, "failed to expand roles")
for i := range roles {
// If all cached values are nil, then the role will not use
// the shared cached value.
roles[i].cachedRegoValue = nil
}
actor := Subject{ actor := Subject{
Roles: RoleNames{RoleOrgMember(uuid.New()), RoleOrgAdmin(uuid.New()), RoleMember()}, Roles: Roles(roles),
ID: uuid.NewString(), ID: uuid.NewString(),
Scope: ScopeAll, Scope: ScopeAll,
Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}, Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()},