Alerting: Handle custom dashboard permissions in migration service (#74504)

* Fix migration of custom dashboard permissions

Dashboard alert permissions were determined by both its dashboard and
folder scoped permissions, while UA alert rules only have folder
scoped permissions.

This means, when migrating an alert, we'll need to decide if the parent folder
is a correct location for the newly created alert rule so that users, teams,
and org roles have the same access to it as they did in legacy.

To do this, we translate both the folder and dashboard resource
permissions to two sets of SetResourcePermissionCommands. Each of these
encapsulates a mapping of all:

OrgRoles -> Viewer/Editor/Admin
Teams -> Viewer/Editor/Admin
Users -> Viewer/Editor/Admin

When the dashboard permissions (including those inherited from the parent
folder) differ from the parent folder permissions alone, we need to create a
new folder to represent the access-level of the legacy dashboard.

Compromises:

When determining the SetResourcePermissionCommands we only take into account
managed and basic roles. Fixed and custom roles introduce significant complexity
and synchronicity hurdles. Instead, we log a warning they had the potential to
override the newly created folder permissions.

Also, we don't attempt to reconcile datasource permissions that were
not necessary in legacy alerting. Users without access to the necessary
datasources to edit an alert rule will need to obtain said access separate from
the migration.
This commit is contained in:
Matthew Jacobson 2023-10-12 23:12:40 +01:00 committed by GitHub
parent 372082d254
commit 5f48619c9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1390 additions and 316 deletions

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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
})
}

View File

@ -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 {

View File

@ -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()
}

View File

@ -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

View File

@ -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 := &quotatest.FakeQuotaService{}
orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService)
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),
}

View File

@ -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)
}

View File

@ -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 = "<22>6<EFBFBD>M<EFBFBD><4D>)uk譹1(<28>h`$<24>o<EFBFBD>N>mĕ<6D><C495><EFBFBD><EFBFBD>cS2<53>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)
})
}