grafana-iam: Implement resourcepermission list (#110769)

* WIP: List

* make toV0ResourcePermissions work with an ordered list of assignments to ensure consistency in the results

* Test templates

* Split list query in two. I clearly need scopePatterns

* Add pagination with offsets

* Remove unecessary comment

* implement listiterator

* add listiterator tests

* return the correct resource version

* use SkipIntegrationTestInShortMode

* No need for the extra check on pagination being correctly set

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>

* Spec is out of date

* Remove wrong comment

* Add a test for the pagination token

---------

Co-authored-by: mohammad-hamid <mohammad.hamid@grafana.com>
Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
Gabriel MABILLE
2025-09-10 11:17:53 +02:00
committed by GitHub
co-authored by Ieva mohammad-hamid
parent 7c242eeaef
commit 5c6fd5e5af
26 changed files with 1085 additions and 206 deletions
@@ -0,0 +1,109 @@
package resourcepermission
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
type continueToken struct {
offset int64
}
func readContinueToken(next string) (continueToken, error) {
var err error
token := continueToken{}
if next == "" {
return token, nil
}
parts := strings.Split(next, "/")
sub := strings.Split(parts[0], ":")
if sub[0] != "start" {
return token, fmt.Errorf("expected internal ID in second slug")
}
token.offset, err = strconv.ParseInt(sub[1], 10, 64)
if err != nil {
return token, fmt.Errorf("error parsing updated")
}
return token, err
}
func (r *continueToken) String() string {
return fmt.Sprintf("start:%d", r.offset)
}
// listIterator implements resource.ListIterator for iterating over resource permissions.
type listIterator struct {
// List of resourcePermissions to iterate over
resourcePermissions []v0alpha1.ResourcePermission
// Initial offset
initOffset int64
// Current index in the resource permission slice (1-based)
idx int
// Error encountered during iteration
err error
// Continue token for pagination
token continueToken
}
func (r *listIterator) cur() *v0alpha1.ResourcePermission {
// idx is 1-based
return &r.resourcePermissions[r.idx-1]
}
func (r *listIterator) Close() error {
return nil
}
// ContinueToken implements resource.ListIterator.
func (r *listIterator) ContinueToken() string {
return r.token.String()
}
// Error implements resource.ListIterator.
func (r *listIterator) Error() error {
return r.err
}
// Folder implements resource.ListIterator.
func (r *listIterator) Folder() string {
return ""
}
// Name implements resource.ListIterator.
func (r *listIterator) Name() string {
return r.cur().Name
}
// Namespace implements resource.ListIterator.
func (r *listIterator) Namespace() string {
return r.resourcePermissions[r.idx-1].GetNamespace()
}
// Next implements resource.ListIterator.
func (r *listIterator) Next() bool {
if r.err != nil || r.idx >= len(r.resourcePermissions) {
return false
}
r.idx++
r.token.offset = r.initOffset + int64(r.idx)
return true
}
// ResourceVersion implements resource.ListIterator.
func (r *listIterator) ResourceVersion() int64 {
return r.cur().GetUpdateTimestamp().UnixMilli()
}
// Value implements resource.ListIterator.
func (r *listIterator) Value() []byte {
b, err := json.Marshal(r.cur())
r.err = err
return b
}
@@ -10,6 +10,7 @@ type Mapper interface {
ActionSets() []string
Scope(name string) string
ActionSet(level string) (string, error)
ScopePattern() string
}
type mapper struct {
@@ -43,3 +44,7 @@ func (m mapper) ActionSet(level string) (string, error) {
}
return actionSet, nil
}
func (m mapper) ScopePattern() string {
return m.resource + ":uid:%"
}
@@ -14,6 +14,7 @@ import (
"github.com/grafana/authlib/types"
v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
idStore "github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
@@ -43,11 +44,16 @@ type IdentityStore interface {
GetUserInternalID(ctx context.Context, ns types.NamespaceInfo, query idStore.GetUserInternalIDQuery) (*idStore.GetUserInternalIDResult, error)
}
type PageQuery struct {
ScopePatterns []string
OrgID int64
Pagination common.Pagination
}
type ListResourcePermissionsQuery struct {
Scope string
Scopes []string
OrgID int64
ActionSets []string
// TODO Pagination common.Pagination
}
type DeleteResourcePermissionsQuery struct {
@@ -85,84 +91,124 @@ type rbacAssignment struct {
IsServiceAccount bool `xorm:"is_service_account"`
}
// 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 {
// newV0ResourcePermission creates a new v0alpha1.ResourcePermission from the given groupResourceName and permission specs.
// Specs are sorted for consistency, created and updated are used for the metadata timestamps and resourceVersion is set to the updated timestamp in milliseconds.
func newV0ResourcePermission(grn *groupResourceName, specs []v0alpha1.ResourcePermissionspecPermission, created, updated time.Time, namespace string) v0alpha1.ResourcePermission {
// Sort specs for consistency
sort.Slice(specs, func(i, j int) bool {
if specs[i].Kind != specs[j].Kind {
return specs[i].Kind < specs[j].Kind
}
if specs[i].Name != specs[j].Name {
return specs[i].Name < specs[j].Name
}
return specs[i].Verb < specs[j].Verb
})
r := v0alpha1.ResourcePermission{
TypeMeta: v0alpha1.ResourcePermissionInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
Name: grn.string(),
Namespace: namespace,
ResourceVersion: fmt.Sprint(updated.UnixMilli()),
CreationTimestamp: metav1.NewTime(created.UTC()),
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: grn.v0alpha1(),
Permissions: specs,
},
}
r.SetUpdateTimestamp(updated.UTC())
return r
}
// toV0ResourcePermissions translates a list of rbacAssignments into a list of v0alpha1.ResourcePermissions.
// it is assumed that assignments are sorted by scope
func (s *ResourcePermSqlBackend) toV0ResourcePermissions(assignments []rbacAssignment, namespace string) ([]v0alpha1.ResourcePermission, error) {
if len(assignments) == 0 {
return nil, nil
}
resourcePermissions := make([]v0alpha1.ResourcePermission, 0, len(permsByResource))
for resource, perms := range permsByResource {
specs := make([]v0alpha1.ResourcePermissionspecPermission, 0, len(perms))
var (
created = assignments[0].Created
updated = assignments[0].Updated
permissionKind v0alpha1.ResourcePermissionSpecPermissionKind
var (
created = time.Now()
updated = time.Now()
permissionKind v0alpha1.ResourcePermissionSpecPermissionKind
)
for i := range perms {
// Find the most recent updated time
if i == 0 || perms[i].Updated.After(updated) {
updated = perms[i].Updated
}
// Find the oldest created time
if i == 0 || perms[i].Created.Before(created) {
created = perms[i].Created
}
perm := perms[i]
switch perm.SubjectType {
case "user":
if perm.IsServiceAccount {
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount
} else {
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindUser
}
case "team":
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindTeam
case "builtin_role":
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindBasicRole
default:
return nil, errors.New("unknown subject type: " + perm.SubjectType)
}
resourcePermissions = make([]v0alpha1.ResourcePermission, 0, 8)
specs = make([]v0alpha1.ResourcePermissionspecPermission, 0, 4)
)
actionParts := strings.SplitN(perm.Action, ":", 2)
if len(actionParts) < 2 || actionParts[1] == "" {
return nil, fmt.Errorf("invalid action format: %s", perm.Action)
}
verb := actionParts[1]
specs = append(specs, v0alpha1.ResourcePermissionspecPermission{
Kind: permissionKind,
Name: perm.SubjectUID,
Verb: verb,
})
}
sort.Slice(specs, func(i, j int) bool {
if specs[i].Kind != specs[j].Kind {
return specs[i].Kind < specs[j].Kind
}
if specs[i].Name != specs[j].Name {
return specs[i].Name < specs[j].Name
}
return specs[i].Verb < specs[j].Verb
})
r := v0alpha1.ResourcePermission{
TypeMeta: v0alpha1.ResourcePermissionInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
Name: resource.string(),
ResourceVersion: fmt.Sprint(updated.UnixMilli()),
CreationTimestamp: metav1.NewTime(created.UTC()),
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: resource.v0alpha1(),
Permissions: specs,
},
}
r.SetUpdateTimestamp(updated.UTC())
resourcePermissions = append(resourcePermissions, r)
grn, err := s.parseScope(assignments[0].Scope)
if err != nil {
return nil, err
}
for _, assign := range assignments {
// Ensure all assignments belong to the same resource
parsedGrn, err := s.parseScope(assign.Scope)
if err != nil {
return nil, err
}
// If it's a new resource, flush the current specs to a ResourcePermission and start a new one
if *parsedGrn != *grn {
resourcePermissions = append(
resourcePermissions,
newV0ResourcePermission(grn, specs, created, updated, namespace),
)
// Reset for the new resource
grn = parsedGrn
specs = make([]v0alpha1.ResourcePermissionspecPermission, 0, 4)
created = assign.Created
updated = assign.Updated
}
// Find the most recent updated time
if assign.Updated.After(updated) {
updated = assign.Updated
}
// Find the oldest created time
if assign.Created.Before(created) {
created = assign.Created
}
// Determine permission kind
switch assign.SubjectType {
case "user":
if assign.IsServiceAccount {
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount
} else {
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindUser
}
case "team":
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindTeam
case "builtin_role":
permissionKind = v0alpha1.ResourcePermissionSpecPermissionKindBasicRole
default:
return nil, errors.New("unknown subject type: " + assign.SubjectType)
}
// Determine verb from action
actionParts := strings.SplitN(assign.Action, ":", 2)
if len(actionParts) < 2 || actionParts[1] == "" {
return nil, fmt.Errorf("invalid action format: %s", assign.Action)
}
verb := actionParts[1]
// Append the translated permission spec
specs = append(specs, v0alpha1.ResourcePermissionspecPermission{
Kind: permissionKind,
Name: assign.SubjectUID,
Verb: verb,
})
}
// Flush the final resource
resourcePermissions = append(
resourcePermissions,
newV0ResourcePermission(grn, specs, created, updated, namespace),
)
return resourcePermissions, nil
}
@@ -19,68 +19,64 @@ func setupBackendNoDB(t *testing.T) *ResourcePermSqlBackend {
}
func TestToV0ResourcePermissions(t *testing.T) {
backend := setupBackendNoDB(t)
t.Run("empty permissions", func(t *testing.T) {
result, err := toV0ResourcePermissions(map[groupResourceName][]rbacAssignment{})
result, err := backend.toV0ResourcePermissions([]rbacAssignment{}, "default")
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][]rbacAssignment{
permissions := []rbacAssignment{
{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test123",
}: {
{
ID: 1,
Action: "dashboards:admin",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "testuser",
SubjectType: "user",
IsServiceAccount: false,
},
{
ID: 2,
Action: "dashboards:edit",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "sa1",
SubjectType: "user",
IsServiceAccount: true,
},
{
ID: 3,
Action: "dashboards:view",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "testteam",
SubjectType: "team",
IsServiceAccount: false,
},
{
ID: 4,
Action: "dashboards:view",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "Viewer",
SubjectType: "builtin_role",
IsServiceAccount: false,
},
ID: 1,
Action: "dashboards:admin",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "testuser",
SubjectType: "user",
IsServiceAccount: false,
},
{
ID: 2,
Action: "dashboards:edit",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "sa1",
SubjectType: "user",
IsServiceAccount: true,
},
{
ID: 3,
Action: "dashboards:view",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "testteam",
SubjectType: "team",
IsServiceAccount: false,
},
{
ID: 4,
Action: "dashboards:view",
Scope: "dashboards:uid:test123",
Created: now,
Updated: now,
RoleName: "managed:role1",
SubjectUID: "Viewer",
SubjectType: "builtin_role",
IsServiceAccount: false,
},
}
result, err := toV0ResourcePermissions(permissions)
result, err := backend.toV0ResourcePermissions(permissions, "default")
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result, 1)
@@ -0,0 +1,8 @@
SELECT p.updated AS latest_update
FROM {{ .Ident .PermissionTable }} AS p
INNER JOIN {{ .Ident .RoleTable }} AS r ON p.role_id = r.id
WHERE r.name LIKE {{ .Arg .ManagedPattern }}
AND r.org_id = {{ .Arg .OrgID }}
AND ( {{ range $index, $scopePattern := .ScopePatterns }}{{ if $index }} OR {{ end }} p.scope LIKE {{ $.Arg $scopePattern }}{{ end }} )
ORDER BY p.updated DESC
LIMIT 1;
@@ -0,0 +1,10 @@
SELECT p.scope
FROM {{ .Ident .PermissionTable }} AS p
INNER JOIN {{ .Ident .RoleTable }} AS r ON p.role_id = r.id
WHERE r.name LIKE {{ .Arg .ManagedRolePattern }}
AND r.org_id = {{ .Arg .Query.OrgID }}
AND ( {{ range $index, $scopePattern := .Query.ScopePatterns }}{{ if $index }} OR {{ end }} p.scope LIKE {{ $.Arg $scopePattern }}{{ end }} )
GROUP BY p.scope
ORDER BY p.scope
LIMIT {{ .Arg .Query.Pagination.Limit }}
OFFSET {{ .Arg .Query.Pagination.Continue }}
@@ -21,7 +21,9 @@ AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
{{ if .Query.OrgID }}
AND COALESCE(ur.org_id, tr.org_id, r.org_id) = {{ .Arg .Query.OrgID }}
{{ end }}
{{ if .Query.Scope }}
AND p.scope = {{ .Arg .Query.Scope }}
{{ if eq (len .Query.Scopes) 1 }}
AND p.scope = {{ .Arg (index .Query.Scopes 0) }}
{{ else if gt (len .Query.Scopes) 1 }}
AND p.scope IN ({{ .ArgList .Query.Scopes }})
{{ end }}
ORDER BY p.id
ORDER BY p.scope
+120 -22
View File
@@ -6,9 +6,11 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
@@ -16,16 +18,118 @@ import (
)
// List
func (s *ResourcePermSqlBackend) newRoleIterator(ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, pagination *common.Pagination) (*listIterator, error) {
var (
scope string
actionSets = make([]string, 0, 3*len(s.mappers))
scopePatterns = make([]string, 0, len(s.mappers))
assignments = make([]rbacAssignment, 0, 8)
scopes = make([]string, 0, 8)
)
for _, mapper := range s.mappers {
actionSets = append(actionSets, mapper.ActionSets()...)
}
for _, mapper := range s.mappers {
scopePatterns = append(scopePatterns, mapper.ScopePattern())
}
// Run in a transaction to ensure a consistent view of the data
err := dbHelper.DB.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error {
// Get page
rawPageQuery, pageArgs, err := buildPageQueryFromTemplate(dbHelper, &PageQuery{
ScopePatterns: scopePatterns,
OrgID: ns.OrgID,
Pagination: *pagination,
})
if err != nil {
return err
}
rows, err := tx.Query(ctx, rawPageQuery, pageArgs...)
if err != nil {
if rows != nil {
_ = rows.Close()
}
return fmt.Errorf("querying resource permissions: %w", err)
}
defer func() {
_ = rows.Close()
}()
for rows.Next() {
if err := rows.Scan(&scope); err != nil {
return fmt.Errorf("scanning resource permission: %w", err)
}
scopes = append(scopes, scope)
}
if len(scopes) == 0 {
// No results
return nil
}
// Get assignments for the page
assignments, err = s.getRbacAssignmentsWithTx(ctx, dbHelper, tx, &ListResourcePermissionsQuery{
Scopes: scopes,
OrgID: ns.OrgID,
ActionSets: actionSets,
})
return err
})
if err != nil {
return nil, err
}
if len(assignments) == 0 {
// No results
return &listIterator{}, nil
}
v0ResourcePermissions, err := s.toV0ResourcePermissions(assignments, ns.Value)
if err != nil {
return nil, err
}
return &listIterator{
resourcePermissions: v0ResourcePermissions,
initOffset: pagination.Continue,
}, nil
}
func (s *ResourcePermSqlBackend) latestUpdate(ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo) int64 {
scopePatterns := make([]string, 0, len(s.mappers)*3)
for _, mapper := range s.mappers {
scopePatterns = append(scopePatterns, mapper.ScopePattern())
}
query, args, err := buildLatestUpdateQueryFromTemplate(dbHelper, ns.OrgID, scopePatterns)
if err != nil {
s.logger.FromContext(ctx).Warn("Failed to build latest update query", "error", err)
return timeNow().UnixMilli()
}
var maxUpdated time.Time
err = dbHelper.DB.GetSqlxSession().Get(ctx, &maxUpdated, query, args...)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
s.logger.FromContext(ctx).Warn("Failed to get latest update for roles", "error", err)
}
return timeNow().UnixMilli()
}
return maxUpdated.UnixMilli()
}
// 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][]rbacAssignment, error) {
// getRbacAssignmentsWithTx queries resource permissions based on the provided ListResourcePermissionsQuery and groups them by resource (e.g. {folder.grafana.app, folders, fold1})
func (s *ResourcePermSqlBackend) getRbacAssignmentsWithTx(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, tx *session.SessionTx, query *ListResourcePermissionsQuery) ([]rbacAssignment, error) {
rawQuery, args, err := buildListResourcePermissionsQueryFromTemplate(sql, query)
if err != nil {
return nil, err
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, rawQuery, args...)
rows, err := tx.Query(ctx, rawQuery, args...)
if err != nil {
if rows != nil {
_ = rows.Close()
@@ -36,7 +140,7 @@ func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql
_ = rows.Close()
}()
permissions := make(map[groupResourceName][]rbacAssignment)
permissions := make([]rbacAssignment, 0, 8)
for rows.Next() {
var perm rbacAssignment
if err := rows.Scan(
@@ -45,14 +149,7 @@ func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql
); err != nil {
return nil, fmt.Errorf("scanning resource permission: %w", err)
}
key, err := s.parseScope(perm.Scope)
if err != nil {
s.logger.Warn("skipping", "scope", perm.Scope, "err", err)
continue
}
permissions[*key] = append(permissions[*key], perm)
permissions = append(permissions, perm)
}
return permissions, nil
@@ -66,26 +163,27 @@ func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql
}
resourceQuery := &ListResourcePermissionsQuery{
Scope: mapper.Scope(grn.Name),
Scopes: []string{mapper.Scope(grn.Name)},
OrgID: ns.OrgID,
ActionSets: mapper.ActionSets(),
}
permsByResource, err := s.getResourcePermissions(ctx, sql, resourceQuery)
if err != nil {
return nil, err
var assignments []rbacAssignment
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error {
assignments, err = s.getRbacAssignmentsWithTx(ctx, sql, tx, resourceQuery)
return err
})
if len(assignments) == 0 {
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scopes, errNotFound)
}
if len(permsByResource) == 0 {
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scope, errNotFound)
}
resourcePermission, err := toV0ResourcePermissions(permsByResource)
resourcePermission, err := s.toV0ResourcePermissions(assignments, ns.Value)
if err != nil {
return nil, err
}
if resourcePermission == nil {
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scope, errNotFound)
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scopes, errNotFound)
}
return &resourcePermission[0], nil
@@ -14,6 +14,7 @@ import (
"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/common"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/storage/legacysql"
@@ -21,6 +22,77 @@ import (
"github.com/grafana/grafana/pkg/util/testutil"
)
var (
created = time.Date(2025, 9, 2, 0, 0, 0, 0, time.UTC)
updated = time.Date(2025, 9, 3, 0, 0, 0, 0, time.UTC) // The "dashboards:admin" permission was updated later
fold1ResourcePermission = v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
CreationTimestamp: metav1.Time{Time: created},
ResourceVersion: fmt.Sprint(created.UnixMilli()),
},
TypeMeta: metav1.TypeMeta{
Kind: "ResourcePermission",
APIVersion: "iam.grafana.app/v0alpha1",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold1",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "user-1",
Verb: "view",
},
},
},
}
dash1ResourcePermission = v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "dashboard.grafana.app-dashboards-dash1",
CreationTimestamp: metav1.Time{Time: created},
ResourceVersion: fmt.Sprint(updated.UnixMilli()),
},
TypeMeta: metav1.TypeMeta{
Kind: "ResourcePermission",
APIVersion: "iam.grafana.app/v0alpha1",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "edit",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount,
Name: "sa-1",
Verb: "view",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindTeam,
Name: "team-1",
Verb: "admin",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "user-1",
Verb: "edit",
},
},
},
}
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
@@ -119,7 +191,7 @@ func setupTestRoles(t *testing.T, store db.DB) {
require.NoError(t, err)
}
func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T) {
func TestIntegration_ResourcePermSqlBackend_newRoleIterator(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
backend := setupBackend(t)
@@ -127,7 +199,65 @@ func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T)
require.NoError(t, err)
setupTestRoles(t, sql.DB)
created := time.Date(2025, 9, 2, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
orgID int64
pagination common.Pagination
want []v0alpha1.ResourcePermission
err error
}{
{
name: "should return all permissions for org-1",
orgID: 1,
pagination: common.Pagination{
Limit: 100,
},
want: []v0alpha1.ResourcePermission{dash1ResourcePermission, fold1ResourcePermission},
err: nil,
},
{
name: "should return a partial list of permissions for org-1",
orgID: 1,
pagination: common.Pagination{
Continue: 1,
Limit: 1,
},
want: []v0alpha1.ResourcePermission{fold1ResourcePermission},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := types.NamespaceInfo{OrgID: tt.orgID}
it, err := backend.newRoleIterator(context.Background(), sql, ns, &tt.pagination)
require.NoError(t, err)
var result []v0alpha1.ResourcePermission
for it.Next() {
result = append(result, *it.cur())
}
require.NoError(t, it.Error())
for i := range result {
require.Equal(t, tt.want[i].Name, result[i].Name)
require.Equal(t, tt.want[i].CreationTimestamp, result[i].CreationTimestamp)
require.Equal(t, tt.want[i].ResourceVersion, result[i].ResourceVersion)
require.NotZero(t, result[i].GetUpdateTimestamp())
require.Equal(t, tt.want[i].TypeMeta, result[i].TypeMeta)
for j := range result[i].Spec.Permissions {
require.Equal(t, tt.want[i].Spec.Permissions[j], result[i].Spec.Permissions[j])
}
require.Equal(t, tt.want[i].Spec.Resource, result[i].Spec.Resource)
}
})
}
}
func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
backend := setupBackend(t)
sql, err := backend.dbProvider(context.Background())
require.NoError(t, err)
setupTestRoles(t, sql.DB)
tests := []struct {
name string
@@ -140,29 +270,8 @@ func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T)
name: "should return only org-1 permissions for fold1",
resource: "folder.grafana.app-folders-fold1",
orgID: 1,
want: v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
CreationTimestamp: metav1.Time{Time: created},
ResourceVersion: fmt.Sprint(created.UnixMilli()),
},
TypeMeta: v0alpha1.ResourcePermissionInfo.TypeMeta(),
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold1",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "user-1",
Verb: "view",
},
},
},
},
err: nil,
want: fold1ResourcePermission,
err: nil,
},
{
name: "should return empty for org-2",
@@ -206,7 +315,7 @@ func TestIntegration_ResourcePermSqlBackend_getResourcePermission(t *testing.T)
}
}
func TestIntegrationResourcePermSqlBackend_deleteResourcePermission(t *testing.T) {
func TestIntegration_ResourcePermSqlBackend_deleteResourcePermission(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
backend := setupBackend(t)
@@ -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"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
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"
@@ -64,8 +65,61 @@ func (s *ResourcePermSqlBackend) ListHistory(context.Context, *resourcepb.ListRe
return 0, errNotImplemented
}
func (s *ResourcePermSqlBackend) ListIterator(context.Context, *resourcepb.ListRequest, func(resource.ListIterator) error) (int64, error) {
return 0, errNotImplemented
func (s *ResourcePermSqlBackend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, callback func(resource.ListIterator) error) (int64, error) {
opts := req.Options
if opts == nil || opts.Key == nil || opts.Key.Namespace == "" {
return 0, apierrors.NewBadRequest("list requires a valid namespace")
}
ns, err := types.ParseNamespace(opts.Key.Namespace)
if err != nil {
return 0, err
}
if ns.OrgID <= 0 {
return 0, apierrors.NewBadRequest(errInvalidNamespace.Error())
}
if req.ResourceVersion != 0 {
return 0, apierrors.NewBadRequest("list with explicit resourceVersion is not supported by this storage backend")
}
limit := req.Limit
if limit <= 0 {
limit = 50
}
token, err := readContinueToken(req.NextPageToken)
if err != nil {
return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid continue token: %v", err))
}
pagination := &common.Pagination{
Limit: limit,
Continue: token.offset,
}
dbHelper, err := s.dbProvider(ctx)
if err != nil {
logger := s.logger.FromContext(ctx)
logger.Error("Failed to get database helper", "error", err)
return 0, errDatabaseHelper
}
iterator, err := s.newRoleIterator(ctx, dbHelper, ns, pagination)
if iterator != nil {
defer func() {
_ = iterator.Close()
}()
}
if err != nil {
return 0, err
}
err = callback(iterator)
if err != nil {
return 0, err
}
return s.latestUpdate(ctx, dbHelper, ns), nil
}
func (s *ResourcePermSqlBackend) ListModifiedSince(ctx context.Context, key resource.NamespacedResource, sinceRv int64) (int64, iter.Seq2[*resource.ModifiedResource, error]) {
@@ -3,6 +3,7 @@ package resourcepermission
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
@@ -329,7 +330,293 @@ func TestWriteEvent_Add(t *testing.T) {
})
}
func TestWriteEvent_Delete(t *testing.T) {
func TestIntegration_ResourcePermSqlBackend_ListIterator(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
// No result => list resource version should be current time
now := time.Date(2025, 9, 9, 0, 0, 0, 0, time.UTC)
timeNow = func() time.Time {
return now
}
backend := setupBackend(t)
sql, err := backend.dbProvider(context.Background())
require.NoError(t, err)
setupTestRoles(t, sql.DB)
gr := v0alpha1.ResourcePermissionInfo.GroupResource()
t.Run("Should error with invalid namespace", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "invalid",
},
},
Limit: 10,
}
callbackCalled := false
count, err := backend.ListIterator(context.Background(), req, func(resource.ListIterator) error {
callbackCalled = true
return nil
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid namespace")
require.Zero(t, count)
require.False(t, callbackCalled)
})
t.Run("Should error with invalid continue token", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
NextPageToken: "invalid-token",
Limit: 10,
}
callbackCalled := false
count, err := backend.ListIterator(context.Background(), req, func(resource.ListIterator) error {
callbackCalled = true
return nil
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid continue token")
require.Zero(t, count)
require.False(t, callbackCalled)
})
t.Run("Should list all resources for org-1", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
Limit: 100,
}
var results []v0alpha1.ResourcePermission
listRV, err := backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
var perm v0alpha1.ResourcePermission
err := json.Unmarshal(it.Value(), &perm)
require.NoError(t, err)
results = append(results, perm)
// Test iterator methods
require.NotEmpty(t, it.Name())
require.Equal(t, "default", it.Namespace())
require.NotZero(t, it.ResourceVersion())
require.Empty(t, it.Folder()) // ResourcePermissions don't have folders
}
return it.Error()
})
require.NoError(t, err)
require.Equal(t, updated.UnixMilli(), listRV)
require.Len(t, results, 2)
// Results should be sorted by scope (alphabetically)
require.Equal(t, "dashboard.grafana.app-dashboards-dash1", results[0].Name)
require.Equal(t, "folder.grafana.app-folders-fold1", results[1].Name)
// Verify dash1 permissions
require.Len(t, results[0].Spec.Permissions, 4)
require.Equal(t, "dashboard.grafana.app", results[0].Spec.Resource.ApiGroup)
require.Equal(t, "dashboards", results[0].Spec.Resource.Resource)
require.Equal(t, "dash1", results[0].Spec.Resource.Name)
// Verify fold1 permissions
require.Len(t, results[1].Spec.Permissions, 1)
require.Equal(t, "folder.grafana.app", results[1].Spec.Resource.ApiGroup)
require.Equal(t, "folders", results[1].Spec.Resource.Resource)
require.Equal(t, "fold1", results[1].Spec.Resource.Name)
})
t.Run("Should handle pagination with continue token", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
Limit: 1,
NextPageToken: "start:1", // Skip first item
}
var results []v0alpha1.ResourcePermission
listRV, err := backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
var perm v0alpha1.ResourcePermission
err := json.Unmarshal(it.Value(), &perm)
require.NoError(t, err)
results = append(results, perm)
// Test continue token
require.NotEmpty(t, it.ContinueToken())
}
return it.Error()
})
require.NoError(t, err)
require.Equal(t, updated.UnixMilli(), listRV) // Only fold1
require.Len(t, results, 1)
require.Equal(t, "folder.grafana.app-folders-fold1", results[0].Name)
})
t.Run("Should use default limit when not specified", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
// No limit specified, should default to 50
}
callbackCalled := false
listRV, err := backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
callbackCalled = true
// Just verify the iterator works
for it.Next() {
require.NotNil(t, it.Value())
}
return it.Error()
})
require.NoError(t, err)
require.True(t, callbackCalled)
require.Equal(t, updated.UnixMilli(), listRV)
})
t.Run("Should return empty results for org with no permissions", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "org-999", // Non-existent org
},
},
Limit: 10,
}
var results []v0alpha1.ResourcePermission
listRV, err := backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
var perm v0alpha1.ResourcePermission
err := json.Unmarshal(it.Value(), &perm)
require.NoError(t, err)
results = append(results, perm)
}
return it.Error()
})
require.NoError(t, err)
require.Equal(t, listRV, now.UnixMilli())
require.Empty(t, results)
})
t.Run("Should propagate callback errors", func(t *testing.T) {
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
Limit: 10,
}
expectedErr := errors.New("callback error")
listRV, err := backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
return expectedErr
})
require.Error(t, err)
require.Equal(t, expectedErr, err)
require.Zero(t, listRV)
})
t.Run("Should paginate through all results", func(t *testing.T) {
expectedResults := []string{
"dashboard.grafana.app-dashboards-dash1",
"folder.grafana.app-folders-fold1",
}
req := &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: gr.Group,
Resource: gr.Resource,
Namespace: "default",
},
},
Limit: 1,
NextPageToken: "start:0",
}
var perm v0alpha1.ResourcePermission
continueToken := "start:0"
results := []string{}
_, _ = backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
continueToken = it.ContinueToken()
require.NoError(t, json.Unmarshal(it.Value(), &perm))
results = append(results, perm.Name)
}
return nil
})
require.Equal(t, "start:1", continueToken)
req.NextPageToken = continueToken
_, _ = backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
continueToken = it.ContinueToken()
require.NoError(t, json.Unmarshal(it.Value(), &perm))
results = append(results, perm.Name)
}
return nil
})
require.Equal(t, "start:2", continueToken)
// No more results, token should not change
req.NextPageToken = continueToken
_, _ = backend.ListIterator(context.Background(), req, func(it resource.ListIterator) error {
for it.Next() {
continueToken = it.ContinueToken()
require.NoError(t, json.Unmarshal(it.Value(), &perm))
results = append(results, perm.Name)
}
return nil
})
require.Equal(t, "start:2", continueToken) // No change
// Verify we got all expected results
require.Len(t, results, 2)
require.ElementsMatch(t, expectedResults, results)
})
}
func TestIntegration_WriteEvent_Delete(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
backend := setupBackend(t)
sql, err := backend.dbProvider(context.Background())
require.NoError(t, err)
@@ -23,6 +23,8 @@ var (
roleInsertTplt = mustTemplate("role_insert.sql")
assignmentInsertTplt = mustTemplate("assignment_insert.sql")
permissionInsertTplt = mustTemplate("permission_insert.sql")
pageQueryTplt = mustTemplate("page_query.sql")
latestUpdateTplt = mustTemplate("latest_update_query.sql")
)
func mustTemplate(filename string) *template.Template {
@@ -34,6 +36,70 @@ func mustTemplate(filename string) *template.Template {
// List
type pageQueryTemplate struct {
sqltemplate.SQLTemplate
Query *PageQuery
PermissionTable string
RoleTable string
ManagedRolePattern string
}
func (r pageQueryTemplate) Validate() error {
return nil
}
func buildPageQueryFromTemplate(dbHelper *legacysql.LegacyDatabaseHelper, query *PageQuery) (string, []interface{}, error) {
req := pageQueryTemplate{
SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()),
Query: query,
PermissionTable: "permission",
RoleTable: "role",
ManagedRolePattern: "managed:%",
}
rawQuery, err := sqltemplate.Execute(pageQueryTplt, req)
if err != nil {
return "", nil, fmt.Errorf("execute template %q: %w", pageQueryTplt.Name(), err)
}
return rawQuery, req.GetArgs(), nil
}
type latestUpdateTemplate struct {
sqltemplate.SQLTemplate
OrgID int64
ScopePatterns []string
PermissionTable string
RoleTable string
ManagedPattern string
}
func (l latestUpdateTemplate) Validate() error {
if l.OrgID <= 0 {
return fmt.Errorf("orgID must be set")
}
if len(l.ScopePatterns) == 0 {
return fmt.Errorf("at least one scope pattern is required")
}
return nil
}
func buildLatestUpdateQueryFromTemplate(dbHelper *legacysql.LegacyDatabaseHelper, orgID int64, scopePatterns []string) (string, []interface{}, error) {
req := latestUpdateTemplate{
SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()),
OrgID: orgID,
ScopePatterns: scopePatterns,
PermissionTable: dbHelper.Table("permission"),
RoleTable: dbHelper.Table("role"),
ManagedPattern: "managed:%",
}
rawQuery, err := sqltemplate.Execute(latestUpdateTplt, req)
if err != nil {
return "", nil, fmt.Errorf("execute template %q: %w", latestUpdateTplt.Name(), err)
}
return rawQuery, req.GetArgs(), nil
}
type listResourcePermissionsQueryTemplate struct {
sqltemplate.SQLTemplate
Query *ListResourcePermissionsQuery
@@ -47,29 +113,21 @@ type listResourcePermissionsQueryTemplate struct {
ManagedRolePattern string
}
// BooleanStr provides the BooleanStr functionality that the template is trying to access
func (r listResourcePermissionsQueryTemplate) BooleanStr(value bool) string {
if value {
return "1"
}
return "0"
}
func (r listResourcePermissionsQueryTemplate) Validate() error {
return nil
}
func buildListResourcePermissionsQueryFromTemplate(sql *legacysql.LegacyDatabaseHelper, query *ListResourcePermissionsQuery) (string, []interface{}, error) {
func buildListResourcePermissionsQueryFromTemplate(dbHelper *legacysql.LegacyDatabaseHelper, query *ListResourcePermissionsQuery) (string, []interface{}, error) {
req := listResourcePermissionsQueryTemplate{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
SQLTemplate: sqltemplate.New(dbHelper.DialectForDriver()),
Query: query,
PermissionTable: sql.Table("permission"),
RoleTable: sql.Table("role"),
UserTable: sql.Table("user"),
TeamTable: sql.Table("team"),
BuiltinRoleTable: sql.Table("builtin_role"),
UserRoleTable: sql.Table("user_role"),
TeamRoleTable: sql.Table("team_role"),
PermissionTable: dbHelper.Table("permission"),
RoleTable: dbHelper.Table("role"),
UserTable: dbHelper.Table("user"),
TeamTable: dbHelper.Table("team"),
BuiltinRoleTable: dbHelper.Table("builtin_role"),
UserRoleTable: dbHelper.Table("user_role"),
TeamRoleTable: dbHelper.Table("team_role"),
ManagedRolePattern: "managed:%",
}
@@ -4,6 +4,7 @@ import (
"testing"
"text/template"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
@@ -56,6 +57,31 @@ func TestTemplates(t *testing.T) {
return &v
}
getPageQuery := func(q *PageQuery) sqltemplate.SQLTemplate {
v := pageQueryTemplate{
SQLTemplate: sqltemplate.New(nodb.DialectForDriver()),
Query: q,
PermissionTable: nodb.Table("permission"),
RoleTable: nodb.Table("role"),
ManagedRolePattern: "managed:%",
}
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
getLastestUpdateQuery := func(orgID int64, scopePatterns []string) sqltemplate.SQLTemplate {
v := latestUpdateTemplate{
SQLTemplate: sqltemplate.New(nodb.DialectForDriver()),
OrgID: orgID,
ScopePatterns: scopePatterns,
PermissionTable: nodb.Table("permission"),
RoleTable: nodb.Table("role"),
ManagedPattern: "managed:%",
}
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
getListResourcePermissionsQuery := func(q *ListResourcePermissionsQuery) sqltemplate.SQLTemplate {
v := listResourcePermissionsQueryTemplate{
SQLTemplate: sqltemplate.New(nodb.DialectForDriver()),
@@ -133,6 +159,25 @@ func TestTemplates(t *testing.T) {
}),
},
},
pageQueryTplt: {
{
Name: "basic_page_query",
Data: getPageQuery(&PageQuery{
ScopePatterns: []string{"folders:uid:%", "dashboards:uid:%"},
OrgID: 3,
Pagination: common.Pagination{
Limit: 100,
Continue: 5,
},
}),
},
},
latestUpdateTplt: {
{
Name: "basic_latest_update_query",
Data: getLastestUpdateQuery(3, []string{"folders:uid:%", "dashboards:uid:%"}),
},
},
resourcePermissionsQueryTplt: {
{
Name: "basic_query",
@@ -141,7 +186,7 @@ func TestTemplates(t *testing.T) {
{
Name: "with_all_fields",
Data: getListResourcePermissionsQuery(&ListResourcePermissionsQuery{
Scope: "123",
Scopes: []string{"123"},
OrgID: 3,
ActionSets: []string{"folders:admin", "folders:edit", "folders:view"},
}),
@@ -10,10 +10,8 @@ spec:
permissions:
- kind: User
name: few8wwtp1si68e
verbs:
- edit
verb: edit
- kind: BasicRole
name: Editor
verbs:
- view
verb: view
@@ -0,0 +1,8 @@
SELECT p.updated AS latest_update
FROM `grafana`.`permission` AS p
INNER JOIN `grafana`.`role` AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
ORDER BY p.updated DESC
LIMIT 1;
@@ -0,0 +1,10 @@
SELECT p.scope
FROM `grafana`.`permission` AS p
INNER JOIN `grafana`.`role` AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
GROUP BY p.scope
ORDER BY p.scope
LIMIT 100
OFFSET 5
@@ -15,4 +15,4 @@ LEFT JOIN `grafana`.`team` t ON tr.team_id = t.id
LEFT JOIN `grafana`.`builtin_role` br ON r.id = br.role_id
WHERE r.name LIKE 'managed:%'
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
ORDER BY p.id
ORDER BY p.scope
@@ -18,4 +18,4 @@ AND p.action IN ('folders:admin', 'folders:edit', 'folders:view')
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
AND COALESCE(ur.org_id, tr.org_id, r.org_id) = 3
AND p.scope = '123'
ORDER BY p.id
ORDER BY p.scope
@@ -0,0 +1,8 @@
SELECT p.updated AS latest_update
FROM "grafana"."permission" AS p
INNER JOIN "grafana"."role" AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
ORDER BY p.updated DESC
LIMIT 1;
@@ -0,0 +1,10 @@
SELECT p.scope
FROM "grafana"."permission" AS p
INNER JOIN "grafana"."role" AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
GROUP BY p.scope
ORDER BY p.scope
LIMIT 100
OFFSET 5
@@ -15,4 +15,4 @@ LEFT JOIN "grafana"."team" t ON tr.team_id = t.id
LEFT JOIN "grafana"."builtin_role" br ON r.id = br.role_id
WHERE r.name LIKE 'managed:%'
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
ORDER BY p.id
ORDER BY p.scope
@@ -18,4 +18,4 @@ AND p.action IN ('folders:admin', 'folders:edit', 'folders:view')
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
AND COALESCE(ur.org_id, tr.org_id, r.org_id) = 3
AND p.scope = '123'
ORDER BY p.id
ORDER BY p.scope
@@ -0,0 +1,8 @@
SELECT p.updated AS latest_update
FROM "grafana"."permission" AS p
INNER JOIN "grafana"."role" AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
ORDER BY p.updated DESC
LIMIT 1;
@@ -0,0 +1,10 @@
SELECT p.scope
FROM "grafana"."permission" AS p
INNER JOIN "grafana"."role" AS r ON p.role_id = r.id
WHERE r.name LIKE 'managed:%'
AND r.org_id = 3
AND ( p.scope LIKE 'folders:uid:%' OR p.scope LIKE 'dashboards:uid:%' )
GROUP BY p.scope
ORDER BY p.scope
LIMIT 100
OFFSET 5
@@ -15,4 +15,4 @@ LEFT JOIN "grafana"."team" t ON tr.team_id = t.id
LEFT JOIN "grafana"."builtin_role" br ON r.id = br.role_id
WHERE r.name LIKE 'managed:%'
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
ORDER BY p.id
ORDER BY p.scope
@@ -18,4 +18,4 @@ AND p.action IN ('folders:admin', 'folders:edit', 'folders:view')
AND (u.uid IS NOT NULL OR t.uid IS NOT NULL OR br.role IS NOT NULL)
AND COALESCE(ur.org_id, tr.org_id, r.org_id) = 3
AND p.scope = '123'
ORDER BY p.id
ORDER BY p.scope