diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go new file mode 100644 index 00000000000..c6128790d1a --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go @@ -0,0 +1,44 @@ +package acimpl + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +const ( + ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder" + ossBasicRoleSeedTimeout = 2 * time.Minute +) + +// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations +func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error { + if s.sql == nil || s.seeder == nil { + return nil + } + + run := func(ctx context.Context) error { + desired := map[accesscontrol.SeedPermission]struct{}{} + for role, permissions := range rolesSnapshot { + for _, permission := range permissions { + desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{} + } + } + s.seeder.SetDesiredPermissions(desired) + return s.seeder.Seed(ctx) + } + + if s.serverLock == nil { + return run(ctx) + } + + var err error + errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) { + err = run(ctx) + }) + if errLock != nil { + return errLock + } + return err +} diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go new file mode 100644 index 00000000000..986a32b66fc --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go @@ -0,0 +1,128 @@ +package acimpl + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "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/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + sql := db.InitTestDB(t) + store := database.ProvideService(sql) + + svc := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + + require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + + require.NoError(t, svc.RegisterFixedRoles(ctx)) + + // verify permission is persisted to DB for basic:viewer + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + return nil + })) + + // ensure RegisterFixedRoles refreshes it back to defaults + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + ts := time.Now() + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID) + require.NoError(t, err) + p := accesscontrol.Permission{ + RoleID: role.ID, + Action: "custom:keep", + Scope: "", + Created: ts, + Updated: ts, + } + p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) + _, err = sess.Table("permission").Insert(&p) + return err + })) + + svc2 := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + require.NoError(t, svc2.RegisterFixedRoles(ctx)) + + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + + count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count() + require.NoError(t, err) + require.Equal(t, int64(0), count) + return nil + })) +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 1ea8bf95f77..3aab2ad1248 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -30,6 +30,7 @@ import ( "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/accesscontrol/seeding" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -96,6 +97,12 @@ func ProvideOSSService( roles: accesscontrol.BuildBasicRoleDefinitions(), store: store, permRegistry: permRegistry, + sql: db, + serverLock: lock, + } + + if backend, ok := store.(*database.AccessControlStore); ok { + s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend) } return s @@ -112,8 +119,11 @@ type Service struct { rolesMu sync.RWMutex roles map[string]*accesscontrol.RoleDTO store accesscontrol.Store + seeder *seeding.Seeder permRegistry permreg.PermissionRegistry isInitialized bool + sql db.DB + serverLock *serverlock.ServerLockService } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error { defer span.End() s.rolesMu.Lock() - defer s.rolesMu.Unlock() - + registrations := s.registrations.Slice() s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { s.registerRolesLocked(registration) return true }) s.isInitialized = true + + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.Unlock() + + if s.seeder != nil { + if err := s.seeder.SeedRoles(ctx, registrations); err != nil { + return err + } + if err := s.seeder.RemoveAbsentRoles(ctx); err != nil { + return err + } + } + + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + return nil } +// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the +// current registration list, using the shared seeding registration logic. +// +// it has to be called while holding the roles lock +func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission { + desired := map[accesscontrol.SeedPermission]struct{}{} + s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { + seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude) + return true + }) + + out := make(map[string][]accesscontrol.Permission) + for sp := range desired { + out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{ + Action: sp.Action, + Scope: sp.Scope, + }) + } + return out +} + // registerRolesLocked processes a single role registration and adds permissions to basic roles. // Must be called with s.rolesMu locked. func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) { @@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs defer span.End() acRegs := pluginutils.ToRegistrations(ID, name, regs) + updatedBasicRoles := false for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err @@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs if initialized { s.rolesMu.Lock() s.registerRolesLocked(r) + updatedBasicRoles = true s.rolesMu.Unlock() s.cache.Flush() } } + if updatedBasicRoles { + s.rolesMu.RLock() + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.RUnlock() + + // plugin roles can be declared after startup - keep DB in sync + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + } + return nil } diff --git a/pkg/services/accesscontrol/database/seeder.go b/pkg/services/accesscontrol/database/seeder.go new file mode 100644 index 00000000000..2f53d20b514 --- /dev/null +++ b/pkg/services/accesscontrol/database/seeder.go @@ -0,0 +1,623 @@ +package database + +import ( + "context" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm/core" +) + +const basicRolePermBatchSize = 500 + +// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name. +func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) { + out := map[string]*accesscontrol.RoleDTO{} + + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + type roleRow struct { + ID int64 `xorm:"id"` + OrgID int64 `xorm:"org_id"` + Version int64 `xorm:"version"` + UID string `xorm:"uid"` + Name string `xorm:"name"` + DisplayName string `xorm:"display_name"` + Description string `xorm:"description"` + Group string `xorm:"group_name"` + Hidden bool `xorm:"hidden"` + Updated time.Time `xorm:"updated"` + Created time.Time `xorm:"created"` + } + + roles := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%"). + Find(&roles); err != nil { + return err + } + + if len(roles) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(roles)) + roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles)) + for _, r := range roles { + dto := &accesscontrol.RoleDTO{ + ID: r.ID, + OrgID: r.OrgID, + Version: r.Version, + UID: r.UID, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + Group: r.Group, + Hidden: r.Hidden, + Updated: r.Updated, + Created: r.Created, + } + out[dto.Name] = dto + roleByID[dto.ID] = dto + roleIDs = append(roleIDs, dto.ID) + } + + type permRow struct { + RoleID int64 `xorm:"role_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + perms := []permRow{} + if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil { + return err + } + + for _, p := range perms { + dto := roleByID[p.RoleID] + if dto == nil { + continue + } + dto.Permissions = append(dto.Permissions, accesscontrol.Permission{ + RoleID: p.RoleID, + Action: p.Action, + Scope: p.Scope, + }) + } + + return nil + }) + + return out, err +} + +func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table("role"). + Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID). + Update(map[string]any{ + "display_name": wantedRole.DisplayName, + "description": wantedRole.Description, + "group_name": wantedRole.Group, + "hidden": wantedRole.Hidden, + "updated": time.Now(), + }) + return err + }) +} + +func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + type key struct{ Action, Scope string } + existing := map[key]struct{}{} + for _, p := range existingRole.Permissions { + existing[key{p.Action, p.Scope}] = struct{}{} + } + desired := map[key]struct{}{} + for _, p := range wantedRole.Permissions { + desired[key{p.Action, p.Scope}] = struct{}{} + } + + toAdd := make([]accesscontrol.Permission, 0) + toRemove := make([]accesscontrol.SeedPermission, 0) + + now := time.Now() + for k := range desired { + if _, ok := existing[k]; ok { + continue + } + perm := accesscontrol.Permission{ + RoleID: existingRole.ID, + Action: k.Action, + Scope: k.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toAdd = append(toAdd, perm) + } + + for k := range existing { + if _, ok := desired[k]; ok { + continue + } + toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope}) + } + + if len(toAdd) == 0 && len(toRemove) == 0 { + return nil + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if len(toRemove) > 0 { + if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil { + return err + } + } + + if len(toAdd) > 0 { + _, err := sess.InsertMulti(toAdd) + return err + } + + return nil + }) +} + +func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error { + now := time.Now() + uid := role.UID + if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) { + uid = accesscontrol.PrefixedRoleUID(role.Name) + } + r := accesscontrol.Role{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: uid, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Hidden: role.Hidden, + Created: now, + Updated: now, + } + if r.Version == 0 { + r.Version = 1 + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if _, err := sess.Insert(&r); err != nil { + return err + } + + if len(role.Permissions) == 0 { + return nil + } + + // De-duplicate permissions on (action, scope) to avoid unique constraint violations. + // Some role definitions may accidentally include duplicates. + type permKey struct{ Action, Scope string } + seen := make(map[permKey]struct{}, len(role.Permissions)) + + perms := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + k := permKey{Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: r.ID, + Action: p.Action, + Scope: p.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + perms = append(perms, perm) + } + _, err := sess.InsertMulti(perms) + return err + }) +} + +func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error { + if len(roleUIDs) == 0 { + return nil + } + + uids := make([]any, 0, len(roleUIDs)) + for _, uid := range roleUIDs { + uids = append(uids, uid) + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + type row struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + rows := []row{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return err + } + if len(rows) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(rows)) + for _, r := range rows { + roleIDs = append(roleIDs, r.ID) + } + + // Remove permissions and assignments first to avoid FK issues (if enabled). + { + args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + + args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...) + _, err := sess.Exec(args...) + return err + }) +} + +// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory. +// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table. +func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + var out map[accesscontrol.SeedPermission]struct{} + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + rows, err := LoadBasicRoleSeedPermissions(sess) + if err != nil { + return err + } + + out = make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + r.Origin = "" + out[r] = struct{}{} + } + return nil + }) + return out, err +} + +func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + rolesToUpgrade := seeding.RolesToUpgrade(added, removed) + + // Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction. + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + defs := accesscontrol.BuildBasicRoleDefinitions() + builtinToRoleID, err := EnsureBasicRolesExist(sess, defs) + if err != nil { + return err + } + + backend := &ossBasicRoleSeedBackend{ + sess: sess, + now: time.Now(), + builtinToRoleID: builtinToRoleID, + desired: nil, + dbType: s.sql.GetDBType(), + } + if err := backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + + return BumpBasicRoleVersions(sess, rolesToUpgrade) + }) +} + +// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role. +// It returns a mapping from builtin role name (for example "Admin") to role ID. +func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) { + uidToBuiltin := make(map[string]string, len(defs)) + uids := make([]any, 0, len(defs)) + for builtin, def := range defs { + uidToBuiltin[def.UID] = builtin + uids = append(uids, def.UID) + } + + type roleRow struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + + rows := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return nil, err + } + + ts := time.Now() + + builtinToRoleID := make(map[string]int64, len(defs)) + for _, r := range rows { + br, ok := uidToBuiltin[r.UID] + if !ok { + continue + } + builtinToRoleID[br] = r.ID + } + + for builtin, def := range defs { + roleID, ok := builtinToRoleID[builtin] + if !ok { + role := accesscontrol.Role{ + OrgID: def.OrgID, + Version: def.Version, + UID: def.UID, + Name: def.Name, + DisplayName: def.DisplayName, + Description: def.Description, + Group: def.Group, + Hidden: def.Hidden, + Created: ts, + Updated: ts, + } + if _, err := sess.Insert(&role); err != nil { + return nil, err + } + roleID = role.ID + builtinToRoleID[builtin] = roleID + } + + has, err := sess.Table("builtin_role"). + Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID). + Exist() + if err != nil { + return nil, err + } + if !has { + br := accesscontrol.BuiltinRole{ + RoleID: roleID, + OrgID: accesscontrol.GlobalOrgID, + Role: builtin, + Created: ts, + Updated: ts, + } + if _, err := sess.Table("builtin_role").Insert(&br); err != nil { + return nil, err + } + } + } + + return builtinToRoleID, nil +} + +// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs. +// +// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back +// to a WHERE ... OR ... form for MSSQL. +func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error { + if len(perms) == 0 { + return nil + } + + if dbType == migrator.MSSQL { + // MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors. + where := make([]string, 0, len(perms)) + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + where = append(where, "(action = ? AND scope = ?)") + args = append(args, p.Action, p.Scope) + } + _, err := sess.Exec( + append([]any{ + "DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")", + }, args...)..., + ) + return err + } + + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + args = append(args, p.Action, p.Scope) + } + sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" + + strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))" + _, err := sess.Exec(append([]any{sql}, args...)...) + return err +} + +type ossBasicRoleSeedBackend struct { + sess *db.Session + now time.Time + builtinToRoleID map[string]int64 + desired map[accesscontrol.SeedPermission]struct{} + dbType core.DbType +} + +func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + rows, err := LoadBasicRoleSeedPermissions(b.sess) + if err != nil { + return nil, err + } + + out := make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + // Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh). + r.Origin = "" + out[r] = struct{}{} + } + return out, nil +} + +func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + return b.desired, nil +} + +func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + // Delete removed permissions (this includes user-defined permissions that aren't in desired). + if len(removed) > 0 { + permsByRoleID := map[int64][]accesscontrol.SeedPermission{} + for _, p := range removed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + permsByRoleID[roleID] = append(permsByRoleID[roleID], p) + } + + for roleID, perms := range permsByRoleID { + // Chunk to keep statement sizes and parameter counts bounded. + if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error { + return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end]) + }); err != nil { + return err + } + } + } + + // Insert added permissions and updated-target permissions. + toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated)) + toInsertSeed = append(toInsertSeed, added...) + for _, v := range updated { + toInsertSeed = append(toInsertSeed, v) + } + if len(toInsertSeed) == 0 { + return nil + } + + // De-duplicate on (role_id, action, scope). This avoids unique constraint violations when: + // - the same permission appears in both added and updated + // - multiple plugin origins grant the same permission (Origin is not persisted in permission table) + type permKey struct { + RoleID int64 + Action string + Scope string + } + seen := make(map[permKey]struct{}, len(toInsertSeed)) + + toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed)) + for _, p := range toInsertSeed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: roleID, + Action: p.Action, + Scope: p.Scope, + Created: b.now, + Updated: b.now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toInsert = append(toInsert, perm) + } + + return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error { + // MySQL: ignore conflicts to make seeding idempotent under retries/concurrency. + // Conflicts can happen if the same permission already exists (unique on role_id, action, scope). + if b.dbType == migrator.MySQL { + args := make([]any, 0, (end-start)*8) + for i := start; i < end; i++ { + p := toInsert[i] + args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created) + } + sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` + + strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...) + _, err := b.sess.Exec(sql...) + return err + } + + _, err := b.sess.InsertMulti(toInsert[start:end]) + return err + }) +} + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + if err := eachFn(i, end); err != nil { + return err + } + i = end + } + return nil +} + +// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin). +// Unknown role names are ignored. +func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error { + if len(basicRoles) == 0 { + return nil + } + + defs := accesscontrol.BuildBasicRoleDefinitions() + uids := make([]any, 0, len(basicRoles)) + for _, br := range basicRoles { + def, ok := defs[br] + if !ok { + continue + } + uids = append(uids, def.UID) + } + if len(uids) == 0 { + return nil + } + + sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")" + _, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...) + return err +} + +// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles. +// It sets Origin to empty. +func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) { + rows := []accesscontrol.SeedPermission{} + err := sess.SQL( + `SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE 'basic:%'`, + accesscontrol.GlobalOrgID, + ).Find(&rows) + return rows, err +} diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index a0f2f47b77d..ff6637219a4 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -130,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error { // Reconcile schedules as job that will run and reconcile resources between // legacy access control and zanzana. func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { + // Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions. + // This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service. + r.waitForBasicRolesSeeded(ctx) r.reconcile(ctx) // FIXME: @@ -145,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } } +func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { + var count int64 + // Basic role permissions are stored on "basic:%" roles in the global org (0). + // In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs. + type row struct { + Count int64 `xorm:"count"` + } + _ = r.store.WithDbSession(ctx, func(sess *db.Session) error { + var rr row + _, err := sess.SQL( + `SELECT COUNT(*) AS count + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE ?`, + accesscontrol.GlobalOrgID, + accesscontrol.BasicRolePrefix+"%", + ).Get(&rr) + if err != nil { + return err + } + count = rr.Count + return nil + }) + return count > 0 +} + +func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { + // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. + const ( + maxWait = 15 * time.Second + interval = 1 * time.Second + ) + + deadline := time.NewTimer(maxWait) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if r.hasBasicRolePermissions(ctx) { + return + } + select { + case <-ctx.Done(): + return + case <-deadline.C: + return + case <-ticker.C: + } + } +} + func (r *ZanzanaReconciler) reconcile(ctx context.Context) { run := func(ctx context.Context, namespace string) (ok bool) { now := time.Now() diff --git a/pkg/services/accesscontrol/dualwrite/reconciler_test.go b/pkg/services/accesscontrol/dualwrite/reconciler_test.go new file mode 100644 index 00000000000..0defea011a0 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/reconciler_test.go @@ -0,0 +1,67 @@ +package dualwrite + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) { + env := setupTestEnv(t) + + r := &ZanzanaReconciler{ + store: env.db, + } + + ctx := context.Background() + require.False(t, r.hasBasicRolePermissions(ctx)) + + err := env.db.WithDbSession(ctx, func(sess *db.Session) error { + now := time.Now() + + _, err := sess.Exec( + `INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + accesscontrol.GlobalOrgID, + "basic_viewer_uid_test", + accesscontrol.BasicRolePrefix+"viewer", + "Viewer", + "Basic", + "Viewer role", + false, + 1, + now, + now, + ) + if err != nil { + return err + } + + var roleID int64 + if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil { + return err + } + + _, err = sess.Exec( + `INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + roleID, + "dashboards:read", + "dashboards:*", + "", + "", + "", + now, + now, + ) + return err + }) + require.NoError(t, err) + + require.True(t, r.hasBasicRolePermissions(ctx)) +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b18fb4134f3..85df44750d2 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -1,6 +1,7 @@ package accesscontrol import ( + "context" "encoding/json" "errors" "fmt" @@ -594,3 +595,18 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type SeedPermission struct { + BuiltInRole string `xorm:"builtin_role"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + Origin string `xorm:"origin"` +} + +type RoleStore interface { + LoadRoles(ctx context.Context) (map[string]*RoleDTO, error) + SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + CreateRole(ctx context.Context, role RoleDTO) error + DeleteRoles(ctx context.Context, roleUIDs []string) error +} diff --git a/pkg/services/accesscontrol/seeding/seeder.go b/pkg/services/accesscontrol/seeding/seeder.go new file mode 100644 index 00000000000..121be48dbb0 --- /dev/null +++ b/pkg/services/accesscontrol/seeding/seeder.go @@ -0,0 +1,452 @@ +package seeding + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" +) + +type Seeder struct { + log log.Logger + roleStore accesscontrol.RoleStore + backend SeedingBackend + builtinsPermissions map[accesscontrol.SeedPermission]struct{} + seededFixedRoles map[string]bool + seededPluginRoles map[string]bool + seededPlugins map[string]bool + hasSeededAlready bool +} + +// SeedingBackend provides the seed-set specific operations needed to seed. +type SeedingBackend interface { + // LoadPrevious returns the currently stored permissions for previously seeded roles. + LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) + + // Apply updates the database to match the desired permissions. + Apply(ctx context.Context, + added, removed []accesscontrol.SeedPermission, + updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, + ) error +} + +func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder { + return &Seeder{ + log: log, + roleStore: roleStore, + backend: backend, + builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{}, + seededFixedRoles: map[string]bool{}, + seededPluginRoles: map[string]bool{}, + seededPlugins: map[string]bool{}, + hasSeededAlready: false, + } +} + +// SetDesiredPermissions replaces the in-memory desired permission set used by Seed(). +func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) { + if desired == nil { + s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{} + return + } + s.builtinsPermissions = desired +} + +// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions. +func (s *Seeder) Seed(ctx context.Context) error { + previous, err := s.backend.LoadPrevious(ctx) + if err != nil { + return err + } + + // - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins). + // - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default). + if len(previous) > 0 { + filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous)) + for p := range previous { + // Legacy plugin app access permissions (Origin set) are granted by default and managed outside seeding. + // Keep them out of the diff so seeding doesn't try to remove or "re-add" them on every run. + if p.Action == pluginaccesscontrol.ActionAppAccess && p.Origin != "" { + continue + } + if p.Origin != "" && !s.seededPlugins[p.Origin] { + continue + } + filtered[p] = struct{}{} + } + previous = filtered + } + + added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions) + + if err := s.backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + return nil +} + +// SeedRoles populates the database with the roles and their assignments +// It will create roles that do not exist and update roles that have changed +// Do not use for provisioning. Validation is not enforced. +func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error { + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList)) + + // Diff existing roles with the ones we want to seed. + // If a role is missing, we add it to the missingRoles list + for _, registration := range registrationList { + registration := registration + role, ok := roleMap[registration.Role.Name] + switch { + case registration.Role.IsFixed(): + s.seededFixedRoles[registration.Role.Name] = true + case registration.Role.IsPlugin(): + s.seededPluginRoles[registration.Role.Name] = true + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true + } + + s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude) + + if !ok { + missingRoles = append(missingRoles, registration) + continue + } + + if needsRoleUpdate(role, registration.Role) { + if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil { + return err + } + } + + if needsPermissionsUpdate(role, registration.Role) { + if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil { + return err + } + } + } + + for _, registration := range missingRoles { + if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil { + return err + } + } + + return nil +} + +func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if len(existingRole.Permissions) != len(wantedRole.Permissions) { + return true + } + + for _, p := range wantedRole.Permissions { + found := false + for _, ep := range existingRole.Permissions { + if ep.Action == p.Action && ep.Scope == p.Scope { + found = true + break + } + } + if !found { + return true + } + } + + return false +} + +func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if existingRole.Name != wantedRole.Name { + return false + } + + if existingRole.DisplayName != wantedRole.DisplayName { + return true + } + + if existingRole.Description != wantedRole.Description { + return true + } + + if existingRole.Group != wantedRole.Group { + return true + } + + if existingRole.Hidden != wantedRole.Hidden { + return true + } + + return false +} + +// Deprecated: SeedRole is deprecated and should not be used. +// SeedRoles only does boot up seeding and should not be used for runtime seeding. +func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error { + addedPermissions := make(map[string]struct{}, len(role.Permissions)) + permissions := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + key := fmt.Sprintf("%s:%s", p.Action, p.Scope) + if _, ok := addedPermissions[key]; !ok { + addedPermissions[key] = struct{}{} + permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope}) + } + } + + wantedRole := accesscontrol.RoleDTO{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: role.UID, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Permissions: permissions, + Hidden: role.Hidden, + } + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + existingRole := roleMap[wantedRole.Name] + if existingRole == nil { + if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil { + return err + } + } else { + if needsRoleUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil { + return err + } + } + if needsPermissionsUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil { + return err + } + } + } + + // Remember seeded roles + if wantedRole.IsFixed() { + s.seededFixedRoles[wantedRole.Name] = true + } + isPluginRole := wantedRole.IsPlugin() + if isPluginRole { + s.seededPluginRoles[wantedRole.Name] = true + + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true + } + + s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{}) + return nil +} + +func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) { + AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles) +} + +// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin). +// - It expands parents via accesscontrol.BuiltInRolesWithParents. +// - It can optionally ignore plugin app access permissions (which are granted by default). +func AppendDesiredPermissions( + out map[accesscontrol.SeedPermission]struct{}, + logger log.Logger, + role *accesscontrol.RoleDTO, + builtInRoles []string, + excludedRoles []string, +) { + if out == nil || role == nil { + return + } + + for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { + // Skip excluded grants + if slices.Contains(excludedRoles, builtInRole) { + continue + } + + for _, perm := range role.Permissions { + if role.IsPlugin() && perm.Action == pluginaccesscontrol.ActionAppAccess { + logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored", + "role", role.Name, "permission", perm.Action, "scope", perm.Scope) + continue + } + + sp := accesscontrol.SeedPermission{ + BuiltInRole: builtInRole, + Action: perm.Action, + Scope: perm.Scope, + } + + if role.IsPlugin() { + sp.Origin = pluginutils.PluginIDFromName(role.Name) + } + + out[sp] = struct{}{} + } + } +} + +// permissionDiff returns: +// - added: present in desired permissions, not in previous permissions +// - removed: present in previous permissions, not in desired permissions +// - updated: same role + action, but scope changed +func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) { + addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0) + for n := range desired { + if _, already := previous[n]; !already { + addedSet[n] = struct{}{} + } else { + delete(previous, n) + } + } + + // Check if any of the new permissions is actually an old permission with an updated scope + updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0) + for n := range addedSet { + for p := range previous { + if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action { + updated[p] = n + delete(addedSet, n) + } + } + } + + for p := range addedSet { + added = append(added, p) + } + + for p := range previous { + if p.Action == pluginaccesscontrol.ActionAppAccess && + p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() { + // Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added + s.log.Info("This permission already existed so it will not be removed", + "role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope) + continue + } + + removed = append(removed, p) + } + + return added, removed, updated +} + +func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) { + removable := []accesscontrol.SeedPermission{} + + for key := range s.builtinsPermissions { + if matchPermissionByPluginID(key, ID) { + removable = append(removable, key) + } + } + + for _, perm := range removable { + delete(s.builtinsPermissions, perm) + } +} + +func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool { + if perm.Origin != pluginID { + return false + } + actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID)) + scopeTemplate := fmt.Sprintf(":%s", pluginID) + return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate) +} + +// RolesToUpgrade returns the unique basic roles that should have their version incremented. +func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string { + set := map[string]struct{}{} + for _, p := range added { + set[p.BuiltInRole] = struct{}{} + } + for _, p := range removed { + set[p.BuiltInRole] = struct{}{} + } + out := make([]string, 0, len(set)) + for r := range set { + out = append(out, r) + } + return out +} + +func (s *Seeder) ClearPluginRoles(ID string) { + expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID) + + for roleName := range s.seededPluginRoles { + if strings.HasPrefix(roleName, expectedPrefix) { + delete(s.seededPluginRoles, roleName) + } + } +} + +func (s *Seeder) MarkSeededAlready() { + s.hasSeededAlready = true +} + +func (s *Seeder) HasSeededAlready() bool { + return s.hasSeededAlready +} + +func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error { + roleMap, errGet := s.roleStore.LoadRoles(ctx) + if errGet != nil { + s.log.Error("failed to get fixed roles from store", "err", errGet) + return errGet + } + + toRemove := []string{} + for _, r := range roleMap { + if r == nil { + continue + } + if r.IsFixed() { + if !s.seededFixedRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + continue + } + + if r.IsPlugin() { + if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] { + // To be resilient to failed plugin loadings + // ignore stored roles related to plugins that have not registered this time + s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name) + continue + } + if !s.seededPluginRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + } + } + + if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil { + s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete) + return errDelete + } + return nil +} diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 4d95d64b024..613d021b236 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -33,8 +33,6 @@ import ( ) func TestIntegrationFolderTreeZanzana(t *testing.T) { - // TODO: Add back OSS seeding and enable this test - t.Skip("Skipping folder tree test with Zanzana") testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{