Privilege escalation
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.