grafana/pkg/services/ngalert/migration/permissions.go
Jo 0de66a8099
Authz: Remove use of SignedInUser copy for permission evaluation (#78448)
* remove use of SignedInUserCopies

* add extra safety to not cross assign permissions

unwind circular dependency

dashboardacl->dashboardaccess

fix missing import

* correctly set teams for permissions

* fix missing inits

* nit: check err

* exit early for api keys
2023-11-22 14:20:22 +01:00

423 lines
18 KiB
Go

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"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"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: 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 dashboardaccess.PermissionType.
// A sort of reverse accesscontrol Service.MapActions similar to api.dashboardPermissionMap.
permissionMap = map[string]dashboardaccess.PermissionType{
"View": dashboardaccess.PERMISSION_VIEW,
"Edit": dashboardaccess.PERMISSION_EDIT,
"Admin": dashboardaccess.PERMISSION_ADMIN,
}
)
// getMigrationUser returns a background user for the given orgID with permissions to execute migration-related tasks.
func getMigrationUser(orgID int64) identity.Requester {
return accesscontrol.BackgroundUser("ngalert_migration", orgID, org.RoleAdmin, migratorPermissions)
}
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 {
// nolint:staticcheck
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.
// nolint:staticcheck
permissionsToFolder, ok := om.permissionsMap[parentFolder.ID]
if !ok {
permissionsToFolder = make(map[permissionHash]*folder.Folder)
// nolint:staticcheck
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, hash) // Include hash in the name to avoid collision.
}
// isBasic returns true if the given roleName is a basic role.
func isBasic(roleName string) bool {
return strings.HasPrefix(roleName, accesscontrol.BasicRolePrefix)
}
// 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
// dashboardaccess.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 dashboardaccess.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]dashboardaccess.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 {
// 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)
}
}
permissions := make([]accesscontrol.SetResourcePermissionCommand, 0, len(keep))
for p, pType := range keep {
p.Permission = pType.String()
permissions = append(permissions, p)
}
// 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
}
// 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]dashboardaccess.PermissionType {
var lowestPermission dashboardaccess.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]dashboardaccess.PermissionType)
for _, p := range unusedOldPerms {
existing, ok := nonManagedPermissionTypes[p.RoleName]
if ok && existing == dashboardaccess.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 = dashboardaccess.PERMISSION_EDIT
} else if p.Contains([]string{dashboards.ActionDashboardsRead}) {
existing = dashboardaccess.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) {
// nolint:staticcheck
if f, ok := om.folderCache[dash.FolderID]; ok {
return f, nil
}
// nolint:staticcheck
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
}
// nolint:staticcheck
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) {
// nolint:staticcheck
return nil, fmt.Errorf("folder with id %v not found", dash.FolderID)
}
// nolint:staticcheck
return nil, fmt.Errorf("get folder %d: %w", dash.FolderID, err)
}
// nolint:staticcheck
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 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: %w", err)
}
om.generalAlertingFolder = f
return f, err
}
return nil, fmt.Errorf("get folder '%s': %w", generalAlertingFolderTitle, err)
}
om.generalAlertingFolder = f
return f, nil
}
// createFolder creates a new folder with given permissions.
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,
SignedInUser: getMigrationUser(orgID).(*user.SignedInUser),
})
if err != nil {
if errors.Is(err, dashboards.ErrFolderSameNameExists) {
// If the folder already exists, we return the existing folder.
// This isn't perfect since permissions might have been manually modified,
// but the only folders we should be creating here are ones with permission
// hash suffix or general alerting. Neither of which is likely to spuriously
// conflict with an existing folder.
om.log.Warn("Folder already exists, using existing folder", "title", title)
f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &title, SignedInUser: getMigrationUser(orgID)})
if err != nil {
return nil, err
}
return f, nil
}
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
}