From 5c6fd5e5af18845ebca47fefa26912c6c057607b Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 10 Sep 2025 11:17:53 +0200 Subject: [PATCH] `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 * Spec is out of date * Remove wrong comment * Add a test for the pagination token --------- Co-authored-by: mohammad-hamid Co-authored-by: Ieva --- .../iam/resourcepermission/list_iterator.go | 109 +++++++ .../apis/iam/resourcepermission/mapper.go | 5 + .../apis/iam/resourcepermission/models.go | 192 +++++++----- .../iam/resourcepermission/models_test.go | 98 +++--- .../queries/latest_update_query.sql | 8 + .../resourcepermission/queries/page_query.sql | 10 + .../queries/resource_permission_query.sql | 8 +- .../apis/iam/resourcepermission/sql.go | 142 +++++++-- .../apis/iam/resourcepermission/sql_test.go | 161 ++++++++-- .../iam/resourcepermission/storage_backend.go | 58 +++- .../storage_backend_test.go | 289 +++++++++++++++++- .../apis/iam/resourcepermission/templates.go | 92 ++++-- .../iam/resourcepermission/templates_test.go | 47 ++- .../testdata/00_resourceperm.yaml | 6 +- ...update_query-basic_latest_update_query.sql | 8 + .../mysql--page_query-basic_page_query.sql | 10 + ...-resource_permission_query-basic_query.sql | 2 +- ...ource_permission_query-with_all_fields.sql | 2 +- ...update_query-basic_latest_update_query.sql | 8 + .../postgres--page_query-basic_page_query.sql | 10 + ...-resource_permission_query-basic_query.sql | 2 +- ...ource_permission_query-with_all_fields.sql | 2 +- ...update_query-basic_latest_update_query.sql | 8 + .../sqlite--page_query-basic_page_query.sql | 10 + ...-resource_permission_query-basic_query.sql | 2 +- ...ource_permission_query-with_all_fields.sql | 2 +- 26 files changed, 1085 insertions(+), 206 deletions(-) create mode 100644 pkg/registry/apis/iam/resourcepermission/list_iterator.go create mode 100644 pkg/registry/apis/iam/resourcepermission/queries/latest_update_query.sql create mode 100644 pkg/registry/apis/iam/resourcepermission/queries/page_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/mysql--latest_update_query-basic_latest_update_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/mysql--page_query-basic_page_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/postgres--latest_update_query-basic_latest_update_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/postgres--page_query-basic_page_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/sqlite--latest_update_query-basic_latest_update_query.sql create mode 100755 pkg/registry/apis/iam/resourcepermission/testdata/sqlite--page_query-basic_page_query.sql diff --git a/pkg/registry/apis/iam/resourcepermission/list_iterator.go b/pkg/registry/apis/iam/resourcepermission/list_iterator.go new file mode 100644 index 00000000000..1088af3cfca --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/list_iterator.go @@ -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 +} diff --git a/pkg/registry/apis/iam/resourcepermission/mapper.go b/pkg/registry/apis/iam/resourcepermission/mapper.go index cd93fa5bcdd..17ad1d8daa6 100644 --- a/pkg/registry/apis/iam/resourcepermission/mapper.go +++ b/pkg/registry/apis/iam/resourcepermission/mapper.go @@ -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:%" +} diff --git a/pkg/registry/apis/iam/resourcepermission/models.go b/pkg/registry/apis/iam/resourcepermission/models.go index cefd9699562..44b03a16058 100644 --- a/pkg/registry/apis/iam/resourcepermission/models.go +++ b/pkg/registry/apis/iam/resourcepermission/models.go @@ -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 } diff --git a/pkg/registry/apis/iam/resourcepermission/models_test.go b/pkg/registry/apis/iam/resourcepermission/models_test.go index 2823e909496..b4e36eb28ba 100644 --- a/pkg/registry/apis/iam/resourcepermission/models_test.go +++ b/pkg/registry/apis/iam/resourcepermission/models_test.go @@ -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) diff --git a/pkg/registry/apis/iam/resourcepermission/queries/latest_update_query.sql b/pkg/registry/apis/iam/resourcepermission/queries/latest_update_query.sql new file mode 100644 index 00000000000..9c3487185e9 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/queries/latest_update_query.sql @@ -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; diff --git a/pkg/registry/apis/iam/resourcepermission/queries/page_query.sql b/pkg/registry/apis/iam/resourcepermission/queries/page_query.sql new file mode 100644 index 00000000000..a814ab5dc1e --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/queries/page_query.sql @@ -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 }} diff --git a/pkg/registry/apis/iam/resourcepermission/queries/resource_permission_query.sql b/pkg/registry/apis/iam/resourcepermission/queries/resource_permission_query.sql index 9fc9b6f555c..e6009bc7183 100644 --- a/pkg/registry/apis/iam/resourcepermission/queries/resource_permission_query.sql +++ b/pkg/registry/apis/iam/resourcepermission/queries/resource_permission_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/sql.go b/pkg/registry/apis/iam/resourcepermission/sql.go index b8a5125cc51..ba8c0d93ea1 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql.go +++ b/pkg/registry/apis/iam/resourcepermission/sql.go @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/sql_test.go b/pkg/registry/apis/iam/resourcepermission/sql_test.go index 13a1c3e6df8..977ac4b9981 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql_test.go +++ b/pkg/registry/apis/iam/resourcepermission/sql_test.go @@ -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) diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go index 5cb22afe2cf..8285b717a02 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/authlib/types" "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/infra/log" + "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]) { diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go index ae4337a1849..f8ace252491 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go @@ -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) diff --git a/pkg/registry/apis/iam/resourcepermission/templates.go b/pkg/registry/apis/iam/resourcepermission/templates.go index aa8a6f26edf..afe130970d3 100644 --- a/pkg/registry/apis/iam/resourcepermission/templates.go +++ b/pkg/registry/apis/iam/resourcepermission/templates.go @@ -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:%", } diff --git a/pkg/registry/apis/iam/resourcepermission/templates_test.go b/pkg/registry/apis/iam/resourcepermission/templates_test.go index 843ecc12aa1..6bc492587b7 100644 --- a/pkg/registry/apis/iam/resourcepermission/templates_test.go +++ b/pkg/registry/apis/iam/resourcepermission/templates_test.go @@ -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"}, }), diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml b/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml index 88eea67f292..802503081a5 100644 --- a/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml +++ b/pkg/registry/apis/iam/resourcepermission/testdata/00_resourceperm.yaml @@ -10,10 +10,8 @@ spec: permissions: - kind: User name: few8wwtp1si68e - verbs: - - edit + verb: edit - kind: BasicRole name: Editor - verbs: - - view + verb: view \ No newline at end of file diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--latest_update_query-basic_latest_update_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--latest_update_query-basic_latest_update_query.sql new file mode 100755 index 00000000000..355dec1a4db --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--latest_update_query-basic_latest_update_query.sql @@ -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; diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--page_query-basic_page_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--page_query-basic_page_query.sql new file mode 100755 index 00000000000..9db8696e87e --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--page_query-basic_page_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-basic_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-basic_query.sql index da6ac22085b..ff83a0de881 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-basic_query.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-basic_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-with_all_fields.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-with_all_fields.sql index 134db52f15e..f54ad954d41 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-with_all_fields.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--resource_permission_query-with_all_fields.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--latest_update_query-basic_latest_update_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--latest_update_query-basic_latest_update_query.sql new file mode 100755 index 00000000000..555f54a9239 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--latest_update_query-basic_latest_update_query.sql @@ -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; diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--page_query-basic_page_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--page_query-basic_page_query.sql new file mode 100755 index 00000000000..a1bf4486359 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--page_query-basic_page_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-basic_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-basic_query.sql index 2dd6d343f94..571359a495a 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-basic_query.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-basic_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-with_all_fields.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-with_all_fields.sql index 2562573bfcb..a4abdc6bb7a 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-with_all_fields.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--resource_permission_query-with_all_fields.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--latest_update_query-basic_latest_update_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--latest_update_query-basic_latest_update_query.sql new file mode 100755 index 00000000000..555f54a9239 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--latest_update_query-basic_latest_update_query.sql @@ -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; diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--page_query-basic_page_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--page_query-basic_page_query.sql new file mode 100755 index 00000000000..a1bf4486359 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--page_query-basic_page_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-basic_query.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-basic_query.sql index 2dd6d343f94..571359a495a 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-basic_query.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-basic_query.sql @@ -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 diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-with_all_fields.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-with_all_fields.sql index 2562573bfcb..a4abdc6bb7a 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-with_all_fields.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--resource_permission_query-with_all_fields.sql @@ -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