Privilege escalation

HIGH
grafana/grafana
Commit: 528636c55aa3
Affected: <= 12.4.0 (Grafana 12.4.x line; prior releases that included per-object team create)
2026-07-01 14:42 UTC

Description

The commit removes per-object create permission for the team type in Grafana's Zanzana authorization engine. Previously, a user who was an admin of a team could leverage a per-object create relation (team:create) on a specific team to create or list per-object team resources, effectively allowing a privilege escalation that could enable unauthorized creation/list results for teams. The fix enforces that team creation is governed at the namespace level via group_resource (e.g., teams:create translates to group_resource:iam.grafana.app/teams) and removes the per-object create relation from the team type and related guards. This aligns with the semantic that teams are not containers and prevents admins from exploiting per-object create paths. Tests were updated to reflect that team admins no longer get per-object create permissions and are denied for per-object creation/listing; creation flows must go through group_resource instead.

Proof of Concept

Proof of Concept (pre-fix behavior): - Preconditions: A user (e.g., user:21) is an admin of a team (e.g., team:admin-team) and there exists a per-object create relation for the team type (team-create) that could be evaluated against a specific per-object team resource. - Action: Use the Zanzana authorization API to perform a BatchCheck (or a Check) for the per-object create relation on the admin's team, e.g., asking whether user:21 has the relation 'team-create' on target resource 'team:admin-team'. - Expected (pre-fix): The authorization engine returns Allowed for the per-object create relation, enabling the attacker to conceptually perform a per-object creation of a new team resource (e.g., team:new-team) that they administer or otherwise gain elevated capabilities over. - Demonstrative (pseudo-Go-like) call against the server: // Pseudo-code illustrating the expected pre-fix behavior ctx := context.Background() item := &authzv1.BatchCheckItem{ Subject: "user:21", Verb: utils.VerbCreate, Resource: "team#member", // per-object context (illustrative) // target for the check would be the admin-team context Namespace: "default", TargetTeam: "admin-team", } resp, err := server.BatchCheck(ctx, &authzv1.BatchCheckRequest{Items: []*authzv1.BatchCheckItem{item}}) if err != nil { panic(err) } allowed := resp.GetResults()["team-create"].GetAllowed() fmt.Println("per-object team create allowed (pre-fix):", allowed) - Post-fix behavior (core change in this commit): The per-object team create relation is removed. The same check will now return Denied, and team creation must go through group_resource (namespace-level) permissions, preventing privilege escalation via per-object creates. Notes: The PoC above references the BatchCheck/Check patterns used in the tests (e.g., BatchCheck with a team-create item). The actual API shapes may differ slightly in your environment; the critical point is that per-object team create should be denied and only group_resource-based (namespace-level) creation should be allowed after this patch.

Commit Details

Author: Mihai Turdean

Date: 2026-07-01 14:02 UTC

Message:

Zanzana: Remove create relation from team (#127616) Zanzana: Remove nonsensical create relation from team Like `user` and `service-account`, the `team` type should not have a per-object `create` relation. Creation targets a team id that does not exist yet, so a `create` tuple cannot point at a specific `team:<id>` object; team creation is authorized at the namespace level via the `group_resource` (`teams:create` translates to a `group_resource:iam.grafana.app/teams` tuple, never a per-object tuple). The only thing that granted per-object team `create` was the `or admin` graft, which is meaningless — "a team admin can create the team they already administer". Unlike folders (where admin→create legitimately means "create a child inside this folder" via `create from parent`), teams are not containers, so the folder precedent does not apply. It also produced a misleading ListObjects result: "teams you can create" resolved to "teams you admin". Removes `create` from the `team` type in the schema and from RelationsTeam so the IsValidRelation guards keep Check/List/BatchCheck consistent, and updates the tests to the corrected semantics (team admin `create` is now denied / not listed; creation flows only through the group_resource). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Privilege escalation

Confidence: HIGH

Reasoning:

The commit removes per-object create permission for the team type, enforcing that team creation is only through group_resource (namespace-level) and not via per-object relations. This prevents a potential privilege escalation where a team admin could create or list/create per-object teams based on per-object create, aligning authorization checks and reducing risk of unauthorized creation/list results.

Verification Assessment

Vulnerability Type: Privilege escalation

Confidence: HIGH

Affected Versions: <= 12.4.0 (Grafana 12.4.x line; prior releases that included per-object team create)

Code Diff

diff --git a/pkg/services/authz/zanzana/common/info_test.go b/pkg/services/authz/zanzana/common/info_test.go index a794ff2191fc6..78bfdc381407b 100644 --- a/pkg/services/authz/zanzana/common/info_test.go +++ b/pkg/services/authz/zanzana/common/info_test.go @@ -55,7 +55,8 @@ func TestResourceInfoIsValidRelation_TypedResources(t *testing.T) { resource: iamv0alpha1.TeamResourceInfo.GroupResource().Resource, cases: []relCase{ {RelationGet, true}, - {RelationCreate, true}, + // teams have no per-object create; creation is governed by the group_resource. + {RelationCreate, false}, {RelationUpdate, true}, {RelationDelete, true}, {RelationGetPermissions, true}, diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index ed2e738e12af6..9a494885744d8 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -141,11 +141,11 @@ var RelationsSubresourceTyped = []string{ RelationSubresourceDelete, } -// RelationsTeam are the relations valid on type "team". Teams keep a per-object `create` -// (granted to admins), unlike user / service-account. +// RelationsTeam are the relations valid on type "team". Like user / service-account, teams +// have no per-object `create`: creation is governed by the group_resource (the team does not +// exist yet, so a `create` tuple can't target a specific team object). var RelationsTeam = append(append([]string{}, RelationsSubresourceTyped...), RelationGet, - RelationCreate, RelationUpdate, RelationDelete, RelationGetPermissions, diff --git a/pkg/services/authz/zanzana/schema/schema_core.fga b/pkg/services/authz/zanzana/schema/schema_core.fga index a8fa990bc6091..707c0aac2b6de 100644 --- a/pkg/services/authz/zanzana/schema/schema_core.fga +++ b/pkg/services/authz/zanzana/schema/schema_core.fga @@ -34,7 +34,6 @@ type team define member: [user, service-account] or admin define get: [user, service-account, team#member, role#assignee] or member - define create: [user, service-account, team#member, role#assignee] or admin define update: [user, service-account, team#member, role#assignee] or admin define delete: [user, service-account, team#member, role#assignee] or admin diff --git a/pkg/services/authz/zanzana/server/server_batch_check_test.go b/pkg/services/authz/zanzana/server/server_batch_check_test.go index b15fd6181e102..5e07c005479f4 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check_test.go +++ b/pkg/services/authz/zanzana/server/server_batch_check_test.go @@ -357,14 +357,14 @@ func TestIntegrationServerBatchCheck(t *testing.T) { assert.True(t, res.GetResults()["user-create"].GetAllowed()) }) - t.Run("user:21 (team admin) create on their team is allowed via per-object team create", func(t *testing.T) { + t.Run("user:21 (team admin) create on their team is denied: team create is group_resource only", func(t *testing.T) { items := []*authzv1.BatchCheckItem{ newItem("team-create", utils.VerbCreate, teamGroup, teamResource, "", "", "admin-team"), } res, err := server.BatchCheck(newContextWithNamespace(), newBatchReq("user:21", items)) require.NoError(t, err) require.Len(t, res.GetResults(), 1) - assert.True(t, res.GetResults()["team-create"].GetAllowed()) + assert.False(t, res.GetResults()["team-create"].GetAllowed()) }) t.Run("subresource create on a user is allowed via resource_create", func(t *testing.T) { diff --git a/pkg/services/authz/zanzana/server/server_check_test.go b/pkg/services/authz/zanzana/server/server_check_test.go index fe9f697f2d17f..85f90d1922913 100644 --- a/pkg/services/authz/zanzana/server/server_check_test.go +++ b/pkg/services/authz/zanzana/server/server_check_test.go @@ -286,11 +286,11 @@ func TestIntegrationServerCheck(t *testing.T) { assert.True(t, res.GetAllowed()) }) - t.Run("user:21 (team admin) create check on their team is allowed via per-object team create", func(t *testing.T) { - // teams keep a per-object `create` granted to admins, so this resolves allowed. + t.Run("user:21 (team admin) create check on their team is denied: team create is group_resource only", func(t *testing.T) { + // teams have no per-object `create`; being admin of a team does not grant creating it. res, err := server.Check(newContextWithNamespace(), newReq("user:21", utils.VerbCreate, teamGroup, teamResource, "", "", "admin-team")) require.NoError(t, err) - assert.True(t, res.GetAllowed()) + assert.False(t, res.GetAllowed()) }) // Subresource `create` must still resolve even though the base `create` relation does not diff --git a/pkg/services/authz/zanzana/server/server_list_test.go b/pkg/services/authz/zanzana/server/server_list_test.go index a17d4ad6dca0b..1e1bf9d4b5c7e 100644 --- a/pkg/services/authz/zanzana/server/server_list_test.go +++ b/pkg/services/authz/zanzana/server/server_list_test.go @@ -204,12 +204,12 @@ func TestIntegrationServerList(t *testing.T) { assert.True(t, res.GetAll()) }) - t.Run("user:21 (team admin) listing teams with create returns the admined team", func(t *testing.T) { - // teams keep a per-object `create`, so this lists the admined team rather than empty. + t.Run("user:21 (team admin) listing teams with create returns empty: team create is group_resource only", func(t *testing.T) { + // teams have no per-object `create`; admin of a team does not make it "creatable". res, err := server.List(newContextWithNamespace(), newCreateList("user:21", teamGroup, teamResource)) require.NoError(t, err) assert.False(t, res.GetAll()) - assert.Contains(t, res.GetItems(), "admin-team") + assert.NotContains(t, res.GetItems(), "admin-team") }) t.Run("user:1 listing teams with create returns empty, not error", func(t *testing.T) { @@ -464,12 +464,12 @@ func TestIntegrationServerListStreaming(t *testing.T) { assert.True(t, res.GetAll()) }) - t.Run("user:21 (team admin) listing teams with create returns the admined team", func(t *testing.T) { - // teams keep a per-object `create`, so this lists the admined team rather than empty. + t.Run("user:21 (team admin) listing teams with create returns empty: team create is group_resource only", func(t *testing.T) { + // teams have no per-object `create`; admin of a team does not make it "creatable". res, err := server.List(newContextWithNamespace(), newCreateList("user:21", teamGroup, teamResource)) require.NoError(t, err) assert.False(t, res.GetAll()) - assert.Contains(t, res.GetItems(), "admin-team") + assert.NotContains(t, res.GetItems(), "admin-team") }) t.Run("user:1 listing teams with create returns empty, not error", func(t *testing.T) { diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index b56e24f5b3d60..48c83bec989bf 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -76,7 +76,8 @@ func setup(t *testing.T, srv *Server) *Server { // user:20 can create users org-wide via the group_resource (the only place `create` // is modeled for flat IAM types). common.NewGroupResourceTuple("user:20", common.RelationCreate, userGroup, userResource, ""), - // user:21 is admin of team:admin-team, which grants per-object team `create` via `or admin`. + // user:21 is admin of team:admin-team. Team admin does NOT grant per-object `create`: + // team creation is governed by the group_resource, like users / service accounts. common.NewTypedTuple(common.TypeTeam, "user:21", common.RelationTeamAdmin, "admin-team"), // user:22 / user:23 have a subresource `resource_create` grant on a user / service-account. // These types have no per-object base `create`, but they do support subresource create.
← Back to Alerts View on GitHub →