Access control / Authorization (RBAC)
Description
This commit fixes an authorization bypass in the provisioning workflow by wiring explicit RBAC checks for the Playlist resource. Previously, provisioning could export/sync playlists without proper RBAC checks because the Playlist resource (playlists) did not have a defined mapping to the two-action model (read/write) and the provisioning identity could bypass checks. The patch adds: (1) service identity permissions for playlists (playlists:read, playlists:write), (2) provisioning identity token permissions to include playlist-related scopes, (3) an RBAC mapper entry for playlist.grafana.app mapping verbs to playlists:read / playlists:write, with create treated as write and scope skipping for creates, and (4) tests validating the provisioning identity carries and enforces the correct permissions. In short, this closes an access-control gap that could allow unauthorized export/sync of playlists during provisioning.
Proof of Concept
PoC outline (conceptual, not a live exploit):
- Environment: Grafana 12.4.x prior to this fix, with provisioning enabled and a provisioning job that can export playlists.
- Attackers setup: A provisioning identity (service identity) without explicit playlists.permissions (i.e., lacking playlists:read and playlists:write).
- Pre-fix risk scenario: The provisioning preflight/authorization path does not correctly enforce playlists authorization, so the provisioning job could enumerate and export playlists solely based on other resource checks (e.g., dashboards/folders) or via a blanket service check, leading to unauthorized access to playlist data.
- Attack steps (illustrative):
1) Run a provisioning export/sync job under an identity that does not include playlists:read or playlists:write in its token/permissions.
2) The provisioning system enumerates active kinds and performs authorization checks. Due to missing/misaligned mapping for playlists, the system grants access to export playlists anyway.
3) Observe that playlist resources are provisioned/exported despite the identity lacking explicit playlists permissions.
- Mitigation after fix: The identity must present playlists:read/write (and the RBAC mapper maps playlist verbs to those actions). The provisioning preflight now requires proper authorization for the playlists resource, and tests have been added to verify this behavior.
Note: Below is a minimal, illustrative Go-like snippet showing how a preflight might be checked before the fix (and why it could be bypassed without the mapping):
// Pseudo illustrating old behavior (potentially vulnerable):
identity := identity.NewServiceIdentity("default") // lacks playlists:read/write
// Preflight check for exporting a playlist
res := authz.CheckServicePermissions(identity.GetRequester(), "playlist.grafana.app", "playlists", "list")
if res.Allowed {
// Proceed with export of playlists
}
// After the patch, the mapper ensures:
// - GET/LIST/WATCH map to playlists:read
// - CREATE/UPDATE/PATCH/DELETE map to playlists:write
// - Create may skip scope as playlists aren’t folder-scoped
// Hence, an identity without playlists permissions would not be allowed to export playlists during provisioning.
Commit Details
Author: Roberto Jiménez Sánchez
Date: 2026-06-12 18:34 UTC
Message:
Provisioning: wire authorization for Playlist export/sync identity (#126067)
Triage Assessment
Vulnerability Type: Access control / Authorization
Confidence: HIGH
Reasoning:
The commit wires authorization for the Playlist resource in provisioning, maps playlists to its own actions (playlists:read / playlists:write), and ensures the provisioning identity has to be authorized to access playlists just like dashboards/folders. This closes a potential authorization bypass where provisioning could export/sync playlists without proper RBAC checks. Added tests and RBAC mapper rules to enforce and verify correct access.
Verification Assessment
Vulnerability Type: Access control / Authorization (RBAC)
Confidence: HIGH
Affected Versions: <= 12.4.0
Code Diff
diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod
index 619704b75c0b4..b543ec4880e36 100644
--- a/pkg/apimachinery/go.mod
+++ b/pkg/apimachinery/go.mod
@@ -38,7 +38,9 @@ require (
github.com/go-openapi/testify/enable/yaml/v2 v2.5.0 // indirect
github.com/go-openapi/testify/v2 v2.5.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/go-querystring v1.2.0 // indirect
github.com/grafana/dskit v0.0.0-20260427162712-0457a92dacc3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -56,6 +58,7 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go
index bd31c19c5aa94..cb2adfa54faa4 100644
--- a/pkg/apimachinery/identity/context.go
+++ b/pkg/apimachinery/identity/context.go
@@ -173,6 +173,8 @@ var serviceIdentityPermissions = getWildcardPermissions(
"library.panels:read", // ActionLibraryPanelsRead
"library.panels:write", // ActionLibraryPanelsWrite
"library.panels:delete", // ActionLibraryPanelsDelete
+ "playlists:read", // playlist.ActionPlaylistsRead
+ "playlists:write", // playlist.ActionPlaylistsWrite
"alert.provisioning:write",
"alert.provisioning.secrets:read",
"users:read", // accesscontrol.ActionUsersRead,
@@ -185,6 +187,7 @@ var serviceIdentityPermissions = getWildcardPermissions(
var serviceIdentityTokenPermissions = []string{
"folder.grafana.app:*",
"dashboard.grafana.app:*",
+ "playlist.grafana.app:*",
"secret.grafana.app:*",
"query.grafana.app:*",
"datasource.grafana.app:*",
diff --git a/pkg/apimachinery/identity/context_test.go b/pkg/apimachinery/identity/context_test.go
index 10c043517d712..05690cd05b148 100644
--- a/pkg/apimachinery/identity/context_test.go
+++ b/pkg/apimachinery/identity/context_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/grafana/authlib/authn"
+ "github.com/grafana/authlib/authz"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -26,6 +27,47 @@ func TestRequesterFromContext(t *testing.T) {
})
}
+func TestWithProvisioningIdentity(t *testing.T) {
+ ctx, requester, err := identity.WithProvisioningIdentity(context.Background(), "default")
+ require.NoError(t, err)
+ require.NotNil(t, requester)
+
+ fromCtx, err := identity.GetRequester(ctx)
+ require.NoError(t, err)
+ require.Equal(t, requester.GetUID(), fromCtx.GetUID())
+
+ // The provisioning export/sync job enumerates every active kind and runs an authz
+ // preflight via authlib's CheckServicePermissions against the identity's token
+ // permissions. Playlist must be authorized the same way dashboards and folders are,
+ // otherwise enabling Playlist as an active resource breaks export of every kind.
+ t.Run("token permissions authorize playlists for list/read/write", func(t *testing.T) {
+ kinds := []struct {
+ group string
+ resource string
+ }{
+ {"playlist.grafana.app", "playlists"},
+ {"dashboard.grafana.app", "dashboards"},
+ {"folder.grafana.app", "folders"},
+ }
+ for _, k := range kinds {
+ for _, verb := range []string{"list", "get", "create", "update", "delete"} {
+ res := authz.CheckServicePermissions(requester, k.group, k.resource, verb)
+ require.True(t, res.ServiceCall, "%s.%s should be a service call", k.resource, k.group)
+ require.True(t, res.Allowed, "%s.%s %s should be allowed for the provisioning identity", k.resource, k.group, verb)
+ }
+ }
+ })
+
+ // The playlist apiserver guards access with its own authorizer, which evaluates the
+ // legacy playlists:read / playlists:write actions against the requester's permission
+ // map. The provisioning identity must carry those actions to read and write playlists.
+ t.Run("legacy permissions grant playlist actions", func(t *testing.T) {
+ perms := requester.GetPermissions()
+ require.Contains(t, perms, "playlists:read")
+ require.Contains(t, perms, "playlists:write")
+ })
+}
+
func TestWithServiceIdentity(t *testing.T) {
t.Run("with a custom service identity name", func(t *testing.T) {
customName := "custom-service"
diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go
index b6080abe5b31f..4549188828112 100644
--- a/pkg/services/authz/rbac/mapper.go
+++ b/pkg/services/authz/rbac/mapper.go
@@ -293,6 +293,28 @@ func NewMapperRegistry() MapperRegistry {
"folder.grafana.app": {
"folders": newFolderTranslation(),
},
+ "playlist.grafana.app": {
+ // Playlists only define two actions (playlists:read / playlists:write) and are
+ // neither folder-scoped nor scope-checked by their own authorizer, so writes map
+ // to playlists:write and create skips scope. This lets the provisioning export
+ // preflight (run under the requesting user) authorize playlists like other kinds.
+ "playlists": translation{
+ resource: "playlists",
+ attribute: "uid",
+ verbMapping: map[string]string{
+ utils.VerbGet: "playlists:read",
+ utils.VerbList: "playlists:read",
+ utils.VerbWatch: "playlists:read",
+ utils.VerbCreate: "playlists:write",
+ utils.VerbUpdate: "playlists:write",
+ utils.VerbPatch: "playlists:write",
+ utils.VerbDelete: "playlists:write",
+ utils.VerbDeleteCollection: "playlists:write",
+ },
+ folderSupport: false,
+ skipScopeOnVerb: map[string]bool{utils.VerbCreate: true},
+ },
+ },
"iam.grafana.app": {
"permissions": translation{
resource: "permissions",
diff --git a/pkg/services/authz/rbac/mapper_test.go b/pkg/services/authz/rbac/mapper_test.go
index d83bdca83b236..36f29f1f6144b 100644
--- a/pkg/services/authz/rbac/mapper_test.go
+++ b/pkg/services/authz/rbac/mapper_test.go
@@ -43,6 +43,32 @@ func TestMapperRegistry_DatasourceWildcard(t *testing.T) {
assert.False(t, ok, "Get(datasource group, \"dashboards\") must not return a mapping")
}
+// TestMapperRegistry_Playlist verifies playlists map to their real two-action model
+// (playlists:read / playlists:write) rather than the default create/delete actions, and
+// that create skips scope since playlists are neither folder-scoped nor scope-checked.
+// This is what lets the provisioning export preflight authorize playlists.
+func TestMapperRegistry_Playlist(t *testing.T) {
+ reg := NewMapperRegistry()
+
+ mapping, ok := reg.Get("playlist.grafana.app", "playlists", "")
+ require.True(t, ok, "playlists should be registered in the mapper")
+ require.NotNil(t, mapping)
+
+ for _, verb := range []string{utils.VerbGet, utils.VerbList, utils.VerbWatch} {
+ action, ok := mapping.Action(verb)
+ assert.True(t, ok)
+ assert.Equal(t, "playlists:read", action, "verb %q should map to read", verb)
+ }
+ for _, verb := range []string{utils.VerbCreate, utils.VerbUpdate, utils.VerbPatch, utils.VerbDelete, utils.VerbDeleteCollection} {
+ action, ok := mapping.Action(verb)
+ assert.True(t, ok)
+ assert.Equal(t, "playlists:write", action, "verb %q should map to write (no playlists:create/delete action exists)", verb)
+ }
+
+ assert.True(t, mapping.SkipScope(utils.VerbCreate), "create must skip scope; playlists are not folder-scoped")
+ assert.False(t, mapping.HasFolderSupport(), "playlists are not folder-scoped")
+}
+
// TestFindGroupKey_WildcardMatching exercises findGroupKey via a minimal mapper.
// It covers: exact match, wildcard match, group starts with *, key not wildcard (continue),
// suffix mismatch, empty group, and no match.
diff --git a/pkg/tests/apis/provisioning/common/testing.go b/pkg/tests/apis/provisioning/common/testing.go
index a8873b57d10cd..fd921b5852b6d 100644
--- a/pkg/tests/apis/provisioning/common/testing.go
+++ b/pkg/tests/apis/provisioning/common/testing.go
@@ -1180,6 +1180,84 @@ func WaitForResourcesDeleted(t *testing.T, ctx context.Context, client dynamic.R
}, WaitTimeoutDefault, WaitIntervalDefault, "expected %s to be deleted", resourceKind)
}
+// RequireResource polls until the named resource is gettable via client and returns it.
+// Use after a write/sync to assert a resource has been provisioned into Grafana.
+func RequireResource(t *testing.T, ctx context.Context, client dynamic.ResourceInterface, name string) *unstructured.Unstructured {
+ t.Helper()
+ var got *unstructured.Unstructured
+ require.EventuallyWithT(t, func(collect *assert.CollectT) {
+ obj, err := client.Get(ctx, name, metav1.GetOptions{})
+ if !assert.NoError(collect, err, "get %q", name) {
+ return
+ }
+ got = obj
+ }, WaitTimeoutDefault, WaitIntervalDefault, "resource %q should be provisioned", name)
+ return got
+}
+
+// ResourceToJSON marshals an unstructured resource to JSON, e.g. for a files-endpoint write.
+func ResourceToJSON(t *testing.T, obj *unstructured.Unstructured) []byte {
+ t.Helper()
+ data, err := json.Marshal(obj.Object)
+ require.NoError(t, err)
+ return data
+}
+
+// ExportedResourceFiles walks the repository directory and returns the paths of files whose
+// apiVersion has the given group prefix (e.g. "playlist.grafana.app/"). It lets export tests
+// assert what was written without hard-coding the generated file names.
+func (h *ProvisioningTestHelper) ExportedResourceFiles(t *testing.T, groupPrefix string) []string {
+ t.Helper()
+ var matches []string
+ err := filepath.WalkDir(h.ProvisioningPath, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ switch strings.ToLower(filepath.Ext(p)) {
+ case ".json", ".yaml", ".yml":
+ default:
+ return nil
+ }
+ apiVersion, _, _ := unstructured.NestedString(h.LoadYAMLOrJSONFile(p).Object, "apiVersion")
+ if strings.HasPrefix(apiVersion, groupPrefix) {
+ matches = append(matches, p)
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ return matches
+}
+
+// PlaylistGVR is the playlist resource served by the App SDK apiserver.
+var PlaylistGVR = schema.GroupVersionResource{
+ Group: "playlist.grafana.app",
+ Version: "v1",
+ Resource: "playlists",
+}
+
+// NewPlaylist builds a minimal playlist resource for provisioning tests.
+func NewPlaylist(name, title string) *unstructured.Unstructured {
+ return &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": PlaylistGVR.GroupVersion().String(),
+ "kind": "Playlist",
+ "metadata": map[string]any{
+ "name": name,
+ },
+ "spec": map[string]any{
+ "title": title,
+ "interval": "5m",
+ "items": []any{
+ map[string]any{"type": "dashboard_by_tag", "value": "provisioning"},
+ },
+ },
+ },
+ }
+}
+
// GrafanaOption is a functional option for RunGrafana.
type GrafanaOption func(opts *testinfra.GrafanaOpts)
@@ -1661,6 +1739,24 @@ func (h *ProvisioningTestHelper) PostFilesRequest(t *testing.T, repo string, opt
return resp
}
+// ListRepositoryFiles returns the file listing from the repository's files endpoint
+// (GET .../files/). It is a directory listing only — it does not parse resources or run
+// a dry-run, so it is safe to call regardless of whether the files are already
+// provisioned in Grafana.
+func (h *ProvisioningTestHelper) ListRepositoryFiles(t *testing.T, ctx context.Context, repo string) []provisioning.FileItem {
+ t.Helper()
+ rsp := h.AdminREST.Get().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ Suffix("files/").
+ Do(ctx)
+ require.NoError(t, rsp.Error(), "listing repository files should succeed")
+ list := &provisioning.FileList{}
+ require.NoError(t, rsp.Into(list))
+ return list.Items
+}
+
// FilesClient provides convenience methods for interacting with the provisioning
// files subresource (/repositories/{repo}/files/{path}) via direct HTTP.
// It avoids the Kubernetes REST client limitation with '/' in subresource names.
diff --git a/pkg/tests/apis/provisioning/playlists/helpers_test.go b/pkg/tests/apis/provisioning/playlists/helpers_test.go
new file mode 100644
index 0000000000000..17e6e0438397c
--- /dev/null
+++ b/pkg/tests/apis/provisioning/playlists/helpers_test.go
@@ -0,0 +1,53 @@
+package playlists
+
+import (
+ "testing"
+
+ ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/tests/apis"
+ "github.com/grafana/grafana/pkg/tests/apis/provisioning/common"
+ "github.com/grafana/grafana/pkg/tests/testinfra"
+)
+
+// env runs a single shared Grafana server for the package with Playlist enabled as an
+// active provisioning resource. Playlist ships ":disabled" by default; enabling it here
+// exercises the export/sync paths that the provisioning identity must be authorized for.
+var env = common.NewSharedEnv(
+ common.WithoutProvisioningFolderMetadata,
+ func(opts *testinfra.GrafanaOpts) {
+ // Setting [provisioning] resources replaces the default set. This package only
+ // needs playlists; folders remain because they are foundational to provisioning.
+ opts.ProvisioningResources = []string{
+ "folder.grafana.app/Folder:folder",
+ "playlist.grafana.app/Playlist",
+ }
+ // Exercise the playlist apiserver's RBAC authorizer (playlists:read/write),
+ // which the provisioning identity must satisfy for both directions.
+ opts.EnableFeatureToggles = append(opts.EnableFeatureToggles, featuremgmt.FlagPlaylistsRBAC)
+ },
+)
+
+func sharedHelper(t *testing.T) *common.ProvisioningTestHelper {
+ t.Helper()
+ helper := env.GetCleanHelper(t)
+ helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient()
+ return helper
+}
+
+// playlistClient returns a dynamic client for playlists scoped to Org1 admin in the
+// default namespace (org1). It is not part of the shared helper, so each test builds
+// its own.
+func playlistClient(t *testing.T, helper *common.ProvisioningTestHelper) *apis.K8sResourceClient {
+ t.Helper()
+ return helper.GetResourceClient(apis.ResourceClientArgs{
+ User: helper.Org1.Admin,
+ Namespace: "default",
+ GVR: common.PlaylistGVR,
+ })
+}
+
+func TestMain(m *testing.M) {
+ env.RunTestMain(m)
+}
diff --git a/pkg/tests/apis/provisioning/playlists/playlist_test.go b/pkg/tests/apis/provisioning/playlists/playlist_test.go
new file mode 100644
index 0000000000000..4a0b6f2b1b542
--- /dev/null
+++ b/pkg/tests/apis/provisioning/playlists/playlist_test.go
@@ -0,0 +1,359 @@
+packa
... [truncated]