grafana-iam: Implement resourcepermission get (#110256)

* resource permissions get

* address review feedback

* address comments
- read using rp name
- narrow by scope and actionsets
- update sql tests

* align with verb simplification

* keep original format to avoid conflicts

* add sqltests

* cleanup

* Remove unecessary errors

* Move query template to queries

* Use splitN to make sure we have three parts

* Revert user permission management for now. We don't need it

* Revert error change

* group permissions by resource

* extract parse scope

* Move sql_test

* Move & test parseScope

* Add tests to getResourcePermission

* Linting

* Use namespace

* Add test to the backend

* Ongoing tests

* Remove pagination, fix query boolean, insert basic role binding

* Linting

* Straightened the created and updated times

* error handling and uniformization with other backend

* Restore comments to avoid later conflicts

* Integration testing

* switch to function, no need to make it a method

* isServiceAccount should default to FALSE instead of TRUE :surprised:

* PR feedback

* Sort spec permissions

* Shouldn't happen but double proofing

---------

Co-authored-by: Gabriel Mabille <gabriel.mabille@grafana.com>
This commit is contained in:
mohammad-hamid
2025-09-04 17:14:15 +02:00
committed by GitHub
co-authored by Gabriel Mabille
parent 40bf167cb7
commit abcdf20105
16 changed files with 1075 additions and 8 deletions
@@ -0,0 +1,30 @@
package resourcepermission
type Mapper interface {
ActionSets() []string
Scope(name string) string
}
type mapper struct {
resource string
actionSets []string
}
func NewMapper(resource string, levels []string) Mapper {
sets := make([]string, 0, len(levels))
for _, level := range levels {
sets = append(sets, resource+":"+level)
}
return mapper{
resource: resource,
actionSets: sets,
}
}
func (m mapper) ActionSets() []string {
return m.actionSets
}
func (m mapper) Scope(name string) string {
return m.resource + ":uid:" + name
}
@@ -3,9 +3,159 @@ package resourcepermission
import (
"errors"
"fmt"
"sort"
"strings"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
var (
errNotImplemented = errors.New("not supported by this storage backend")
errEmptyName = fmt.Errorf("name cannot be empty")
errDatabaseHelper = errors.New("failed to get database")
errNotImplemented = errors.New("not supported by this storage backend")
errEmptyName = errors.New("name cannot be empty")
errUnknownGroupResource = errors.New("unknown group/resource")
errNotFound = errors.New("not found")
errInvalidName = errors.New("invalid name")
errInvalidScope = errors.New("invalid scope")
errInvalidNamespace = errors.New("invalid namespace")
defaultLevels = []string{"view", "edit", "admin"}
)
type ListResourcePermissionsQuery struct {
Scope string
OrgID int64
ActionSets []string
// TODO Pagination common.Pagination
}
type flatResourcePermission struct {
ID int64 `xorm:"id"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
Created time.Time `xorm:"created"`
Updated time.Time `xorm:"updated"`
RoleName string `xorm:"role_name"`
SubjectUID string `xorm:"subject_uid"`
SubjectType string `xorm:"subject_type"` // 'user', 'team', or 'builtin_role'
IsServiceAccount bool `xorm:"is_service_account"`
}
// toV0ResourcePermissions converts flatResourcePermission grouped by resource (e.g. {folder.grafana.app, folders, fold1}) to a list of v0alpha1.ResourcePermission
func toV0ResourcePermissions(permsByResource map[groupResourceName][]flatResourcePermission) ([]v0alpha1.ResourcePermission, error) {
if len(permsByResource) == 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 = 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)
}
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)
}
return resourcePermissions, nil
}
type groupResourceName struct {
Group string
Resource string
Name string
}
func (g *groupResourceName) string() string {
return g.Group + "-" + g.Resource + "-" + g.Name
}
func (g *groupResourceName) v0alpha1() v0alpha1.ResourcePermissionspecResource {
return v0alpha1.ResourcePermissionspecResource{
ApiGroup: g.Group,
Resource: g.Resource,
Name: g.Name,
}
}
// parseScope parses a scope string (e.g. folders:uid:1) into a groupResourceName (e.g. {folder.grafana.app, folders, fold1}).
func (s *ResourcePermSqlBackend) parseScope(scope string) (*groupResourceName, error) {
parts := strings.SplitN(scope, ":", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("%w: %s", errInvalidScope, scope)
}
gr, ok := s.reverseMappers[parts[0]]
if !ok {
return nil, fmt.Errorf("%w: %s", errUnknownGroupResource, parts[0])
}
return &groupResourceName{
Group: gr.Group,
Resource: gr.Resource,
Name: parts[2],
}, nil
}
@@ -0,0 +1,155 @@
package resourcepermission
import (
"context"
"testing"
"time"
v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/stretchr/testify/require"
)
// setupBackendNoDB sets up a ResourcePermSqlBackend with a no-op dbProvider for tests that do not require DB access.
func setupBackendNoDB(t *testing.T) *ResourcePermSqlBackend {
noProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) {
return nil, nil
}
return ProvideStorageBackend(noProvider)
}
func TestToV0ResourcePermissions(t *testing.T) {
t.Run("empty permissions", func(t *testing.T) {
result, err := toV0ResourcePermissions(map[groupResourceName][]flatResourcePermission{})
require.NoError(t, err)
require.Nil(t, result)
})
now := time.Now()
t.Run("multiple permission are sorted by kind, name, verb", func(t *testing.T) {
permissions := map[groupResourceName][]flatResourcePermission{
{
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,
},
},
}
result, err := toV0ResourcePermissions(permissions)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result, 1)
require.Len(t, result[0].Spec.Permissions, 4)
perm := result[0].Spec.Permissions[0]
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, perm.Kind)
require.Equal(t, "Viewer", perm.Name)
require.Equal(t, "view", perm.Verb)
perm = result[0].Spec.Permissions[1]
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount, perm.Kind)
require.Equal(t, "sa1", perm.Name)
require.Equal(t, "edit", perm.Verb)
perm = result[0].Spec.Permissions[2]
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindTeam, perm.Kind)
require.Equal(t, "testteam", perm.Name)
require.Equal(t, "view", perm.Verb)
perm = result[0].Spec.Permissions[3]
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindUser, perm.Kind)
require.Equal(t, "testuser", perm.Name)
require.Equal(t, "admin", perm.Verb)
})
}
func TestParseScope(t *testing.T) {
backend := setupBackendNoDB(t)
tests := []struct {
name string
scope string
expected *groupResourceName
expectError error
}{
{
name: "valid scope",
scope: "dashboards:uid:dash1",
expected: &groupResourceName{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
},
},
{
name: "invalid scope format",
scope: "dashboards:someotherformat",
expected: nil,
expectError: errInvalidScope,
},
{
name: "unknown group resource",
scope: "unknown:uid:u1",
expected: nil,
expectError: errUnknownGroupResource,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := backend.parseScope(tt.scope)
if tt.expectError != nil {
require.ErrorIs(t, err, tt.expectError)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, tt.expected.Group, result.Group)
require.Equal(t, tt.expected.Resource, result.Resource)
require.Equal(t, tt.expected.Name, result.Name)
})
}
}
@@ -0,0 +1,27 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, {{ .Arg false }}) as is_service_account
FROM {{ .Ident .PermissionTable }} p
INNER JOIN {{ .Ident .RoleTable }} r ON p.role_id = r.id
LEFT JOIN {{ .Ident .UserRoleTable }} ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN {{ .Ident .UserTable }} u ON ur.user_id = u.id
LEFT JOIN {{ .Ident .TeamRoleTable }} tr ON r.id = tr.role_id AND tr.org_id = r.org_id
LEFT JOIN {{ .Ident .TeamTable }} t ON tr.team_id = t.id
LEFT JOIN {{ .Ident .BuiltinRoleTable }} br ON r.id = br.role_id
WHERE r.name LIKE {{ .Arg .ManagedRolePattern }}
{{ if .Query.ActionSets }}
AND p.action IN ({{ .ArgList .Query.ActionSets }})
{{ end }}
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 }}
{{ end }}
ORDER BY p.id
@@ -1,8 +1,98 @@
package resourcepermission
import (
"context"
"fmt"
"strings"
"github.com/grafana/authlib/types"
v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/storage/legacysql"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// List
// Get
// getResourcePermissions queries resource permissions based on the provided ListResourcePermissionsQuery and groups them by resource (e.g. {folder.grafana.app, folders, fold1})
func (s *ResourcePermSqlBackend) getResourcePermissions(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *ListResourcePermissionsQuery) (map[groupResourceName][]flatResourcePermission, error) {
rawQuery, args, err := buildListResourcePermissionsQueryFromTemplate(sql, query)
if err != nil {
return nil, err
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, rawQuery, args...)
if err != nil {
if rows != nil {
_ = rows.Close()
}
return nil, fmt.Errorf("querying resource permissions: %w", err)
}
defer func() {
_ = rows.Close()
}()
permissions := make(map[groupResourceName][]flatResourcePermission)
for rows.Next() {
var perm flatResourcePermission
if err := rows.Scan(
&perm.ID, &perm.Action, &perm.Scope, &perm.Created, &perm.Updated, &perm.RoleName,
&perm.SubjectUID, &perm.SubjectType, &perm.IsServiceAccount,
); 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)
}
return permissions, nil
}
// getResourcePermission retrieves a single ResourcePermission by its name in the format <group>-<resource>-<name> (e.g. dashboard.grafana.app-dashboards-ad5rwqs)
func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, name string) (*v0alpha1.ResourcePermission, error) {
// e.g. dashboard.grafana.app-dashboards-ad5rwqs
parts := strings.SplitN(name, "-", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("%w: %s", errInvalidName, name)
}
group, resourceType, uid := parts[0], parts[1], parts[2]
mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resourceType}]
if !ok {
return nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resourceType)
}
resourceQuery := &ListResourcePermissionsQuery{
Scope: mapper.Scope(uid),
OrgID: ns.OrgID,
ActionSets: mapper.ActionSets(),
}
permsByResource, err := s.getResourcePermissions(ctx, sql, resourceQuery)
if err != nil {
return nil, err
}
if len(permsByResource) == 0 {
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scope, errNotFound)
}
resourcePermission, err := toV0ResourcePermissions(permsByResource)
if err != nil {
return nil, err
}
if resourcePermission == nil {
return nil, fmt.Errorf("resource permission %q: %w", resourceQuery.Scope, errNotFound)
}
return &resourcePermission[0], nil
}
// Create
@@ -0,0 +1,203 @@
package resourcepermission
import (
"context"
"fmt"
"testing"
"time"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func setupBackend(t *testing.T) *ResourcePermSqlBackend {
store := db.InitTestDB(t)
sqlHelper := &legacysql.LegacyDatabaseHelper{
DB: store,
Table: func(name string) string { return name },
}
dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) {
return sqlHelper, nil
}
return ProvideStorageBackend(dbProvider)
}
func setupTestRoles(t *testing.T, store db.DB) {
sess := store.GetSqlxSession()
_, err := sess.Exec(context.Background(),
`INSERT INTO role (id, version, org_id, uid, name, display_name, description, group_name, hidden, created, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
// Managed roles
1, 0, 1, "managed_users_1_permissions_org1", "managed:users:1:permissions", "", "", "", false, "2025-09-02", "2025-09-02", // Org-1 - User 1
2, 0, 2, "managed_users_2_permissions_org2", "managed:users:2:permissions", "", "", "", false, "2025-09-02", "2025-09-02", // Org-2 - User 2
3, 0, 1, "managed_users_3_permissions_org1", "managed:users:3:permissions", "", "", "", false, "2025-09-02", "2025-09-02", // Org-1 - Service Account
4, 0, 1, "managed_builtins_editor_permissions_org1", "managed:builtins:editor:permissions", "", "", "", false, "2025-09-02", "2025-09-02", // Org-1 - Builtin Editor
5, 0, 1, "managed_teams_1_permissions_org1", "managed:teams:1:permissions", "", "", "", false, "2025-09-02", "2025-09-02", // Org-1 - Team 1
)
require.NoError(t, err)
// Permissions
_, err = sess.Exec(context.Background(),
`INSERT INTO permission (role_id, action, scope, created, updated)
VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
// Permissions for managed:users:1:permissions
1, "folders:view", "folders:uid:fold1", "2025-09-02", "2025-09-02",
1, "dashboards:edit", "dashboards:uid:dash1", "2025-09-02", "2025-09-02",
// Permissions for managed:users:2:permissions
2, "folders:edit", "folders:uid:fold1", "2025-09-02", "2025-09-02",
// Permissions for managed:users:3:permissions (service account)
3, "dashboards:view", "dashboards:uid:dash1", "2025-09-02", "2025-09-02",
// Permissions for managed:builtins:editor:permissions
4, "dashboards:edit", "dashboards:uid:dash1", "2025-09-02", "2025-09-02",
// Permissions for managed:teams:1:permissions
5, "dashboards:admin", "dashboards:uid:dash1", "2025-09-02", "2025-09-03", // Recently updated
)
require.NoError(t, err)
_, err = sess.Exec(context.Background(),
`INSERT INTO`+store.GetDialect().Quote("user")+`(id, org_id, uid, login, email, is_admin, is_service_account, created, updated, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
// Users
1, 1, "user-1", "user-1", "user-1@example.com", false, false, "2025-09-02 00:00:00", "2025-09-02 00:00:00", 0,
2, 1, "user-2", "user-2", "user-2@example.com", false, false, "2025-09-02 00:00:00", "2025-09-02 00:00:00", 0,
3, 1, "sa-1", "sa-1", "", false, true, "2025-09-02 00:00:00", "2025-09-02 00:00:00", 0,
)
require.NoError(t, err)
_, err = sess.Exec(context.Background(),
`INSERT INTO team (id, org_id, uid, name, created, updated)
VALUES (?, ?, ?, ?, ?, ?)`,
// Teams
1, 1, "team-1", "team-1", "2025-09-02 00:00:00", "2025-09-02 00:00:00",
)
require.NoError(t, err)
// User role bindings
_, err = sess.Exec(context.Background(),
`INSERT INTO user_role (org_id, user_id, role_id, created)
VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)`,
1, 1, 1, "2025-09-02 00:00:00", // User-1 -> managed:users:1:permissions
2, 2, 2, "2025-09-02 00:00:00", // User-2 -> managed:users:2:permissions
1, 3, 3, "2025-09-02 00:00:00", // ServiceAccount -> managed:users:3:permissions
)
require.NoError(t, err)
// Team role bindings
_, err = sess.Exec(context.Background(),
`INSERT INTO team_role (org_id, team_id, role_id, created)
VALUES (?, ?, ?, ?)`,
1, 1, 5, "2025-09-02 00:00:00", // Team-1 -> managed:teams:1:permissions
)
require.NoError(t, err)
// basic role bindings
_, err = sess.Exec(context.Background(),
`INSERT INTO builtin_role (org_id, role, role_id, created, updated)
VALUES (?, ?, ?, ?, ?)`,
1, "Editor", 4, "2025-09-02 00:00:00", "2025-09-02 00:00:00", // Builtin Editor -> managed:builtins:editor:permissions
)
require.NoError(t, err)
}
func TestResourcePermSqlBackend_getResourcePermission(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
backend := setupBackend(t)
sql, err := backend.dbProvider(context.Background())
require.NoError(t, err)
setupTestRoles(t, sql.DB)
created := time.Date(2025, 9, 2, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
resource string
orgID int64
want v0alpha1.ResourcePermission
err error
}{
{
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,
},
{
name: "should return empty for org-2",
orgID: 2,
resource: "dashboard.grafana.app-dashboards-dash1",
err: errNotFound,
},
{
name: "should return an error for unknown resource type",
orgID: 1,
resource: "unknown.grafana.app-unknown-u1",
err: errUnknownGroupResource,
},
{
name: "should return an error for invalid resource name",
orgID: 1,
resource: "invalid.grafana.app-invalid",
err: errInvalidName,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := types.NamespaceInfo{
OrgID: tt.orgID,
}
got, err := backend.getResourcePermission(context.Background(), sql, ns, tt.resource)
if tt.err != nil {
require.Error(t, err)
require.ErrorIs(t, err, tt.err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want.Name, got.Name)
require.Equal(t, tt.want.CreationTimestamp, got.CreationTimestamp)
require.Equal(t, tt.want.ResourceVersion, got.ResourceVersion)
require.NotZero(t, got.GetUpdateTimestamp())
require.Equal(t, tt.want.TypeMeta, got.TypeMeta)
require.Equal(t, tt.want.Spec, got.Spec)
})
}
}
@@ -2,12 +2,14 @@ package resourcepermission
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"net/http"
"sync"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
@@ -25,6 +27,9 @@ type ResourcePermSqlBackend struct {
dbProvider legacysql.LegacyDatabaseProvider
logger log.Logger
mappers map[schema.GroupResource]Mapper // group/resource -> rbac mapper
reverseMappers map[string]schema.GroupResource // rbac kind -> group/resource
subscribers []chan *resource.WrittenEvent
mutex sync.Mutex
}
@@ -34,6 +39,15 @@ func ProvideStorageBackend(dbProvider legacysql.LegacyDatabaseProvider) *Resourc
dbProvider: dbProvider,
logger: log.New("resourceperm_storage_backend"),
mappers: map[schema.GroupResource]Mapper{
{Group: "folder.grafana.app", Resource: "folders"}: NewMapper("folders", defaultLevels),
{Group: "dashboard.grafana.app", Resource: "dashboards"}: NewMapper("dashboards", defaultLevels),
},
reverseMappers: map[string]schema.GroupResource{
"folders": {Group: "folder.grafana.app", Resource: "folders"},
"dashboards": {Group: "dashboard.grafana.app", Resource: "dashboards"},
},
subscribers: make([]chan *resource.WrittenEvent, 0),
mutex: sync.Mutex{},
}
@@ -57,11 +71,55 @@ func (s *ResourcePermSqlBackend) ListModifiedSince(ctx context.Context, key reso
}
}
func (s *ResourcePermSqlBackend) ReadResource(_ context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse {
return &resource.BackendReadResponse{
Key: req.GetKey(),
Error: &resourcepb.ErrorResult{Code: http.StatusForbidden, Message: errNotImplemented.Error()},
func (s *ResourcePermSqlBackend) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse {
rsp := &resource.BackendReadResponse{Key: req.GetKey()}
ns, err := types.ParseNamespace(req.Key.Namespace)
if err != nil {
rsp.Error = resource.AsErrorResult(err)
return rsp
}
if ns.OrgID <= 0 {
rsp.Error = resource.AsErrorResult(apierrors.NewBadRequest(errInvalidNamespace.Error()))
return rsp
}
if req.ResourceVersion > 0 {
rsp.Error = resource.AsErrorResult(apierrors.NewBadRequest("resourceVersion is not supported"))
return rsp
}
dbHelper, err := s.dbProvider(ctx)
if err != nil {
// Hide the error from the user, but log it
logger := s.logger.FromContext(ctx)
logger.Error("Failed to get database helper", "error", err)
rsp.Error = resource.AsErrorResult(errDatabaseHelper)
return rsp
}
resourcePermission, err := s.getResourcePermission(ctx, dbHelper, ns, req.Key.Name)
if err != nil {
if errors.Is(err, errNotFound) {
rsp.Error = resource.AsErrorResult(
apierrors.NewNotFound(v0alpha1.ResourcePermissionInfo.GroupResource(), req.Key.Name),
)
} else if errors.Is(err, errUnknownGroupResource) || errors.Is(err, errInvalidName) {
rsp.Error = resource.AsErrorResult(apierrors.NewBadRequest(err.Error()))
} else {
rsp.Error = resource.AsErrorResult(err)
}
return rsp
}
rsp.ResourceVersion = resourcePermission.GetUpdateTimestamp().UnixMilli()
rsp.Value, err = json.Marshal(resourcePermission)
if err != nil {
rsp.Error = resource.AsErrorResult(err)
return rsp
}
return rsp
}
func (s *ResourcePermSqlBackend) WatchWriteEvents(ctx context.Context) (<-chan *resource.WrittenEvent, error) {
@@ -0,0 +1,110 @@
package resourcepermission
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestIntegration_ResourcePermSqlBackend_ReadResource(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
backend := setupBackend(t)
sql, err := backend.dbProvider(context.Background())
require.NoError(t, err)
setupTestRoles(t, sql.DB)
updated1 := time.Date(2025, 9, 2, 0, 0, 0, 0, time.UTC)
updated2 := time.Date(2025, 9, 3, 0, 0, 0, 0, time.UTC) // managed role for team 1 has a later updated permission
t.Run("ReadResource - Invalid namespace", func(t *testing.T) {
resp := backend.ReadResource(context.Background(), &resourcepb.ReadRequest{
Key: &resourcepb.ResourceKey{Name: "folder.grafana.app-folders-fold1", Namespace: "invalid"},
})
require.NotNil(t, resp)
require.NotNil(t, resp.Error)
require.Contains(t, resp.Error.Message, errInvalidNamespace.Error())
require.Equal(t, int32(400), resp.Error.Code)
})
t.Run("ReadResource - Get fold1 resource permissions", func(t *testing.T) {
resp := backend.ReadResource(context.Background(), &resourcepb.ReadRequest{
Key: &resourcepb.ResourceKey{Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
})
require.NotNil(t, resp)
require.Nil(t, resp.Error)
require.NotNil(t, resp.Value)
require.Equal(t, updated1.UnixMilli(), resp.ResourceVersion)
var permission v0alpha1.ResourcePermission
err := json.Unmarshal(resp.Value, &permission)
require.NoError(t, err)
require.Equal(t, "folder.grafana.app-folders-fold1", permission.Name)
require.Len(t, permission.Spec.Permissions, 1)
require.Equal(t, "user-1", permission.Spec.Permissions[0].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindUser, permission.Spec.Permissions[0].Kind)
require.Equal(t, "view", permission.Spec.Permissions[0].Verb)
})
t.Run("ReadResource - Get fold1 in org-2 resource permissions", func(t *testing.T) {
resp := backend.ReadResource(context.Background(), &resourcepb.ReadRequest{
Key: &resourcepb.ResourceKey{Name: "folder.grafana.app-folders-fold1", Namespace: "org-2"},
})
require.NotNil(t, resp)
require.Nil(t, resp.Error)
require.NotNil(t, resp.Value)
require.Equal(t, updated1.UnixMilli(), resp.ResourceVersion)
var permission v0alpha1.ResourcePermission
err := json.Unmarshal(resp.Value, &permission)
require.NoError(t, err)
require.Equal(t, "folder.grafana.app-folders-fold1", permission.Name)
require.Len(t, permission.Spec.Permissions, 1)
require.Equal(t, "user-2", permission.Spec.Permissions[0].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindUser, permission.Spec.Permissions[0].Kind)
require.Equal(t, "edit", permission.Spec.Permissions[0].Verb)
})
t.Run("ReadResource - Get dash1 resource permissions", func(t *testing.T) {
resp := backend.ReadResource(context.Background(), &resourcepb.ReadRequest{
Key: &resourcepb.ResourceKey{Name: "dashboard.grafana.app-dashboards-dash1", Namespace: "default"},
})
require.NotNil(t, resp)
require.Nil(t, resp.Error)
require.NotNil(t, resp.Value)
require.Equal(t, updated2.UnixMilli(), resp.ResourceVersion)
var permission v0alpha1.ResourcePermission
err := json.Unmarshal(resp.Value, &permission)
require.NoError(t, err)
require.Equal(t, "dashboard.grafana.app-dashboards-dash1", permission.Name)
require.Len(t, permission.Spec.Permissions, 4)
require.Equal(t, "Editor", permission.Spec.Permissions[0].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, permission.Spec.Permissions[0].Kind)
require.Equal(t, "edit", permission.Spec.Permissions[0].Verb)
require.Equal(t, "sa-1", permission.Spec.Permissions[1].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount, permission.Spec.Permissions[1].Kind)
require.Equal(t, "view", permission.Spec.Permissions[1].Verb)
require.Equal(t, "team-1", permission.Spec.Permissions[2].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindTeam, permission.Spec.Permissions[2].Kind)
require.Equal(t, "admin", permission.Spec.Permissions[2].Verb)
require.Equal(t, "user-1", permission.Spec.Permissions[3].Name)
require.Equal(t, v0alpha1.ResourcePermissionSpecPermissionKindUser, permission.Spec.Permissions[3].Kind)
require.Equal(t, "edit", permission.Spec.Permissions[3].Verb)
})
}
@@ -1,8 +1,79 @@
package resourcepermission
import (
"embed"
"fmt"
"text/template"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
// Templates setup.
var (
//go:embed queries/*.sql
sqlTemplatesFS embed.FS
sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `queries/*.sql`))
resourcePermissionsQueryTplt = mustTemplate("resource_permission_query.sql")
)
func mustTemplate(filename string) *template.Template {
if t := sqlTemplates.Lookup(filename); t != nil {
return t
}
panic(fmt.Sprintf("template file not found: %s", filename))
}
// List
// Get
type listResourcePermissionsQueryTemplate struct {
sqltemplate.SQLTemplate
Query *ListResourcePermissionsQuery
PermissionTable string
RoleTable string
UserTable string
TeamTable string
BuiltinRoleTable string
UserRoleTable string
TeamRoleTable string
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) {
req := listResourcePermissionsQueryTemplate{
SQLTemplate: sqltemplate.New(sql.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"),
ManagedRolePattern: "managed:%",
}
rawQuery, err := sqltemplate.Execute(resourcePermissionsQueryTplt, req)
if err != nil {
return "", nil, fmt.Errorf("execute template %q: %w", resourcePermissionsQueryTplt.Name(), err)
}
return rawQuery, req.GetArgs(), nil
}
// Create
@@ -0,0 +1,56 @@
package resourcepermission
import (
"testing"
"text/template"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks"
)
func TestListResourcePermissionsQuery(t *testing.T) {
nodb := &legacysql.LegacyDatabaseHelper{
Table: func(n string) string {
return "grafana." + n
},
}
getListResourcePermissionsQuery := func(q *ListResourcePermissionsQuery) sqltemplate.SQLTemplate {
v := listResourcePermissionsQueryTemplate{
SQLTemplate: sqltemplate.New(nodb.DialectForDriver()),
Query: q,
PermissionTable: nodb.Table("permission"),
RoleTable: nodb.Table("role"),
UserTable: nodb.Table("user"),
TeamTable: nodb.Table("team"),
BuiltinRoleTable: nodb.Table("builtin_role"),
UserRoleTable: nodb.Table("user_role"),
TeamRoleTable: nodb.Table("team_role"),
ManagedRolePattern: "managed:%",
}
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{
RootDir: "testdata",
SQLTemplatesFS: sqlTemplatesFS,
Templates: map[*template.Template][]mocks.TemplateTestCase{
resourcePermissionsQueryTplt: {
{
Name: "basic_query",
Data: getListResourcePermissionsQuery(&ListResourcePermissionsQuery{}),
},
{
Name: "with_all_fields",
Data: getListResourcePermissionsQuery(&ListResourcePermissionsQuery{
Scope: "123",
OrgID: 3,
ActionSets: []string{"folders:admin", "folders:edit", "folders:view"},
}),
},
},
},
})
}
@@ -0,0 +1,18 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM `grafana`.`permission` p
INNER JOIN `grafana`.`role` r ON p.role_id = r.id
LEFT JOIN `grafana`.`user_role` ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN `grafana`.`user` u ON ur.user_id = u.id
LEFT JOIN `grafana`.`team_role` tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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
@@ -0,0 +1,21 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM `grafana`.`permission` p
INNER JOIN `grafana`.`role` r ON p.role_id = r.id
LEFT JOIN `grafana`.`user_role` ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN `grafana`.`user` u ON ur.user_id = u.id
LEFT JOIN `grafana`.`team_role` tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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 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
@@ -0,0 +1,18 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM "grafana"."permission" p
INNER JOIN "grafana"."role" r ON p.role_id = r.id
LEFT JOIN "grafana"."user_role" ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN "grafana"."user" u ON ur.user_id = u.id
LEFT JOIN "grafana"."team_role" tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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
@@ -0,0 +1,21 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM "grafana"."permission" p
INNER JOIN "grafana"."role" r ON p.role_id = r.id
LEFT JOIN "grafana"."user_role" ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN "grafana"."user" u ON ur.user_id = u.id
LEFT JOIN "grafana"."team_role" tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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 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
@@ -0,0 +1,18 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM "grafana"."permission" p
INNER JOIN "grafana"."role" r ON p.role_id = r.id
LEFT JOIN "grafana"."user_role" ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN "grafana"."user" u ON ur.user_id = u.id
LEFT JOIN "grafana"."team_role" tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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
@@ -0,0 +1,21 @@
SELECT
p.id, p.action, p.scope, p.created, p.updated,
r.name as role_name,
COALESCE(u.uid, t.uid, br.role) as subject_uid,
CASE WHEN u.uid IS NOT NULL THEN 'user'
WHEN t.uid IS NOT NULL THEN 'team'
ELSE 'builtin_role' END as subject_type,
COALESCE(u.is_service_account, FALSE) as is_service_account
FROM "grafana"."permission" p
INNER JOIN "grafana"."role" r ON p.role_id = r.id
LEFT JOIN "grafana"."user_role" ur ON r.id = ur.role_id AND ur.org_id = r.org_id
LEFT JOIN "grafana"."user" u ON ur.user_id = u.id
LEFT JOIN "grafana"."team_role" tr ON r.id = tr.role_id AND tr.org_id = r.org_id
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 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