diff --git a/pkg/services/sqlstore/migrations/accesscontrol/managed_permission_migrator.go b/pkg/services/sqlstore/migrations/accesscontrol/managed_permission_migrator.go new file mode 100644 index 00000000000..02b5378e341 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/managed_permission_migrator.go @@ -0,0 +1,163 @@ +// This migration ensures that permissions attributed to a managed role are also granted +// to parent roles. +// Example setup: +// editor read, query datasources:uid:2 +// editor read, query datasources:uid:1 +// admin read, query, write datasources:uid:1 +// we'd need to create admin read, query, write datasources:uid:2 + +package accesscontrol + +import ( + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "xorm.io/xorm" +) + +const ManagedPermissionsMigrationID = "managed permissions migration" + +func AddManagedPermissionsMigration(mg *migrator.Migrator) { + mg.AddMigration(ManagedPermissionsMigrationID, &managedPermissionMigrator{}) +} + +type managedPermissionMigrator struct { + migrator.MigrationBase +} + +func (sp *managedPermissionMigrator) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (sp *managedPermissionMigrator) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + logger := log.New("managed permissions migrator") + + type Permission struct { + RoleName string `xorm:"role_name"` + RoleID int64 `xorm:"role_id"` + OrgID int64 `xorm:"org_id"` + Action string + Scope string + } + + // get all permissions associated with a managed builtin role + managedPermissions := []Permission{} + if errFindPermissions := sess.SQL(`SELECT r.name as role_name, r.id as role_id, r.org_id as org_id,p.action, p.scope + FROM permission AS p + INNER JOIN role AS r ON p.role_id = r.id + WHERE r.name LIKE ?`, "managed:builtins%"). + Find(&managedPermissions); errFindPermissions != nil { + logger.Error("could not get the managed permissions", "error", errFindPermissions) + return errFindPermissions + } + + roleMap := make(map[int64]map[string]int64) // map[org_id][role_name] = role_id + permissionMap := make(map[int64]map[string]map[Permission]bool) // map[org_id][role_name][Permission] = toInsert + + // for each managed permission make a map of which permissions need to be added to inheritors + for _, p := range managedPermissions { + if _, ok := roleMap[p.OrgID]; !ok { + roleMap[p.OrgID] = map[string]int64{p.RoleName: p.RoleID} + } else { + roleMap[p.OrgID][p.RoleName] = p.RoleID + } + + // this ensures we can use p as a key in the map between different permissions + // ensuring we're only comparing on the action and scope + roleName := p.RoleName + p.RoleName = "" + p.RoleID = 0 + + // Add the permission to the map of permissions as "false" - already exists + if _, ok := permissionMap[p.OrgID]; !ok { + permissionMap[p.OrgID] = map[string]map[Permission]bool{roleName: {p: false}} + } else { + if _, ok := permissionMap[p.OrgID][roleName]; !ok { + permissionMap[p.OrgID][roleName] = map[Permission]bool{p: false} + } else { + permissionMap[p.OrgID][roleName][p] = false + } + } + + // Add parent roles + permissions to the map as "true" -- need to be inserted + basicRoleName := ParseRoleFromName(roleName) + for _, parent := range models.RoleType(basicRoleName).Parents() { + parentManagedRoleName := "managed:builtins:" + strings.ToLower(string(parent)) + ":permissions" + + if _, ok := permissionMap[p.OrgID][parentManagedRoleName]; !ok { + permissionMap[p.OrgID][parentManagedRoleName] = map[Permission]bool{p: true} + } else { + if _, ok := permissionMap[p.OrgID][parentManagedRoleName][p]; !ok { + permissionMap[p.OrgID][parentManagedRoleName][p] = true + } + } + } + } + + now := time.Now() + + // Create missing permissions + for orgID, orgMap := range permissionMap { + for managedRole, permissions := range orgMap { + // ensure managed role exists, create and add to map if it doesn't + ok, err := sess.Get(&accesscontrol.Role{Name: managedRole, OrgID: orgID}) + if err != nil { + return err + } + + if !ok { + uid, err := generateNewRoleUID(sess, orgID) + if err != nil { + return err + } + createdRole := accesscontrol.Role{Name: managedRole, OrgID: orgID, UID: uid, Created: now, Updated: now} + if _, err := sess.Insert(&createdRole); err != nil { + logger.Error("Unable to create managed role", "error", err) + return err + } + + connection := accesscontrol.BuiltinRole{ + RoleID: createdRole.ID, + OrgID: orgID, + Role: ParseRoleFromName(createdRole.Name), + Created: now, + Updated: now, + } + + if _, err := sess.Insert(&connection); err != nil { + logger.Error("Unable to create managed role connection", "error", err) + return err + } + + roleMap[orgID][managedRole] = createdRole.ID + } + + // assign permissions if they don't exist to the role + roleID := roleMap[orgID][managedRole] + for p, toInsert := range permissions { + if toInsert { + perm := accesscontrol.Permission{RoleID: roleID, Action: p.Action, Scope: p.Scope, Created: now, Updated: now} + if _, err := sess.Insert(&perm); err != nil { + logger.Error("Unable to create managed permission", "error", err) + return err + } + } + } + } + } + + return nil +} + +// Converts from managed:builtins::permissions to +// Example: managed:builtins:editor:permissions -> Editor +func ParseRoleFromName(roleName string) string { + return cases.Title(language.AmericanEnglish). + String(strings.TrimSuffix(strings.TrimPrefix(roleName, "managed:builtins:"), ":permissions")) +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go index 38f880f5674..2b8a0b47944 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go @@ -1,6 +1,10 @@ package accesscontrol -import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +import ( + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +const CodeMigrationSQL = "code migration" func AddMigration(mg *migrator.Migrator) { permissionV1 := migrator.Table{ diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go index 8bc1cd056ec..30430b559ee 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go @@ -7,6 +7,7 @@ import ( "xorm.io/xorm" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" @@ -22,6 +23,16 @@ type rawPermission struct { Action, Scope string } +func (rp *rawPermission) toPermission(roleID int64, ts time.Time) accesscontrol.Permission { + return accesscontrol.Permission{ + RoleID: roleID, + Action: rp.Action, + Scope: rp.Scope, + Updated: ts, + Created: ts, + } +} + // Setup users var ( now = time.Now() @@ -209,9 +220,7 @@ func setupTestDB(t *testing.T) *xorm.Engine { err = migrator.NewDialect(x).CleanDB() require.NoError(t, err) - mg := migrator.NewMigrator(x, &setting.Cfg{ - IsFeatureToggleEnabled: func(key string) bool { return key == "accesscontrol" }, - }) + mg := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) migrations := &migrations.OSSMigrations{} migrations.AddMigration(mg) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go new file mode 100644 index 00000000000..a903d42faea --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go @@ -0,0 +1,202 @@ +package test + +import ( + "strconv" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +func TestManagedPermissionsMigration(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + team1Scope := accesscontrol.Scope("teams", "id", "1") + team2Scope := accesscontrol.Scope("teams", "id", "2") + + type teamMigrationTestCase struct { + desc string + putRolePerms map[int64]map[string][]rawPermission + wantRolePerms map[int64]map[string][]rawPermission + } + testCases := []teamMigrationTestCase{ + { + desc: "empty perms", + putRolePerms: map[int64]map[string][]rawPermission{}, + wantRolePerms: map[int64]map[string][]rawPermission{}, + }, + { + desc: "only unrelated perms", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:users:1:permissions": {{Action: "teams:read", Scope: team1Scope}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:users:1:permissions": {{Action: "teams:read", Scope: team1Scope}}, + }, + }, + }, + { + desc: "inherit permissions from managed role", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:builtins:viewer:permissions": { + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:editor:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + }, + }, + 2: { + "managed:users:1:permissions": {{Action: "teams:read", Scope: team1Scope}}, + "managed:builtins:viewer:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:editor:permissions": { + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:admin:permissions": { + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + {Action: "teams:write", Scope: team1Scope}, + }, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:builtins:viewer:permissions": { + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:editor:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:admin:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + }, + 2: { + "managed:users:1:permissions": {{Action: "teams:read", Scope: team1Scope}}, + "managed:builtins:viewer:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:editor:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + }, + "managed:builtins:admin:permissions": { + {Action: "teams:delete", Scope: team1Scope}, + {Action: "teams.permissions:read", Scope: team1Scope}, + {Action: "teams.permissions:write", Scope: team2Scope}, + {Action: "teams:write", Scope: team1Scope}, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?; +DELETE FROM permission; DELETE FROM role`, acmig.ManagedPermissionsMigrationID) + require.NoError(t, errDeleteMig) + + // put permissions + putTestPermissions(t, x, tc.putRolePerms) + + // Run accesscontrol migration (permissions insertion should not have conflicted) + acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) + acmig.AddManagedPermissionsMigration(acmigrator) + + errRunningMig := acmigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + // verify got == want + for orgID, roles := range tc.wantRolePerms { + for roleName := range roles { + // Check managed roles exist + role := accesscontrol.Role{} + hasRole, errManagedRoleSearch := x.Table("role").Where("org_id = ? AND name = ?", orgID, roleName).Get(&role) + + require.NoError(t, errManagedRoleSearch) + require.True(t, hasRole, "expected role to exist", "orgID", orgID, "role", roleName) + + // Check permissions associated with each role + perms := []accesscontrol.Permission{} + count, errManagedPermsSearch := x.Table("permission").Where("role_id = ?", role.ID).FindAndCount(&perms) + + require.NoError(t, errManagedPermsSearch) + require.Equal(t, int64(len(tc.wantRolePerms[orgID][roleName])), count, "expected role to be tied to permissions", "orgID", orgID, "role", roleName) + + gotRawPerms := convertToRawPermissions(perms) + require.ElementsMatch(t, gotRawPerms, tc.wantRolePerms[orgID][roleName], "expected role to have permissions", "orgID", orgID, "role", roleName) + + // Check assignment of the roles + br := accesscontrol.BuiltinRole{} + has, errAssignmentSearch := x.Table("builtin_role").Where("role_id = ? AND role = ? AND org_id = ?", role.ID, acmig.ParseRoleFromName(roleName), orgID).Get(&br) + require.NoError(t, errAssignmentSearch) + require.True(t, has, "expected assignment of role to builtin role", "orgID", orgID, "role", roleName) + } + } + }) + } +} + +func putTestPermissions(t *testing.T, x *xorm.Engine, rolePerms map[int64]map[string][]rawPermission) { + for orgID, roles := range rolePerms { + for roleName, perms := range roles { + uid := strconv.FormatInt(orgID, 10) + strings.ReplaceAll(roleName, ":", "_") + role := accesscontrol.Role{ + OrgID: orgID, + Version: 1, + UID: uid, + Name: roleName, + Updated: now, + Created: now, + } + roleCount, errInsertRole := x.Insert(&role) + require.NoError(t, errInsertRole) + require.Equal(t, int64(1), roleCount) + + br := accesscontrol.BuiltinRole{ + RoleID: role.ID, + OrgID: role.OrgID, + Role: acmig.ParseRoleFromName(roleName), + Updated: now, + Created: now, + } + brCount, err := x.Insert(br) + require.NoError(t, err) + require.Equal(t, int64(1), brCount) + + permissions := []accesscontrol.Permission{} + for _, p := range perms { + permissions = append(permissions, p.toPermission(role.ID, now)) + } + permissionsCount, err := x.Insert(permissions) + require.NoError(t, err) + require.Equal(t, int64(len(perms)), permissionsCount) + } + } +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 88fbd410519..facb4b8225d 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -97,6 +97,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { addPublicDashboardMigration(mg) ualert.CreateDefaultFoldersForAlertingMigration(mg) addDbFileStorageMigration(mg) + + accesscontrol.AddManagedPermissionsMigration(mg) } func addMigrationLogMigrations(mg *Migrator) {