From 6eeef432de2e1ac6d155b623bae303627831d3e5 Mon Sep 17 00:00:00 2001 From: Ieva Date: Fri, 4 Oct 2024 18:03:04 +0300 Subject: [PATCH] RBAC: Add dash and folder action sets where they are missing (#92832) * add dash and folder action sets where they are missing * remove an empty line, try to make linting pass --- .../accesscontrol/action_set_migration.go | 141 +++++++++ .../test/action_set_migration_test.go | 282 ++++++++++++++++++ .../sqlstore/migrations/migrations.go | 2 + 3 files changed, 425 insertions(+) create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/action_set_migration.go create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/test/action_set_migration_test.go diff --git a/pkg/services/sqlstore/migrations/accesscontrol/action_set_migration.go b/pkg/services/sqlstore/migrations/accesscontrol/action_set_migration.go new file mode 100644 index 00000000000..f04810ab912 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/action_set_migration.go @@ -0,0 +1,141 @@ +package accesscontrol + +import ( + "fmt" + "strings" + "time" + + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +const AddActionSetMigrationID = "adding action set permissions" + +func AddActionSetPermissionsMigrator(mg *migrator.Migrator) { + mg.AddMigration(AddActionSetMigrationID, &actionSetMigrator{}) +} + +type actionSetMigrator struct { + sess *xorm.Session + migrator *migrator.Migrator + migrator.MigrationBase +} + +var _ migrator.CodeMigration = new(actionSetMigrator) + +func (m *actionSetMigrator) SQL(migrator.Dialect) string { + return "code migration" +} + +func (m *actionSetMigrator) Exec(sess *xorm.Session, migrator *migrator.Migrator) error { + m.sess = sess + m.migrator = migrator + return m.addActionSetActions() +} + +func (m *actionSetMigrator) addActionSetActions() error { + var results []accesscontrol.Permission + + // Find action sets and dashboard permissions for managed roles + // We don't need all dashboard permissions, just enough to help us determine what action set permissions to add + sql := ` + SELECT permission.role_id, permission.action, permission.scope FROM permission + LEFT JOIN role ON permission.role_id = role.id + WHERE permission.action IN ('dashboards:read', 'dashboards:write', 'dashboards.permissions:read', 'dashboards:view', 'dashboards:edit', 'dashboards:admin', 'folders:view', 'folders:edit', 'folders:admin') + AND role.name LIKE 'managed:%' +` + if err := m.sess.SQL(sql).Find(&results); err != nil { + return fmt.Errorf("failed to query permissions: %w", err) + } + + // group permissions by map[roleID]map[scope]actionSet + groupedPermissions := make(map[int64]map[string]string) + hasActionSet := make(map[int64]map[string]bool) + for _, result := range results { + // keep track of which dash/folder permission grants already have an action set permission + if isActionSetAction(result.Action) { + if _, ok := hasActionSet[result.RoleID]; !ok { + hasActionSet[result.RoleID] = make(map[string]bool) + } + hasActionSet[result.RoleID][result.Scope] = true + delete(groupedPermissions[result.RoleID], result.Scope) + continue + } + + // don't add action set permissions where they already exist + if _, has := hasActionSet[result.RoleID]; has && hasActionSet[result.RoleID][result.Scope] { + continue + } + + if _, ok := groupedPermissions[result.RoleID]; !ok { + groupedPermissions[result.RoleID] = make(map[string]string) + } + + // store the most permissive action set permission + currentActionSet := groupedPermissions[result.RoleID][result.Scope] + switch result.Action { + case "dashboards:read": + if currentActionSet == "" { + groupedPermissions[result.RoleID][result.Scope] = "view" + } + case "dashboards:write": + if currentActionSet != "admin" { + groupedPermissions[result.RoleID][result.Scope] = "edit" + } + case "dashboards.permissions:read": + groupedPermissions[result.RoleID][result.Scope] = "admin" + } + } + + toAdd := make([]accesscontrol.Permission, 0, len(groupedPermissions)) + + now := time.Now() + for roleID, permissions := range groupedPermissions { + for scope, action := range permissions { + // should never be the case, but keeping this check for extra safety + if _, ok := hasActionSet[roleID][scope]; ok { + continue + } + + if strings.HasPrefix(scope, "folders:") { + action = fmt.Sprintf("folders:%s", action) + } else { + action = fmt.Sprintf("dashboards:%s", action) + } + + kind, attr, identifier := accesscontrol.SplitScope(scope) + toAdd = append(toAdd, accesscontrol.Permission{ + RoleID: roleID, + Scope: scope, + Action: action, + Kind: kind, + Attribute: attr, + Identifier: identifier, + Created: now, + Updated: now, + }) + } + } + + if len(toAdd) > 0 { + err := batch(len(toAdd), batchSize, func(start, end int) error { + m.migrator.Logger.Debug(fmt.Sprintf("inserting permissions %v", toAdd[start:end])) + if _, err := m.sess.InsertMulti(toAdd[start:end]); err != nil { + return fmt.Errorf("failed to add action sets: %w", err) + } + return nil + }) + if err != nil { + return err + } + m.migrator.Logger.Debug("updated managed roles with dash and folder action set permissions") + } + + return nil +} + +func isActionSetAction(action string) bool { + return action == "dashboards:view" || action == "dashboards:edit" || action == "dashboards:admin" || action == "folders:view" || action == "folders:edit" || action == "folders:admin" +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/action_set_migration_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/action_set_migration_test.go new file mode 100644 index 00000000000..949b257aaf2 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/action_set_migration_test.go @@ -0,0 +1,282 @@ +package test + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +func TestActionSetMigration(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + type migrationTestCase struct { + desc string + existingRolePerms map[string]map[string][]string + expectedActionSets map[string]map[string]string + } + testCases := []migrationTestCase{ + { + desc: "empty perms", + existingRolePerms: map[string]map[string][]string{}, + }, + { + desc: "dashboard permissions that are not managed don't get an action set", + existingRolePerms: map[string]map[string][]string{ + "my_custom_role": { + "dashboards:uid:1": ossaccesscontrol.DashboardViewActions, + }, + }, + }, + { + desc: "managed permissions that are not dashboard permissions don't get an action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "datasources:uid:1": {"datasources:query", "datasources:read"}, + }, + }, + }, + { + desc: "managed dash viewer gets a viewer action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": ossaccesscontrol.DashboardViewActions, + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": "dashboards:view", + }, + }, + }, + { + desc: "managed dash editor gets an editor action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": ossaccesscontrol.DashboardEditActions, + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": "dashboards:edit", + }, + }, + }, + { + desc: "managed dash admin gets an admin action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": ossaccesscontrol.DashboardAdminActions, + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": "dashboards:admin", + }, + }, + }, + { + desc: "managed folder viewer gets a viewer action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderViewActions, ossaccesscontrol.DashboardViewActions...), + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": "folders:view", + }, + }, + }, + { + desc: "managed folder editor gets an editor action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderEditActions, ossaccesscontrol.DashboardEditActions...), + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": "folders:edit", + }, + }, + }, + { + desc: "managed folder admin gets an admin action set", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderAdminActions, ossaccesscontrol.DashboardAdminActions...), + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": "folders:admin", + }, + }, + }, + { + desc: "can add action sets for multiple folders and dashboards under the same managed permission", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderAdminActions, ossaccesscontrol.DashboardAdminActions...), + "dashboards:uid:1": ossaccesscontrol.DashboardEditActions, + "datasources:uid:1": {"datasources:query", "datasources:read"}, + "folders:uid:2": append(ossaccesscontrol.FolderViewActions, ossaccesscontrol.DashboardViewActions...), + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": "folders:admin", + "folders:uid:2": "folders:view", + "dashboards:uid:1": "dashboards:edit", + }, + }, + }, + { + desc: "can add action sets for multiple managed roles", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderAdminActions, ossaccesscontrol.DashboardAdminActions...), + "folders:uid:2": append(ossaccesscontrol.FolderViewActions, ossaccesscontrol.DashboardViewActions...), + }, + "managed:users:1:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderEditActions, ossaccesscontrol.DashboardEditActions...), + "dashboards:uid:1": ossaccesscontrol.DashboardEditActions, + }, + "managed:teams:1:permissions": { + "folders:uid:1": append(ossaccesscontrol.FolderEditActions, ossaccesscontrol.DashboardEditActions...), + "folders:uid:2": append(ossaccesscontrol.FolderAdminActions, ossaccesscontrol.DashboardAdminActions...), + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "folders:uid:1": "folders:admin", + "folders:uid:2": "folders:view", + }, + "managed:users:1:permissions": { + "folders:uid:1": "folders:edit", + "dashboards:uid:1": "dashboards:edit", + }, + "managed:teams:1:permissions": { + "folders:uid:1": "folders:edit", + "folders:uid:2": "folders:admin", + }, + }, + }, + { + desc: "can handle existing action sets", + existingRolePerms: map[string]map[string][]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": append(ossaccesscontrol.DashboardAdminActions, "dashboards:admin"), + "dashboards:uid:2": ossaccesscontrol.DashboardViewActions, + "dashboards:uid:4": append(ossaccesscontrol.DashboardViewActions, "dashboards:view"), + }, + "managed:users:1:permissions": { + "dashboards:uid:1": append(ossaccesscontrol.DashboardEditActions, "dashboards:edit"), + "dashboards:uid:2": append(ossaccesscontrol.DashboardViewActions, "dashboards:view"), + "dashboards:uid:3": ossaccesscontrol.DashboardEditActions, + "dashboards:uid:4": ossaccesscontrol.DashboardAdminActions, + }, + }, + expectedActionSets: map[string]map[string]string{ + "managed:builtins:viewer:permissions": { + "dashboards:uid:1": "dashboards:admin", + "dashboards:uid:2": "dashboards:view", + "dashboards:uid:4": "dashboards:view", + }, + "managed:users:1:permissions": { + "dashboards:uid:1": "dashboards:edit", + "dashboards:uid:2": "dashboards:view", + "dashboards:uid:3": "dashboards:edit", + "dashboards:uid:4": "dashboards:admin", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration, roles and permissions + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?`, acmig.AddActionSetMigrationID) + require.NoError(t, errDeleteMig) + _, errDeleteRole := x.Exec(`DELETE FROM role`) + require.NoError(t, errDeleteRole) + _, errDeletePerms := x.Exec(`DELETE FROM permission`) + require.NoError(t, errDeletePerms) + + orgID := 1 + rolePerms := map[string][]rawPermission{} + for roleName, permissions := range tc.existingRolePerms { + rawPerms := []rawPermission{} + for scope, actions := range permissions { + for _, action := range actions { + rawPerms = append(rawPerms, rawPermission{Scope: scope, Action: action}) + } + } + rolePerms[roleName] = rawPerms + } + perms := map[int64]map[string][]rawPermission{int64(orgID): rolePerms} + + // seed DB with permissions + putTestPermissions(t, x, perms) + + // Run action set migration + acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) + acmig.AddActionSetPermissionsMigrator(acmigrator) + + errRunningMig := acmigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + // verify got == want + for roleName, existingPerms := range tc.existingRolePerms { + // Check the role exists + role := accesscontrol.Role{} + hasRole, err := x.Table("role").Where("org_id = ? AND name = ?", orgID, roleName).Get(&role) + require.NoError(t, err) + require.True(t, hasRole, "expected role to exist", "role", roleName) + + // Check permissions associated with each role + perms := []accesscontrol.Permission{} + _, err = x.Table("permission").Where("role_id = ?", role.ID).FindAndCount(&perms) + require.NoError(t, err) + + gotRawPerms := convertToScopeActionMap(perms) + expectedPerms := getExpectedPerms(existingPerms, tc.expectedActionSets[roleName]) + require.Equal(t, len(gotRawPerms), len(expectedPerms), "expected role to contain the same amount of scopes", "role", roleName) + for scope, actions := range expectedPerms { + require.ElementsMatch(t, gotRawPerms[scope], actions, "expected role to have the same permissions", "role", roleName) + } + } + }) + } +} + +func convertToScopeActionMap(perms []accesscontrol.Permission) map[string][]string { + result := map[string][]string{} + for _, perm := range perms { + if _, ok := result[perm.Scope]; !ok { + result[perm.Scope] = []string{} + } + result[perm.Scope] = append(result[perm.Scope], perm.Action) + } + return result +} + +func getExpectedPerms(existingPerms map[string][]string, actionSets map[string]string) map[string][]string { + for scope := range existingPerms { + if actionSet, ok := actionSets[scope]; ok { + if !slices.Contains(existingPerms[scope], actionSet) { + existingPerms[scope] = append(existingPerms[scope], actionSets[scope]) + } + } + } + return existingPerms +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index f8de7ec326e..9d592a8e53d 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -133,6 +133,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { ualert.AddRuleMetadata(mg) accesscontrol.AddOrphanedMigrations(mg) + + accesscontrol.AddActionSetPermissionsMigrator(mg) } func addStarMigrations(mg *Migrator) {