diff --git a/pkg/services/ngalert/migration/alert_rule.go b/pkg/services/ngalert/migration/alert_rule.go index 2ef2ec03fe9..be8d39a3da1 100644 --- a/pkg/services/ngalert/migration/alert_rule.go +++ b/pkg/services/ngalert/migration/alert_rule.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" + migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -40,14 +41,14 @@ func addMigrationInfo(da *migrationStore.DashAlert, dashboardUID string) (map[st } // MigrateAlert migrates a single dashboard alert from legacy alerting to unified alerting. -func (om *OrgMigration) migrateAlert(ctx context.Context, l log.Logger, da *migrationStore.DashAlert, dashboardUID string, folderUID string) (*ngmodels.AlertRule, error) { +func (om *OrgMigration) migrateAlert(ctx context.Context, l log.Logger, da *migrationStore.DashAlert, info migmodels.DashboardUpgradeInfo) (*ngmodels.AlertRule, error) { l.Debug("Migrating alert rule to Unified Alerting") cond, err := transConditions(ctx, da, om.migrationStore) if err != nil { return nil, fmt.Errorf("transform conditions: %w", err) } - lbls, annotations := addMigrationInfo(da, dashboardUID) + lbls, annotations := addMigrationInfo(da, info.DashboardUID) message := MigrateTmpl(l.New("field", "message"), da.Message) annotations["message"] = message @@ -63,15 +64,29 @@ func (om *OrgMigration) migrateAlert(ctx context.Context, l log.Logger, da *migr } // Here we ensure that the alert rule title is unique within the folder. - dedupSet := om.AlertTitleDeduplicator(folderUID) - name := truncateRuleName(da.Name) - if dedupSet.contains(name) { - dedupedName := dedupSet.deduplicate(name) - l.Debug("Duplicate alert rule name detected, renaming", "old_name", name, "new_name", dedupedName) + titleDedupSet := om.AlertTitleDeduplicator(info.NewFolderUID) + name := truncate(da.Name, store.AlertDefinitionMaxTitleLength) + if titleDedupSet.contains(name) { + dedupedName := titleDedupSet.deduplicate(name) + l.Debug("Duplicate alert rule name detected, renaming", "oldName", name, "newName", dedupedName) name = dedupedName } - dedupSet.add(name) + titleDedupSet.add(name) + // Here we ensure that the alert rule group is unique within the folder. + // This is so that we don't have to ensure that the alerts rules have the same interval. + groupDedupSet := om.AlertTitleDeduplicator(info.NewFolderUID) + panelSuffix := fmt.Sprintf(" - %d", da.PanelID) + truncatedDashboard := truncate(info.DashboardName, store.AlertRuleMaxRuleGroupNameLength-len(panelSuffix)) + groupName := fmt.Sprintf("%s%s", truncatedDashboard, panelSuffix) // Unique to this dash alert but still contains useful info. + if groupDedupSet.contains(groupName) { + dedupedGroupName := groupDedupSet.deduplicate(groupName) + l.Debug("Duplicate alert rule group name detected, renaming", "oldGroup", groupName, "newGroup", dedupedGroupName) + groupName = dedupedGroupName + } + groupDedupSet.add(groupName) + + dashUID := info.DashboardUID ar := &ngmodels.AlertRule{ OrgID: da.OrgID, Title: name, @@ -80,10 +95,10 @@ func (om *OrgMigration) migrateAlert(ctx context.Context, l log.Logger, da *migr Data: data, IntervalSeconds: ruleAdjustInterval(da.Frequency), Version: 1, - NamespaceUID: folderUID, // Folder already created, comes from env var. - DashboardUID: &dashboardUID, + NamespaceUID: info.NewFolderUID, + DashboardUID: &dashUID, PanelID: &da.PanelID, - RuleGroup: name, + RuleGroup: groupName, For: da.For, Updated: time.Now().UTC(), Annotations: annotations, @@ -264,10 +279,10 @@ func transExecErr(l log.Logger, s string) ngmodels.ExecutionErrorState { } } -// truncateRuleName truncates the rule name to the maximum allowed length. -func truncateRuleName(daName string) string { - if len(daName) > store.AlertDefinitionMaxTitleLength { - return daName[:store.AlertDefinitionMaxTitleLength] +// truncate truncates the given name to the maximum allowed length. +func truncate(daName string, length int) string { + if len(daName) > length { + return daName[:length] } return daName } diff --git a/pkg/services/ngalert/migration/alert_rule_test.go b/pkg/services/ngalert/migration/alert_rule_test.go index 8ffa233d79a..49296ea0bc4 100644 --- a/pkg/services/ngalert/migration/alert_rule_test.go +++ b/pkg/services/ngalert/migration/alert_rule_test.go @@ -2,6 +2,7 @@ package migration import ( "context" + "fmt" "strings" "testing" @@ -12,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log/logtest" legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" + migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -127,17 +129,22 @@ func TestAddMigrationInfo(t *testing.T) { func TestMakeAlertRule(t *testing.T) { sqlStore := db.InitTestDB(t) + info := migmodels.DashboardUpgradeInfo{ + DashboardUID: "dashboarduid", + DashboardName: "dashboardname", + NewFolderUID: "ewfolderuid", + NewFolderName: "newfoldername", + } t.Run("when mapping rule names", func(t *testing.T) { t.Run("leaves basic names untouched", func(t *testing.T) { service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) da := createTestDashAlert() - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.Equal(t, da.Name, ar.Title) - require.Equal(t, ar.Title, ar.RuleGroup) }) t.Run("truncates very long names to max length", func(t *testing.T) { @@ -146,7 +153,7 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) @@ -158,7 +165,7 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) @@ -166,7 +173,7 @@ func TestMakeAlertRule(t *testing.T) { da = createTestDashAlert() da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - ar, err = m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err = m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) @@ -174,7 +181,6 @@ func TestMakeAlertRule(t *testing.T) { require.Len(t, parts, 2) require.Greater(t, len(parts[1]), 8, "unique identifier should be longer than 9 characters") require.Equal(t, store.AlertDefinitionMaxTitleLength-1, len(parts[0])+len(parts[1]), "truncated name + underscore + unique identifier should together be DefaultFieldMaxLength") - require.Equal(t, ar.Title, ar.RuleGroup) }) }) @@ -183,7 +189,7 @@ func TestMakeAlertRule(t *testing.T) { m := service.newOrgMigration(1) da := createTestDashAlert() - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.False(t, ar.IsPaused) }) @@ -194,7 +200,7 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.State = "paused" - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.NoError(t, err) require.True(t, ar.IsPaused) }) @@ -205,7 +211,7 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.ParsedSettings.NoDataState = uuid.NewString() - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.Nil(t, err) require.Equal(t, models.NoData, ar.NoDataState) }) @@ -216,7 +222,7 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.ParsedSettings.ExecutionErrorState = uuid.NewString() - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.Nil(t, err) require.Equal(t, models.ErrorErrState, ar.ExecErrState) }) @@ -227,13 +233,78 @@ func TestMakeAlertRule(t *testing.T) { da := createTestDashAlert() da.Message = "Instance ${instance} is down" - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, "dashboard", "folder") + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) require.Nil(t, err) expected := "{{- $mergedLabels := mergeLabelValues $values -}}\n" + "Instance {{$mergedLabels.instance}} is down" require.Equal(t, expected, ar.Annotations["message"]) }) + + t.Run("create unique group from dashboard title and panel", func(t *testing.T) { + service := NewTestMigrationService(t, sqlStore, nil) + m := service.newOrgMigration(1) + da := createTestDashAlert() + da.PanelID = 42 + + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) + + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("%s - %d", info.DashboardName, da.PanelID), ar.RuleGroup) + }) + + t.Run("truncate rule group if dashboard name + panel id is too long", func(t *testing.T) { + service := NewTestMigrationService(t, sqlStore, nil) + m := service.newOrgMigration(1) + da := createTestDashAlert() + da.PanelID = 42 + info := migmodels.DashboardUpgradeInfo{ + DashboardUID: "dashboarduid", + DashboardName: strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-1), + NewFolderUID: "newfolderuid", + NewFolderName: "newfoldername", + } + + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) + + require.NoError(t, err) + require.Len(t, ar.RuleGroup, store.AlertRuleMaxRuleGroupNameLength) + require.Equal(t, fmt.Sprintf("%s - %d", strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-5), da.PanelID), ar.RuleGroup) + }) + + t.Run("deduplicate rule group name if truncation is not unique", func(t *testing.T) { + service := NewTestMigrationService(t, sqlStore, nil) + m := service.newOrgMigration(1) + da := createTestDashAlert() + da.PanelID = 42 + info := migmodels.DashboardUpgradeInfo{ + DashboardUID: "dashboarduid", + DashboardName: strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-1), + NewFolderUID: "newfolderuid", + NewFolderName: "newfoldername", + } + + _, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) + require.NoError(t, err) + + da = createTestDashAlert() + da.PanelID = 42 + info = migmodels.DashboardUpgradeInfo{ + DashboardUID: "dashboarduid", + DashboardName: strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-1), + NewFolderUID: "newfolderuid", + NewFolderName: "newfoldername", + } + + ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, &da, info) + + require.NoError(t, err) + require.Len(t, ar.RuleGroup, store.AlertRuleMaxRuleGroupNameLength) + parts := strings.SplitN(ar.RuleGroup, "_", 2) + require.Len(t, parts, 2) + require.Greater(t, len(parts[1]), 8, "unique identifier should be longer than 9 characters") + require.Equal(t, store.AlertDefinitionMaxTitleLength-1, len(parts[0])+len(parts[1]), "truncated name + underscore + unique identifier should together be DefaultFieldMaxLength") + }) } func createTestDashAlert() migrationStore.DashAlert { diff --git a/pkg/services/ngalert/migration/migration_test.go b/pkg/services/ngalert/migration/migration_test.go index d5edee7b274..85ef5c73925 100644 --- a/pkg/services/ngalert/migration/migration_test.go +++ b/pkg/services/ngalert/migration/migration_test.go @@ -697,7 +697,7 @@ func TestDashAlertQueryMigration(t *testing.T) { NamespaceUID: "folder5-1", DashboardUID: pointer("dash1-1"), PanelID: pointer(int64(1)), - RuleGroup: "alert1", + RuleGroup: "dash1-1", RuleGroupIndex: 1, NoDataState: ngModels.NoData, ExecErrState: ngModels.AlertingErrState, @@ -713,6 +713,8 @@ func TestDashAlertQueryMigration(t *testing.T) { mutator(rule) } + rule.RuleGroup = fmt.Sprintf("%s - %d", *rule.DashboardUID, *rule.PanelID) + rule.Annotations["__dashboardUid__"] = *rule.DashboardUID rule.Annotations["__panelId__"] = strconv.FormatInt(*rule.PanelID, 10) return rule diff --git a/pkg/services/ngalert/migration/models.go b/pkg/services/ngalert/migration/models.go index 90553f88b70..a5047caeabb 100644 --- a/pkg/services/ngalert/migration/models.go +++ b/pkg/services/ngalert/migration/models.go @@ -6,6 +6,7 @@ import ( pb "github.com/prometheus/alertmanager/silence/silencepb" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" @@ -28,9 +29,12 @@ type OrgMigration struct { seenUIDs Deduplicator silences []*pb.MeshSilence alertRuleTitleDedup map[string]Deduplicator // Folder -> Deduplicator (Title). + alertRuleGroupDedup map[string]Deduplicator // Folder -> Deduplicator (Group). - // cache for folders created for dashboards that have custom permissions - folderCache map[string]*folder.Folder + // Migrated folder for a dashboard based on permissions. Parent Folder ID -> unique dashboard permission -> custom folder. + permissionsMap map[int64]map[permissionHash]*folder.Folder + folderCache map[int64]*folder.Folder // Folder ID -> Folder. + folderPermissionCache map[string][]accesscontrol.ResourcePermission // Folder UID -> Folder Permissions. generalAlertingFolder *folder.Folder state *migmodels.OrgMigrationState @@ -51,7 +55,12 @@ func (ms *MigrationService) newOrgMigration(orgID int64) *OrgMigration { silences: make([]*pb.MeshSilence, 0), alertRuleTitleDedup: make(map[string]Deduplicator), - folderCache: make(map[string]*folder.Folder), + // We deduplicate alert rule groups so that we don't have to ensure that the alerts rules have the same interval. + alertRuleGroupDedup: make(map[string]Deduplicator), + + permissionsMap: make(map[int64]map[permissionHash]*folder.Folder), + folderCache: make(map[int64]*folder.Folder), + folderPermissionCache: make(map[string][]accesscontrol.ResourcePermission), state: &migmodels.OrgMigrationState{ OrgID: orgID, @@ -71,6 +80,17 @@ func (om *OrgMigration) AlertTitleDeduplicator(folderUID string) Deduplicator { return om.alertRuleTitleDedup[folderUID] } +func (om *OrgMigration) AlertGroupDeduplicator(folderUID string) Deduplicator { + if _, ok := om.alertRuleGroupDedup[folderUID]; !ok { + om.alertRuleGroupDedup[folderUID] = Deduplicator{ + set: make(map[string]struct{}), + caseInsensitive: om.migrationStore.CaseInsensitive(), + maxLen: store.AlertRuleMaxRuleGroupNameLength, + } + } + return om.alertRuleGroupDedup[folderUID] +} + type AlertPair struct { AlertRule *models.AlertRule DashAlert *migrationStore.DashAlert diff --git a/pkg/services/ngalert/migration/models/state.go b/pkg/services/ngalert/migration/models/state.go index e7c5b78120b..c965443ba67 100644 --- a/pkg/services/ngalert/migration/models/state.go +++ b/pkg/services/ngalert/migration/models/state.go @@ -5,3 +5,11 @@ type OrgMigrationState struct { OrgID int64 `json:"orgId"` CreatedFolders []string `json:"createdFolders"` } + +// DashboardUpgradeInfo contains information about a dashboard that was upgraded. +type DashboardUpgradeInfo struct { + DashboardUID string + DashboardName string + NewFolderUID string + NewFolderName string +} diff --git a/pkg/services/ngalert/migration/permissions.go b/pkg/services/ngalert/migration/permissions.go index ce6f139b880..34c564ecf3a 100644 --- a/pkg/services/ngalert/migration/permissions.go +++ b/pkg/services/ngalert/migration/permissions.go @@ -2,8 +2,12 @@ package migration import ( "context" + "crypto" + "encoding/hex" "errors" "fmt" + "sort" + "strings" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -11,26 +15,40 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/folder" + migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" ) +// DASHBOARD_FOLDER is the format used to generate the folder name for migrated dashboards with custom permissions. const DASHBOARD_FOLDER = "%s Alerts - %s" // MaxFolderName is the maximum length of the folder name generated using DASHBOARD_FOLDER format const MaxFolderName = 255 var ( + // migratorPermissions are the permissions required for the background user to migrate alerts. migratorPermissions = []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, {Action: dashboards.ActionFoldersPermissionsRead, Scope: dashboards.ScopeFoldersAll}, {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, {Action: dashboards.ActionFoldersCreate}, - {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, {Action: datasources.ActionRead, Scope: datasources.ScopeAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, } + + // generalAlertingFolderTitle is the title of the general alerting folder. This is used for dashboard alerts in the general folder. generalAlertingFolderTitle = "General Alerting" + + // permissionMap maps the "friendly" permission name for a ResourcePermissions actions to the dashboards.PermissionType. + // A sort of reverse accesscontrol Service.MapActions similar to api.dashboardPermissionMap. + permissionMap = map[string]dashboards.PermissionType{ + "View": dashboards.PERMISSION_VIEW, + "Edit": dashboards.PERMISSION_EDIT, + "Admin": dashboards.PERMISSION_ADMIN, + } ) // getMigrationUser returns a background user for the given orgID with permissions to execute migration-related tasks. @@ -38,106 +56,327 @@ func getMigrationUser(orgID int64) identity.Requester { return accesscontrol.BackgroundUser("ngalert_migration", orgID, org.RoleAdmin, migratorPermissions) } -// getAlertFolderNameFromDashboard generates a folder name for alerts that belong to a dashboard. Formats the string according to DASHBOARD_FOLDER format. -// If the resulting string exceeds the migrations.MaxTitleLength, the dashboard title is stripped to be at the maximum length -func getAlertFolderNameFromDashboard(dash *dashboards.Dashboard) string { - maxLen := MaxFolderName - len(fmt.Sprintf(DASHBOARD_FOLDER, "", dash.UID)) - title := dash.Title +func (om *OrgMigration) migratedFolder(ctx context.Context, log log.Logger, dashID int64) (*migmodels.DashboardUpgradeInfo, error) { + dash, err := om.migrationStore.GetDashboard(ctx, om.orgID, dashID) + if err != nil { + return nil, err + } + l := log.New("dashboardTitle", dash.Title, "dashboardUid", dash.UID) + + dashFolder, err := om.getFolder(ctx, dash) + if err != nil { + l.Warn("Failed to find folder for dashboard", "missing_folder_id", dash.FolderID, "error", err) + } + if dashFolder != nil { + l = l.New("folderUid", dashFolder.UID, "folderName", dashFolder.Title) + } + + migratedFolder, err := om.getOrCreateMigratedFolder(ctx, l, dash, dashFolder) + if err != nil { + return nil, err + } + + return &migmodels.DashboardUpgradeInfo{ + DashboardUID: dash.UID, + DashboardName: dash.Title, + NewFolderUID: migratedFolder.UID, + NewFolderName: migratedFolder.Title, + }, nil +} + +// getOrCreateMigratedFolder returns the folder that alerts in a given dashboard should migrate to. +// If the dashboard has no custom permissions, this should be the same folder as dash.FolderID. +// If the dashboard has custom permissions that affect access, this should be a new folder with migrated permissions relating to both the dashboard and parent folder. +// Any dashboard that has greater read/write permissions for an orgRole/team/user compared to its folder will necessitate creating a new folder with the same permissions as the dashboard. +func (om *OrgMigration) getOrCreateMigratedFolder(ctx context.Context, l log.Logger, dash *dashboards.Dashboard, parentFolder *folder.Folder) (*folder.Folder, error) { + // If parentFolder does not exist then the dashboard is an orphan. We migrate the alert to the general alerting folder. + // The general alerting folder is only accessible to admins. + if parentFolder == nil { + l.Warn("Migrating alert to the general alerting folder: original folder not found") + f, err := om.getOrCreateGeneralAlertingFolder(ctx, om.orgID) + if err != nil { + return nil, fmt.Errorf("general alerting folder: %w", err) + } + return f, nil + } + + // Check if the dashboard has custom permissions. If it does, we need to create a new folder for it. + // This folder will be cached for re-use for each dashboard in the folder with the same permissions. + permissionsToFolder, ok := om.permissionsMap[parentFolder.ID] + if !ok { + permissionsToFolder = make(map[permissionHash]*folder.Folder) + om.permissionsMap[dash.FolderID] = permissionsToFolder + + folderPerms, err := om.getFolderPermissions(ctx, parentFolder) + if err != nil { + return nil, fmt.Errorf("folder permissions: %w", err) + } + newFolderPerms, _ := om.convertResourcePerms(folderPerms) + + // We assign the folder to the cache so that any dashboards with identical equivalent permissions will use the parent folder instead of creating a new one. + folderPermsHash, err := createHash(newFolderPerms) + if err != nil { + return nil, fmt.Errorf("hash of folder permissions: %w", err) + } + permissionsToFolder[folderPermsHash] = parentFolder + } + + // Now we compute the hash of the dashboard permissions and check if we have a folder for it. If not, we create a new one. + perms, err := om.getDashboardPermissions(ctx, dash) + if err != nil { + return nil, fmt.Errorf("dashboard permissions: %w", err) + } + newPerms, unusedPerms := om.convertResourcePerms(perms) + hash, err := createHash(newPerms) + if err != nil { + return nil, fmt.Errorf("hash of dashboard permissions: %w", err) + } + + customFolder, ok := permissionsToFolder[hash] + if !ok { + folderName := generateAlertFolderName(parentFolder, hash) + l.Info("Dashboard has custom permissions, create a new folder for alerts.", "newFolder", folderName) + f, err := om.createFolder(ctx, om.orgID, folderName, newPerms) + if err != nil { + return nil, err + } + + // If the role is not managed or basic we don't attempt to migrate its permissions. This is because + // the logic to migrate would be complex, error-prone, and even if done correctly would have significant + // drawbacks in the case of custom provisioned roles. Instead, we log if the role has dashboard permissions that could + // potentially override the folder permissions. These overrides would always be to increase permissions not decrease them, + // so the risk of giving users access to alerts they shouldn't have access to is mitigated. + overrides := potentialOverrides(unusedPerms, newPerms) + if len(overrides) > 0 { + l.Warn("Some roles were not migrated but had the potential to allow additional access. Please verify the permissions of the new folder.", "roles", overrides, "newFolder", folderName) + } + + permissionsToFolder[hash] = f + return f, nil + } + + return customFolder, nil +} + +// generateAlertFolderName generates a folder name for alerts that belong to a dashboard with custom permissions. +// Formats the string according to DASHBOARD_FOLDER format. +// If the resulting string's length exceeds migration.MaxFolderName, the dashboard title is stripped to be at the maximum length. +func generateAlertFolderName(f *folder.Folder, hash permissionHash) string { + maxLen := MaxFolderName - len(fmt.Sprintf(DASHBOARD_FOLDER, "", hash)) + title := f.Title if len(title) > maxLen { title = title[:maxLen] } - return fmt.Sprintf(DASHBOARD_FOLDER, title, dash.UID) // include UID to the name to avoid collision + return fmt.Sprintf(DASHBOARD_FOLDER, title, hash) // Include hash in the name to avoid collision. } -func (om *OrgMigration) getOrCreateMigratedFolder(ctx context.Context, log log.Logger, dashID int64) (*dashboards.Dashboard, *folder.Folder, error) { - dash, err := om.migrationStore.GetDashboard(ctx, om.orgID, dashID) - if err != nil { - if errors.Is(err, dashboards.ErrFolderNotFound) { - return nil, nil, fmt.Errorf("dashboard with ID %v under organisation %d not found: %w", dashID, om.orgID, err) - } - return nil, nil, fmt.Errorf("failed to get dashboard with ID %v under organisation %d: %w", dashID, om.orgID, err) - } - l := log.New( - "dashboardTitle", dash.Title, - "dashboardUID", dash.UID, - ) +// isBasic returns true if the given roleName is a basic role. +func isBasic(roleName string) bool { + return strings.HasPrefix(roleName, accesscontrol.BasicRolePrefix) +} - var migratedFolder *folder.Folder - switch { - case dash.HasACL: - folderName := getAlertFolderNameFromDashboard(dash) - f, ok := om.folderCache[folderName] - if !ok { - l.Info("create a new folder for alerts that belongs to dashboard because it has custom permissions", "folder", folderName) - // create folder and assign the permissions of the dashboard (included default and inherited) - f, err = om.createFolder(ctx, om.orgID, folderName) - if err != nil { - return nil, nil, fmt.Errorf("create new folder: %w", err) - } - permissions, err := om.migrationStore.GetACL(ctx, dash.OrgID, dash.ID) - if err != nil { - return nil, nil, fmt.Errorf("failed to get dashboard %d under organisation %d permissions: %w", dash.ID, dash.OrgID, err) - } - err = om.migrationStore.SetACL(ctx, f.OrgID, f.ID, permissions) - if err != nil { - return nil, nil, fmt.Errorf("failed to set folder %d under organisation %d permissions: %w", f.ID, f.OrgID, err) - } - om.folderCache[folderName] = f - } - migratedFolder = f - case dash.FolderID > 0: - // get folder if exists - f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{ID: &dash.FolderID, OrgID: dash.OrgID, SignedInUser: getMigrationUser(dash.OrgID)}) - if err != nil { - // If folder does not exist then the dashboard is an orphan and we migrate the alert to the general folder. - l.Warn("Failed to find folder for dashboard. Migrate rule to the default folder", "missing_folder_id", dash.FolderID, "error", err) - migratedFolder, err = om.getOrCreateGeneralFolder(ctx, dash.OrgID) - if err != nil { - return nil, nil, err +// convertResourcePerms converts the given resource permissions (from a dashboard or folder) to a set of unique, sorted SetResourcePermissionCommands. +// This is done by iterating over the managed, basic, and inherited resource permissions and adding the highest permission for each orgrole/user/team. +// +// # Details +// +// There are two role types that we consider: +// - managed (ex. managed:users:1:permissions, managed:builtins:editor:permissions, managed:teams:1:permissions): +// These are the only roles that exist in OSS. For each of these roles, we add the actions of the highest +// dashboards.PermissionType between the folder and the dashboard. Permissions from the folder are inherited. +// The added actions should have scope=folder:uid:xxxxxx, where xxxxxx is the new folder uid. +// - basic (ex. basic:admin, basic:editor): +// These are roles used in enterprise. Every user should have one of these roles. They should be considered +// equivalent to managed:builtins. The highest dashboards.PermissionType between the two should be used. +// +// There are two role types that we do not consider: +// - fixed: (ex. fixed:dashboards:reader, fixed:dashboards:writer): +// These are roles with fixed actions/scopes. They should not be given any extra actions/scopes because they +// can be overwritten. Because of this, to ensure that all users with this role have the correct access to the +// new folder we would need to find all users with this role and add a permission for +// action folders:read/write -> folder:uid:xxxxxx to their managed:users:X:permissions. +// This will eventually fall out of sync. +// - custom: Custom roles created via API or provisioning. +// Similar to fixed roles, we can't give them any extra actions/scopes because they can be overwritten. +// +// For now, we choose the simpler approach of handling managed and basic roles. Fixed and custom roles will not +// be taken into account, but we will log a warning if they had the potential to override the folder permissions. +func (om *OrgMigration) convertResourcePerms(rperms []accesscontrol.ResourcePermission) ([]accesscontrol.SetResourcePermissionCommand, []accesscontrol.ResourcePermission) { + keep := make(map[accesscontrol.SetResourcePermissionCommand]dashboards.PermissionType) + unusedPerms := make([]accesscontrol.ResourcePermission, 0) + for _, p := range rperms { + if p.IsManaged || p.IsInherited || isBasic(p.RoleName) { + if permission := om.migrationStore.MapActions(p); permission != "" { + sp := accesscontrol.SetResourcePermissionCommand{ + UserID: p.UserId, + TeamID: p.TeamId, + BuiltinRole: p.BuiltInRole, + } + // We could have redundant perms, ex: if one is inherited from the parent folder, or we have basic roles from enterprise. + // We use the highest permission available. + pType := permissionMap[permission] + current, ok := keep[sp] + if !ok || pType > current { + keep[sp] = pType + } } } else { - migratedFolder = f - } - default: - migratedFolder, err = om.getOrCreateGeneralFolder(ctx, dash.OrgID) - if err != nil { - return nil, nil, err + // Keep track of unused perms, so we can later log a warning if they had the potential to override the folder permissions. + unusedPerms = append(unusedPerms, p) } } - if migratedFolder.UID == "" { - return nil, nil, fmt.Errorf("empty folder identifier") + permissions := make([]accesscontrol.SetResourcePermissionCommand, 0, len(keep)) + for p, pType := range keep { + p.Permission = pType.String() + permissions = append(permissions, p) } - return dash, migratedFolder, nil + // Stable sort since we will be creating a hash of this to compare dashboard perms to folder perms. + sort.SliceStable(permissions, func(i, j int) bool { + if permissions[i].BuiltinRole != permissions[j].BuiltinRole { + return permissions[i].BuiltinRole < permissions[j].BuiltinRole + } + if permissions[i].UserID != permissions[j].UserID { + return permissions[i].UserID < permissions[j].UserID + } + if permissions[i].TeamID != permissions[j].TeamID { + return permissions[i].TeamID < permissions[j].TeamID + } + return permissions[i].Permission < permissions[j].Permission + }) + + return permissions, unusedPerms } -// getOrCreateGeneralFolder returns the general folder under the specific organisation -// If the general folder does not exist it creates it. -func (om *OrgMigration) getOrCreateGeneralFolder(ctx context.Context, orgID int64) (*folder.Folder, error) { +// potentialOverrides returns a map of roles from unusedOldPerms that have dashboard permissions that could potentially +// override the given folder permissions in newPerms. These overrides are always to increase permissions not decrease them. +func potentialOverrides(unusedOldPerms []accesscontrol.ResourcePermission, newPerms []accesscontrol.SetResourcePermissionCommand) map[string]dashboards.PermissionType { + var lowestPermission dashboards.PermissionType + for _, p := range newPerms { + if p.BuiltinRole == string(org.RoleEditor) || p.BuiltinRole == string(org.RoleViewer) { + pType := permissionMap[p.Permission] + if pType < lowestPermission { + lowestPermission = pType + } + } + } + + nonManagedPermissionTypes := make(map[string]dashboards.PermissionType) + for _, p := range unusedOldPerms { + existing, ok := nonManagedPermissionTypes[p.RoleName] + if ok && existing == dashboards.PERMISSION_EDIT { + // We've already handled the highest permission we care about, no need to check this role anymore. + continue + } + + if p.Contains([]string{dashboards.ActionDashboardsWrite}) { + existing = dashboards.PERMISSION_EDIT + } else if p.Contains([]string{dashboards.ActionDashboardsRead}) { + existing = dashboards.PERMISSION_VIEW + } + + if existing > lowestPermission && existing > nonManagedPermissionTypes[p.RoleName] { + nonManagedPermissionTypes[p.RoleName] = existing + } + } + + return nonManagedPermissionTypes +} + +type permissionHash string + +// createHash returns a hash of the given permissions. +func createHash(setResourcePermissionCommands []accesscontrol.SetResourcePermissionCommand) (permissionHash, error) { + // Speed is not particularly important here. + digester := crypto.MD5.New() + var separator = []byte{255} + for _, perm := range setResourcePermissionCommands { + _, err := fmt.Fprint(digester, separator) + if err != nil { + return "", err + } + _, err = fmt.Fprint(digester, perm) + if err != nil { + return "", err + } + } + return permissionHash(hex.EncodeToString(digester.Sum(nil))), nil +} + +// getFolderPermissions Get permissions for folder. +func (om *OrgMigration) getFolderPermissions(ctx context.Context, f *folder.Folder) ([]accesscontrol.ResourcePermission, error) { + if p, ok := om.folderPermissionCache[f.UID]; ok { + return p, nil + } + p, err := om.migrationStore.GetFolderPermissions(ctx, getMigrationUser(f.OrgID), f.UID) + if err != nil { + return nil, err + } + om.folderPermissionCache[f.UID] = p + return p, nil +} + +// getDashboardPermissions Get permissions for dashboard. +func (om *OrgMigration) getDashboardPermissions(ctx context.Context, d *dashboards.Dashboard) ([]accesscontrol.ResourcePermission, error) { + p, err := om.migrationStore.GetDashboardPermissions(ctx, getMigrationUser(om.orgID), d.UID) + if err != nil { + return nil, err + } + return p, nil +} + +// getFolder returns the parent folder for the given dashboard. If the dashboard is in the general folder, it returns the general alerting folder. +func (om *OrgMigration) getFolder(ctx context.Context, dash *dashboards.Dashboard) (*folder.Folder, error) { + if f, ok := om.folderCache[dash.FolderID]; ok { + return f, nil + } + + if dash.FolderID <= 0 { + // Don't use general folder since it has no uid, instead we use a new "General Alerting" folder. + migratedFolder, err := om.getOrCreateGeneralAlertingFolder(ctx, om.orgID) + if err != nil { + return nil, fmt.Errorf("get or create general folder: %w", err) + } + return migratedFolder, err + } + + f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{ID: &dash.FolderID, OrgID: om.orgID, SignedInUser: getMigrationUser(om.orgID)}) + if err != nil { + if errors.Is(err, dashboards.ErrFolderNotFound) { + return nil, fmt.Errorf("folder with id %v not found", dash.FolderID) + } + return nil, fmt.Errorf("get folder %d: %w", dash.FolderID, err) + } + om.folderCache[dash.FolderID] = f + return f, nil +} + +// getOrCreateGeneralAlertingFolder returns the general alerting folder under the specific organisation +// If the general alerting folder does not exist it creates it. +func (om *OrgMigration) getOrCreateGeneralAlertingFolder(ctx context.Context, orgID int64) (*folder.Folder, error) { if om.generalAlertingFolder != nil { return om.generalAlertingFolder, nil } f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &generalAlertingFolderTitle, SignedInUser: getMigrationUser(orgID)}) if err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { - // create folder - generalAlertingFolder, err := om.createFolder(ctx, orgID, generalAlertingFolderTitle) + // create general alerting folder without permissions to mimic the general folder. + f, err := om.createFolder(ctx, orgID, generalAlertingFolderTitle, nil) if err != nil { - return nil, fmt.Errorf("create general alerting folder '%s': %w", generalAlertingFolderTitle, err) + return nil, fmt.Errorf("create general alerting folder: %w", err) } - om.generalAlertingFolder = generalAlertingFolder - return om.generalAlertingFolder, nil + om.generalAlertingFolder = f + return f, err } - return nil, fmt.Errorf("get general alerting folder '%s': %w", generalAlertingFolderTitle, err) + return nil, fmt.Errorf("get folder '%s': %w", generalAlertingFolderTitle, err) } om.generalAlertingFolder = f - - return om.generalAlertingFolder, nil + return f, nil } // createFolder creates a new folder with given permissions. -func (om *OrgMigration) createFolder(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { +func (om *OrgMigration) createFolder(ctx context.Context, orgID int64, title string, newPerms []accesscontrol.SetResourcePermissionCommand) (*folder.Folder, error) { f, err := om.migrationStore.CreateFolder(ctx, &folder.CreateFolderCommand{ OrgID: orgID, Title: title, @@ -147,6 +386,13 @@ func (om *OrgMigration) createFolder(ctx context.Context, orgID int64, title str return nil, err } + if len(newPerms) > 0 { + _, err = om.migrationStore.SetFolderPermissions(ctx, orgID, f.UID, newPerms...) + if err != nil { + return nil, fmt.Errorf("set permissions: %w", err) + } + } + om.state.CreatedFolders = append(om.state.CreatedFolders, f.UID) return f, nil diff --git a/pkg/services/ngalert/migration/permissions_test.go b/pkg/services/ngalert/migration/permissions_test.go new file mode 100644 index 00000000000..c6577e5f259 --- /dev/null +++ b/pkg/services/ngalert/migration/permissions_test.go @@ -0,0 +1,820 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/alerting/models" + "github.com/grafana/grafana/pkg/services/dashboards" + ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +// TestDashAlertPermissionMigration tests the execution of the migration specifically for dashboards with custom permissions. +// +//nolint:gocyclo +func TestDashAlertPermissionMigration(t *testing.T) { + genLegacyAlert := func(name string, dashboardId int64, mutators ...func(*models.Alert)) *models.Alert { + a := createAlert(t, 1, int(dashboardId), 1, name, nil) + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(a) + } + } + return a + } + + genAlert := func(title string, namespaceUID string, dashboardUID string, mutators ...func(*ngModels.AlertRule)) *ngModels.AlertRule { + a := &ngModels.AlertRule{ + ID: 1, + OrgID: 1, + Title: title, + Condition: "A", + Data: []ngModels.AlertQuery{ + { + RefID: "A", + DatasourceUID: "__expr__", + Model: json.RawMessage(`{"conditions":[],"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"classic_conditions"}`), + }, + }, + NamespaceUID: namespaceUID, + DashboardUID: &dashboardUID, + RuleGroup: fmt.Sprintf("Dashboard Title %s - %d", dashboardUID, 1), + IntervalSeconds: 60, + Version: 1, + PanelID: pointer(int64(1)), + RuleGroupIndex: 1, + NoDataState: ngModels.NoData, + ExecErrState: ngModels.AlertingErrState, + For: 60 * time.Second, + Annotations: map[string]string{ + "message": "message", + "__dashboardUid__": dashboardUID, + "__panelId__": "1", + }, + Labels: map[string]string{}, + IsPaused: false, + } + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(a) + } + } + return a + } + + withPanelId := func(id int64) func(*ngModels.AlertRule) { + return func(a *ngModels.AlertRule) { + a.PanelID = pointer(id) + a.Annotations["__panelId__"] = fmt.Sprintf("%d", id) + a.RuleGroup = fmt.Sprintf("Dashboard Title %s - %d", *a.DashboardUID, id) + } + } + + genFolder := func(t *testing.T, id int64, uid string, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { + d := createFolder(t, id, 1, uid) + d.Title = "Original Folder " + uid + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(d) + } + } + return d + } + + genCreatedFolder := func(t *testing.T, title string, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { + d := createFolder(t, 1, 1, "") // Leave generated UID blank, so we don't compare. + d.Title = title + d.CreatedBy = -1 + d.UpdatedBy = -1 + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(d) + } + } + return d + } + + genDashboard := func(t *testing.T, id int64, uid string, folderId int64, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { + d := createDashboard(t, id, 1, uid, folderId, nil) + d.Title = "Dashboard Title " + uid + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(d) + } + } + return d + } + + genPerms := func(perms ...accesscontrol.SetResourcePermissionCommand) []accesscontrol.SetResourcePermissionCommand { + return perms + } + + type expectedAlertMigration struct { + Alert *ngModels.AlertRule + Folder *dashboards.Dashboard + Perms []accesscontrol.SetResourcePermissionCommand + } + + type testcase struct { + name string + enterprise bool + folders []*dashboards.Dashboard + folderPerms map[string][]accesscontrol.SetResourcePermissionCommand // UID -> Perms + dashboards []*dashboards.Dashboard + dashboardPerms map[string][]accesscontrol.SetResourcePermissionCommand // UID -> Perms + alerts []*models.Alert + roles map[accesscontrol.Role][]accesscontrol.Permission + + expected []expectedAlertMigration + } + + // Used to perform the same tests for each of builtins, users, and teams. + splitTestcase := func(raw testcase) []testcase { + permTypes := make(map[string]func(accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand, 3) + permTypes["builtins"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { + return p + } + mapping := map[string]int64{ + string(org.RoleEditor): 1, + string(org.RoleViewer): 2, + } + permTypes["users"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { + id, ok := mapping[p.BuiltinRole] + if !ok { + return p + } + p.UserID = id + p.BuiltinRole = "" + return p + } + permTypes["teams"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { + id, ok := mapping[p.BuiltinRole] + if !ok { + return p + } + p.TeamID = id + p.BuiltinRole = "" + return p + } + + applyTransform := func(tt testcase, pfunc func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand) testcase { + folderPerms := make(map[string][]accesscontrol.SetResourcePermissionCommand, len(tt.folderPerms)) + for _, f := range tt.folders { + perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(tt.folderPerms[f.UID])) + for _, p := range tt.folderPerms[f.UID] { + perms = append(perms, pfunc(p)) + } + folderPerms[f.UID] = perms + } + tt.folderPerms = folderPerms + + dashboardPerms := make(map[string][]accesscontrol.SetResourcePermissionCommand, len(tt.dashboardPerms)) + for _, d := range tt.dashboards { + perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(tt.dashboardPerms[d.UID])) + for _, p := range tt.dashboardPerms[d.UID] { + perms = append(perms, pfunc(p)) + } + dashboardPerms[d.UID] = perms + } + tt.dashboardPerms = dashboardPerms + + expected := make([]expectedAlertMigration, 0, len(tt.expected)) + for _, ex := range tt.expected { + permissions := make([]accesscontrol.SetResourcePermissionCommand, 0, len(ex.Perms)) + for _, p := range ex.Perms { + permissions = append(permissions, pfunc(p)) + } + ex.Perms = permissions + + sort.SliceStable(permissions, func(i, j int) bool { + if permissions[i].BuiltinRole != permissions[j].BuiltinRole { + return permissions[i].BuiltinRole < permissions[j].BuiltinRole + } + if permissions[i].UserID != permissions[j].UserID { + return permissions[i].UserID < permissions[j].UserID + } + if permissions[i].TeamID != permissions[j].TeamID { + return permissions[i].TeamID < permissions[j].TeamID + } + return permissions[i].Permission < permissions[j].Permission + }) + + f := *ex.Folder + if strings.Contains(f.Title, "%s") { + hash, err := createHash(permissions) + require.NoError(t, err) + f.Title = fmt.Sprintf(f.Title, hash) + } + + expected = append(expected, expectedAlertMigration{ + Alert: ex.Alert, + Folder: &f, + Perms: permissions, + }) + } + tt.expected = expected + + return tt + } + + cases := make([]testcase, 0, 3) + for k, pfunc := range permTypes { + tt := applyTransform(raw, pfunc) + tt.name = k + cases = append(cases, tt) + } + return cases + } + + basicFolder := genFolder(t, 1, "f_1") + basicDashboard := genDashboard(t, 2, "d_1", basicFolder.ID) + defaultPerms := genPerms( + accesscontrol.SetResourcePermissionCommand{BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + accesscontrol.SetResourcePermissionCommand{BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + ) + + basicAlert1 := genLegacyAlert("alert1", basicDashboard.ID, func(a *models.Alert) { a.PanelID = 1 }) + basicAlert2 := genLegacyAlert("alert2", basicDashboard.ID, func(a *models.Alert) { a.PanelID = 2 }) + + basicPerms := func() map[accesscontrol.Role][]accesscontrol.Permission { + basic := make(map[accesscontrol.Role][]accesscontrol.Permission) + var permissions []accesscontrol.Permission + ts := time.Now() + for _, action := range append(ossaccesscontrol.DashboardAdminActions, ossaccesscontrol.FolderAdminActions...) { + if isDashboardAction := strings.HasPrefix(action, "dashboards"); isDashboardAction { + permissions = append(permissions, accesscontrol.Permission{ + Action: action, + Scope: dashboards.ScopeDashboardsAll, + Created: ts, + Updated: ts, + }) + } + permissions = append(permissions, accesscontrol.Permission{ + Action: action, + Scope: dashboards.ScopeFoldersAll, + Created: ts, + Updated: ts, + }) + } + basic[accesscontrol.Role{Name: accesscontrol.BasicRolePrefix + "admin"}] = permissions + return basic + } + + tc := []testcase{ + { + name: "alerts in dashboard and folder with default permissions migrate to same folder", + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{basicDashboard}, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, + alerts: []*models.Alert{basicAlert1, basicAlert2}, + expected: []expectedAlertMigration{ + { + Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID, withPanelId(basicAlert1.PanelID)), + Folder: basicFolder, + Perms: defaultPerms, + }, + { + Alert: genAlert(basicAlert2.Name, basicFolder.UID, basicDashboard.UID, withPanelId(basicAlert2.PanelID)), + Folder: basicFolder, + Perms: defaultPerms, + }, + }, + }, + { + name: "dashboard override cannot lessen folder permissions", + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{basicDashboard}, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + basicDashboard.UID: { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_VIEW.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + alerts: []*models.Alert{basicAlert1}, + expected: []expectedAlertMigration{ + { + Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), + Folder: basicFolder, + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, // Inherits from Folder. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + }, + }, + { + name: "dashboard with various permission overrides should create new folder", + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{ + genDashboard(t, 2, "d_1", basicFolder.ID), + genDashboard(t, 3, "d_2", basicFolder.ID), + genDashboard(t, 4, "d_3", basicFolder.ID), + genDashboard(t, 5, "d_4", basicFolder.ID), + genDashboard(t, 6, "d_5", basicFolder.ID), + }, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "d_1": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + "d_2": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + }, + "d_3": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + "d_4": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + "d_5": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + }, + }, + alerts: []*models.Alert{genLegacyAlert("alert1", 2), genLegacyAlert("alert2", 3), genLegacyAlert("alert3", 4), genLegacyAlert("alert4", 5), genLegacyAlert("alert5", 6)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "", "d_1"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + }, + { + Alert: genAlert("alert2", "", "d_2"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + }, + }, + { + Alert: genAlert("alert3", "", "d_3"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + }, + { + Alert: genAlert("alert4", "", "d_4"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + { + Alert: genAlert("alert5", "", "d_5"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Change. + }, + }, + }, + }, + { + name: "missing dashboard permission is inherited from folder", + folders: []*dashboards.Dashboard{genFolder(t, 1, "f_1"), genFolder(t, 2, "f_2")}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "f_1": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, + }, + "f_2": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_VIEW.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + dashboards: []*dashboards.Dashboard{ + genDashboard(t, 3, "d_1", 1), + genDashboard(t, 4, "d_2", 1), + genDashboard(t, 5, "d_3", 2), + genDashboard(t, 6, "d_4", 2), + }, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "d_1": { + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + "d_2": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + }, + "d_3": { + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + "d_4": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + }, + }, + alerts: []*models.Alert{genLegacyAlert("alert1", 3), genLegacyAlert("alert2", 4), genLegacyAlert("alert3", 5), genLegacyAlert("alert4", 6)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "f_1", "d_1"), + Folder: genFolder(t, 1, "f_1"), // Original folder since the perms didn't change. + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Inherits from Folder. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Overrides from Folder. + }, + }, + { + Alert: genAlert("alert2", "f_1", "d_2"), + Folder: genFolder(t, 1, "f_1"), // Original folder since the perms didn't change. + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_ADMIN.String()}, // Overrides from Folder. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_ADMIN.String()}, // Inherits from Folder. + }, + }, + { + Alert: genAlert("alert3", "f_2", "d_3"), + Folder: genFolder(t, 2, "f_2"), // Original folder since the perms didn't change. + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_VIEW.String()}, // Inherits from Folder. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + { + Alert: genAlert("alert4", "", "d_4"), + Folder: genCreatedFolder(t, "Original Folder f_2 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, // Inherits from Folder. + }, + }, + }, + }, + { + name: "missing dashboard and folder view permission is still missing", + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + basicFolder.UID: { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + }, + }, + dashboards: []*dashboards.Dashboard{basicDashboard}, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + basicDashboard.UID: { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_VIEW.String()}, + }, + }, + alerts: []*models.Alert{basicAlert1}, + expected: []expectedAlertMigration{ + { + Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), + Folder: basicFolder, + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + }, + }, + }, + }, + + // General folder. + { + name: "dashboard in general folder with default permissions migrates to General Alerting subfolder for permission", + dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "d_1": defaultPerms, + }, + alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "f_1", "d_1"), + Folder: genCreatedFolder(t, "General Alerting Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, // From Dashboard. + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, // From Dashboard. + }, + }, + }, + }, + { + name: "dashboard in general folder with some perms migrates to General Alerting subfolder with correct permissions", + dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "d_1": { // Missing viewer. + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + }, + }, + alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "f_1", "d_1"), + Folder: genCreatedFolder(t, "General Alerting Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, // From Dashboard. + }, + }, + }, + }, + { + name: "dashboard in general folder with empty perms migrates to General Alerting", + dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. + alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "f_1", "d_1"), + Folder: genCreatedFolder(t, "General Alerting"), + Perms: []accesscontrol.SetResourcePermissionCommand{}, + }, + }, + }, + + // The following tests handled extra requirements of enterprise RBAC in that they include basic, fixed, and custom roles. + { + name: "should handle basic roles the same as managed builtin roles", + enterprise: true, + roles: basicPerms(), + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{ + genDashboard(t, 2, "d_1", basicFolder.ID), + }, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ + "d_1": { + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + }, + alerts: []*models.Alert{genLegacyAlert("alert1", 2)}, + expected: []expectedAlertMigration{ + { + Alert: genAlert("alert1", "", "d_1"), + Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), + Perms: []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleAdmin), Permission: dashboards.PERMISSION_ADMIN.String()}, // From basic:admin. + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_EDIT.String()}, // Change. + }, + }, + }, + }, + { + name: "should ignore fixed roles even if they would affect access", + enterprise: true, + roles: map[accesscontrol.Role][]accesscontrol.Permission{ + accesscontrol.Role{Name: "fixed:dashboards:writer"}: { + {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsDelete, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsPermissionsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, + }, + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{basicDashboard}, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, + alerts: []*models.Alert{basicAlert1}, + expected: []expectedAlertMigration{ // Expect no new folder. + { + Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), + Folder: basicFolder, + Perms: defaultPerms, + }, + }, + }, + { + name: "should ignore custom roles even if they would affect access", + enterprise: true, + roles: map[accesscontrol.Role][]accesscontrol.Permission{ + accesscontrol.Role{Name: "custom role"}: { + {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsDelete, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, + {Action: dashboards.ActionDashboardsPermissionsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, + }, + folders: []*dashboards.Dashboard{basicFolder}, + folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, + dashboards: []*dashboards.Dashboard{basicDashboard}, + dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, + alerts: []*models.Alert{basicAlert1}, + expected: []expectedAlertMigration{ // Expect no new folder. + { + Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), + Folder: basicFolder, + Perms: defaultPerms, + }, + }, + }, + } + for _, ttRaw := range tc { + t.Run(ttRaw.name, func(t *testing.T) { + for _, tt := range splitTestcase(ttRaw) { + t.Run(tt.name, func(t *testing.T) { + sqlStore := db.InitTestDB(t) + x := sqlStore.GetEngine() + + if tt.enterprise { + createRoles(t, context.Background(), sqlStore, tt.roles) + } + + service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) + setupLegacyAlertsTables(t, x, nil, tt.alerts, tt.folders, tt.dashboards) + + for i := 1; i < 3; i++ { + _, err := x.Insert(user.User{ + ID: int64(i), + OrgID: 1, + Name: fmt.Sprintf("user%v", i), + Login: fmt.Sprintf("user%v", i), + Email: fmt.Sprintf("user%v@example.org", i), + Created: now, + Updated: now, + }) + require.NoError(t, err) + } + + for i := 1; i < 3; i++ { + _, err := x.Insert(team.Team{ + ID: int64(i), + OrgID: 1, + UID: fmt.Sprintf("team%v", i), + Name: fmt.Sprintf("team%v", i), + Created: now, + Updated: now, + }) + require.NoError(t, err) + } + + for _, f := range tt.folders { + _, err := service.migrationStore.SetFolderPermissions(context.Background(), 1, f.UID, tt.folderPerms[f.UID]...) + require.NoError(t, err) + } + for _, d := range tt.dashboards { + _, err := service.migrationStore.SetDashboardPermissions(context.Background(), 1, d.UID, tt.dashboardPerms[d.UID]...) + require.NoError(t, err) + } + + err := service.Run(context.Background()) + require.NoError(t, err) + + // construct actuals. + orgId := int64(1) + rules := getAlertRules(t, x, orgId) + actual := make([]expectedAlertMigration, 0, len(rules)) + for i, r := range rules { + // Remove generated fields. + require.NotEqual(t, r.Labels["rule_uid"], "") + delete(r.Labels, "rule_uid") + require.NotEqual(t, r.Annotations["__alertId__"], "") + delete(r.Annotations, "__alertId__") + + folder := getDashboard(t, x, orgId, r.NamespaceUID) + rperms, err := service.migrationStore.GetFolderPermissions(context.Background(), getMigrationUser(orgId), folder.UID) + require.NoError(t, err) + + expected := tt.expected[i] + if expected.Folder.UID == "" { + // We're expecting the UID to be generated, so remove it from comparison. + folder.UID = "" + r.NamespaceUID = "" + expected.Alert.NamespaceUID = "" + } + + keep := make(map[accesscontrol.SetResourcePermissionCommand]dashboards.PermissionType) + for _, p := range rperms { + if permission := service.migrationStore.MapActions(p); permission != "" { + sp := accesscontrol.SetResourcePermissionCommand{ + UserID: p.UserId, + TeamID: p.TeamId, + BuiltinRole: p.BuiltInRole, + } + pType := permissionMap[permission] + current, ok := keep[sp] + if !ok || pType > current { + keep[sp] = pType + } + } + } + perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(keep)) + for p, pType := range keep { + p.Permission = pType.String() + perms = append(perms, p) + } + + actual = append(actual, expectedAlertMigration{ + Alert: r, + Folder: folder, + Perms: perms, + }) + } + + cOpt := []cmp.Option{ + cmpopts.SortSlices(func(a, b expectedAlertMigration) bool { + return a.Alert.Title < b.Alert.Title + }), + cmpopts.SortSlices(func(a, b accesscontrol.SetResourcePermissionCommand) bool { + if a.BuiltinRole != b.BuiltinRole { + return a.BuiltinRole < b.BuiltinRole + } + if a.UserID != b.UserID { + return a.UserID < b.UserID + } + if a.TeamID != b.TeamID { + return a.TeamID < b.TeamID + } + return a.Permission < b.Permission + }), + cmpopts.IgnoreUnexported(ngModels.AlertRule{}, ngModels.AlertQuery{}), + cmpopts.IgnoreFields(ngModels.AlertRule{}, "ID", "Updated", "UID"), + cmpopts.IgnoreFields(dashboards.Dashboard{}, "ID", "Created", "Updated", "Data", "Slug"), + } + if !cmp.Equal(tt.expected, actual, cOpt...) { + t.Errorf("Unexpected Rule: %v", cmp.Diff(tt.expected, actual, cOpt...)) + } + }) + } + }) + } +} + +func createRoles(t testing.TB, ctx context.Context, store db.DB, rolePerms map[accesscontrol.Role][]accesscontrol.Permission) { + _ = store.WithDbSession(ctx, func(sess *db.Session) error { + ts := time.Now() + var roles []accesscontrol.Role + + basic := accesscontrol.BuildBasicRoleDefinitions() + + var permissions []accesscontrol.Permission + var builtinRoleAssignments []accesscontrol.BuiltinRole + var userRoleAssignments []accesscontrol.UserRole + var teamRoleAssignments []accesscontrol.TeamRole + i := int64(1) + for role, perms := range rolePerms { + if role.IsBasic() { + for roleType, br := range basic { + if br.Name == role.Name { + role = br.Role() + builtinRoleAssignments = append(builtinRoleAssignments, accesscontrol.BuiltinRole{ + OrgID: accesscontrol.GlobalOrgID, RoleID: i, Role: roleType, Created: ts, Updated: ts, + }) + } + } + } else { + userRoleAssignments = append(userRoleAssignments, accesscontrol.UserRole{ + OrgID: accesscontrol.GlobalOrgID, RoleID: i, UserID: 1, Created: ts, + }) + teamRoleAssignments = append(teamRoleAssignments, accesscontrol.TeamRole{ + OrgID: accesscontrol.GlobalOrgID, RoleID: i, TeamID: 1, Created: ts, + }) + } + role.ID = i + role.Created = ts + role.Updated = ts + + roles = append(roles, role) + + for _, p := range perms { + permissions = append(permissions, accesscontrol.Permission{ + RoleID: role.ID, Action: p.Action, Scope: p.Scope, Created: ts, Updated: ts, + }) + } + i++ + } + + _, err := sess.InsertMulti(&roles) + require.NoError(t, err) + + _, err = sess.InsertMulti(&permissions) + require.NoError(t, err) + + _, err = sess.InsertMulti(&builtinRoleAssignments) + require.NoError(t, err) + + _, err = sess.InsertMulti(&userRoleAssignments) + require.NoError(t, err) + + _, err = sess.InsertMulti(&teamRoleAssignments) + require.NoError(t, err) + + return nil + }) +} diff --git a/pkg/services/ngalert/migration/silences.go b/pkg/services/ngalert/migration/silences.go index 0263605c0a8..b5431e854f8 100644 --- a/pkg/services/ngalert/migration/silences.go +++ b/pkg/services/ngalert/migration/silences.go @@ -26,6 +26,7 @@ const ( ErrorAlertName = "DatasourceError" ) +// addErrorSilence adds a silence for the given rule to the orgMigration if the ExecutionErrorState was set to keep_state. func (om *OrgMigration) addErrorSilence(rule *models.AlertRule) error { uid, err := uuid.NewRandom() if err != nil { @@ -58,6 +59,7 @@ func (om *OrgMigration) addErrorSilence(rule *models.AlertRule) error { return nil } +// addNoDataSilence adds a silence for the given rule to the orgMigration if the NoDataState was set to keep_state. func (om *OrgMigration) addNoDataSilence(rule *models.AlertRule) error { uid, err := uuid.NewRandom() if err != nil { diff --git a/pkg/services/ngalert/migration/store/database.go b/pkg/services/ngalert/migration/store/database.go index 99589b57f49..72c5efca837 100644 --- a/pkg/services/ngalert/migration/store/database.go +++ b/pkg/services/ngalert/migration/store/database.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strconv" - "time" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -44,8 +43,11 @@ type Store interface { GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*DashAlert, int, error) - GetACL(ctx context.Context, orgID int64, dashID int64) ([]*DashboardACL, error) - SetACL(ctx context.Context, orgID int64, dashboardID int64, items []*DashboardACL) error + GetDashboardPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) + GetFolderPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) + SetDashboardPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) + SetFolderPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) + MapActions(permission accesscontrol.ResourcePermission) string GetDashboard(ctx context.Context, orgID int64, id int64) (*dashboards.Dashboard, error) GetFolder(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) @@ -62,15 +64,18 @@ type Store interface { } type migrationStore struct { - store db.DB - cfg *setting.Cfg - log log.Logger - kv kvstore.KVStore - alertingStore *store.DBstore - dashboardService dashboards.DashboardService - folderService folder.Service - dataSourceCache datasources.CacheService - orgService org.Service + store db.DB + cfg *setting.Cfg + log log.Logger + kv kvstore.KVStore + alertingStore *store.DBstore + dashboardService dashboards.DashboardService + folderService folder.Service + dataSourceCache datasources.CacheService + folderPermissions accesscontrol.FolderPermissionsService + dashboardPermissions accesscontrol.DashboardPermissionsService + orgService org.Service + legacyAlertNotificationService *legacyalerting.AlertNotificationService } @@ -85,6 +90,8 @@ func ProvideMigrationStore( dashboardService dashboards.DashboardService, folderService folder.Service, dataSourceCache datasources.CacheService, + folderPermissions accesscontrol.FolderPermissionsService, + dashboardPermissions accesscontrol.DashboardPermissionsService, orgService org.Service, legacyAlertNotificationService *legacyalerting.AlertNotificationService, ) (Store, error) { @@ -97,6 +104,8 @@ func ProvideMigrationStore( dashboardService: dashboardService, folderService: folderService, dataSourceCache: dataSourceCache, + folderPermissions: folderPermissions, + dashboardPermissions: dashboardPermissions, orgService: orgService, legacyAlertNotificationService: legacyAlertNotificationService, }, nil @@ -325,155 +334,6 @@ func (ms *migrationStore) GetNotificationChannels(ctx context.Context, orgID int }) } -func (ms *migrationStore) GetFolder(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - return ms.folderService.Get(ctx, cmd) -} - -func (ms *migrationStore) CreateFolder(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { - return ms.folderService.Create(ctx, cmd) -} - -// based on SQLStore.GetDashboardACLInfoList() -func (ms *migrationStore) GetACL(ctx context.Context, orgID, dashboardID int64) ([]*DashboardACL, error) { - var err error - - falseStr := ms.store.GetDialect().BooleanStr(false) - - result := make([]*DashboardACL, 0) - rawSQL := ` - -- get distinct permissions for the dashboard and its parent folder - SELECT DISTINCT - da.id, - da.user_id, - da.team_id, - da.permission, - da.role - FROM dashboard as d - LEFT JOIN dashboard folder on folder.id = d.folder_id - LEFT JOIN dashboard_acl AS da ON - da.dashboard_id = d.id OR - da.dashboard_id = d.folder_id OR - ( - -- include default permissions -- - da.org_id = -1 AND ( - (folder.id IS NOT NULL AND folder.has_acl = ` + falseStr + `) OR - (folder.id IS NULL AND d.has_acl = ` + falseStr + `) - ) - ) - WHERE d.org_id = ? AND d.id = ? AND da.id IS NOT NULL - ORDER BY da.id ASC - ` - err = ms.store.WithDbSession(ctx, func(sess *db.Session) error { - return sess.SQL(rawSQL, orgID, dashboardID).Find(&result) - }) - if err != nil { - return nil, err - } - return result, err -} - -// based on SQLStore.UpdateDashboardACL() -// it should be called from inside a transaction -func (ms *migrationStore) SetACL(ctx context.Context, orgID int64, dashboardID int64, items []*DashboardACL) error { - if dashboardID <= 0 { - return fmt.Errorf("folder id must be greater than zero for a folder permission") - } - return ms.store.WithDbSession(ctx, func(sess *db.Session) error { - // userPermissionsMap is a map keeping the highest permission per user - // for handling conficting inherited (folder) and non-inherited (dashboard) user permissions - userPermissionsMap := make(map[int64]*DashboardACL, len(items)) - // teamPermissionsMap is a map keeping the highest permission per team - // for handling conficting inherited (folder) and non-inherited (dashboard) team permissions - teamPermissionsMap := make(map[int64]*DashboardACL, len(items)) - for _, item := range items { - if item.UserID != 0 { - acl, ok := userPermissionsMap[item.UserID] - if !ok { - userPermissionsMap[item.UserID] = item - } else { - if item.Permission > acl.Permission { - // the higher permission wins - userPermissionsMap[item.UserID] = item - } - } - } - - if item.TeamID != 0 { - acl, ok := teamPermissionsMap[item.TeamID] - if !ok { - teamPermissionsMap[item.TeamID] = item - } else { - if item.Permission > acl.Permission { - // the higher permission wins - teamPermissionsMap[item.TeamID] = item - } - } - } - } - - type keyType struct { - UserID int64 `xorm:"user_id"` - TeamID int64 `xorm:"team_id"` - Role RoleType - Permission permissionType - } - // seen keeps track of inserted perrmissions to avoid duplicates (due to inheritance) - seen := make(map[keyType]struct{}, len(items)) - for _, item := range items { - if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return dashboards.ErrDashboardACLInfoMissing - } - - // ignore duplicate user permissions - if item.UserID != 0 { - acl, ok := userPermissionsMap[item.UserID] - if ok { - if acl.Id != item.Id { - continue - } - } - } - - // ignore duplicate team permissions - if item.TeamID != 0 { - acl, ok := teamPermissionsMap[item.TeamID] - if ok { - if acl.Id != item.Id { - continue - } - } - } - - key := keyType{UserID: item.UserID, TeamID: item.TeamID, Role: "", Permission: item.Permission} - if item.Role != nil { - key.Role = *item.Role - } - if _, ok := seen[key]; ok { - continue - } - - // unset Id so that the new record will get a different one - item.Id = 0 - item.OrgID = orgID - item.DashboardID = dashboardID - item.Created = time.Now() - item.Updated = time.Now() - - sess.Nullable("user_id", "team_id") - if _, err := sess.Insert(item); err != nil { - return err - } - seen[key] = struct{}{} - } - - // Update dashboard HasACL flag - dashboard := dashboards.Dashboard{HasACL: true} - _, err := sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) - - return err - }) -} - // GetOrgDashboardAlerts loads all legacy dashboard alerts for the given org mapped by dashboard id. func (ms *migrationStore) GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*DashAlert, int, error) { var alerts []legacymodels.Alert @@ -506,6 +366,34 @@ func (ms *migrationStore) GetOrgDashboardAlerts(ctx context.Context, orgID int64 return mappedAlerts, len(alerts), nil } +func (ms *migrationStore) GetDashboardPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) { + return ms.dashboardPermissions.GetPermissions(ctx, user, resourceID) +} + +func (ms *migrationStore) GetFolderPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) { + return ms.folderPermissions.GetPermissions(ctx, user, resourceID) +} + +func (ms *migrationStore) GetFolder(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { + return ms.folderService.Get(ctx, cmd) +} + +func (ms *migrationStore) CreateFolder(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + return ms.folderService.Create(ctx, cmd) +} + +func (ms *migrationStore) SetDashboardPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + return ms.dashboardPermissions.SetPermissions(ctx, orgID, resourceID, commands...) +} + +func (ms *migrationStore) SetFolderPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + return ms.folderPermissions.SetPermissions(ctx, orgID, resourceID, commands...) +} + +func (ms *migrationStore) MapActions(permission accesscontrol.ResourcePermission) string { + return ms.dashboardPermissions.MapActions(permission) +} + func (ms *migrationStore) CaseInsensitive() bool { return ms.store.GetDialect().SupportEngine() } diff --git a/pkg/services/ngalert/migration/store/models.go b/pkg/services/ngalert/migration/store/models.go index 7053e4199ed..8fe78ee5722 100644 --- a/pkg/services/ngalert/migration/store/models.go +++ b/pkg/services/ngalert/migration/store/models.go @@ -2,43 +2,10 @@ package store import ( "encoding/json" - "time" legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" ) -type RoleType string - -const ( - RoleNone RoleType = "None" - RoleViewer RoleType = "Viewer" - RoleEditor RoleType = "Editor" - RoleAdmin RoleType = "Admin" -) - -func (r RoleType) IsValid() bool { - return r == RoleViewer || r == RoleAdmin || r == RoleEditor || r == RoleNone -} - -type permissionType int - -type DashboardACL struct { - // nolint:stylecheck - Id int64 - OrgID int64 `xorm:"org_id"` - DashboardID int64 `xorm:"dashboard_id"` - - UserID int64 `xorm:"user_id"` - TeamID int64 `xorm:"team_id"` - Role *RoleType // pointer to be nullable - Permission permissionType - - Created time.Time - Updated time.Time -} - -func (p DashboardACL) TableName() string { return "dashboard_acl" } - // uidOrID for both uid and ID, primarily used for mapping legacy channel to migrated receiver. type UidOrID any diff --git a/pkg/services/ngalert/migration/store/testing.go b/pkg/services/ngalert/migration/store/testing.go index 7f912000f92..c1478cff67b 100644 --- a/pkg/services/ngalert/migration/store/testing.go +++ b/pkg/services/ngalert/migration/store/testing.go @@ -1,26 +1,35 @@ package store import ( + "context" "testing" "time" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" legacyalerting "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/datasources/guardian" datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/ngalert/testutil" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/supportbundles/bundleregistry" + "github.com/grafana/grafana/pkg/services/team/teamimpl" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -28,6 +37,8 @@ func NewTestMigrationStore(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setti if cfg.UnifiedAlerting.BaseInterval == 0 { cfg.UnifiedAlerting.BaseInterval = time.Second * 10 } + features := featuremgmt.WithFeatures() + cfg.IsFeatureToggleEnabled = features.IsEnabled alertingStore := store.DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -37,11 +48,31 @@ func NewTestMigrationStore(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setti dashboardService, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) folderService := testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, bus) + cache := localcache.ProvideService() quotaService := "atest.FakeQuotaService{} + ac := acimpl.ProvideAccessControl(cfg) + routeRegister := routing.ProvideRegister() + acSvc, err := acimpl.ProvideService(cfg, sqlStore, routing.ProvideRegister(), cache, ac, features) + require.NoError(t, err) + + license := licensingtest.NewFakeLicensing() + license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() + teamSvc := teamimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamSvc, cache, quotaService, bundleregistry.ProvideService()) + require.NoError(t, err) + + folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( + features, routeRegister, sqlStore, ac, license, dashboardStore, folderService, acSvc, teamSvc, userSvc) + require.NoError(t, err) + dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions( + features, routeRegister, sqlStore, ac, license, dashboardStore, folderService, acSvc, teamSvc, userSvc) + require.NoError(t, err) + + err = acSvc.RegisterFixedRoles(context.Background()) require.NoError(t, err) - cache := localcache.ProvideService() return &migrationStore{ log: &logtest.Fake{}, cfg: cfg, @@ -51,6 +82,8 @@ func NewTestMigrationStore(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setti dashboardService: dashboardService, folderService: folderService, dataSourceCache: datasourceService.ProvideCacheService(cache, sqlStore, guardian.ProvideGuardian()), + folderPermissions: folderPermissions, + dashboardPermissions: dashboardPermissions, orgService: orgService, legacyAlertNotificationService: legacyalerting.ProvideService(sqlStore, encryptionservice.SetupTestService(t), nil), } diff --git a/pkg/services/ngalert/migration/ualert.go b/pkg/services/ngalert/migration/ualert.go index 085960d14de..918e7859f18 100644 --- a/pkg/services/ngalert/migration/ualert.go +++ b/pkg/services/ngalert/migration/ualert.go @@ -5,20 +5,23 @@ import ( "fmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" "github.com/grafana/grafana/pkg/services/ngalert/models" ) -func (om *OrgMigration) migrateAlerts(ctx context.Context, alerts []*migrationStore.DashAlert, dashboardUID string, folderUID string) ([]*AlertPair, error) { +func (om *OrgMigration) migrateAlerts(ctx context.Context, alerts []*migrationStore.DashAlert, info migmodels.DashboardUpgradeInfo) ([]*AlertPair, error) { log := om.log.New( - "dashboardUID", dashboardUID, - "newFolderUID", folderUID, + "dashboardUid", info.DashboardUID, + "dashboardName", info.DashboardName, + "newFolderUid", info.NewFolderUID, + "newFolderNane", info.NewFolderName, ) pairs := make([]*AlertPair, 0, len(alerts)) for _, da := range alerts { al := log.New("ruleID", da.ID, "ruleName", da.Name) - alertRule, err := om.migrateAlert(ctx, al, da, dashboardUID, folderUID) + alertRule, err := om.migrateAlert(ctx, al, da, info) if err != nil { return nil, fmt.Errorf("migrate alert: %w", err) } @@ -29,12 +32,11 @@ func (om *OrgMigration) migrateAlerts(ctx context.Context, alerts []*migrationSt } func (om *OrgMigration) migrateDashboard(ctx context.Context, dashID int64, alerts []*migrationStore.DashAlert) ([]*AlertPair, error) { - dash, newFolder, err := om.getOrCreateMigratedFolder(ctx, om.log, dashID) + info, err := om.migratedFolder(ctx, om.log, dashID) if err != nil { return nil, fmt.Errorf("get or create migrated folder: %w", err) } - - pairs, err := om.migrateAlerts(ctx, alerts, dash.UID, newFolder.UID) + pairs, err := om.migrateAlerts(ctx, alerts, *info) if err != nil { return nil, fmt.Errorf("migrate and save alerts: %w", err) } diff --git a/pkg/services/ngalert/migration/ualert_test.go b/pkg/services/ngalert/migration/ualert_test.go index 2f4cd49f90b..05a278c1093 100644 --- a/pkg/services/ngalert/migration/ualert_test.go +++ b/pkg/services/ngalert/migration/ualert_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/util" ) @@ -106,13 +106,13 @@ const invalidUri = "�6�M��)uk譹1(�h`$�o�N>mĕ����cS2�dh func Test_getAlertFolderNameFromDashboard(t *testing.T) { t.Run("should include full title", func(t *testing.T) { - dash := &dashboards.Dashboard{ - UID: util.GenerateShortUID(), + hash := util.GenerateShortUID() + f := &folder.Folder{ Title: "TEST", } - folder := getAlertFolderNameFromDashboard(dash) - require.Contains(t, folder, dash.Title) - require.Contains(t, folder, dash.UID) + name := generateAlertFolderName(f, permissionHash(hash)) + require.Contains(t, name, f.Title) + require.Contains(t, name, hash) }) t.Run("should cut title to the length", func(t *testing.T) { title := "" @@ -124,13 +124,13 @@ func Test_getAlertFolderNameFromDashboard(t *testing.T) { } } - dash := &dashboards.Dashboard{ - UID: util.GenerateShortUID(), + hash := util.GenerateShortUID() + f := &folder.Folder{ Title: title, } - folder := getAlertFolderNameFromDashboard(dash) - require.Len(t, folder, MaxFolderName) - require.Contains(t, folder, dash.UID) + name := generateAlertFolderName(f, permissionHash(hash)) + require.Len(t, name, MaxFolderName) + require.Contains(t, name, hash) }) }