diff --git a/pkg/registry/apis/iam/resourcepermission/mapper.go b/pkg/registry/apis/iam/resourcepermission/mapper.go index acb5f889e8b..cd93fa5bcdd 100644 --- a/pkg/registry/apis/iam/resourcepermission/mapper.go +++ b/pkg/registry/apis/iam/resourcepermission/mapper.go @@ -1,8 +1,15 @@ package resourcepermission +import ( + "fmt" + "slices" + "strings" +) + type Mapper interface { ActionSets() []string Scope(name string) string + ActionSet(level string) (string, error) } type mapper struct { @@ -28,3 +35,11 @@ func (m mapper) ActionSets() []string { func (m mapper) Scope(name string) string { return m.resource + ":uid:" + name } + +func (m mapper) ActionSet(level string) (string, error) { + actionSet := m.resource + ":" + strings.ToLower(level) + if !slices.Contains(m.actionSets, actionSet) { + return "", fmt.Errorf("invalid level (%s): %w", level, errInvalidSpec) + } + return actionSet, nil +} diff --git a/pkg/registry/apis/iam/resourcepermission/models.go b/pkg/registry/apis/iam/resourcepermission/models.go index 82e06fe1ece..f92ed42dcbd 100644 --- a/pkg/registry/apis/iam/resourcepermission/models.go +++ b/pkg/registry/apis/iam/resourcepermission/models.go @@ -1,6 +1,7 @@ package resourcepermission import ( + "context" "errors" "fmt" "sort" @@ -8,23 +9,40 @@ import ( "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/authlib/types" v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + idStore "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" + "github.com/grafana/grafana/pkg/services/accesscontrol" ) var ( + timeNow = func() time.Time { return time.Now() } + errDatabaseHelper = errors.New("failed to get database") errNotImplemented = errors.New("not supported by this storage backend") - errEmptyName = errors.New("name cannot be empty") + errNameMismatch = errors.New("name mismatch") + errNamespaceMismatch = errors.New("namespace mismatch") errUnknownGroupResource = errors.New("unknown group/resource") errNotFound = errors.New("not found") + errConflict = errors.New("conflict") + errInvalidSpec = errors.New("invalid spec") errInvalidName = errors.New("invalid name") errInvalidScope = errors.New("invalid scope") errInvalidNamespace = errors.New("invalid namespace") - defaultLevels = []string{"view", "edit", "admin"} + defaultLevels = []string{"view", "edit", "admin"} + allowedBasicRoles = map[string]bool{"Viewer": true, "Editor": true, "Admin": true} ) +type IdentityStore interface { + GetServiceAccountInternalID(ctx context.Context, ns types.NamespaceInfo, query idStore.GetServiceAccountInternalIDQuery) (*idStore.GetServiceAccountInternalIDResult, error) + GetTeamInternalID(ctx context.Context, ns types.NamespaceInfo, query idStore.GetTeamInternalIDQuery) (*idStore.GetTeamInternalIDResult, error) + GetUserInternalID(ctx context.Context, ns types.NamespaceInfo, query idStore.GetUserInternalIDQuery) (*idStore.GetUserInternalIDResult, error) +} + type ListResourcePermissionsQuery struct { Scope string OrgID int64 @@ -32,7 +50,25 @@ type ListResourcePermissionsQuery struct { // TODO Pagination common.Pagination } -type flatResourcePermission struct { +type rbacAssignmentCreate struct { + Action string // e.g. "dashboards:edit" + Scope string // e.g. "folders:uid:1" + RoleName string // e.g. "managed:users:1:permissions + SubjectID any // int64 for user/team, string for builtin_role + AssignmentTable string // "user_role", "team_role", or "builtin_role" + AssignmentColumn string // "user_id", "team_id", or "role" +} + +func (g *rbacAssignmentCreate) permission() accesscontrol.Permission { + p := accesscontrol.Permission{ + Action: g.Action, + Scope: g.Scope, + } + p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) + return p +} + +type rbacAssignment struct { ID int64 `xorm:"id"` Action string `xorm:"action"` Scope string `xorm:"scope"` @@ -44,8 +80,8 @@ type flatResourcePermission struct { IsServiceAccount bool `xorm:"is_service_account"` } -// toV0ResourcePermissions converts flatResourcePermission grouped by resource (e.g. {folder.grafana.app, folders, fold1}) to a list of v0alpha1.ResourcePermission -func toV0ResourcePermissions(permsByResource map[groupResourceName][]flatResourcePermission) ([]v0alpha1.ResourcePermission, error) { +// toV0ResourcePermissions converts rbacAssignment grouped by resource (e.g. {folder.grafana.app, folders, fold1}) to a list of v0alpha1.ResourcePermission +func toV0ResourcePermissions(permsByResource map[groupResourceName][]rbacAssignment) ([]v0alpha1.ResourcePermission, error) { if len(permsByResource) == 0 { return nil, nil } @@ -159,3 +195,24 @@ func (s *ResourcePermSqlBackend) parseScope(scope string) (*groupResourceName, e Name: parts[2], }, nil } + +// splitResourceName splits a resource name in the format -- (e.g. dashboard.grafana.app-dashboards-ad5rwqs) into its components +func (s *ResourcePermSqlBackend) splitResourceName(resourceName string) (Mapper, *groupResourceName, error) { + // e.g. dashboard.grafana.app-dashboards-ad5rwqs + parts := strings.SplitN(resourceName, "-", 3) + if len(parts) != 3 { + return nil, nil, fmt.Errorf("%w: %s", errInvalidName, resourceName) + } + + group, resourceType, uid := parts[0], parts[1], parts[2] + mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resourceType}] + if !ok { + return nil, nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resourceType) + } + + return mapper, &groupResourceName{ + Group: group, + Resource: resourceType, + Name: uid, + }, nil +} diff --git a/pkg/registry/apis/iam/resourcepermission/models_test.go b/pkg/registry/apis/iam/resourcepermission/models_test.go index 316252dfe47..2823e909496 100644 --- a/pkg/registry/apis/iam/resourcepermission/models_test.go +++ b/pkg/registry/apis/iam/resourcepermission/models_test.go @@ -20,14 +20,14 @@ func setupBackendNoDB(t *testing.T) *ResourcePermSqlBackend { func TestToV0ResourcePermissions(t *testing.T) { t.Run("empty permissions", func(t *testing.T) { - result, err := toV0ResourcePermissions(map[groupResourceName][]flatResourcePermission{}) + result, err := toV0ResourcePermissions(map[groupResourceName][]rbacAssignment{}) require.NoError(t, err) require.Nil(t, result) }) now := time.Now() t.Run("multiple permission are sorted by kind, name, verb", func(t *testing.T) { - permissions := map[groupResourceName][]flatResourcePermission{ + permissions := map[groupResourceName][]rbacAssignment{ { Group: "dashboard.grafana.app", Resource: "dashboards", diff --git a/pkg/registry/apis/iam/resourcepermission/queries/assignment_insert.sql b/pkg/registry/apis/iam/resourcepermission/queries/assignment_insert.sql new file mode 100644 index 00000000000..87b4398b4c2 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/queries/assignment_insert.sql @@ -0,0 +1,8 @@ +INSERT INTO {{ .Ident .AssignmentTable }} (org_id, {{ .Ident .AssignmentColumn }}, role_id, created{{ if eq .AssignmentColumn "role" }}, updated{{ end }}) +VALUES ( + {{ .Arg .OrgID }}, + {{ .Arg .SubjectID }}, + {{ .Arg .RoleID }}, + {{ .Arg .Now }}{{ if eq .AssignmentColumn "role" }}, + {{ .Arg .Now }}{{ end }} +) \ No newline at end of file diff --git a/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql b/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql new file mode 100644 index 00000000000..1ba966e21eb --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql @@ -0,0 +1,11 @@ +INSERT INTO {{ .Ident .PermissionTable }} (role_id, action, scope, created, updated, kind, attribute, identifier) +VALUES ( + {{ .Arg $.RoleID }}, + {{ .Arg $.Permission.Action }}, + {{ .Arg $.Permission.Scope }}, + {{ .Arg $.Now }}, + {{ .Arg $.Now }}, + {{ .Arg $.Permission.Kind }}, + {{ .Arg $.Permission.Attribute }}, + {{ .Arg $.Permission.Identifier }} +) diff --git a/pkg/registry/apis/iam/resourcepermission/queries/role_insert.sql b/pkg/registry/apis/iam/resourcepermission/queries/role_insert.sql new file mode 100644 index 00000000000..a954cc5b09f --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/queries/role_insert.sql @@ -0,0 +1,9 @@ +INSERT INTO {{ .Ident .RoleTable }} (version, org_id, uid, name, created, updated) +VALUES ( + 0, + {{ .Arg .OrgID }}, + {{ .Arg .UID }}, + {{ .Arg .Name }}, + {{ .Arg .Now }}, + {{ .Arg .Now }} +) \ No newline at end of file diff --git a/pkg/registry/apis/iam/resourcepermission/sql.go b/pkg/registry/apis/iam/resourcepermission/sql.go index c8ff7e5126f..7f9d78d714f 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql.go +++ b/pkg/registry/apis/iam/resourcepermission/sql.go @@ -2,20 +2,25 @@ package resourcepermission import ( "context" + "database/sql" + "errors" "fmt" "strings" "github.com/grafana/authlib/types" - v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/storage/legacysql" - "k8s.io/apimachinery/pkg/runtime/schema" ) // List // Get // getResourcePermissions queries resource permissions based on the provided ListResourcePermissionsQuery and groups them by resource (e.g. {folder.grafana.app, folders, fold1}) -func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *ListResourcePermissionsQuery) (map[groupResourceName][]flatResourcePermission, error) { +func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *ListResourcePermissionsQuery) (map[groupResourceName][]rbacAssignment, error) { rawQuery, args, err := buildListResourcePermissionsQueryFromTemplate(sql, query) if err != nil { return nil, err @@ -32,9 +37,9 @@ func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql _ = rows.Close() }() - permissions := make(map[groupResourceName][]flatResourcePermission) + permissions := make(map[groupResourceName][]rbacAssignment) for rows.Next() { - var perm flatResourcePermission + var perm rbacAssignment if err := rows.Scan( &perm.ID, &perm.Action, &perm.Scope, &perm.Created, &perm.Updated, &perm.RoleName, &perm.SubjectUID, &perm.SubjectType, &perm.IsServiceAccount, @@ -56,20 +61,13 @@ func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql // getResourcePermission retrieves a single ResourcePermission by its name in the format -- (e.g. dashboard.grafana.app-dashboards-ad5rwqs) func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, name string) (*v0alpha1.ResourcePermission, error) { - // e.g. dashboard.grafana.app-dashboards-ad5rwqs - parts := strings.SplitN(name, "-", 3) - if len(parts) != 3 { - return nil, fmt.Errorf("%w: %s", errInvalidName, name) - } - - group, resourceType, uid := parts[0], parts[1], parts[2] - mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resourceType}] - if !ok { - return nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resourceType) + mapper, grn, err := s.splitResourceName(name) + if err != nil { + return nil, err } resourceQuery := &ListResourcePermissionsQuery{ - Scope: mapper.Scope(uid), + Scope: mapper.Scope(grn.Name), OrgID: ns.OrgID, ActionSets: mapper.ActionSets(), } @@ -96,6 +94,229 @@ func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql // Create +// createAndAssignManagedRole creates a new managed role and assigns it to the given user/team/service account/basic role +func (s *ResourcePermSqlBackend) createAndAssignManagedRole(ctx context.Context, tx *session.SessionTx, dbHelper *legacysql.LegacyDatabaseHelper, orgID int64, assignment rbacAssignmentCreate) (int64, error) { + // Create the managed role + roleUID := accesscontrol.PrefixedRoleUID(fmt.Sprintf("%s:org:%v", assignment.RoleName, orgID)) + insertRoleQuery, args, err := buildInsertRoleQuery(dbHelper, orgID, roleUID, assignment.RoleName) + if err != nil { + return 0, err + } + + _, err = tx.Exec(ctx, insertRoleQuery, args...) + if err != nil { + s.logger.Error("could not insert new role", "orgID", orgID, "roleName", assignment.RoleName, "error", err.Error()) + return 0, fmt.Errorf("could not insert new role") + } + + var roleID int64 + idQuery := fmt.Sprintf("SELECT id FROM %s WHERE org_id = ? AND name = ?", dbHelper.Table("role")) + err = tx.Get(ctx, &roleID, idQuery, orgID, assignment.RoleName) + if err != nil { + s.logger.Error("could not retrieve id of created role", "orgID", orgID, "roleName", assignment.RoleName, "error", err.Error()) + return 0, fmt.Errorf("could not retrieve id of created role") + } + + assignQuery, args, err := buildInsertAssignmentQuery(dbHelper, orgID, roleID, assignment) + if err != nil { + return 0, err + } + _, err = tx.Exec(ctx, assignQuery, args...) + if err != nil { + s.logger.Error("could not insert role assignment", "orgID", orgID, "roleName", assignment.RoleName, "subjectID", assignment.SubjectID, "error", err.Error()) + return 0, fmt.Errorf("could not insert role assignment") + } + + return roleID, nil +} + +// storeRbacAssignment ensures that a role exists for the given assignment, creates and assigns it if it doesn't +// and then ensures that the role has the correct permission for the given scope +func (s *ResourcePermSqlBackend) storeRbacAssignment(ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, tx *session.SessionTx, orgID int64, assignment rbacAssignmentCreate) error { + // Check if role already exists + var roleID int64 + query := fmt.Sprintf("SELECT id FROM %s WHERE org_id = ? AND name = ?", dbHelper.Table("role")) + err := tx.Get(ctx, &roleID, query, orgID, assignment.RoleName) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + s.logger.Error("could not check for existing role", "orgID", orgID, "roleName", assignment.RoleName, "error", err.Error()) + return fmt.Errorf("could not check for existing role") + } + + // Role doesn't exist, create it + if roleID == 0 { + roleID, err = s.createAndAssignManagedRole(ctx, tx, dbHelper, orgID, assignment) + if err != nil { + return err + } + } + + // Add the new permission + insertPermQuery, args, err := buildInsertPermissionQuery(dbHelper, roleID, assignment.permission()) + if err != nil { + return err + } + _, err = tx.Exec(ctx, insertPermQuery, args...) + if err != nil { + s.logger.Error("could not insert role permission", "roleID", roleID, "scope", assignment.Scope, "error", err.Error()) + return fmt.Errorf("could not insert role permission") + } + + return nil +} + +// buildRbacAssignments builds the list of assignments (role assignments and permissions) for a given ResourcePermission spec +// It resolves user/team/service account UIDs to internal IDs for the role name and assignee subjectID +func (s *ResourcePermSqlBackend) buildRbacAssignments(ctx context.Context, ns types.NamespaceInfo, mapper Mapper, v0ResourcePerm *v0alpha1.ResourcePermission, rbacScope string) ([]rbacAssignmentCreate, error) { + assignments := make([]rbacAssignmentCreate, 0, len(v0ResourcePerm.Spec.Permissions)) + + for _, perm := range v0ResourcePerm.Spec.Permissions { + rbacActionSet, err := mapper.ActionSet(perm.Verb) + if err != nil { + return nil, err + } + + switch perm.Kind { + case v0alpha1.ResourcePermissionSpecPermissionKindUser: + userID, err := s.identityStore.GetUserInternalID(ctx, ns, legacy.GetUserInternalIDQuery{ + UID: perm.Name, + OrgID: ns.OrgID, + }) + if err != nil && !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("resolving user %q to internal ID: %w", perm.Name, err) + } + if userID == nil { + return nil, fmt.Errorf("user %q not found: %w", perm.Name, errInvalidSpec) + } + assignments = append(assignments, rbacAssignmentCreate{ + RoleName: fmt.Sprintf("managed:users:%d:permissions", userID.ID), + AssignmentTable: "user_role", + AssignmentColumn: "user_id", + SubjectID: fmt.Sprintf("%d", userID.ID), + Action: rbacActionSet, + Scope: rbacScope, + }) + case v0alpha1.ResourcePermissionSpecPermissionKindTeam: + teamID, err := s.identityStore.GetTeamInternalID(ctx, ns, legacy.GetTeamInternalIDQuery{ + UID: perm.Name, + OrgID: ns.OrgID, + }) + if err != nil && !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("resolving team %q to internal ID: %w", perm.Name, err) + } + if teamID == nil { + return nil, fmt.Errorf("team %q not found: %w", perm.Name, errInvalidSpec) + } + assignments = append(assignments, rbacAssignmentCreate{ + RoleName: fmt.Sprintf("managed:teams:%d:permissions", teamID.ID), + AssignmentTable: "team_role", + AssignmentColumn: "team_id", + SubjectID: fmt.Sprintf("%d", teamID.ID), + Action: rbacActionSet, + Scope: rbacScope, + }) + case v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount: + saID, err := s.identityStore.GetServiceAccountInternalID(ctx, ns, legacy.GetServiceAccountInternalIDQuery{ + UID: perm.Name, + OrgID: ns.OrgID, + }) + if err != nil && !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("resolving service account %q to internal ID: %w", perm.Name, err) + } + if saID == nil { + return nil, fmt.Errorf("service account %q not found: %w", perm.Name, errInvalidSpec) + } + assignments = append(assignments, rbacAssignmentCreate{ + RoleName: fmt.Sprintf("managed:users:%d:permissions", saID.ID), + AssignmentTable: "user_role", + AssignmentColumn: "user_id", + SubjectID: fmt.Sprintf("%d", saID.ID), + Action: rbacActionSet, + Scope: rbacScope, + }) + case v0alpha1.ResourcePermissionSpecPermissionKindBasicRole: + if !allowedBasicRoles[perm.Name] { + return nil, fmt.Errorf("invalid basic role %q: %w", perm.Name, errInvalidSpec) + } + assignments = append(assignments, rbacAssignmentCreate{ + RoleName: fmt.Sprintf("managed:builtins:%s:permissions", strings.ToLower(perm.Name)), + AssignmentTable: "builtin_role", + AssignmentColumn: "role", + SubjectID: perm.Name, + Action: rbacActionSet, + Scope: rbacScope, + }) + default: + return nil, fmt.Errorf("unknown permission kind: %q: %w", perm.Kind, errInvalidSpec) + } + } + + return assignments, nil +} + +// existsResourcePermission checks if a resource permission for the given scope already exists in the given organization +func (s *ResourcePermSqlBackend) existsResourcePermission(ctx context.Context, tx *session.SessionTx, dbHelper *legacysql.LegacyDatabaseHelper, orgID int64, scope string) error { + idQuery := fmt.Sprintf( + `SELECT r.id FROM %s AS r INNER JOIN %s AS p ON p.role_id = r.id WHERE r.org_id = ? AND r.name LIKE ? AND p.scope = ? LIMIT 1`, + dbHelper.Table("role"), dbHelper.Table("permission"), + ) + roleID := int64(0) + err := tx.Get(ctx, &roleID, idQuery, orgID, "managed:%", scope) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + s.logger.Error("could not check for existing resource permission", "orgID", orgID, "scope", scope, "error", err.Error()) + return fmt.Errorf("could not check for existing resource permission") + } + if roleID != 0 { + return errConflict + } + return nil +} + +func (s *ResourcePermSqlBackend) createResourcePermission( + ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, mapper Mapper, grn *groupResourceName, v0ResourcePerm *v0alpha1.ResourcePermission, +) (int64, error) { + if v0ResourcePerm == nil { + return 0, fmt.Errorf("resource permission cannot be nil") + } + + if len(v0ResourcePerm.Spec.Permissions) == 0 { + return 0, fmt.Errorf("resource permission must have at least one permission: %w", errInvalidSpec) + } + + // Validate that the group/resource/name in the name matches the spec + if grn.Group != v0ResourcePerm.Spec.Resource.ApiGroup || + grn.Resource != v0ResourcePerm.Spec.Resource.Resource || + grn.Name != v0ResourcePerm.Spec.Resource.Name { + return 0, fmt.Errorf("resource permission name does not match spec: %w", errInvalidSpec) + } + + assignments, err := s.buildRbacAssignments(ctx, ns, mapper, v0ResourcePerm, mapper.Scope(grn.Name)) + if err != nil { + return 0, err + } + + err = dbHelper.DB.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { + // Check if a resource permission for the same resource already exists + if err = s.existsResourcePermission(ctx, tx, dbHelper, ns.OrgID, mapper.Scope(grn.Name)); err != nil { + return err + } + + for _, assignment := range assignments { + if err := s.storeRbacAssignment(ctx, dbHelper, tx, ns.OrgID, assignment); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + return 0, err + } + + // Return a timestamp as resource version + return timeNow().UnixMilli(), nil +} + // Update // Delete diff --git a/pkg/registry/apis/iam/resourcepermission/sql_test.go b/pkg/registry/apis/iam/resourcepermission/sql_test.go index 53994954588..41dd4423ee7 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql_test.go +++ b/pkg/registry/apis/iam/resourcepermission/sql_test.go @@ -2,18 +2,22 @@ package resourcepermission import ( "context" + "errors" "fmt" "testing" "time" - "github.com/grafana/authlib/types" - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" - metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/authlib/types" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/tests/testsuite" - "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { @@ -31,6 +35,7 @@ func setupBackend(t *testing.T) *ResourcePermSqlBackend { dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) { return sqlHelper, nil } + return ProvideStorageBackend(dbProvider) } @@ -113,7 +118,7 @@ func setupTestRoles(t *testing.T, store db.DB) { require.NoError(t, err) } -func TestResourcePermSqlBackend_getResourcePermission(t *testing.T) { +func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -137,9 +142,9 @@ func TestResourcePermSqlBackend_getResourcePermission(t *testing.T) { resource: "folder.grafana.app-folders-fold1", orgID: 1, want: v0alpha1.ResourcePermission{ - ObjectMeta: metaV1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Name: "folder.grafana.app-folders-fold1", - CreationTimestamp: metaV1.Time{Time: created}, + CreationTimestamp: metav1.Time{Time: created}, ResourceVersion: fmt.Sprint(created.UnixMilli()), }, TypeMeta: v0alpha1.ResourcePermissionInfo.TypeMeta(), @@ -201,3 +206,169 @@ func TestResourcePermSqlBackend_getResourcePermission(t *testing.T) { }) } } + +func TestIntegration_ResourcePermSqlBackend_CreateResourcePermission(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + store := db.InitTestDB(t) + + timeNow = func() time.Time { + return time.Date(2025, 8, 28, 17, 13, 0, 0, time.UTC) + } + + t.Run("should create resource permission", func(t *testing.T) { + resourcePerm := &v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folder.grafana.app-folders-fold1", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "fold1", + }, + Permissions: []v0alpha1.ResourcePermissionspecPermission{ + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", + Verb: "admin", + }, + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser, + Name: "captain", + Verb: "edit", + }, + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount, + Name: "robot", + Verb: "view", + }, + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindTeam, + Name: "devs", + Verb: "admin", + }, + }, + }, + } + + ctx := context.Background() + backend := setupBackend(t) + sqlHelper, _ := backend.dbProvider(ctx) + backend.identityStore = NewFakeIdentityStore(t) + + mapper, grn, err := backend.splitResourceName(resourcePerm.Name) + require.NoError(t, err) + + rv, err := backend.createResourcePermission(ctx, sqlHelper, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm) + require.NoError(t, err) + require.Equal(t, timeNow().UnixMilli(), rv) + + var ( + assigned int + roleID int64 + permission accesscontrol.Permission + sess = store.GetSqlxSession() + ) + + // Check that the roles were created and assigned + err = sess.Get(ctx, &roleID, "SELECT id FROM role WHERE org_id = ? AND name = ?", 1, "managed:users:101:permissions") + require.NoError(t, err) + require.NotZero(t, roleID) + err = sess.Get(ctx, &assigned, "SELECT 1 FROM user_role WHERE org_id = ? AND role_id = ? AND user_id = ?", 1, roleID, "101") + require.NoError(t, err) + require.Equal(t, 1, assigned) + err = sess.Get(ctx, &permission, "SELECT action, scope FROM permission WHERE role_id = ?", roleID) + require.NoError(t, err) + require.Equal(t, "folders:uid:fold1", permission.Scope) + require.Equal(t, "folders:edit", permission.Action) + + err = sess.Get(ctx, &roleID, "SELECT id FROM role WHERE org_id = ? AND name = ?", 1, "managed:users:201:permissions") + require.NoError(t, err) + require.NotZero(t, roleID) + err = sess.Get(ctx, &assigned, "SELECT 1 FROM user_role WHERE org_id = ? AND role_id = ? AND user_id = ?", 1, roleID, "201") + require.NoError(t, err) + require.Equal(t, 1, assigned) + err = sess.Get(ctx, &permission, "SELECT action, scope FROM permission WHERE role_id = ?", roleID) + require.NoError(t, err) + require.Equal(t, "folders:uid:fold1", permission.Scope) + require.Equal(t, "folders:view", permission.Action) + + err = sess.Get(ctx, &roleID, "SELECT id FROM role WHERE org_id = ? AND name = ?", 1, "managed:teams:301:permissions") + require.NoError(t, err) + require.NotZero(t, roleID) + err = sess.Get(ctx, &assigned, "SELECT 1 FROM team_role WHERE org_id = ? AND role_id = ? AND team_id = ?", 1, roleID, "301") + require.NoError(t, err) + require.Equal(t, 1, assigned) + err = sess.Get(ctx, &permission, "SELECT action, scope FROM permission WHERE role_id = ?", roleID) + require.NoError(t, err) + require.Equal(t, "folders:uid:fold1", permission.Scope) + require.Equal(t, "folders:admin", permission.Action) + + err = sess.Get(ctx, &roleID, "SELECT id FROM role WHERE org_id = ? AND name = ?", 1, "managed:builtins:viewer:permissions") + require.NoError(t, err) + require.NotZero(t, roleID) + err = sess.Get(ctx, &assigned, "SELECT 1 FROM builtin_role WHERE org_id = ? AND role = ?", 1, "Viewer") + require.NoError(t, err) + require.Equal(t, 1, assigned) + err = sess.Get(ctx, &permission, "SELECT action, scope FROM permission WHERE role_id = ?", roleID) + require.NoError(t, err) + require.Equal(t, "folders:uid:fold1", permission.Scope) + require.Equal(t, "folders:admin", permission.Action) + }) +} + +type fakeIdentityStore struct { + t *testing.T + + users map[string]int64 + serviceAccounts map[string]int64 + teams map[string]int64 + expectedNs types.NamespaceInfo +} + +func NewFakeIdentityStore(t *testing.T) *fakeIdentityStore { + return &fakeIdentityStore{ + t: t, + users: map[string]int64{"captain": 101}, + serviceAccounts: map[string]int64{"robot": 201}, + teams: map[string]int64{"devs": 301}, + expectedNs: types.NamespaceInfo{Value: "default"}, + } +} + +// GetServiceAccountInternalID implements legacy.LegacyIdentityStore. +func (f *fakeIdentityStore) GetServiceAccountInternalID(ctx context.Context, ns types.NamespaceInfo, query legacy.GetServiceAccountInternalIDQuery) (*legacy.GetServiceAccountInternalIDResult, error) { + require.Equal(f.t, f.expectedNs.Value, ns.Value) + + id, ok := f.serviceAccounts[query.UID] + if !ok { + return nil, errors.New("not found") + } + return &legacy.GetServiceAccountInternalIDResult{ID: id}, nil +} + +// GetTeamInternalID implements legacy.LegacyIdentityStore. +func (f *fakeIdentityStore) GetTeamInternalID(ctx context.Context, ns types.NamespaceInfo, query legacy.GetTeamInternalIDQuery) (*legacy.GetTeamInternalIDResult, error) { + require.Equal(f.t, f.expectedNs.Value, ns.Value) + + id, ok := f.teams[query.UID] + if !ok { + return nil, errors.New("not found") + } + return &legacy.GetTeamInternalIDResult{ID: id}, nil +} + +// GetUserInternalID implements legacy.LegacyIdentityStore. +func (f *fakeIdentityStore) GetUserInternalID(ctx context.Context, ns types.NamespaceInfo, query legacy.GetUserInternalIDQuery) (*legacy.GetUserInternalIDResult, error) { + require.Equal(f.t, f.expectedNs.Value, ns.Value) + + id, ok := f.users[query.UID] + if !ok { + return nil, errors.New("not found") + } + return &legacy.GetUserInternalIDResult{ID: id}, nil +} diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go index dfea11ca847..2feb018004d 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/authlib/types" "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/infra/log" + idStore "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -24,8 +25,9 @@ var ( ) type ResourcePermSqlBackend struct { - dbProvider legacysql.LegacyDatabaseProvider - logger log.Logger + dbProvider legacysql.LegacyDatabaseProvider + identityStore IdentityStore + logger log.Logger mappers map[schema.GroupResource]Mapper // group/resource -> rbac mapper reverseMappers map[string]schema.GroupResource // rbac kind -> group/resource @@ -36,8 +38,9 @@ type ResourcePermSqlBackend struct { func ProvideStorageBackend(dbProvider legacysql.LegacyDatabaseProvider) *ResourcePermSqlBackend { return &ResourcePermSqlBackend{ - dbProvider: dbProvider, - logger: log.New("resourceperm_storage_backend"), + dbProvider: dbProvider, + identityStore: idStore.NewLegacySQLStores(dbProvider), + logger: log.New("resourceperm_storage_backend"), mappers: map[schema.GroupResource]Mapper{ {Group: "folder.grafana.app", Resource: "folders"}: NewMapper("folders", defaultLevels), @@ -136,11 +139,24 @@ func isValidKey(key *resourcepb.ResourceKey, requireName bool) error { return fmt.Errorf("expecting resource (%s != %s)", key.Resource, gr.Resource) } if requireName && key.Name == "" { - return fmt.Errorf("expecting name (uid): %w", errEmptyName) + return fmt.Errorf("expecting name (uid): %w", errInvalidName) } return nil } +func getResourcePermissionFromEvent(event resource.WriteEvent) (*v0alpha1.ResourcePermission, error) { + obj, ok := event.Object.GetRuntimeObject() + if ok && obj != nil { + resourcePermission, ok := obj.(*v0alpha1.ResourcePermission) + if ok { + return resourcePermission, nil + } + } + resourcePermission := &v0alpha1.ResourcePermission{} + err := json.Unmarshal(event.Value, resourcePermission) + return resourcePermission, err +} + func (s *ResourcePermSqlBackend) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) { ns, err := types.ParseNamespace(event.Key.Namespace) if err != nil { @@ -154,8 +170,54 @@ func (s *ResourcePermSqlBackend) WriteEvent(ctx context.Context, event resource. return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid key %q: %v", event.Key, err.Error())) } + mapper, grn, err := s.splitResourceName(event.Key.Name) + if err != nil { + return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid resource name %q: %v", event.Key.Name, err.Error())) + } + + if grn.Name == "" { + return 0, fmt.Errorf("resource name cannot be empty: %w", errInvalidName) + } + switch event.Type { + case resourcepb.WatchEvent_ADDED: + { + var v0resourceperm *v0alpha1.ResourcePermission + v0resourceperm, err = getResourcePermissionFromEvent(event) + if err != nil { + return 0, err + } + + if v0resourceperm.Name != event.Key.Name { + return 0, apierrors.NewBadRequest( + fmt.Sprintf("resource permission name %q != %q: %v", event.Key.Name, v0resourceperm.Name, errNameMismatch.Error()), + ) + } + if v0resourceperm.Namespace != ns.Value { + return 0, apierrors.NewBadRequest( + fmt.Sprintf("namespace %q != %q: %v", ns.Value, v0resourceperm.Namespace, errNamespaceMismatch.Error()), + ) + } + + dbHelper, err := s.dbProvider(ctx) + if err != nil { + return 0, err + } + + rv, err = s.createResourcePermission(ctx, dbHelper, ns, mapper, grn, v0resourceperm) + if err != nil { + if errors.Is(err, errInvalidSpec) || errors.Is(err, errInvalidName) { + return 0, apierrors.NewBadRequest(err.Error()) + } + if errors.Is(err, errConflict) { + return 0, apierrors.NewConflict(v0alpha1.ResourcePermissionInfo.GroupResource(), event.Key.Name, err) + } + return 0, err + } + } default: return 0, fmt.Errorf("unsupported event type: %v", event.Type) } + + return rv, nil } diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go index 21502290d6d..357e52b0605 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go @@ -7,8 +7,13 @@ import ( "time" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -108,3 +113,219 @@ func TestIntegration_ResourcePermSqlBackend_ReadResource(t *testing.T) { require.Equal(t, "edit", permission.Spec.Permissions[3].Verb) }) } + +func TestWriteEvent_Add(t *testing.T) { + store := db.InitTestDB(t) + + timeNow = func() time.Time { + return time.Date(2025, 8, 28, 17, 13, 0, 0, time.UTC) + } + + sqlHelper := &legacysql.LegacyDatabaseHelper{ + DB: store, + Table: func(name string) string { return name }, + } + + dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) { + return sqlHelper, nil + } + + t.Run("should error with invalid namespace", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Name: "folder.grafana.app-folders-fold1", Namespace: "invalid"}, + }) + + require.Zero(t, rv) + require.NotNil(t, err) + require.Contains(t, err.Error(), "requires a valid namespace") + }) + + t.Run("should error if there is no permission", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + + resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folder.grafana.app-folders-fold1", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "fold1", + }, + }, + }) + require.NoError(t, err) + + gr := v0alpha1.ResourcePermissionInfo.GroupResource() + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, + Object: resourcePerm, + }) + require.Zero(t, rv) + require.NotNil(t, err) + require.Contains(t, err.Error(), errInvalidSpec.Error()) + }) + + t.Run("should error if name and spec do not match", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + + resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folder.grafana.app-folders-fold1", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "fold2", + }, + Permissions: []v0alpha1.ResourcePermissionspecPermission{ + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", + Verb: "Admin", + }, + }, + }, + }) + require.NoError(t, err) + + gr := v0alpha1.ResourcePermissionInfo.GroupResource() + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, + Object: resourcePerm, + }) + require.Zero(t, rv) + require.NotNil(t, err) + require.Contains(t, err.Error(), errInvalidSpec.Error()) + }) + + t.Run("should error if resource name is empty", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + + resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folder.grafana.app-folders-", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "", + }, + Permissions: []v0alpha1.ResourcePermissionspecPermission{ + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", + Verb: "Admin", + }, + }, + }, + }) + require.NoError(t, err) + + gr := v0alpha1.ResourcePermissionInfo.GroupResource() + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-", Namespace: "default"}, + Object: resourcePerm, + }) + require.Zero(t, rv) + require.NotNil(t, err) + require.Contains(t, err.Error(), errInvalidName.Error()) + }) + + t.Run("should error if the resource is unknown", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + + resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unknown.grafana.app-unknown-ukn1", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "unknown.grafana.app", + Resource: "unknown", + Name: "ukn1", + }, + Permissions: []v0alpha1.ResourcePermissionspecPermission{ + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", + Verb: "Admin", + }, + }, + }, + }) + require.NoError(t, err) + + gr := v0alpha1.ResourcePermissionInfo.GroupResource() + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "unknown.grafana.app-unknown-ukn1", Namespace: "default"}, + Object: resourcePerm, + }) + require.Zero(t, rv) + require.NotNil(t, err) + require.Contains(t, err.Error(), errUnknownGroupResource.Error()) + }) + + t.Run("should work with valid resource permission", func(t *testing.T) { + backend := ProvideStorageBackend(dbProvider) + backend.identityStore = NewFakeIdentityStore(t) + + resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folder.grafana.app-folders-fold1", + Namespace: "default", + }, + Spec: v0alpha1.ResourcePermissionSpec{ + Resource: v0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "fold1", + }, + Permissions: []v0alpha1.ResourcePermissionspecPermission{ + { + Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", + Verb: "Admin", + }, + }, + }, + }) + require.NoError(t, err) + + gr := v0alpha1.ResourcePermissionInfo.GroupResource() + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, + Object: resourcePerm, + }) + + require.NoError(t, err) + require.Equal(t, timeNow().UnixMilli(), rv) + + t.Run("Should error on duplicate add", func(t *testing.T) { + rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, + Object: resourcePerm, + }) + + require.NotNil(t, err) + require.Contains(t, err.Error(), errConflict.Error()) + require.Zero(t, rv) + }) + }) +} diff --git a/pkg/registry/apis/iam/resourcepermission/templates.go b/pkg/registry/apis/iam/resourcepermission/templates.go index d36ce0abb09..525d7551219 100644 --- a/pkg/registry/apis/iam/resourcepermission/templates.go +++ b/pkg/registry/apis/iam/resourcepermission/templates.go @@ -4,7 +4,9 @@ import ( "embed" "fmt" "text/template" + "time" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -16,6 +18,9 @@ var ( sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `queries/*.sql`)) + roleInsertTplt = mustTemplate("role_insert.sql") + assignmentInsertTplt = mustTemplate("assignment_insert.sql") + permissionInsertTplt = mustTemplate("permission_insert.sql") resourcePermissionsQueryTplt = mustTemplate("resource_permission_query.sql") ) @@ -77,6 +82,99 @@ func buildListResourcePermissionsQueryFromTemplate(sql *legacysql.LegacyDatabase // Create +type insertRoleTemplate struct { + sqltemplate.SQLTemplate + RoleTable string + OrgID int64 + UID string + Name string + Now string +} + +func (t insertRoleTemplate) Validate() error { + return nil +} + +func buildInsertRoleQuery(dbHelper *legacysql.LegacyDatabaseHelper, orgID int64, uid string, name string) (string, []any, error) { + req := insertRoleTemplate{ + SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()), + RoleTable: dbHelper.Table("role"), + OrgID: orgID, + UID: uid, + Name: name, + Now: timeNow().Format(time.DateTime), + } + rawQuery, err := sqltemplate.Execute(roleInsertTplt, req) + if err != nil { + return "", nil, fmt.Errorf("rendering sql template: %w", err) + } + return rawQuery, req.GetArgs(), nil +} + +type insertAssignmentTemplate struct { + sqltemplate.SQLTemplate + AssignmentTable string + AssignmentColumn string + RoleID int64 + OrgID int64 + SubjectID any // int64 or string + Now string +} + +func (t insertAssignmentTemplate) Validate() error { + if t.AssignmentTable == "" { + return fmt.Errorf("assignment table is required") + } + if t.AssignmentColumn == "" { + return fmt.Errorf("assignment column is required") + } + return nil +} + +func buildInsertAssignmentQuery(dbHelper *legacysql.LegacyDatabaseHelper, orgID int64, roleID int64, assignment rbacAssignmentCreate) (string, []any, error) { + req := insertAssignmentTemplate{ + SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()), + AssignmentTable: dbHelper.Table(assignment.AssignmentTable), + AssignmentColumn: assignment.AssignmentColumn, + RoleID: roleID, + OrgID: orgID, + SubjectID: assignment.SubjectID, + Now: timeNow().Format(time.DateTime), + } + rawQuery, err := sqltemplate.Execute(assignmentInsertTplt, req) + if err != nil { + return "", nil, fmt.Errorf("rendering sql template: %w", err) + } + return rawQuery, req.GetArgs(), nil +} + +type insertPermissionTemplate struct { + sqltemplate.SQLTemplate + PermissionTable string + RoleID int64 + Permission accesscontrol.Permission + Now string +} + +func (t insertPermissionTemplate) Validate() error { + return nil +} + +func buildInsertPermissionQuery(dbHelper *legacysql.LegacyDatabaseHelper, roleID int64, permission accesscontrol.Permission) (string, []any, error) { + req := insertPermissionTemplate{ + SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()), + PermissionTable: dbHelper.Table("permission"), + RoleID: roleID, + Permission: permission, + Now: timeNow().Format(time.DateTime), + } + rawQuery, err := sqltemplate.Execute(permissionInsertTplt, req) + if err != nil { + return "", nil, fmt.Errorf("rendering sql template: %w", err) + } + return rawQuery, req.GetArgs(), nil +} + // Update // Delete diff --git a/pkg/registry/apis/iam/resourcepermission/templates_test.go b/pkg/registry/apis/iam/resourcepermission/templates_test.go index 571231773d3..53f5c748777 100644 --- a/pkg/registry/apis/iam/resourcepermission/templates_test.go +++ b/pkg/registry/apis/iam/resourcepermission/templates_test.go @@ -4,18 +4,58 @@ import ( "testing" "text/template" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" ) -func TestListResourcePermissionsQuery(t *testing.T) { +func TestTemplates(t *testing.T) { nodb := &legacysql.LegacyDatabaseHelper{ Table: func(n string) string { return "grafana." + n }, } + getInsertRole := func(orgID int64, name string, displayName string) sqltemplate.SQLTemplate { + v := insertRoleTemplate{ + SQLTemplate: sqltemplate.New(nodb.DialectForDriver()), + RoleTable: nodb.Table("role"), + OrgID: orgID, + UID: name, + Name: displayName, + Now: "2025-08-27 21:35:00", + } + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + + getInsertPermission := func(roleID int64, permission accesscontrol.Permission) sqltemplate.SQLTemplate { + v := insertPermissionTemplate{ + SQLTemplate: sqltemplate.New(nodb.DialectForDriver()), + PermissionTable: nodb.Table("permission"), + RoleID: roleID, + Permission: permission, + Now: "2025-08-27 21:35:00", + } + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + + getInsertAssignment := func(orgID int64, roleID int64, assignment rbacAssignmentCreate) sqltemplate.SQLTemplate { + v := insertAssignmentTemplate{ + SQLTemplate: sqltemplate.New(nodb.DialectForDriver()), + AssignmentTable: nodb.Table(assignment.AssignmentTable), + AssignmentColumn: assignment.AssignmentColumn, + RoleID: roleID, + OrgID: orgID, + SubjectID: assignment.SubjectID, + Now: "2025-08-27 21:35:00", + } + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + getListResourcePermissionsQuery := func(q *ListResourcePermissionsQuery) sqltemplate.SQLTemplate { v := listResourcePermissionsQueryTemplate{ SQLTemplate: sqltemplate.New(nodb.DialectForDriver()), @@ -37,6 +77,50 @@ func TestListResourcePermissionsQuery(t *testing.T) { RootDir: "testdata", SQLTemplatesFS: sqlTemplatesFS, Templates: map[*template.Template][]mocks.TemplateTestCase{ + roleInsertTplt: { + { + Name: "insert_role", + Data: getInsertRole( + 8, + accesscontrol.PrefixedRoleUID("managed:builtins:editor:1:permissions"+":org:8"), + "managed:builtins:editor:1:permissions", + ), + }, + }, + permissionInsertTplt: { + { + Name: "insert_permission", + Data: getInsertPermission(23, accesscontrol.Permission{ + Action: "dashboards:view", + Scope: "dashboard:uid:dash1", + Kind: "dashboard", + Attribute: "uid", + Identifier: "dash1", + }), + }, + }, + assignmentInsertTplt: { + { + Name: "insert user assignment", + Data: getInsertAssignment(8, 23, rbacAssignmentCreate{ + SubjectID: 5, + AssignmentTable: "user_role", + AssignmentColumn: "user_id", + Action: "dashboards:edit", + Scope: "dashboard:uid:dash1", + }), + }, + { + Name: "insert basic role assignment", + Data: getInsertAssignment(74, 96, rbacAssignmentCreate{ + SubjectID: "Viewer", + AssignmentTable: "builtin_role", + AssignmentColumn: "role", + Action: "dashboards:admin", + Scope: "dashboard:uid:dash2", + }), + }, + }, resourcePermissionsQueryTplt: { { Name: "basic_query", diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml b/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml new file mode 100644 index 00000000000..88eea67f292 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml @@ -0,0 +1,19 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ResourcePermission +metadata: + name: dashboard.grafana.app-dashboards-ad5rwqs +spec: + resource: + apiGroup: dashboard.grafana.app + resource: dashboards + name: ad5rwqs + permissions: + - kind: User + name: few8wwtp1si68e + verbs: + - edit + - kind: BasicRole + name: Editor + verbs: + - view + \ No newline at end of file diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert basic role assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert basic role assignment.sql new file mode 100755 index 00000000000..0fb1a4a6ad8 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert basic role assignment.sql @@ -0,0 +1,8 @@ +INSERT INTO `grafana`.`builtin_role` (org_id, `role`, role_id, created, updated) +VALUES ( + 74, + 'Viewer', + 96, + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert user assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert user assignment.sql new file mode 100755 index 00000000000..2e1b2e33f6f --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--assignment_insert-insert user assignment.sql @@ -0,0 +1,7 @@ +INSERT INTO `grafana`.`user_role` (org_id, `user_id`, role_id, created) +VALUES ( + 8, + 5, + 23, + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql new file mode 100755 index 00000000000..b8400809f28 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql @@ -0,0 +1,11 @@ +INSERT INTO `grafana`.`permission` (role_id, action, scope, created, updated, kind, attribute, identifier) +VALUES ( + 23, + 'dashboards:view', + 'dashboard:uid:dash1', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00', + 'dashboard', + 'uid', + 'dash1' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--role_insert-insert_role.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--role_insert-insert_role.sql new file mode 100755 index 00000000000..021edd32c8f --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--role_insert-insert_role.sql @@ -0,0 +1,9 @@ +INSERT INTO `grafana`.`role` (version, org_id, uid, name, created, updated) +VALUES ( + 0, + 8, + 'managed_UEjD8KEQ8e2wzcugTRFOM2179aY', + 'managed:builtins:editor:1:permissions', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert basic role assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert basic role assignment.sql new file mode 100755 index 00000000000..1cec1845cf4 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert basic role assignment.sql @@ -0,0 +1,8 @@ +INSERT INTO "grafana"."builtin_role" (org_id, "role", role_id, created, updated) +VALUES ( + 74, + 'Viewer', + 96, + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert user assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert user assignment.sql new file mode 100755 index 00000000000..5603787dee4 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--assignment_insert-insert user assignment.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user_role" (org_id, "user_id", role_id, created) +VALUES ( + 8, + 5, + 23, + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql new file mode 100755 index 00000000000..8b5f2c2bae3 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql @@ -0,0 +1,11 @@ +INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier) +VALUES ( + 23, + 'dashboards:view', + 'dashboard:uid:dash1', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00', + 'dashboard', + 'uid', + 'dash1' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--role_insert-insert_role.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--role_insert-insert_role.sql new file mode 100755 index 00000000000..e747fc8bf4d --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--role_insert-insert_role.sql @@ -0,0 +1,9 @@ +INSERT INTO "grafana"."role" (version, org_id, uid, name, created, updated) +VALUES ( + 0, + 8, + 'managed_UEjD8KEQ8e2wzcugTRFOM2179aY', + 'managed:builtins:editor:1:permissions', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert basic role assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert basic role assignment.sql new file mode 100755 index 00000000000..1cec1845cf4 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert basic role assignment.sql @@ -0,0 +1,8 @@ +INSERT INTO "grafana"."builtin_role" (org_id, "role", role_id, created, updated) +VALUES ( + 74, + 'Viewer', + 96, + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert user assignment.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert user assignment.sql new file mode 100755 index 00000000000..5603787dee4 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--assignment_insert-insert user assignment.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user_role" (org_id, "user_id", role_id, created) +VALUES ( + 8, + 5, + 23, + '2025-08-27 21:35:00' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql new file mode 100755 index 00000000000..8b5f2c2bae3 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql @@ -0,0 +1,11 @@ +INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier) +VALUES ( + 23, + 'dashboards:view', + 'dashboard:uid:dash1', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00', + 'dashboard', + 'uid', + 'dash1' +) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--role_insert-insert_role.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--role_insert-insert_role.sql new file mode 100755 index 00000000000..e747fc8bf4d --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--role_insert-insert_role.sql @@ -0,0 +1,9 @@ +INSERT INTO "grafana"."role" (version, org_id, uid, name, created, updated) +VALUES ( + 0, + 8, + 'managed_UEjD8KEQ8e2wzcugTRFOM2179aY', + 'managed:builtins:editor:1:permissions', + '2025-08-27 21:35:00', + '2025-08-27 21:35:00' +)