Code Diff
diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go
index a2b0af741af82..e35a733b7a3d4 100644
--- a/pkg/services/authz/rbac/service.go
+++ b/pkg/services/authz/rbac/service.go
@@ -975,9 +975,6 @@ func (s *Service) checkPermissionWithFolderAuthz(ctx context.Context, scopeMap m
return true, nil
}
- // A specific object was named but its parent folder is unknown. Without a
- // folder the only thing that can authorize the request is a wildcard folder
- // grant (handled above).
if req.ParentFolder == "" {
return false, fmt.Errorf("k8s authorizer supports folder level not resource level authorization")
}
@@ -1155,6 +1152,15 @@ func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) (
}
func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, req *listRequest) (*authzv1.ListResponse, error) {
+ // Mapper-miss resources (folder-scoped CRDs, *.ext.grafana.app) use the
+ // dual-check folder-authz model. Fork here before the scopeMap["*"] early
+ // return so a resource-type wildcard does not auto-allow all objects for
+ // folder-scoped CRDs (consistent with checkPermissionWithFolderAuthz).
+ t, mapperFound := s.mapper.Get(req.Group, req.Resource, req.Subresource)
+ if !mapperFound {
+ return s.listPermissionWithFolderAuthz(ctx, scopeMap, req)
+ }
+
if scopeMap["*"] {
return &authzv1.ListResponse{
All: true,
@@ -1166,12 +1172,6 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
defer span.End()
ctxLogger := s.logger.FromContext(ctx)
- t, ok := s.mapper.Get(req.Group, req.Resource, req.Subresource)
- if !ok {
- ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource, "subresource", req.Subresource)
- t = newK8sNativeMapping(req.Group, req.Resource, req.Subresource)
- }
-
var tree folderTree
cacheHit := false
if t.HasFolderSupport() {
@@ -1205,6 +1205,85 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
return res, nil
}
+// listPermissionWithFolderAuthz implements the "stack role AND folder
+// permission" model for the List/Compile path of K8s-native (mapper-miss)
+// resources, mirroring checkPermissionWithFolderAuthz.
+//
+// The stack-role grant lives under the resource-type action with an empty
+// scope (scopeMap[""]). A resource-type wildcard (scopeMap["*"]) is
+// deliberately not treated as the stack role and must not auto-allow, which is
+// why the caller forks here before listPermission's scopeMap["*"] early
+// return. Once the stack role is established we issue a second query for the
+// user's folder grants and return the set of readable folders, so the compiled
+// ItemChecker allows any object whose parent folder is readable.
+func (s *Service) listPermissionWithFolderAuthz(ctx context.Context, scopeMap map[string]bool, req *listRequest) (*authzv1.ListResponse, error) {
+ ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.listPermissionWithFolderAuthz")
+ defer span.End()
+ ctxLogger := s.logger.FromContext(ctx).New("namespace", req.Namespace.Value, "group", req.Group, "resource", req.Resource, "verb", req.Verb)
+
+ // No stack role at all (can't proceed regardless of folder grants).
+ // Gate on scopeMap[""] only: a resource-type wildcard must not be treated
+ // as the stack role nor auto-allow.
+ if !scopeMap[""] {
+ ctxLogger.Debug("folderAuthz: no stack role, returning empty list response")
+ return &authzv1.ListResponse{Zookie: &authzv1.Zookie{Timestamp: time.Now().Unix()}}, nil
+ }
+
+ // Issue a second permission query for the corresponding folder action
+ // (folders:read for reads), including the action-set names so users granted
+ // via managed roles like "folders:edit" are matched.
+ folderAction, folderActionSets := dualCheckFolderAuthz(req.Verb)
+ folderScopeMap, err := s.getCachedIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction)
+ permsFromCache := err == nil
+ if err != nil {
+ folderScopeMap, err = s.getIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction, folderActionSets)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Wildcard folder grant (Folder admin / folders:read scope=*) → allow all.
+ if folderScopeMap["*"] {
+ timestamp := time.Now()
+ if permsFromCache {
+ timestamp = timestamp.Add(-s.settings.CacheTTL)
+ }
+ return &authzv1.ListResponse{
+ All: true,
+ Zookie: &authzv1.Zookie{Timestamp: timestamp.Unix()},
+ }, nil
+ }
+
+ var tree folderTree
+ cacheHit := false
+ if !req.Options.SkipCache {
+ tree, cacheHit = s.getCachedFolderTree(ctx, req.Namespace)
+ }
+ if !cacheHit {
+ tree, err = s.buildFolderTree(ctx, req.Namespace)
+ if err != nil {
+ ctxLogger.Error("could not build folder tree", "error", err)
+ return nil, err
+ }
+ }
+
+ // Feed the folder scopeMap to buildItemList: folder scopes (folders:uid:*)
+ // land in the Folders field (expanded to descendants), Items stays empty.
+ // The prefix is irrelevant here since the folder scopeMap has no resource
+ // scopes. Do not use buildFolderList — it puts folder UIDs in the Items
+ // field, which would deny every real object.
+ res := buildItemList(folderScopeMap, tree, "")
+
+ if cacheHit {
+ res.Zookie = &authzv1.Zookie{Timestamp: time.Now().Add(-s.settings.CacheTTL).Unix()}
+ } else {
+ res.Zookie = &authzv1.Zookie{Timestamp: time.Now().Unix()}
+ }
+
+ span.SetAttributes(attribute.Int("num_folders", len(res.Folders)), attribute.Int("num_items", len(res.Items)))
+ return res, nil
+}
+
func buildFolderList(scopes map[string]bool, tree folderTree) *authzv1.ListResponse {
itemSet := make(map[string]struct{}, len(scopes))
diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go
index 02c90bfa02795..7723b7469d454 100644
--- a/pkg/services/authz/rbac/service_test.go
+++ b/pkg/services/authz/rbac/service_test.go
@@ -1077,6 +1077,120 @@ func TestService_listPermission(t *testing.T) {
}
}
+func TestService_listPermissionWithFolderAuthz(t *testing.T) {
+ const group = "widget.ext.grafana.app"
+
+ folderPerm := func(action, folderUID string) accesscontrol.Permission {
+ return accesscontrol.Permission{
+ Action: action,
+ Scope: "folders:uid:" + folderUID,
+ Kind: "folders",
+ Attribute: "uid",
+ Identifier: folderUID,
+ }
+ }
+
+ type testCase struct {
+ name string
+ // resourceScopeMap is the scope map for the resource action passed to
+ // listPermission (scopeMap[""] signals the stack role).
+ resourceScopeMap map[string]bool
+ // folderPerms are the user's folder grants, queried second.
+ folderPerms []accesscontrol.Permission
+ folders []store.Folder
+ verb string
+ expectedAll bool
+ expectedFolders []string
+ }
+
+ testCases := []testCase{
+ {
+ name: "no stack role returns empty response",
+ resourceScopeMap: map[string]bool{},
+ folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "f1")},
+ folders: []store.Folder{{UID: "f1"}},
+ verb: utils.VerbList,
+ },
+ {
+ name: "resource-type wildcard does not auto-allow without folder grant",
+ resourceScopeMap: map[string]bool{"*": true},
+ folderPerms: []accesscontrol.Permission{},
+ folders: []store.Folder{{UID: "f1"}},
+ verb: utils.VerbList,
+ },
+ {
+ name: "stack role and folders:read on parent returns folder and descendants",
+ resourceScopeMap: map[string]bool{"": true},
+ folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "parent")},
+ folders: []store.Folder{{UID: "parent"}, {UID: "child", ParentUID: new("parent")}},
+ verb: utils.VerbList,
+ expectedFolders: []string{"parent", "child"},
+ },
+ {
+ name: "stack role and folder grant via action-set name is matched",
+ resourceScopeMap: map[string]bool{"": true},
+ folderPerms: []accesscontrol.Permission{folderPerm("folders:edit", "f1")},
+ folders: []store.Folder{{UID: "f1"}},
+ verb: utils.VerbList,
+ expectedFolders: []string{"f1"},
+ },
+ {
+ name: "stack role and folder wildcard returns all",
+ resourceScopeMap: map[string]bool{"": true},
+ folderPerms: []accesscontrol.Permission{{Action: "folders:read", Scope: "*", Kind: "*"}},
+ folders: []store.Folder{{UID: "f1"}},
+ verb: utils.VerbList,
+ expectedAll: true,
+ },
+ {
+ name: "watch verb behaves like list",
+ resourceScopeMap: map[string]bool{"": true},
+ folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "f1")},
+ folders: []store.Folder{{UID: "f1"}},
+ verb: utils.VerbWatch,
+ expectedFolders: []string{"f1"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := setupService()
+ userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
+ fStore := &fakeStore{userID: userID, userPermissions: tc.folderPerms, folders: tc.folders, disableNsCheck: true}
+ s.store = fStore
+ s.permissionStore = fStore
+ s.folderStore = fStore
+ s.identityStore = &fakeIdentityStore{disableNsCheck: true}
+
+ if tc.folders != nil {
+ s.folderCache.Set(context.Background(), folderCacheKey("default"), newFolderTree(tc.folders))
+ }
+
+ list := listRequest{
+ Namespace: types.NamespaceInfo{Value: "default", OrgID: 1},
+ IdentityType: types.TypeUser,
+ UserUID: "test-uid",
+ Group: group,
+ Resource: "widgets",
+ Verb: tc.verb,
+ Action: group + "/widgets:get",
+ Options: &ListRequestOptions{},
+ }
+
+ got, err := s.listPermission(context.Background(), tc.resourceScopeMap, &list)
+ require.NoError(t, err)
+ assert.Equal(t, tc.expectedAll, got.All)
+ assert.ElementsMatch(t, tc.expectedFolders, got.Folders)
+ // The folder-authz path expresses access only through Folders (or
+ // All), never Items: Items matches by object name, which is
+ // meaningless for folder-scoped CRDs. A regression that swapped
+ // buildItemList for buildFolderList would leak folder UIDs here and
+ // silently deny every object, so we assert it stays empty.
+ assert.Empty(t, got.Items)
+ })
+ }
+}
+
func TestService_Check(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
@@ -1596,7 +1710,9 @@ func TestService_K8sNativeFallback(t *testing.T) {
assert.False(t, resp.All)
})
- t.Run("List: unregistered group returns items with K8s-native action", func(t *testing.T) {
+ t.Run("List: unregistered group with resource-scoped grant but no stack role returns empty", func(t *testing.T) {
+ // A scoped grant on the resource is no longer a stack role, so without
+ // an empty-scope grant the folder-authz list path returns nothing.
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"},
@@ -1606,10 +1722,32 @@ func TestService_K8sNativeFallback(t *testing.T) {
Subject: "user:test-uid",
Group: "unregistered.grafana.app",
Resource: "widgets",
- Verb: "get",
+ Verb: "list",
})
require.NoError(t, err)
- assert.ElementsMatch(t, []string{"w1", "w2"}, resp.Items)
+ assert.Empty(t, resp.Items)
+ assert.Empty(t, resp.Folders)
+ assert.False(t, resp.All)
+ })
+
+ t.Run("List: unregistered group with stack role and folder grant returns folders", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{
+ {Action: "unregistered.grafana.app/widgets:get", Scope: ""},
+ {Action: "folders:read", Scope: "folders:uid:f1", Kind: "folders", Attribute: "uid", Identifier: "f1"},
+ })
+ s.folderStore = s.store.(*fakeStore)
+ s.store.(*fakeStore).folders = []store.Folder{{UID: "f1"}}
+ resp, err := s.List(ctx, &authzv1.ListRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "list",
+ })
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"f1"}, resp.Folders)
+ assert.Empty(t, resp.Items)
+ assert.False(t, resp.All)
})
t.Run("List: dashboards/annotations subresource resolves correct mapper entry", func(t *testing.T) {