diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index c9f0f300601..02f729ff2b2 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" acdb "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" @@ -463,7 +464,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog actionSets := resourcepermissions.NewActionSetService(features) acSvc := acimpl.ProvideOSSService( sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(), - features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db.DB(), + features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db.DB(), permreg.ProvidePermissionRegistry(), ) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index 9ad5fe87611..9d7a534232b 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" @@ -90,7 +91,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) } - acService, err := acimpl.ProvideService(cfg, replstore, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient()) + acService, err := acimpl.ProvideService(cfg, replstore, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get access control", err) } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 6440aab312e..47aa9aedc38 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -39,6 +39,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" @@ -345,6 +346,7 @@ var wireBasicSet = wire.NewSet( resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), + permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 790fc5773fd..5b7b509b245 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/api" "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" @@ -50,9 +51,9 @@ var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.E func ProvideService( cfg *setting.Cfg, db db.ReplDB, routeRegister routing.RouteRegister, cache *localcache.CacheService, accessControl accesscontrol.AccessControl, actionResolver accesscontrol.ActionResolver, - features featuremgmt.FeatureToggles, tracer tracing.Tracer, zclient zanzana.Client, + features featuremgmt.FeatureToggles, tracer tracing.Tracer, zclient zanzana.Client, permRegistry permreg.PermissionRegistry, ) (*Service, error) { - service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db.DB()) + service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db.DB(), permRegistry) api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service, cfg); err != nil { @@ -73,7 +74,7 @@ func ProvideService( func ProvideOSSService( cfg *setting.Cfg, store accesscontrol.Store, actionResolver accesscontrol.ActionResolver, cache *localcache.CacheService, features featuremgmt.FeatureToggles, tracer tracing.Tracer, - zclient zanzana.Client, db db.DB, + zclient zanzana.Client, db db.DB, permRegistry permreg.PermissionRegistry, ) *Service { s := &Service{ actionResolver: actionResolver, @@ -85,6 +86,7 @@ func ProvideOSSService( store: store, tracer: tracer, sync: migrator.NewZanzanaSynchroniser(zclient, db), + permRegistry: permRegistry, } return s @@ -102,6 +104,7 @@ type Service struct { store accesscontrol.Store tracer tracing.Tracer sync *migrator.ZanzanaSynchroniser + permRegistry permreg.PermissionRegistry } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -406,6 +409,10 @@ func (s *Service) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistrat return err } + for i := range r.Role.Permissions { + s.permRegistry.RegisterPermission(r.Role.Permissions[i].Action, r.Role.Permissions[i].Scope) + } + s.registrations.Append(r) } @@ -458,6 +465,12 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs return err } + for i := range r.Role.Permissions { + // Register plugin actions and their possible scopes for permission validation + s.permRegistry.RegisterPluginScope(r.Role.Permissions[i].Scope) + s.permRegistry.RegisterPermission(r.Role.Permissions[i].Action, r.Role.Permissions[i].Scope) + } + s.log.Debug("Registering plugin role", "role", r.Role.Name) s.registrations.Append(r) } diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 19245d113f8..e5df3ada30c 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" @@ -42,6 +43,7 @@ func setupTestEnv(t testing.TB) *Service { roles: accesscontrol.BuildBasicRoleDefinitions(), tracer: tracing.InitializeTracerForTest(), store: database.ProvideService(db.InitTestReplDB(t)), + permRegistry: permreg.ProvidePermissionRegistry(), } require.NoError(t, ac.RegisterFixedRoles(context.Background())) return ac @@ -71,6 +73,7 @@ func TestUsageMetrics(t *testing.T) { tracing.InitializeTracerForTest(), nil, nil, + permreg.ProvidePermissionRegistry(), ) assert.Equal(t, tt.expectedValue, s.GetUsageStats(context.Background())["stats.oss.accesscontrol.enabled.count"]) }) diff --git a/pkg/services/accesscontrol/permreg/permreg.go b/pkg/services/accesscontrol/permreg/permreg.go new file mode 100644 index 00000000000..ca1b8ba1fd1 --- /dev/null +++ b/pkg/services/accesscontrol/permreg/permreg.go @@ -0,0 +1,183 @@ +package permreg + +import ( + "strings" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" + "github.com/grafana/grafana/pkg/infra/log" +) + +var ( + // ErrInvalidScope is returned when the scope is not valid for the action + ErrInvalidScopeTplt = "invalid scope: {{.Public.Scope}}, for action: {{.Public.Action}}, expected prefixes are {{.Public.ValidScopesFormat}}" + ErrBaseInvalidScope = errutil.BadRequest("permreg.invalid-scope").MustTemplate(ErrInvalidScopeTplt, errutil.WithPublic(ErrInvalidScopeTplt)) + + ErrUnknownActionTplt = "unknown action: {{.Public.Action}}, was not found in the list of valid actions" + ErrBaseUnknownAction = errutil.BadRequest("permreg.unknown-action").MustTemplate(ErrUnknownActionTplt, errutil.WithPublic(ErrUnknownActionTplt)) +) + +func ErrInvalidScope(scope string, action string, validScopePrefixes PrefixSet) error { + if len(validScopePrefixes) == 0 { + return ErrBaseInvalidScope.Build(errutil.TemplateData{Public: map[string]any{"Scope": scope, "Action": action, "ValidScopesFormat": "[none]"}}) + } + formats := generateValidScopeFormats(validScopePrefixes) + return ErrBaseInvalidScope.Build(errutil.TemplateData{Public: map[string]any{"Scope": scope, "Action": action, "ValidScopesFormat": formats}}) +} + +func ErrUnknownAction(action string) error { + return ErrBaseUnknownAction.Build(errutil.TemplateData{Public: map[string]any{"Action": action}}) +} + +func generateValidScopeFormats(acceptedScopePrefixes PrefixSet) []string { + if len(acceptedScopePrefixes) == 0 { + return []string{} + } + acceptedPrefixesList := make([]string, 0, 10) + acceptedPrefixesList = append(acceptedPrefixesList, "*") + for prefix := range acceptedScopePrefixes { + parts := strings.Split(prefix, ":") + // If the prefix has an attribute part add the intermediate format kind:* + if len(parts) > 2 { + acceptedPrefixesList = append(acceptedPrefixesList, parts[0]+":*") + } + // Add the most specific format kind:attribute:* + acceptedPrefixesList = append(acceptedPrefixesList, prefix+"*") + } + return acceptedPrefixesList +} + +type PermissionRegistry interface { + RegisterPluginScope(scope string) + RegisterPermission(action, scope string) + IsPermissionValid(action, scope string) error + GetScopePrefixes(action string) (PrefixSet, bool) +} + +type PrefixSet map[string]bool + +var _ PermissionRegistry = &permissionRegistry{} + +type permissionRegistry struct { + actionScopePrefixes map[string]PrefixSet // TODO use thread safe map + kindScopePrefix map[string]string + logger log.Logger +} + +func ProvidePermissionRegistry() PermissionRegistry { + return newPermissionRegistry() +} + +func newPermissionRegistry() *permissionRegistry { + // defaultKindScopes maps the most specific accepted scope prefix for a given kind (folders, dashboards, etc) + defaultKindScopes := map[string]string{ + "teams": "teams:id:", + "users": "users:id:", + "datasources": "datasources:uid:", + "dashboards": "dashboards:uid:", + "folders": "folders:uid:", + "annotations": "annotations:type:", + "apikeys": "apikeys:id:", + "orgs": "orgs:id:", + "plugins": "plugins:id:", + "provisioners": "provisioners:", + "reports": "reports:id:", + "permissions": "permissions:type:", + "serviceaccounts": "serviceaccounts:id:", + "settings": "settings:", + "global.users": "global.users:id:", + "roles": "roles:uid:", + "services": "services:", + } + return &permissionRegistry{ + actionScopePrefixes: make(map[string]PrefixSet, 200), + kindScopePrefix: defaultKindScopes, + logger: log.New("accesscontrol.permreg"), + } +} + +func (pr *permissionRegistry) RegisterPluginScope(scope string) { + if scope == "" { + return + } + + scopeParts := strings.Split(scope, ":") + // If the scope is already registered, return + if _, found := pr.kindScopePrefix[scopeParts[0]]; found { + return + } + + // If the scope contains an attribute part, register the kind and attribute + if len(scopeParts) > 2 { + kind, attr := scopeParts[0], scopeParts[1] + pr.kindScopePrefix[kind] = kind + ":" + attr + ":" + pr.logger.Debug("registered scope prefix", "kind", kind, "scope_prefix", kind+":"+attr+":") + return + } + + pr.logger.Debug("registered scope prefix", "kind", scopeParts[0], "scope_prefix", scopeParts[0]+":") + pr.kindScopePrefix[scopeParts[0]] = scopeParts[0] + ":" +} + +func (pr *permissionRegistry) RegisterPermission(action, scope string) { + if _, ok := pr.actionScopePrefixes[action]; !ok { + pr.actionScopePrefixes[action] = PrefixSet{} + } + + if scope == "" { + // scopeless action + return + } + + kind := strings.Split(scope, ":")[0] + scopePrefix, ok := pr.kindScopePrefix[kind] + if !ok { + pr.logger.Warn("unknown scope prefix", "scope", scope) + return + } + + // Add a new entry in case the scope is not empty + pr.actionScopePrefixes[action][scopePrefix] = true +} + +func (pr *permissionRegistry) IsPermissionValid(action, scope string) error { + validScopePrefixes, ok := pr.actionScopePrefixes[action] + if !ok { + return ErrUnknownAction(action) + } + + if ok && len(validScopePrefixes) == 0 { + // Expecting action without any scope + if scope != "" { + return ErrInvalidScope(scope, action, nil) + } + return nil + } + + if !isScopeValid(scope, validScopePrefixes) { + return ErrInvalidScope(scope, action, validScopePrefixes) + } + return nil +} + +func isScopeValid(scope string, validScopePrefixes PrefixSet) bool { + // Super wildcard scope + if scope == "*" { + return true + } + for scopePrefix := range validScopePrefixes { + // Correct scope prefix + if strings.HasPrefix(scope, scopePrefix) { + return true + } + // Scope is wildcard of the correct prefix + if strings.HasSuffix(scope, ":*") && strings.HasPrefix(scopePrefix, scope[:len(scope)-2]) { + return true + } + } + return false +} + +func (pr *permissionRegistry) GetScopePrefixes(action string) (PrefixSet, bool) { + set, ok := pr.actionScopePrefixes[action] + return set, ok +} diff --git a/pkg/services/accesscontrol/permreg/permreg_test.go b/pkg/services/accesscontrol/permreg/permreg_test.go new file mode 100644 index 00000000000..4d78e0ef9a6 --- /dev/null +++ b/pkg/services/accesscontrol/permreg/permreg_test.go @@ -0,0 +1,246 @@ +package permreg + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_permissionRegistry_RegisterPluginScope(t *testing.T) { + tests := []struct { + scope string + wantKind string + wantScope string + }{ + { + scope: "folders:uid:AABBCC", + wantKind: "folders", + wantScope: "folders:uid:", + }, + { + scope: "plugins:id:test-app", + wantKind: "plugins", + wantScope: "plugins:id:", + }, + { + scope: "resource:uid:res", + wantKind: "resource", + wantScope: "resource:uid:", + }, + { + scope: "resource:*", + wantKind: "resource", + wantScope: "resource:", + }, + } + for _, tt := range tests { + t.Run(tt.scope, func(t *testing.T) { + pr := newPermissionRegistry() + pr.RegisterPluginScope(tt.scope) + got, ok := pr.kindScopePrefix[tt.wantKind] + require.True(t, ok) + require.Equal(t, tt.wantScope, got) + }) + } +} + +func Test_permissionRegistry_RegisterPermission(t *testing.T) { + tests := []struct { + name string + action string + scope string + wantKind string + wantPrefixSet PrefixSet + wantSkip bool + }{ + { + name: "register folders read", + action: "folders:read", + scope: "folders:*", + wantKind: "folders", + wantPrefixSet: PrefixSet{"folders:uid:": true}, + }, + { + name: "register app plugin settings read", + action: "test-app.settings:read", + wantKind: "settings", + wantPrefixSet: PrefixSet{}, + }, + { + name: "register an action on an unknown kind", + action: "unknown:action", + scope: "unknown:uid:*", + wantPrefixSet: PrefixSet{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr := newPermissionRegistry() + pr.RegisterPermission(tt.action, tt.scope) + got, ok := pr.actionScopePrefixes[tt.action] + require.True(t, ok) + for k, v := range got { + require.Equal(t, v, tt.wantPrefixSet[k]) + } + }) + } +} + +func Test_permissionRegistry_IsPermissionValid(t *testing.T) { + pr := newPermissionRegistry() + pr.RegisterPermission("folders:read", "folders:uid:") + pr.RegisterPermission("test-app.settings:read", "") + + tests := []struct { + name string + action string + scope string + wantErr bool + }{ + { + name: "valid folders read", + action: "folders:read", + scope: "folders:uid:AABBCC", + wantErr: false, + }, + { + name: "valid folders read with wildcard", + action: "folders:read", + scope: "folders:uid:*", + wantErr: false, + }, + { + name: "valid folders read with kind level wildcard", + action: "folders:read", + scope: "folders:*", + wantErr: false, + }, + { + name: "valid folders read with super wildcard", + action: "folders:read", + scope: "*", + wantErr: false, + }, + { + name: "invalid folders read with wrong kind", + action: "folders:read", + scope: "unknown:uid:AABBCC", + wantErr: true, + }, + { + name: "invalid folders read with wrong attribute", + action: "folders:read", + scope: "folders:id:3", + wantErr: true, + }, + { + name: "valid app plugin settings read", + action: "test-app.settings:read", + scope: "", + wantErr: false, + }, + { + name: "app plugin settings read with a scope", + action: "test-app.settings:read", + scope: "folders:uid:*", + wantErr: true, + }, + { + name: "unknown action", + action: "unknown:write", + scope: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := pr.IsPermissionValid(tt.action, tt.scope) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func Test_permissionRegistry_GetScopePrefixes(t *testing.T) { + pr := newPermissionRegistry() + pr.RegisterPermission("folders:read", "folders:uid:") + pr.RegisterPermission("test-app.settings:read", "") + + tests := []struct { + name string + action string + want PrefixSet + shouldExist bool + }{ + { + name: "get folders read scope prefixes", + action: "folders:read", + want: PrefixSet{"folders:uid:": true}, + shouldExist: true, + }, + { + name: "get app plugin settings read scope prefixes", + action: "test-app.settings:read", + want: PrefixSet{}, + shouldExist: true, + }, + { + name: "get unknown action scope prefixes", + action: "unknown:write", + want: PrefixSet{}, + shouldExist: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := pr.GetScopePrefixes(tt.action) + if !tt.shouldExist { + require.False(t, got1) + return + } + require.True(t, got1) + require.Len(t, tt.want, len(got)) + for k, v := range got { + require.Equal(t, v, tt.want[k]) + } + }) + } +} + +func Test_generateValidScopeFormats(t *testing.T) { + tests := []struct { + name string + prefixSet PrefixSet + want []string + }{ + { + name: "empty prefix set", + prefixSet: PrefixSet{}, + want: []string{}, + }, + { + name: "short prefix", + prefixSet: PrefixSet{"folders:": true}, + want: []string{"*", "folders:*"}, + }, + { + name: "single prefix", + prefixSet: PrefixSet{"folders:uid:": true}, + want: []string{"*", "folders:*", "folders:uid:*"}, + }, + { + name: "multiple prefixes", + prefixSet: PrefixSet{"folders:uid:": true, "dashboards:uid:": true}, + want: []string{"*", "folders:*", "folders:uid:*", "dashboards:*", "dashboards:uid:*"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateValidScopeFormats(tt.prefixSet) + require.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/pkg/services/accesscontrol/permreg/test/testreg.go b/pkg/services/accesscontrol/permreg/test/testreg.go new file mode 100644 index 00000000000..7b5210dd2e6 --- /dev/null +++ b/pkg/services/accesscontrol/permreg/test/testreg.go @@ -0,0 +1,22 @@ +package test + +import "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" + +func ProvidePermissionRegistry() permreg.PermissionRegistry { + permReg := permreg.ProvidePermissionRegistry() + // Test core permissions + permReg.RegisterPermission("datasources:read", "datasources:uid:") + permReg.RegisterPermission("dashboards:read", "dashboards:uid:") + permReg.RegisterPermission("dashboards:read", "folders:uid:") + permReg.RegisterPermission("folders:read", "folders:uid:") + // Test plugins permissions + permReg.RegisterPermission("plugins.app:access", "plugins:id:") + // App + permReg.RegisterPermission("test-app:read", "") + permReg.RegisterPermission("test-app.settings:read", "") + permReg.RegisterPermission("test-app.projects:read", "") + // App 1 + permReg.RegisterPermission("test-app1.catalog:read", "") + permReg.RegisterPermission("test-app1.announcements:read", "") + return permReg +} diff --git a/pkg/services/serviceaccounts/extsvcaccounts/service_test.go b/pkg/services/serviceaccounts/extsvcaccounts/service_test.go index 130fbc98fcc..e8f9650ca7c 100644 --- a/pkg/services/serviceaccounts/extsvcaccounts/service_test.go +++ b/pkg/services/serviceaccounts/extsvcaccounts/service_test.go @@ -15,6 +15,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/extsvcauth" @@ -48,6 +49,7 @@ func setupTestEnv(t *testing.T) *TestEnv { acSvc: acimpl.ProvideOSSService( cfg, env.AcStore, &resourcepermissions.FakeActionSetSvc{}, localcache.New(0, 0), fmgt, tracing.InitializeTracerForTest(), nil, nil, + permreg.ProvidePermissionRegistry(), ), features: fmgt, logger: logger,