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:
co-authored by
Ieva
mohammad-hamid
parent
7c242eeaef
commit
5c6fd5e5af
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
Executable
+8
@@ -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;
|
||||
Vendored
Executable
+10
@@ -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
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Vendored
Executable
+8
@@ -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;
|
||||
Vendored
Executable
+10
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Vendored
Executable
+8
@@ -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;
|
||||
Vendored
Executable
+10
@@ -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
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user