Code Diff
diff --git a/pkg/registry/apis/iam/resourcepermission/mapper.go b/pkg/registry/apis/iam/resourcepermission/mapper.go
index e4faf2300b786..a1d327ae9682e 100644
--- a/pkg/registry/apis/iam/resourcepermission/mapper.go
+++ b/pkg/registry/apis/iam/resourcepermission/mapper.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/grafana/authlib/types"
+ v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"k8s.io/apimachinery/pkg/runtime/schema"
)
@@ -33,21 +34,54 @@ type Mapper interface {
// Used in list queries with SQL LIKE to match all permissions for this resource type.
// Example: "folders:uid:%" matches "folders:uid:abc", "folders:uid:xyz", etc.
ScopePattern() string
+
+ // AllowsKind reports whether the resource type permits assignments to the given permission kind.
+ // Returns true when no kind restriction is configured (all kinds allowed).
+ AllowsKind(kind v0alpha1.ResourcePermissionSpecPermissionKind) bool
}
+// ScopeAttribute defines how a resource is identified in RBAC scope strings.
+// It is the middle segment of a scope, e.g. "uid" in "folders:uid:abc".
+type ScopeAttribute string
+
+const (
+ // ScopeAttributeUID identifies resources by their UID (e.g. "folders:uid:abc").
+ ScopeAttributeUID ScopeAttribute = "uid"
+ // ScopeAttributeID identifies resources by their numeric database ID (e.g. "serviceaccounts:id:123").
+ ScopeAttributeID ScopeAttribute = "id"
+)
+
type mapper struct {
- resource string
- actionSets []string
+ resource string
+ scopeAttribute ScopeAttribute
+ actionSets []string
+ allowedKinds []v0alpha1.ResourcePermissionSpecPermissionKind // nil = all kinds allowed
}
+// NewMapper creates a Mapper for uid-scoped resources (folders, dashboards).
+// All permission kinds are allowed.
func NewMapper(resource string, levels []string) Mapper {
+ return NewMapperWithAttribute(resource, levels, ScopeAttributeUID, nil)
+}
+
+// NewIDScopedMapper creates a Mapper for id-scoped resources (teams, users).
+// All permission kinds are allowed.
+func NewIDScopedMapper(resource string, levels []string) Mapper {
+ return NewMapperWithAttribute(resource, levels, ScopeAttributeID, nil)
+}
+
+// NewMapperWithAttribute creates a Mapper with an explicit scope attribute and optional kind restrictions.
+// When allowedKinds is nil, all permission kinds are permitted.
+func NewMapperWithAttribute(resource string, levels []string, attr ScopeAttribute, allowedKinds []v0alpha1.ResourcePermissionSpecPermissionKind) Mapper {
sets := make([]string, 0, len(levels))
for _, level := range levels {
sets = append(sets, resource+":"+strings.ToLower(level))
}
return mapper{
- resource: resource,
- actionSets: sets,
+ resource: resource,
+ scopeAttribute: attr,
+ actionSets: sets,
+ allowedKinds: allowedKinds,
}
}
@@ -56,7 +90,7 @@ func (m mapper) ActionSets() []string {
}
func (m mapper) Scope(name string) string {
- return m.resource + ":uid:" + name
+ return m.resource + ":" + string(m.scopeAttribute) + ":" + name
}
func (m mapper) ActionSet(level string) (string, error) {
@@ -68,7 +102,14 @@ func (m mapper) ActionSet(level string) (string, error) {
}
func (m mapper) ScopePattern() string {
- return m.resource + ":uid:%"
+ return m.resource + ":" + string(m.scopeAttribute) + ":%"
+}
+
+func (m mapper) AllowsKind(kind v0alpha1.ResourcePermissionSpecPermissionKind) bool {
+ if m.allowedKinds == nil {
+ return true
+ }
+ return slices.Contains(m.allowedKinds, kind)
}
type mapperEntry struct {
@@ -231,44 +272,6 @@ func (m *MappersRegistry) EnabledScopePatterns() []string {
return out
}
-// NewIDScopedMapper creates a Mapper for resources stored with id-based scopes
-// in the legacy permission table (teams, users, service accounts).
-// Unlike the default uid-scoped mapper, its ScopePattern returns ":id:%".
-// The actual uid↔id resolution is handled by scope_resolver functions via the
-// MappersRegistry's identity store, not by the mapper itself.
-func NewIDScopedMapper(resource string, levels []string) Mapper {
- sets := make([]string, 0, len(levels))
- for _, level := range levels {
- sets = append(sets, resource+":"+strings.ToLower(level))
- }
- return idMapper{resource: resource, actionSets: sets}
-}
-
-// idMapper implements Mapper for id-scoped resources. It differs from the default
-// mapper only in ScopePattern (returns ":id:%" for DB queries).
-type idMapper struct {
- resource string
- actionSets []string
-}
-
-func (m idMapper) ActionSets() []string { return m.actionSets }
-
-func (m idMapper) ActionSet(level string) (string, error) {
- actionSet := m.resource + ":" + strings.ToLower(level)
- if !slices.Contains(m.actionSets, actionSet) {
- return "", fmt.Errorf("invalid level (%s): %w", level, errInvalidSpec)
- }
- return actionSet, nil
-}
-
-func (m idMapper) Scope(name string) string {
- return m.resource + ":uid:" + name
-}
-
-func (m idMapper) ScopePattern() string {
- return m.resource + ":id:%"
-}
-
// ParseScopeCtx parses an RBAC scope string into a groupResourceName, resolving id to uid for
// id-scoped resources (teams, users, service accounts) using the provided store and namespace.
// For uid-scoped resources (folders, dashboards) it behaves identically to ParseScope.
diff --git a/pkg/registry/apis/iam/resourcepermission/mapper_test.go b/pkg/registry/apis/iam/resourcepermission/mapper_test.go
index 5bae23a48682e..5db0631e069e8 100644
--- a/pkg/registry/apis/iam/resourcepermission/mapper_test.go
+++ b/pkg/registry/apis/iam/resourcepermission/mapper_test.go
@@ -6,6 +6,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
+
+ v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
// --- NewMappersRegistry defaults ---
@@ -362,3 +364,56 @@ func TestMappersRegistry_MultipleWildcards(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "unknown.alerting.grafana.app", alertGrn.Group)
}
+
+// --- NewMapper / NewIDScopedMapper / NewMapperWithAttribute ---
+
+func TestNewMapper_UIDScoped(t *testing.T) {
+ m := NewMapper("folders", []string{"view", "edit", "admin"})
+
+ assert.Equal(t, "folders:uid:abc", m.Scope("abc"))
+ assert.Equal(t, "folders:uid:%", m.ScopePattern())
+ assert.Equal(t, []string{"folders:view", "folders:edit", "folders:admin"}, m.ActionSets())
+}
+
+func TestNewIDScopedMapper_IDScoped(t *testing.T) {
+ m := NewIDScopedMapper("serviceaccounts", []string{"edit", "admin"})
+
+ assert.Equal(t, "serviceaccounts:id:123", m.Scope("123"))
+ assert.Equal(t, "serviceaccounts:id:%", m.ScopePattern())
+}
+
+func TestNewMapperWithAttribute_ExplicitAttribute(t *testing.T) {
+ uid := NewMapperWithAttribute("folders", []string{"view"}, ScopeAttributeUID, nil)
+ assert.Equal(t, "folders:uid:abc", uid.Scope("abc"))
+ assert.Equal(t, "folders:uid:%", uid.ScopePattern())
+
+ id := NewMapperWithAttribute("serviceaccounts", []string{"edit"}, ScopeAttributeID, nil)
+ assert.Equal(t, "serviceaccounts:id:123", id.Scope("123"))
+ assert.Equal(t, "serviceaccounts:id:%", id.ScopePattern())
+}
+
+// --- AllowsKind ---
+
+func TestMapper_AllowsKind_NilAllowedKinds(t *testing.T) {
+ m := NewMapper("folders", defaultLevels)
+
+ // nil allowedKinds means all kinds are permitted
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindUser))
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindTeam))
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount))
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindBasicRole))
+}
+
+func TestMapper_AllowsKind_RestrictedList(t *testing.T) {
+ allowedKinds := []v0alpha1.ResourcePermissionSpecPermissionKind{
+ v0alpha1.ResourcePermissionSpecPermissionKindUser,
+ v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount,
+ v0alpha1.ResourcePermissionSpecPermissionKindTeam,
+ }
+ m := NewMapperWithAttribute("serviceaccounts", []string{"edit", "admin"}, ScopeAttributeID, allowedKinds)
+
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindUser))
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount))
+ assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindTeam))
+ assert.False(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindBasicRole))
+}
diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go
index c06666eaa093e..6079a2ad7309b 100644
--- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go
+++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go
@@ -49,9 +49,16 @@ func ProvideStorageBackend(dbProvider legacysql.LegacyDatabaseProvider, mappers
schema.GroupResource{Group: "iam.grafana.app", Resource: "users"},
NewIDScopedMapper("users", defaultLevels), nil,
)
+ // BasicRole is excluded: built-in roles already cover all service accounts globally,
+ // so granting a ResourcePermission to a BasicRole on a specific SA is not permitted.
mappers.RegisterMapper(
schema.GroupResource{Group: "iam.grafana.app", Resource: "serviceaccounts"},
- NewIDScopedMapper("serviceaccounts", []string{"Edit", "Admin"}), nil,
+ NewMapperWithAttribute("serviceaccounts", []string{"Edit", "Admin"}, ScopeAttributeID,
+ []v0alpha1.ResourcePermissionSpecPermissionKind{
+ v0alpha1.ResourcePermissionSpecPermissionKindUser,
+ v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount,
+ v0alpha1.ResourcePermissionSpecPermissionKindTeam,
+ }), nil,
)
return &ResourcePermSqlBackend{
dbProvider: dbProvider,