Authorization / Access Control
Description
This commit fixes an authorization/access-control issue in Grafana's Zanzana translation and tuple generation related to team permissions. The changes introduce unscoped mappings and skipScope handling for certain team actions, ensuring proper scoping when translating permissions to access-control tuples. Prior to this fix, some team-management actions could be translated without a concrete scope or could be mapped in a way that bypassed per-team scoping, potentially enabling privilege escalation or unauthorized access across teams. The update reworks how team actions (including teams:create and teams.roles:* related mappings) are translated to the underlying IAM resources, and clarifies which actions require per-team scope versus which can be treated as global. As a result, authorization checks should enforce correct scoping, reducing the risk of improper privilege assignments.
Proof of Concept
Proof-of-concept (hypothetical) scenario to illustrate the potential risk before the fix.
Prerequisites:
- Grafana version prior to the fix (<= 12.4.0) with Zanzana-based authorization enabled.
- A user account/role that includes a permission for a team-related action with no specific scope, e.g., Action: teams:create (unscoped) or teams:read/write with no identifier.
Attack scenario:
- An attacker who obtains a role containing an unscoped team action could trigger a policy translation that maps the action to a broad, group-level resource (e.g., iam.grafana.app/teams) rather than a specific team, effectively bypassing per-team scoping.
- This could allow the attacker to perform admin-like operations (e.g., creating teams) or access/modify teams across the organization without being tied to a particular team in scope.
Proof of concept (hypothetical API flow):
1) Create a user role that includes a permission such as:
RolePermission{ Action: "teams:create", Kind: "", Identifier: "" }
(This represents an unscoped permission for creating teams.)
2) Log in as this user and attempt to create a new team via Grafana API:
curl -s -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name":"evil-team","handle":"evil-team"}' \
https://grafana.example/api/teams
Expected (pre-fix): The server may accept the request and create the new team, since the unscoped mapping could translate to a group-level violation where per-team scoping is bypassed.
Expected (post-fix): The translation now honors scope rules for unscoped actions and/or restricts them to appropriate contexts, leading to a 403 Forbidden or a failed creation if the policy requires a specific team scope.
Note: This PoC is conceptual and depends on the exact authorization engine behavior in a given Grafana deployment. The key point is that unscoped team actions prior to this patch could be interpreted as global permissions, potentially enabling unintended privilege elevation. The fix aligns the translation to enforce proper scope for team-related actions.
Commit Details
Author: Gabriel MABILLE
Date: 2026-06-19 06:25 UTC
Message:
Zanzana: Account for team specific permissions (#126655)
* grafana-iam: Reconcile team permissions to Zanzana
Add team action translations and tuple generation for scoped team
permissions. Move core team actions to translations; keep teams.roles:*
rolebinding handling in tuple helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Unused actions
* linting again
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Triage Assessment
Vulnerability Type: Authorization / Access Control
Confidence: HIGH
Reasoning:
The commit adjusts how team-related permissions are translated into access control tuples, introducing unscoped mappings and skipScope handling for certain actions. This directly affects authorization checks (who can perform team actions) and prevents incorrect scoping of permissions (which could lead to privilege escalation or unauthorized access). The changes reorganize mapping for teams and team-role-bindings, ensuring proper enforcement of permissions at scale.
Verification Assessment
Vulnerability Type: Authorization / Access Control
Confidence: HIGH
Affected Versions: <=12.4.0
Code Diff
diff --git a/pkg/services/authz/zanzana/common/translations.go b/pkg/services/authz/zanzana/common/translations.go
index 25175852d25c4..97931dd22ca97 100644
--- a/pkg/services/authz/zanzana/common/translations.go
+++ b/pkg/services/authz/zanzana/common/translations.go
@@ -8,6 +8,7 @@ import (
dashboards "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1"
+ iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
const (
@@ -46,14 +47,21 @@ type actionMapping struct {
group string
resource string
subresource string
+ // skipScope marks actions that are valid without a scope (e.g. create verbs).
+ // TranslateToResourceTuple treats these as wildcard when kind/name are empty.
+ skipScope bool
}
func newMapping(relation, subresource string) actionMapping {
return newScopedMapping(relation, "", "", subresource)
}
+func newUnscopedMapping(relation string) actionMapping {
+ return actionMapping{relation: relation, skipScope: true}
+}
+
func newScopedMapping(relation, group, resource, subresource string) actionMapping {
- return actionMapping{relation, group, resource, subresource}
+ return actionMapping{relation: relation, group: group, resource: resource, subresource: subresource}
}
var (
@@ -62,6 +70,9 @@ var (
dashboardGroup = dashboards.DashboardResourceInfo.GroupResource().Group
dashboardResource = dashboards.DashboardResourceInfo.GroupResource().Resource
+
+ iamGroup = iamv0.TeamResourceInfo.GroupResource().Group
+ teamsResource = iamv0.TeamResourceInfo.GroupResource().Resource
)
var resourceTranslations = map[string]resourceTranslation{
@@ -110,6 +121,19 @@ var resourceTranslations = map[string]resourceTranslation{
"dashboards:admin": newMapping(RelationSetAdmin, ""),
},
},
+ KindTeams: {
+ typ: TypeTeam,
+ group: iamGroup, // "iam.grafana.app"
+ resource: teamsResource, // "teams"
+ mapping: map[string]actionMapping{
+ "teams:read": newMapping(RelationGet, ""),
+ "teams:write": newMapping(RelationUpdate, ""),
+ "teams:create": newUnscopedMapping(RelationCreate),
+ "teams:delete": newMapping(RelationDelete, ""),
+ "teams.permissions:read": newMapping(RelationGetPermissions, ""),
+ "teams.permissions:write": newMapping(RelationSetPermissions, ""),
+ },
+ },
}
func TranslateToCheckRequest(namespace, action, kind, name string) (*authlib.CheckRequest, bool) {
diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go
index db5ea776fe67a..bd41582c15684 100644
--- a/pkg/services/authz/zanzana/common/tuple.go
+++ b/pkg/services/authz/zanzana/common/tuple.go
@@ -9,6 +9,7 @@ import (
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1"
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1"
+ iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
@@ -40,6 +41,12 @@ const (
KindFolders string = folderV1.RESOURCE
)
+var (
+ KindTeams string = iamv0.TeamKind().GroupVersionResource().Resource
+ KindUsers string = iamv0.UserKind().GroupVersionResource().Resource
+ KindServiceAccounts string = iamv0.ServiceAccountKind().GroupVersionResource().Resource
+)
+
const (
RelationTeamMember string = "member"
RelationTeamAdmin string = "admin"
@@ -269,19 +276,32 @@ func NewObjectEntry(objectType, group, resource, subresource, name string) strin
return obj
}
-func TranslateToResourceTuple(subject string, action, kind, name string) (*openfgav1.TupleKey, bool) {
- translation, ok := resourceTranslations[kind]
-
- if !ok {
- return nil, false
+// lookupActionMapping finds the translation and action mapping for the given kind and action.
+// When kind is empty (unscoped permission), it searches all translations for a skipScope match.
+func lookupActionMapping(kind, action string) (resourceTranslation, actionMapping, bool) {
+ if kind != "" {
+ translation, ok := resourceTranslations[kind]
+ if !ok {
+ return resourceTranslation{}, actionMapping{}, false
+ }
+ m, ok := translation.mapping[action]
+ return translation, m, ok
+ }
+ for _, translation := range resourceTranslations {
+ if m, ok := translation.mapping[action]; ok && m.skipScope {
+ return translation, m, true
+ }
}
+ return resourceTranslation{}, actionMapping{}, false
+}
- m, ok := translation.mapping[action]
+func TranslateToResourceTuple(subject string, action, kind, name string) (*openfgav1.TupleKey, bool) {
+ translation, m, ok := lookupActionMapping(kind, action)
if !ok {
return nil, false
}
- if name == "*" {
+ if m.skipScope || name == "*" {
if m.group != "" && m.resource != "" {
return NewGroupResourceTuple(subject, m.relation, m.group, m.resource, m.subresource), true
}
diff --git a/pkg/services/authz/zanzana/tuple_helpers.go b/pkg/services/authz/zanzana/tuple_helpers.go
index 300727f5c5d4e..76c57459f6a70 100644
--- a/pkg/services/authz/zanzana/tuple_helpers.go
+++ b/pkg/services/authz/zanzana/tuple_helpers.go
@@ -53,14 +53,6 @@ const (
// Team-management actions. Hardcoded for the same reason as the user-management
// actions above (the enterprise-only teams.roles:* actions live in pkg/extensions).
const (
- actionTeamsCreate = "teams:create"
- actionTeamsRead = "teams:read"
- actionTeamsWrite = "teams:write"
- actionTeamsDelete = "teams:delete"
-
- actionTeamsPermissionsRead = "teams.permissions:read"
- actionTeamsPermissionsWrite = "teams.permissions:write"
-
actionTeamsRolesRead = "teams.roles:read"
actionTeamsRolesAdd = "teams.roles:add"
actionTeamsRolesRemove = "teams.roles:remove"
@@ -70,7 +62,6 @@ const (
var (
iamGroup = iamv0.UserResourceInfo.GroupResource().Group
usersResource = iamv0.UserResourceInfo.GroupResource().Resource
- teamsResource = iamv0.TeamResourceInfo.GroupResource().Resource
roleBindingsResource = iamv0.RoleBindingInfo.GroupResource().Resource
)
@@ -117,20 +108,12 @@ var userManagementMappings = map[string]iamActionMapping{
actionUsersRolesRemove: {resource: roleBindingsResource, relations: []string{RelationDelete}},
}
-// teamManagementMappings maps each team-management action to the iam group_resource
+// teamRoleBindingMappings maps each team-management action to the iam group_resource
// relation(s) it grants, inverting the iam "teams" and "rolebindings" mapper entries.
// Core team actions gate iam.grafana.app/teams; teams.roles:* gate
// iam.grafana.app/rolebindings (the same resource user role-bindings gate — team and
// user role-assignments are not distinguished at the group_resource level).
-var teamManagementMappings = map[string]iamActionMapping{
- actionTeamsRead: {resource: teamsResource, relations: []string{RelationGet}},
- actionTeamsWrite: {resource: teamsResource, relations: []string{RelationUpdate}},
- actionTeamsCreate: {resource: teamsResource, relations: []string{RelationCreate}, skipScope: true},
- actionTeamsDelete: {resource: teamsResource, relations: []string{RelationDelete}},
-
- actionTeamsPermissionsRead: {resource: teamsResource, relations: []string{RelationGetPermissions}},
- actionTeamsPermissionsWrite: {resource: teamsResource, relations: []string{RelationSetPermissions}},
-
+var teamRoleBindingMappings = map[string]iamActionMapping{
actionTeamsRolesRead: {resource: roleBindingsResource, relations: []string{RelationGet}},
actionTeamsRolesAdd: {resource: roleBindingsResource, relations: []string{RelationCreate, RelationUpdate}},
actionTeamsRolesRemove: {resource: roleBindingsResource, relations: []string{RelationDelete}},
@@ -214,7 +197,7 @@ func ConvertRolePermissionsToTuples(roleUID string, permissions []RolePermission
// Team-management actions map onto iam group_resources via a dedicated path,
// like the user-management actions above (see TeamManagementToTuples).
if isTeamManagementAction(perm.Action) {
- for _, t := range TeamManagementToTuples(subject, perm) {
+ for _, t := range TeamRoleBindingManagementToTuples(subject, perm) {
tupleMap[t.String()] = t
}
continue
@@ -405,17 +388,16 @@ func isAllUsersScope(kind, identifier string) bool {
return kind == scopeKindPermissions && identifier == scopeIdentifierDelegate
}
-// TeamManagementToTuples translates a team-management permission into iam tuples.
-// Only "all" scopes translate (see isAllTeamsScope); specific-instance scopes are
-// dropped, since the FGA teams type is uid-based while the legacy scope is id-based
-// and rolebindings is wildcard-only. teams:create is unscoped and always translates.
-func TeamManagementToTuples(subject string, permission RolePermission) []*openfgav1.TupleKey {
- m, ok := teamManagementMappings[permission.Action]
+// TeamRoleBindingManagementToTuples translates teams.roles:* actions into iam
+// group_resource tuples. Only wildcard scopes translate; specific-instance scopes
+// are dropped since rolebindings have no per-instance FGA type.
+func TeamRoleBindingManagementToTuples(subject string, permission RolePermission) []*openfgav1.TupleKey {
+ m, ok := teamRoleBindingMappings[permission.Action]
if !ok {
return nil
}
- if !m.skipScope && !isAllTeamsScope(permission.Kind, permission.Identifier) {
+ if !isAllTeamsScope(permission.Kind, permission.Identifier) {
return nil
}
@@ -429,7 +411,7 @@ func TeamManagementToTuples(subject string, permission RolePermission) []*openfg
// isTeamManagementAction reports whether the action is one of the team-management
// actions handled by TeamManagementToTuples.
func isTeamManagementAction(action string) bool {
- _, ok := teamManagementMappings[action]
+ _, ok := teamRoleBindingMappings[action]
return ok
}
diff --git a/pkg/services/authz/zanzana/tuple_helpers_test.go b/pkg/services/authz/zanzana/tuple_helpers_test.go
index 6b68c2acf0692..50a3377b0c9bd 100644
--- a/pkg/services/authz/zanzana/tuple_helpers_test.go
+++ b/pkg/services/authz/zanzana/tuple_helpers_test.go
@@ -359,20 +359,24 @@ func TestConvertRolePermissionsToTuples(t *testing.T) {
}), tupleKeyStrings(tuples))
})
- t.Run("scoped team-management permissions are dropped", func(t *testing.T) {
+ t.Run("scoped team-management permissions are translated to team tuples", func(t *testing.T) {
// Specific-team scopes (teams:id:<n>) cannot be expressed: the FGA teams
// type is uid-based while the legacy scope is id-based, so they are dropped.
// teams:create is exempt because the mapper authorizes it without a scope.
permissions := []RolePermission{
- {Action: "teams:read", Kind: "teams", Identifier: "5"},
- {Action: "teams:write", Kind: "teams", Identifier: "5"},
- {Action: "teams.permissions:write", Kind: "teams", Identifier: "5"},
- {Action: "teams.roles:read", Kind: "teams", Identifier: "5"},
+ {Action: "teams:read", Kind: "teams", Identifier: "t5"},
+ {Action: "teams:write", Kind: "teams", Identifier: "t5"},
+ {Action: "teams.permissions:write", Kind: "teams", Identifier: "t5"},
}
tuples, err := ConvertRolePermissionsToTuples("role-team-scoped", permissions)
require.NoError(t, err)
- require.Empty(t, tuples)
+
+ require.ElementsMatch(t, tupleKeyStrings([]*openfgav1.TupleKey{
+ {User: "role:role-team-scoped#assignee", Relation: "get", Object: "team:t5"},
+ {User: "role:role-team-scoped#assignee", Relation: "update", Object: "team:t5"},
+ {User: "role:role-team-scoped#assignee", Relation: "set_permissions", Object: "team:t5"},
+ }), tupleKeyStrings(tuples))
})
}
@@ -459,9 +463,8 @@ func TestUserManagementToTuples(t *testing.T) {
})
}
-func TestTeamManagementToTuples(t *testing.T) {
+func TestTeamRoleBindingManagementToTuples(t *testing.T) {
const subject = "role:role-1#assignee"
- const teamsObject = "group_resource:iam.grafana.app/teams"
const roleBindingsObject = "group_resource:iam.grafana.app/rolebindings"
t.Run("maps each action to its tuples under an all-scope", func(t *testing.T) {
@@ -471,26 +474,6 @@ func TestTeamManagementToTuples(t *testing.T) {
identifier string
expected []*openfgav1.TupleKey
}{
- // teams:create carries no scope (skipScope) and always translates.
- {"teams:create", "", "", []*openfgav1.TupleKey{
- {User: subject, Relation: "create", Object: teamsObject},
- }},
- {"teams:read", "teams", "*", []*openfgav1.TupleKey{
- {User: subject, Relation: "get", Object: teamsObject},
- }},
- {"teams:write", "teams", "*", []*openfgav1.TupleKey{
- {User: subject, Relation: "update", Object: teamsObject},
- }},
- {"teams:delete", "teams", "*", []*openfgav1.TupleKey{
- {User: subject, Relation: "delete", Object: teamsObject},
- }},
- {"teams.permissions:read", "teams", "*", []*openfgav1.TupleKey{
- {User: subject, Relation: "get_permissions", Object: teamsObject},
- }},
- {"teams.permissions:write", "teams", "*", []*openfgav1.TupleKey{
- {User: subject, Relation: "set_permissions", Object: teamsObject},
- }},
- // teams.roles:* gate rolebindings (same object as users.roles:*).
{"teams.roles:read", "teams", "*", []*openfgav1.TupleKey{
{User: subject, Relation: "get", Object: roleBindingsObject},
}},
@@ -505,7 +488,7 @@ func TestTeamManagementToTuples(t *testing.T) {
for _, tc := range cases {
t.Run(tc.action, func(t *testing.T) {
- tuples := TeamManagementToTuples(subject, RolePermission{
+ tuples := TeamRoleBindingManagementToTuples(subject, RolePermission{
Action: tc.action, Kind: tc.kind, Identifier: tc.identifier,
})
require.ElementsMatch(t, tupleKeyStrings(tc.expected), tupleKeyStrings(tuples))
@@ -513,19 +496,15 @@ func TestTeamManagementToTuples(t *testing.T) {
}
})
- t.Run("drops specific-instance scopes (except create)", func(t *testing.T) {
- require.Nil(t, TeamManagementToTuples(subject, RolePermission{Action: "teams:read", Kind: "teams", Identifier: "5"}))
- require.Nil(t, TeamManagementToTuples(subject, RolePermission{Action: "teams.roles:add", Kind: "teams", Identifier: "5"}))
-
- require.ElementsMatch(t,
- tupleKeyStrings([]*openfgav1.TupleKey{{User: subject, Relation: "create", Object: teamsObject}}),
- tupleKeyStrings(TeamManagementToTuples(subject, RolePermission{Action: "teams:create", Kind: "teams", Identifier: "5"})),
- )
+ t.Run("drops instance specific rolebinding permissions", func(t *testing.T) {
+ require.Nil(t, TeamRoleBindingManagementToTuples(subject, RolePermission{Action: "teams.roles:add", Kind: "teams", Identifier: "t5"}))
+ require.Nil(t, TeamRoleBindingManagementToTuples(subject, RolePermission{Action: "teams.roles:remove", Kind: "teams", Identifier: "t5"}))
+ require.Nil(t, TeamRoleBindingManagementToTuples(subject, RolePermission{Act
... [truncated]