Code Diff
diff --git a/pkg/services/authz/rbac/k8s_native_mapping.go b/pkg/services/authz/rbac/k8s_native_mapping.go
new file mode 100644
index 0000000000000..08debeab3ce9f
--- /dev/null
+++ b/pkg/services/authz/rbac/k8s_native_mapping.go
@@ -0,0 +1,97 @@
+package rbac
+
+import "github.com/grafana/grafana/pkg/apimachinery/utils"
+
+// k8sVerbMap maps K8s verbs to the canonical set of RBAC verbs used in K8s-native
+// action strings. Multiple K8s verbs collapse to a single RBAC verb:
+//
+// list, watch → get
+// patch → update
+// deletecollection → delete
+var k8sVerbMap = map[string]string{
+ utils.VerbGet: "get",
+ utils.VerbList: "get",
+ utils.VerbWatch: "get",
+ utils.VerbCreate: "create",
+ utils.VerbUpdate: "update",
+ utils.VerbPatch: "update",
+ utils.VerbDelete: "delete",
+ utils.VerbDeleteCollection: "delete",
+ utils.VerbGetPermissions: "get_permissions",
+ utils.VerbSetPermissions: "set_permissions",
+}
+
+// k8sNativeMapping is a deterministic Mapping for resources not registered in the
+// mapper. Actions follow the {group}/{resource}:{verb} format and all values are
+// derived at call time from the group and resource name alone.
+type k8sNativeMapping struct {
+ group string
+ resource string
+}
+
+func newK8sNativeMapping(group, resource string) Mapping {
+ return &k8sNativeMapping{group: group, resource: resource}
+}
+
+// Action returns the RBAC action for the given K8s verb in the format
+// {group}/{resource}:{rbacVerb}.
+func (m *k8sNativeMapping) Action(verb string) (string, bool) {
+ v, ok := k8sVerbMap[verb]
+ if !ok {
+ return "", false
+ }
+ return m.group + "/" + m.resource + ":" + v, true
+}
+
+// ActionSets returns nil; K8s-native resources have no legacy RBAC action sets.
+// Can be challenged if we want to support (folders:admin, folders:edit, folders:view) action sets.
+func (m *k8sNativeMapping) ActionSets(_ string) []string {
+ return nil
+}
+
+// Scope returns the RBAC scope for the given resource instance name.
+// Format: {resource}:uid:{name}
+func (m *k8sNativeMapping) Scope(name string) string {
+ return m.group + "/" + m.resource + ":uid:" + name
+}
+
+// Prefix returns the scope prefix used for list queries.
+// Format: {resource}:uid:
+func (m *k8sNativeMapping) Prefix() string {
+ return m.group + "/" + m.resource + ":uid:"
+}
+
+// AllActions returns the deduplicated set of RBAC actions for this resource.
+func (m *k8sNativeMapping) AllActions() []string {
+ base := m.group + "/" + m.resource + ":"
+ seen := make(map[string]struct{}, len(k8sVerbMap))
+ actions := make([]string, 0, len(k8sVerbMap))
+ for _, v := range k8sVerbMap {
+ action := base + v
+ if _, ok := seen[action]; ok {
+ continue
+ }
+ seen[action] = struct{}{}
+ actions = append(actions, action)
+ }
+ return actions
+}
+
+// HasFolderSupport always returns true for K8s-native resources.
+//
+// Defaulting to true is the safe choice: if a resource does not live in a
+// folder, the permission would not have been granted on any parent folder
+// in the first place.
+func (m *k8sNativeMapping) HasFolderSupport() bool {
+ return true
+}
+
+// SkipScope always returns false; no verb skips scope checks by default.
+func (m *k8sNativeMapping) SkipScope(_ string) bool {
+ return false
+}
+
+// Resource returns the K8s resource name (without the group prefix).
+func (m *k8sNativeMapping) Resource() string {
+ return m.resource
+}
diff --git a/pkg/services/authz/rbac/k8s_native_mapping_test.go b/pkg/services/authz/rbac/k8s_native_mapping_test.go
new file mode 100644
index 0000000000000..267f7d88854c5
--- /dev/null
+++ b/pkg/services/authz/rbac/k8s_native_mapping_test.go
@@ -0,0 +1,117 @@
+package rbac
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+)
+
+func TestK8sNativeMapping_Action(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+
+ tests := []struct {
+ verb string
+ expectedAction string
+ expectOk bool
+ }{
+ // Direct verbs
+ {utils.VerbGet, "myapp.ext.grafana.app/widgets:get", true},
+ {utils.VerbCreate, "myapp.ext.grafana.app/widgets:create", true},
+ {utils.VerbUpdate, "myapp.ext.grafana.app/widgets:update", true},
+ {utils.VerbDelete, "myapp.ext.grafana.app/widgets:delete", true},
+ {utils.VerbGetPermissions, "myapp.ext.grafana.app/widgets:get_permissions", true},
+ {utils.VerbSetPermissions, "myapp.ext.grafana.app/widgets:set_permissions", true},
+ // Collapsed verbs
+ {utils.VerbList, "myapp.ext.grafana.app/widgets:get", true},
+ {utils.VerbWatch, "myapp.ext.grafana.app/widgets:get", true},
+ {utils.VerbPatch, "myapp.ext.grafana.app/widgets:update", true},
+ {utils.VerbDeleteCollection, "myapp.ext.grafana.app/widgets:delete", true},
+ // Unknown verb
+ {"unknownverb", "", false},
+ {"", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.verb, func(t *testing.T) {
+ action, ok := m.Action(tt.verb)
+ assert.Equal(t, tt.expectOk, ok)
+ assert.Equal(t, tt.expectedAction, action)
+ })
+ }
+}
+
+func TestK8sNativeMapping_ActionSets(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ // K8s-native resources have no legacy RBAC action sets.
+ for _, verb := range []string{utils.VerbGet, utils.VerbList, utils.VerbCreate, utils.VerbUpdate, utils.VerbDelete} {
+ assert.Nil(t, m.ActionSets(verb), "ActionSets should be nil for verb %q", verb)
+ }
+}
+
+func TestK8sNativeMapping_Scope(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+
+ assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:abc123", m.Scope("abc123"))
+ assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:some-uid", m.Scope("some-uid"))
+ assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:", m.Scope(""))
+}
+
+func TestK8sNativeMapping_Prefix(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:", m.Prefix())
+}
+
+func TestK8sNativeMapping_HasFolderSupport(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ assert.True(t, m.HasFolderSupport(),
+ "K8s-native mappings always report folder support so that folder-scoped inheritance works correctly")
+}
+
+func TestK8sNativeMapping_SkipScope(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ for _, verb := range []string{utils.VerbGet, utils.VerbCreate, utils.VerbUpdate, utils.VerbDelete, utils.VerbGetPermissions, utils.VerbSetPermissions} {
+ assert.False(t, m.SkipScope(verb), "SkipScope should be false for verb %q", verb)
+ }
+}
+
+func TestK8sNativeMapping_Resource(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ assert.Equal(t, "widgets", m.Resource(), "Resource() should return the K8s resource name without the group prefix")
+}
+
+func TestK8sNativeMapping_AllActions(t *testing.T) {
+ m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets")
+ actions := m.AllActions()
+
+ // Must be deduplicated (list/watch collapse to get, patch to update, deletecollection to delete)
+ expected := []string{
+ "myapp.ext.grafana.app/widgets:create",
+ "myapp.ext.grafana.app/widgets:delete",
+ "myapp.ext.grafana.app/widgets:get",
+ "myapp.ext.grafana.app/widgets:get_permissions",
+ "myapp.ext.grafana.app/widgets:set_permissions",
+ "myapp.ext.grafana.app/widgets:update",
+ }
+
+ sort.Strings(actions)
+ assert.Equal(t, expected, actions)
+}
+
+func TestK8sNativeMapping_DifferentGroups(t *testing.T) {
+ // Two apps with the same resource name must produce distinct actions.
+ m1 := newK8sNativeMapping("app1.ext.grafana.app", "widgets")
+ m2 := newK8sNativeMapping("app2.ext.grafana.app", "widgets")
+
+ action1, ok1 := m1.Action(utils.VerbGet)
+ action2, ok2 := m2.Action(utils.VerbGet)
+
+ require.True(t, ok1)
+ require.True(t, ok2)
+ assert.NotEqual(t, action1, action2, "different groups must produce distinct actions for the same resource")
+ assert.Equal(t, "app1.ext.grafana.app/widgets:get", action1)
+ assert.Equal(t, "app2.ext.grafana.app/widgets:get", action2)
+}
diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go
index 91eef34f937b6..463ed32f29638 100644
--- a/pkg/services/authz/rbac/service.go
+++ b/pkg/services/authz/rbac/service.go
@@ -587,8 +587,8 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri
t, ok := s.mapper.Get(group, resource)
if !ok {
- ctxLogger.Error("unsupported resource", "group", group, "resource", resource)
- return "", nil, status.Error(codes.NotFound, "unsupported resource")
+ ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", group, "resource", resource)
+ t = newK8sNativeMapping(group, resource)
}
action, ok := t.Action(verb)
@@ -858,8 +858,8 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool,
t, ok := s.mapper.Get(req.Group, req.Resource)
if !ok {
- ctxLogger.Error("unsupport resource", "group", req.Group, "resource", req.Resource)
- return false, status.Error(codes.NotFound, "unsupported resource")
+ ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource)
+ t = newK8sNativeMapping(req.Group, req.Resource)
}
if req.Name == "" && req.Verb != utils.VerbCreate {
@@ -1019,8 +1019,8 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
t, ok := s.mapper.Get(req.Group, req.Resource)
if !ok {
- ctxLogger.Error("unsupported resource", "group", req.Group, "resource", req.Resource)
- return nil, status.Error(codes.NotFound, "unsupported resource")
+ ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource)
+ t = newK8sNativeMapping(req.Group, req.Resource)
}
var tree folderTree
diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go
index a12aefc20df75..213902aa82d18 100644
--- a/pkg/services/authz/rbac/service_test.go
+++ b/pkg/services/authz/rbac/service_test.go
@@ -1006,18 +1006,6 @@ func TestService_Check(t *testing.T) {
},
expectErr: true,
},
- {
- name: "should error if an unknown group is provided",
- req: &authzv1.CheckRequest{
- Namespace: "org-12",
- Subject: "user:test-uid",
- Group: "unknown.grafana.app",
- Resource: "unknown",
- Verb: "get",
- Name: "u1",
- },
- expectErr: true,
- },
{
name: "should error if an unknown verb is provided",
req: &authzv1.CheckRequest{
@@ -1264,7 +1252,9 @@ func TestService_Check(t *testing.T) {
expected: true,
},
{
- name: "should deny rendering access to another app resources",
+ // Unregistered groups fall back to the K8s-native mapping. The renderer has no
+ // permissions for K8s-native actions, so the check is denied without an error.
+ name: "should deny rendering access to unregistered app resources",
req: &authzv1.CheckRequest{
Namespace: "org-12",
Subject: "render:0",
@@ -1273,8 +1263,7 @@ func TestService_Check(t *testing.T) {
Verb: "get",
Name: "dash1",
},
- expected: false,
- expectErr: true,
+ expected: false,
},
}
t.Run("Rendering permission check", func(t *testing.T) {
@@ -1295,6 +1284,102 @@ func TestService_Check(t *testing.T) {
})
}
+func TestService_K8sNativeFallback(t *testing.T) {
+ callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
+ Claims: jwt.Claims{
+ Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
+ Audience: []string{"authzservice"},
+ },
+ Rest: authn.AccessTokenClaims{Namespace: "org-12"},
+ })
+
+ setup := func(permissions []accesscontrol.Permission) *Service {
+ s := setupService()
+ userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
+ fStore := &fakeStore{userID: userID, userPermissions: permissions}
+ s.store = fStore
+ s.permissionStore = fStore
+ s.identityStore = &fakeIdentityStore{}
+ return s
+ }
+
+ ctx := types.WithAuthInfo(context.Background(), callingService)
+
+ t.Run("Check: unregistered group denied when no permissions", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{})
+ resp, err := s.Check(ctx, &authzv1.CheckRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "get",
+ Name: "w1",
+ })
+ require.NoError(t, err)
+ assert.False(t, resp.Allowed)
+ })
+
+ t.Run("Check: unregistered group allowed with K8s-native action", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{
+ {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w1"},
+ })
+ resp, err := s.Check(ctx, &authzv1.CheckRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "get",
+ Name: "w1",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.Allowed)
+ })
+
+ t.Run("Check: unknown verb still errors", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{})
+ _, err := s.Check(ctx, &authzv1.CheckRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "unknownverb",
+ Name: "w1",
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("List: unregistered group returns empty without error", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{})
+ resp, err := s.List(ctx, &authzv1.ListRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "get",
+ })
+ require.NoError(t, err)
+ assert.Empty(t, resp.Items)
+ assert.Empty(t, resp.Folders)
+ assert.False(t, resp.All)
+ })
+
+ t.Run("List: unregistered group returns items with K8s-native action", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{
+ {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w1"},
+ {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w2"},
+ })
+ resp, err := s.List(ctx, &authzv1.ListRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "get",
+ })
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"w1", "w2"}, resp.Items)
+ })
+}
+
func TestService_CacheCheck(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
@@ -1487,17 +1572,6 @@ func TestService_List(t *testing.T) {
},
expectErr: true,
},
- {
- name: "should
... [truncated]