RBAC: Expand action sets when fetching permissions (#87967)

* logic to expand action set to the underlying actions when permissions are fetched from the DB

* updates needed for dependency injection

* clean up some code, also deduplicate scopes when grouping scopes and actions

* expand on a comment

* rename a method
This commit is contained in:
Ieva 2024-05-21 15:09:26 +01:00 committed by GitHub
parent cb0bcb6fe4
commit 3e77768144
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 224 additions and 45 deletions

View File

@ -448,8 +448,6 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
license := licensingtest.NewFakeLicensing() license := licensingtest.NewFakeLicensing()
license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe()
acSvc := acimpl.ProvideOSSService(sc.cfg, acdb.ProvideService(sc.db), localcache.ProvideService(), features, tracing.InitializeTracerForTest())
quotaSrv := quotatest.New(false, nil) quotaSrv := quotatest.New(false, nil)
dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db), quotaSrv) dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db), quotaSrv)
@ -462,6 +460,8 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
cfg := setting.NewCfg() cfg := setting.NewCfg()
actionSets := resourcepermissions.NewActionSetService(ac) actionSets := resourcepermissions.NewActionSetService(ac)
acSvc := acimpl.ProvideOSSService(sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(), features, tracing.InitializeTracerForTest())
folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions(
cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets)
require.NoError(b, err) require.NoError(b, err)

View File

@ -89,7 +89,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx
if err != nil { if err != nil {
return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err)
} }
acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, features, tracer) acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, nil, features, tracer)
if err != nil { if err != nil {
return nil, fmt.Errorf("%v: %w", "failed to get access control", err) return nil, fmt.Errorf("%v: %w", "failed to get access control", err)
} }

View File

@ -351,6 +351,9 @@ var wireBasicSet = wire.NewSet(
secretsMigrations.ProvideMigrateFromPluginService, secretsMigrations.ProvideMigrateFromPluginService,
secretsMigrations.ProvideSecretMigrationProvider, secretsMigrations.ProvideSecretMigrationProvider,
wire.Bind(new(secretsMigrations.SecretMigrationProvider), new(*secretsMigrations.SecretMigrationProviderImpl)), wire.Bind(new(secretsMigrations.SecretMigrationProvider), new(*secretsMigrations.SecretMigrationProviderImpl)),
resourcepermissions.NewActionSetService,
wire.Bind(new(accesscontrol.ActionResolver), new(*resourcepermissions.InMemoryActionSets)),
wire.Bind(new(resourcepermissions.ActionSetService), new(*resourcepermissions.InMemoryActionSets)),
acimpl.ProvideAccessControl, acimpl.ProvideAccessControl,
navtreeimpl.ProvideService, navtreeimpl.ProvideService,
wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)),
@ -382,7 +385,6 @@ var wireBasicSet = wire.NewSet(
// Kubernetes API server // Kubernetes API server
grafanaapiserver.WireSet, grafanaapiserver.WireSet,
apiregistry.WireSet, apiregistry.WireSet,
resourcepermissions.NewActionSetService,
) )
var wireSet = wire.NewSet( var wireSet = wire.NewSet(

View File

@ -232,11 +232,29 @@ func BuildPermissionsMap(permissions []Permission) map[string]bool {
// GroupScopesByAction will group scopes on action // GroupScopesByAction will group scopes on action
func GroupScopesByAction(permissions []Permission) map[string][]string { func GroupScopesByAction(permissions []Permission) map[string][]string {
m := make(map[string][]string) // Use a map to deduplicate scopes.
// User can have the same permission from multiple sources (e.g. team, basic role, directly assigned etc).
// User will also have duplicate permissions if action sets are used, as we will be double writing permissions for a while.
m := make(map[string]map[string]struct{})
for i := range permissions { for i := range permissions {
m[permissions[i].Action] = append(m[permissions[i].Action], permissions[i].Scope) if _, ok := m[permissions[i].Action]; !ok {
m[permissions[i].Action] = make(map[string]struct{})
}
m[permissions[i].Action][permissions[i].Scope] = struct{}{}
} }
return m
res := make(map[string][]string, len(m))
for action, scopes := range m {
scopeList := make([]string, len(scopes))
i := 0
for scope := range scopes {
scopeList[i] = scope
i++
}
res[action] = scopeList
}
return res
} }
// Reduce will reduce a list of permissions to its minimal form, grouping scopes by action // Reduce will reduce a list of permissions to its minimal form, grouping scopes by action

View File

@ -46,8 +46,8 @@ var SharedWithMeFolderPermission = accesscontrol.Permission{
var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix} var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix}
func ProvideService(cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, func ProvideService(cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService,
accessControl accesscontrol.AccessControl, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (*Service, error) { accessControl accesscontrol.AccessControl, actionResolver accesscontrol.ActionResolver, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (*Service, error) {
service := ProvideOSSService(cfg, database.ProvideService(db), cache, features, tracer) service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer)
api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints()
if err := accesscontrol.DeclareFixedRoles(service, cfg); err != nil { if err := accesscontrol.DeclareFixedRoles(service, cfg); err != nil {
@ -65,15 +65,16 @@ func ProvideService(cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegis
return service, nil return service, nil
} }
func ProvideOSSService(cfg *setting.Cfg, store accesscontrol.Store, cache *localcache.CacheService, features featuremgmt.FeatureToggles, tracer tracing.Tracer) *Service { func ProvideOSSService(cfg *setting.Cfg, store accesscontrol.Store, actionResolver accesscontrol.ActionResolver, cache *localcache.CacheService, features featuremgmt.FeatureToggles, tracer tracing.Tracer) *Service {
s := &Service{ s := &Service{
cache: cache, actionResolver: actionResolver,
cfg: cfg, cache: cache,
features: features, cfg: cfg,
log: log.New("accesscontrol.service"), features: features,
roles: accesscontrol.BuildBasicRoleDefinitions(), log: log.New("accesscontrol.service"),
store: store, roles: accesscontrol.BuildBasicRoleDefinitions(),
tracer: tracer, store: store,
tracer: tracer,
} }
return s return s
@ -81,14 +82,15 @@ func ProvideOSSService(cfg *setting.Cfg, store accesscontrol.Store, cache *local
// Service is the service implementing role based access control. // Service is the service implementing role based access control.
type Service struct { type Service struct {
cache *localcache.CacheService actionResolver accesscontrol.ActionResolver
cfg *setting.Cfg cache *localcache.CacheService
features featuremgmt.FeatureToggles cfg *setting.Cfg
log log.Logger features featuremgmt.FeatureToggles
registrations accesscontrol.RegistrationList log log.Logger
roles map[string]*accesscontrol.RoleDTO registrations accesscontrol.RegistrationList
store accesscontrol.Store roles map[string]*accesscontrol.RoleDTO
tracer tracing.Tracer store accesscontrol.Store
tracer tracing.Tracer
} }
func (s *Service) GetUsageStats(_ context.Context) map[string]any { func (s *Service) GetUsageStats(_ context.Context) map[string]any {
@ -138,6 +140,9 @@ func (s *Service) getUserPermissions(ctx context.Context, user identity.Requeste
if err != nil { if err != nil {
return nil, err return nil, err
} }
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
dbPermissions = s.actionResolver.ExpandActionSets(dbPermissions)
}
return append(permissions, dbPermissions...), nil return append(permissions, dbPermissions...), nil
} }
@ -157,8 +162,11 @@ func (s *Service) getBasicRolePermissions(ctx context.Context, role string, orgI
OrgID: orgID, OrgID: orgID,
RolePrefixes: OSSRolesPrefixes, RolePrefixes: OSSRolesPrefixes,
}) })
permissions = append(permissions, dbPermissions...) if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
return permissions, err dbPermissions = s.actionResolver.ExpandActionSets(dbPermissions)
}
return append(permissions, dbPermissions...), err
} }
func (s *Service) getTeamsPermissions(ctx context.Context, teamIDs []int64, orgID int64) (map[int64][]accesscontrol.Permission, error) { func (s *Service) getTeamsPermissions(ctx context.Context, teamIDs []int64, orgID int64) (map[int64][]accesscontrol.Permission, error) {
@ -170,6 +178,13 @@ func (s *Service) getTeamsPermissions(ctx context.Context, teamIDs []int64, orgI
OrgID: orgID, OrgID: orgID,
RolePrefixes: OSSRolesPrefixes, RolePrefixes: OSSRolesPrefixes,
}) })
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
for teamID, permissions := range teamPermissions {
teamPermissions[teamID] = s.actionResolver.ExpandActionSets(permissions)
}
}
return teamPermissions, err return teamPermissions, err
} }
@ -199,6 +214,9 @@ func (s *Service) getUserDirectPermissions(ctx context.Context, user identity.Re
return nil, err return nil, err
} }
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
permissions = s.actionResolver.ExpandActionSets(permissions)
}
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
permissions = append(permissions, SharedWithMeFolderPermission) permissions = append(permissions, SharedWithMeFolderPermission)
} }
@ -404,6 +422,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
return nil return nil
} }
// TODO potential changes needed here?
// SearchUsersPermissions returns all users' permissions filtered by action prefixes // SearchUsersPermissions returns all users' permissions filtered by action prefixes
func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Requester, func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Requester,
options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
@ -418,6 +437,7 @@ func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Reque
// Reroute to the user specific implementation of search permissions // Reroute to the user specific implementation of search permissions
// because it leverages the user permission cache. // because it leverages the user permission cache.
// TODO
userPerms, err := s.SearchUserPermissions(ctx, usr.GetOrgID(), options) userPerms, err := s.SearchUserPermissions(ctx, usr.GetOrgID(), options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -64,6 +64,7 @@ func TestUsageMetrics(t *testing.T) {
s := ProvideOSSService( s := ProvideOSSService(
cfg, cfg,
database.ProvideService(db.InitTestDB(t)), database.ProvideService(db.InitTestDB(t)),
&actest.FakeActionResolver{},
localcache.ProvideService(), localcache.ProvideService(),
featuremgmt.WithFeatures(), featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest(), tracing.InitializeTracerForTest(),

View File

@ -156,3 +156,22 @@ func (f *FakePermissionsService) DeleteResourcePermissions(ctx context.Context,
func (f *FakePermissionsService) MapActions(permission accesscontrol.ResourcePermission) string { func (f *FakePermissionsService) MapActions(permission accesscontrol.ResourcePermission) string {
return f.ExpectedMappedAction return f.ExpectedMappedAction
} }
type FakeActionResolver struct {
ExpectedErr error
ExpectedActionSets []string
ExpectedActions []string
ExpectedPermissions []accesscontrol.Permission
}
func (f *FakeActionResolver) ResolveAction(action string) []string {
return f.ExpectedActionSets
}
func (f *FakeActionResolver) ResolveActionSet(actionSet string) []string {
return f.ExpectedActions
}
func (f *FakeActionResolver) ExpandActionSets(permissions []accesscontrol.Permission) []accesscontrol.Permission {
return f.ExpectedPermissions
}

View File

@ -16,7 +16,9 @@ type ScopeAttributeResolver interface {
} }
type ActionResolver interface { type ActionResolver interface {
Resolve(action string) []string ResolveAction(action string) []string
ResolveActionSet(actionSet string) []string
ExpandActionSets(permissions []Permission) []Permission
} }
// ScopeAttributeResolverFunc is an adapter to allow functions to implement ScopeAttributeResolver interface // ScopeAttributeResolverFunc is an adapter to allow functions to implement ScopeAttributeResolver interface
@ -94,7 +96,7 @@ func (s *Resolvers) GetActionSetResolver() ActionSetResolver {
if s.actionResolver == nil { if s.actionResolver == nil {
return []string{action} return []string{action}
} }
actionSetActions := s.actionResolver.Resolve(action) actionSetActions := s.actionResolver.ResolveAction(action)
actions := append(actionSetActions, action) actions := append(actionSetActions, action)
s.log.Debug("Resolved action", "action", action, "resolved_actions", actions) s.log.Debug("Resolved action", "action", action, "resolved_actions", actions)
return actions return actions

View File

@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts"
@ -756,7 +757,7 @@ type InMemoryActionSets struct {
} }
// NewActionSetService returns a new instance of InMemoryActionSetService. // NewActionSetService returns a new instance of InMemoryActionSetService.
func NewActionSetService(a *acimpl.AccessControl) ActionSetService { func NewActionSetService(a *acimpl.AccessControl) *InMemoryActionSets {
actionSets := &InMemoryActionSets{ actionSets := &InMemoryActionSets{
log: log.New("resourcepermissions.actionsets"), log: log.New("resourcepermissions.actionsets"),
actionSetToActions: make(map[string][]string), actionSetToActions: make(map[string][]string),
@ -766,20 +767,14 @@ func NewActionSetService(a *acimpl.AccessControl) ActionSetService {
return actionSets return actionSets
} }
func (s *InMemoryActionSets) Resolve(action string) []string { func (s *InMemoryActionSets) ResolveAction(action string) []string {
actionSets := s.actionToActionSets[action] actionSets := s.actionToActionSets[action]
sets := make([]string, 0, len(actionSets)) sets := make([]string, 0, len(actionSets))
for _, actionSet := range actionSets { for _, actionSet := range actionSets {
setParts := strings.Split(actionSet, ":")
if len(setParts) != 2 {
s.log.Debug("skipping resolution for action set with invalid name", "action set", actionSet)
continue
}
prefix := setParts[0]
// Only use action sets for folders and dashboards for now // Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:query`) // We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if prefix != "folders" && prefix != "dashboards" { if !isFolderOrDashboardAction(actionSet) {
continue continue
} }
sets = append(sets, actionSet) sets = append(sets, actionSet)
@ -788,6 +783,35 @@ func (s *InMemoryActionSets) Resolve(action string) []string {
return sets return sets
} }
func (s *InMemoryActionSets) ResolveActionSet(actionSet string) []string {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(actionSet) {
return nil
}
return s.actionSetToActions[actionSet]
}
func isFolderOrDashboardAction(action string) bool {
return strings.HasPrefix(action, dashboards.ScopeDashboardsRoot) || strings.HasPrefix(action, dashboards.ScopeFoldersRoot)
}
func (s *InMemoryActionSets) ExpandActionSets(permissions []accesscontrol.Permission) []accesscontrol.Permission {
var expandedPermissions []accesscontrol.Permission
for _, permission := range permissions {
resolvedActions := s.ResolveActionSet(permission.Action)
if len(resolvedActions) == 0 {
expandedPermissions = append(expandedPermissions, permission)
continue
}
for _, action := range resolvedActions {
permission.Action = action
expandedPermissions = append(expandedPermissions, permission)
}
}
return expandedPermissions
}
// GetActionSet returns the action set for the given action. // GetActionSet returns the action set for the given action.
func (s *InMemoryActionSets) GetActionSet(actionName string) []string { func (s *InMemoryActionSets) GetActionSet(actionName string) []string {
actionSet, ok := s.actionSetToActions[actionName] actionSet, ok := s.actionSetToActions[actionName]

View File

@ -796,10 +796,6 @@ func TestStore_StoreActionSet(t *testing.T) {
} }
func TestStore_ResolveActionSet(t *testing.T) { func TestStore_ResolveActionSet(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
actionSetService := NewActionSetService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())) actionSetService := NewActionSetService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
actionSetService.StoreActionSet("folders", "edit", []string{"folders:read", "folders:write", "dashboards:read", "dashboards:write"}) actionSetService.StoreActionSet("folders", "edit", []string{"folders:read", "folders:write", "dashboards:read", "dashboards:write"})
actionSetService.StoreActionSet("folders", "view", []string{"folders:read", "dashboards:read"}) actionSetService.StoreActionSet("folders", "view", []string{"folders:read", "dashboards:read"})
@ -836,8 +832,105 @@ func TestStore_ResolveActionSet(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) { t.Run(tt.desc, func(t *testing.T) {
actionSets := actionSetService.Resolve(tt.action) actionSets := actionSetService.ResolveAction(tt.action)
require.ElementsMatch(t, tt.expectedActionSets, actionSets) require.ElementsMatch(t, tt.expectedActionSets, actionSets)
}) })
} }
} }
func TestStore_ExpandActions(t *testing.T) {
actionSetService := NewActionSetService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
actionSetService.StoreActionSet("folders", "edit", []string{"folders:read", "folders:write", "dashboards:read", "dashboards:write"})
actionSetService.StoreActionSet("folders", "view", []string{"folders:read", "dashboards:read"})
actionSetService.StoreActionSet("dashboards", "view", []string{"dashboards:read"})
type actionSetTest struct {
desc string
permissions []accesscontrol.Permission
expectedPermissions []accesscontrol.Permission
}
tests := []actionSetTest{
{
desc: "should return empty list if no permissions are passed in",
permissions: []accesscontrol.Permission{},
expectedPermissions: []accesscontrol.Permission{},
},
{
desc: "should return unchanged permissions if none of actions are part of any action sets",
permissions: []accesscontrol.Permission{
{
Action: "datasources:create",
},
{
Action: "users:read",
Scope: "users:*",
},
},
expectedPermissions: []accesscontrol.Permission{
{
Action: "datasources:create",
},
{
Action: "users:read",
Scope: "users:*",
},
},
},
{
desc: "should return unchanged permissions if none of actions are part of any action sets",
permissions: []accesscontrol.Permission{
{
Action: "datasources:create",
},
{
Action: "users:read",
Scope: "users:*",
},
},
expectedPermissions: []accesscontrol.Permission{
{
Action: "datasources:create",
},
{
Action: "users:read",
Scope: "users:*",
},
},
},
{
desc: "should be able to expand one permission and leave others unchanged",
permissions: []accesscontrol.Permission{
{
Action: "folders:view",
Scope: "folders:uid:1",
},
{
Action: "users:read",
Scope: "users:*",
},
},
expectedPermissions: []accesscontrol.Permission{
{
Action: "folders:read",
Scope: "folders:uid:1",
},
{
Action: "dashboards:read",
Scope: "folders:uid:1",
},
{
Action: "users:read",
Scope: "users:*",
},
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
permissions := actionSetService.ExpandActionSets(tt.permissions)
require.ElementsMatch(t, tt.expectedPermissions, permissions)
})
}
}

View File

@ -43,7 +43,7 @@ func setupTestEnv(t *testing.T) *TestEnv {
} }
logger := log.New("extsvcaccounts.test") logger := log.New("extsvcaccounts.test")
env.S = &ExtSvcAccountsService{ env.S = &ExtSvcAccountsService{
acSvc: acimpl.ProvideOSSService(cfg, env.AcStore, localcache.New(0, 0), fmgt, tracing.InitializeTracerForTest()), acSvc: acimpl.ProvideOSSService(cfg, env.AcStore, &actest.FakeActionResolver{}, localcache.New(0, 0), fmgt, tracing.InitializeTracerForTest()),
features: fmgt, features: fmgt,
logger: logger, logger: logger,
metrics: newMetrics(nil, env.SaSvc, logger), metrics: newMetrics(nil, env.SaSvc, logger),