grafana-iam: Implement resourcepermission creation (#110246)
* Extract from #108753 Co-Authored-By: mohammad-hamid <mohammad.hamid@grafana.com> * Tackle create Co-Authored-By: mohammad-hamid <mohammad.hamid@grafana.com> * WIP use identity store to resolve role names * WIP * create role * Remove unecessary comments * comments * sql templates * test role insert tplt * Add tests 😅 * Test permission insert template * Test permission delete template * Test assignment_insert template * Manually test insertion * Remove delete permissions. This is a create case we don't have permissions for that resource * generate name handled by the apiserver library * Remove comment and conversion * Small renaming nits * changes from main * Add storage backend tests * Add test to sql * Test role contains a unique permission * linting * Account for pr feedback Co-authored-by: Ieva <ieva.vasiljeva@grafana.com> * Reuse mappers * Move function to models * Add check between name and spec resource * Check if the resource does not already exist Co-authored-by: Ieva <ieva.vasiljeva@grafana.com> * fix query * Check basic roles Co-authored-by: Ieva <ieva.vasiljeva@grafana.com> * Account for error * Make struct names consistent * Nit. I prefer createAndAssignManagedRole * Remove notifyign * log errors instead of returning them * Fix exist query join * Test errors * Remove dup --------- Co-authored-by: mohammad-hamid <mohammad.hamid@grafana.com> Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
co-authored by
mohammad-hamid
Ieva
parent
726c7ba71b
commit
801fde02a7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <group>-<resource>-<name> (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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}
|
||||
)
|
||||
@@ -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 }}
|
||||
)
|
||||
@@ -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 }}
|
||||
)
|
||||
@@ -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 <group>-<resource>-<name> (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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
Executable
+8
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+7
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+11
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+9
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+8
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+7
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+11
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+9
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+8
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+7
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+11
@@ -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'
|
||||
)
|
||||
Vendored
Executable
+9
@@ -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'
|
||||
)
|
||||
Reference in New Issue
Block a user