Access: Add AfterCreate hooks for Roles/Core Roles (#112666)

As part of migrating Grafana's authorization system to Zanzana (OpenFGA), we need to ensure that role permissions defined in the IAM API are automatically synced to the authorization backend. Without this sync, roles created through the API would not be enforced by Zanzana, creating an inconsistency between defined permissions and actual authorization decisions.

This is a critical piece of the dual-write pattern during the migration to Zanzana, ensuring that:

    Role permissions are immediately available for authorization checks
    The legacy RBAC system and new Zanzana system remain in sync
    Users experience consistent permission enforcement regardless of which backend is queried

safe to revert
This commit is contained in:
Jo
2025-10-23 09:47:39 +02:00
committed by GitHub
parent cd961af818
commit 2e1704b56f
7 changed files with 1816 additions and 74 deletions
+131 -5
View File
@@ -12,8 +12,10 @@ import (
"k8s.io/apimachinery/pkg/runtime"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
var (
@@ -100,17 +102,17 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r
return
}
rp, ok := obj.(*iamv0.ResourcePermission)
if !ok {
return
}
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent writes to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.Observe(time.Since(wait).Seconds()) // Record wait time
rp, ok := obj.(*iamv0.ResourcePermission)
if !ok {
return
}
go func(rp *iamv0.ResourcePermission) {
defer func() {
// Release the ticket after write is done
@@ -168,3 +170,127 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r
}
}(rp.DeepCopy()) // Pass a copy of the object
}
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
// Convert IAM permissions to zanzana.RolePermission format
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
for _, perm := range permissions {
// Split the scope to get kind, attribute, identifier
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
rolePerms = append(rolePerms, zanzana.RolePermission{
Action: perm.Action,
Kind: kind,
Identifier: identifier,
})
}
// Translate to Zanzana tuples
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
if err != nil {
return nil, err
}
// Convert directly to v1 tuples using common utility
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
return v1Tuples, nil
}
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
// Extract permissions based on the object type
var roleUID, namespace string
var permissions []iamv0.CoreRolespecPermission
var roleType string
// Try CoreRole first
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
roleUID = coreRole.Name
namespace = coreRole.Namespace
// Deep copy permissions to avoid race conditions
permissions = make([]iamv0.CoreRolespecPermission, len(coreRole.Spec.Permissions))
copy(permissions, coreRole.Spec.Permissions)
roleType = "core role"
} else if role, ok := obj.(*iamv0.Role); ok {
// Try Role
roleUID = role.Name
namespace = role.Namespace
// Convert and copy permissions to avoid race conditions
permissions = make([]iamv0.CoreRolespecPermission, len(role.Spec.Permissions))
for i, p := range role.Spec.Permissions {
permissions[i] = iamv0.CoreRolespecPermission(p)
}
roleType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
go func() {
defer func() {
<-b.zTickets
}()
tuples, err := convertRolePermissionsToTuples(roleUID, permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
"permissionsCnt", len(permissions),
)
return
}
// Avoid writing if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to write for role",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"permissionsCnt", len(permissions),
)
return
}
b.logger.Debug("writing role permissions to zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(tuples),
"permissionsCnt", len(permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: tuples,
},
})
if err != nil {
b.logger.Error("failed to write role permissions to zanzana",
"err", err,
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(tuples),
)
}
}()
}
+348 -37
View File
@@ -23,14 +23,34 @@ func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) err
return f.writeCallback(ctx, req)
}
func TestAfterResourcePermissionCreate(t *testing.T) {
t.Skip("Need to fix its flaky behavior in CI")
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
t.Helper()
for _, exp := range expected {
found := false
for _, act := range actual {
if act.User == exp.User &&
act.Relation == exp.Relation &&
act.Object == exp.Object {
found = true
break
}
}
if !found {
require.Fail(t, "Expected tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
}
}
}
func TestAfterResourcePermissionCreate(t *testing.T) {
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
folderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
@@ -51,16 +71,13 @@ func TestAfterResourcePermissionCreate(t *testing.T) {
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-2", req.Namespace)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:u1", Relation: "view", Object: "folder:fold1"},
)
require.Equal(
t,
req.Writes.TupleKeys[1],
&v1.TupleKey{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"},
)
expectedTuples := []*v1.TupleKey{
{User: "user:u1", Relation: "view", Object: "folder:fold1"},
{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
@@ -68,10 +85,15 @@ func TestAfterResourcePermissionCreate(t *testing.T) {
b.AfterResourcePermissionCreate(&folderPerm, nil)
})
// Wait for the ticket to be released
<-b.zTickets
t.Run("should create zanzana entries for dashboard resource permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
dashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
@@ -95,26 +117,18 @@ func TestAfterResourcePermissionCreate(t *testing.T) {
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
tuple1 := req.Writes.TupleKeys[0]
require.NotNil(t, tuple1.Condition)
require.Equal(t, "group_filter", tuple1.Condition.Name)
tuple1.Condition = nil
require.Equal(
t,
tuple1,
&v1.TupleKey{User: "service-account:sa1", Relation: "view", Object: object},
)
// Verify all tuples have the group_filter condition
for _, tuple := range req.Writes.TupleKeys {
require.NotNil(t, tuple.Condition, "Condition should not be nil for tuple %+v", tuple)
require.Equal(t, "group_filter", tuple.Condition.Name)
}
tuple2 := req.Writes.TupleKeys[1]
require.NotNil(t, tuple2.Condition)
require.Equal(t, "group_filter", tuple2.Condition.Name)
tuple2.Condition = nil
require.Equal(
t,
tuple2,
&v1.TupleKey{User: "team:team1", Relation: "edit", Object: object},
)
expectedTuples := []*v1.TupleKey{
{User: "service-account:sa1", Relation: "view", Object: object},
{User: "team:team1", Relation: "edit", Object: object},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
@@ -122,3 +136,300 @@ func TestAfterResourcePermissionCreate(t *testing.T) {
b.AfterResourcePermissionCreate(&dashPerm, nil)
})
}
func TestAfterCoreRoleCreate(t *testing.T) {
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role",
Description: "Test role for folders",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
testCoreRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-1", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "dashboard-role-uid",
Namespace: "default",
},
Spec: iamv0.CoreRoleSpec{
Title: "Dashboard Role",
Description: "Test role for dashboards",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
},
},
}
testDashboardRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
require.Contains(t, tuple.Object, "dashboard")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should handle wildcard scopes", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "wildcard-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Wildcard Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:*"},
},
},
}
testWildcardEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
// Wildcard should create a group_resource tuple
require.Contains(t, tuple.Object, "group_resource:")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should skip untranslatable permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "mixed-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Mixed Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
},
},
}
testMixedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// Should only have 1 tuple (the untranslatable one should be skipped)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries}
b.AfterRoleCreate(&coreRole, nil)
})
}
func TestAfterRoleCreate(t *testing.T) {
t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-3",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role",
Description: "Custom role for folders",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-3", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries}
b.AfterRoleCreate(&role, nil)
})
t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "dash-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Dashboard Custom Role",
Description: "Custom role for dashboards",
Permissions: []iamv0.RolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
},
},
}
testDashRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries}
b.AfterRoleCreate(&role, nil)
})
t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "merge-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Merge Test Role",
Permissions: []iamv0.RolespecPermission{
// These should create folder resource tuples that get merged
{Action: "dashboards:read", Scope: "folders:uid:parent-folder"},
{Action: "dashboards:write", Scope: "folders:uid:parent-folder"},
},
},
}
testMergedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// After merging, we should have tuples for the folder resource actions
require.Greater(t, len(req.Writes.TupleKeys), 0)
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:merge-role-uid#assignee", tuple.User)
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries}
b.AfterRoleCreate(&role, nil)
})
}
+8
View File
@@ -263,12 +263,20 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
if err != nil {
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana")
coreRoleStore.AfterCreate = b.AfterRoleCreate
}
storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore
roleStore, err := NewLocalStore(iamv0.RoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.rolesStorage)
if err != nil {
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana")
roleStore.AfterCreate = b.AfterRoleCreate
}
storage[iamv0.RoleInfo.StoragePath()] = roleStore
roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage)
@@ -185,7 +185,7 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
// For resource actions on folders we need to merge the tuples into one with combined subresources.
if zanzana.IsFolderResourceTuple(tuple) {
key := tupleStringWithoutCondition(tuple)
key := zanzana.TupleStringWithoutCondition(tuple)
if t, ok := tuples[tuple.Object][key]; ok {
zanzana.MergeFolderResourceTuples(t, tuple)
} else {
@@ -202,14 +202,6 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
}
}
func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string {
c := tuple.Condition
tuple.Condition = nil
s := tuple.String()
tuple.Condition = c
return s
}
// basicRoleBindingsCollector collects role bindings for basic roles
func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
@@ -371,36 +363,41 @@ func rolePermissionsCollector(store db.DB) legacyTupleCollector {
return nil, err
}
// Group permissions by role UID
rolePermissionsMap := make(map[string][]zanzana.RolePermission)
for _, p := range permissions {
rolePermissionsMap[p.RoleUID] = append(rolePermissionsMap[p.RoleUID], zanzana.RolePermission{
Action: p.Action,
Kind: p.Kind,
Identifier: p.Identifier,
})
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, p := range permissions {
tuple, ok := zanzana.TranslateToResourceTuple(
zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee),
p.Action,
p.Kind,
p.Identifier,
)
if !ok {
// Convert permissions for each role using the shared utility
for roleUID, perms := range rolePermissionsMap {
roleTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, perms)
if err != nil {
reconcilerLogger.Warn("Failed to convert role permissions to tuples", "roleUID", roleUID, "err", err)
continue
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
// For resource actions on folders we need to merge the tuples into one with combined subresources.
if zanzana.IsFolderResourceTuple(tuple) {
key := tupleStringWithoutCondition(tuple)
if t, ok := tuples[tuple.Object][key]; ok {
zanzana.MergeFolderResourceTuples(t, tuple)
} else {
tuples[tuple.Object][key] = tuple
// Add tuples to the result map
for _, tuple := range roleTuples {
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
continue
// Use the appropriate key based on whether it's a folder resource tuple
var key string
if zanzana.IsFolderResourceTuple(tuple) {
key = zanzana.TupleStringWithoutCondition(tuple)
} else {
key = tuple.String()
}
tuples[tuple.Object][key] = tuple
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
@@ -482,7 +479,7 @@ func zanzanaCollector(relations []string) zanzanaTupleCollector {
}
for _, t := range tuples {
if zanzana.IsFolderResourceTuple(t.Key) {
out[tupleStringWithoutCondition(t.Key)] = t.Key
out[zanzana.TupleStringWithoutCondition(t.Key)] = t.Key
} else {
out[t.Key.String()] = t.Key
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
package zanzana
import (
"github.com/grafana/grafana/pkg/infra/log"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
// TupleStringWithoutCondition returns the string representation of a tuple without its condition.
// This is useful for deduplicating tuples that have the same user, relation, and object
// but different conditions that need to be merged.
func TupleStringWithoutCondition(tuple *openfgav1.TupleKey) string {
c := tuple.Condition
tuple.Condition = nil
s := tuple.String()
tuple.Condition = c
return s
}
// RolePermission represents a permission that can be converted to a Zanzana tuple.
type RolePermission struct {
Action string
Kind string
Identifier string
}
// ConvertRolePermissionsToTuples converts role permissions to Zanzana tuples with proper merging.
// It handles:
// - Translation of RBAC action/kind/identifier to Zanzana tuples
// - Special handling for folder resource tuples (which need to be merged)
// - Deduplication of tuples
//
// Returns a slice of tuples ready to be written to Zanzana, or nil if no valid tuples could be created.
func ConvertRolePermissionsToTuples(roleUID string, permissions []RolePermission) ([]*openfgav1.TupleKey, error) {
if len(permissions) == 0 {
return nil, nil
}
// Subject for role permissions: role:{uid}#assignee
subject := NewTupleEntry(TypeRole, roleUID, RelationAssignee)
// Use a map to track tuples, with special handling for folder resource tuples
tupleMap := make(map[string]*openfgav1.TupleKey)
folderResourceTuples := make(map[string]*openfgav1.TupleKey) // key is tuple without condition
for _, perm := range permissions {
// Convert RBAC action/kind to Zanzana tuple
tuple, ok := TranslateToResourceTuple(subject, perm.Action, perm.Kind, perm.Identifier)
if !ok {
// Skip permissions that can't be translated
log.New("zanzana").Debug("skipping permission that can't be translated", "permission", perm)
continue
}
// Handle folder resource tuples specially - they need to be merged
if IsFolderResourceTuple(tuple) {
// Create a key without the condition for deduplication
key := TupleStringWithoutCondition(tuple)
if existing, exists := folderResourceTuples[key]; exists {
// Merge this tuple with the existing one
MergeFolderResourceTuples(existing, tuple)
} else {
folderResourceTuples[key] = tuple
}
continue
}
// For non-folder resource tuples, just add to the map
tupleMap[tuple.String()] = tuple
}
// Collect all tuples
tuples := make([]*openfgav1.TupleKey, 0, len(tupleMap)+len(folderResourceTuples))
for _, t := range tupleMap {
tuples = append(tuples, t)
}
for _, t := range folderResourceTuples {
tuples = append(tuples, t)
}
return tuples, nil
}
@@ -0,0 +1,145 @@
package zanzana
import (
"testing"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestTupleStringWithoutCondition(t *testing.T) {
tuple := &openfgav1.TupleKey{
User: "user:123",
Relation: "view",
Object: "folder:abc",
Condition: &openfgav1.RelationshipCondition{
Name: "group_filter",
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"group_resource": structpb.NewStringValue("dashboards.grafana.app/dashboards"),
},
},
},
}
result := TupleStringWithoutCondition(tuple)
// Verify the condition is preserved
require.NotNil(t, tuple.Condition)
require.Equal(t, "group_filter", tuple.Condition.Name)
// Verify the string doesn't include the condition
require.NotContains(t, result, "group_filter")
require.Contains(t, result, "user:123")
require.Contains(t, result, "view")
require.Contains(t, result, "folder:abc")
}
func TestConvertRolePermissionsToTuples(t *testing.T) {
t.Run("should convert folder permissions", func(t *testing.T) {
permissions := []RolePermission{
{Action: "folders:read", Kind: "folders", Identifier: "folder1"},
{Action: "folders:write", Kind: "folders", Identifier: "folder1"},
}
tuples, err := ConvertRolePermissionsToTuples("role-123", permissions)
require.NoError(t, err)
require.Len(t, tuples, 2)
// Verify all tuples have the correct subject
for _, tuple := range tuples {
require.Equal(t, "role:role-123#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
}
// Verify relations are correct
relations := []string{tuples[0].Relation, tuples[1].Relation}
require.Contains(t, relations, "get")
require.Contains(t, relations, "update")
})
t.Run("should convert dashboard permissions", func(t *testing.T) {
permissions := []RolePermission{
{Action: "dashboards:read", Kind: "dashboards", Identifier: "dash1"},
{Action: "dashboards:delete", Kind: "dashboards", Identifier: "dash1"},
}
tuples, err := ConvertRolePermissionsToTuples("role-456", permissions)
require.NoError(t, err)
require.Len(t, tuples, 2)
// Verify all tuples have the correct subject
for _, tuple := range tuples {
require.Equal(t, "role:role-456#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
require.Contains(t, tuple.Object, "dashboard")
}
})
t.Run("should handle wildcard scopes", func(t *testing.T) {
permissions := []RolePermission{
{Action: "folders:read", Kind: "folders", Identifier: "*"},
}
tuples, err := ConvertRolePermissionsToTuples("role-789", permissions)
require.NoError(t, err)
require.Len(t, tuples, 1)
tuple := tuples[0]
require.Equal(t, "role:role-789#assignee", tuple.User)
require.Contains(t, tuple.Object, "group_resource:")
})
t.Run("should skip untranslatable permissions", func(t *testing.T) {
permissions := []RolePermission{
{Action: "folders:read", Kind: "folders", Identifier: "folder1"},
{Action: "unknown:action", Kind: "unknown", Identifier: "something"},
}
tuples, err := ConvertRolePermissionsToTuples("role-abc", permissions)
require.NoError(t, err)
// Only the valid permission should be converted
require.Len(t, tuples, 1)
require.Equal(t, "role:role-abc#assignee", tuples[0].User)
require.Equal(t, "folder:folder1", tuples[0].Object)
})
t.Run("should merge folder resource tuples", func(t *testing.T) {
permissions := []RolePermission{
{Action: "dashboards:read", Kind: "folders", Identifier: "parent-folder"},
{Action: "dashboards:write", Kind: "folders", Identifier: "parent-folder"},
}
tuples, err := ConvertRolePermissionsToTuples("role-merge", permissions)
require.NoError(t, err)
// Folder resource tuples should be merged
require.Greater(t, len(tuples), 0)
// All tuples should have the correct subject
for _, tuple := range tuples {
require.Equal(t, "role:role-merge#assignee", tuple.User)
}
})
t.Run("should return nil for empty permissions", func(t *testing.T) {
tuples, err := ConvertRolePermissionsToTuples("role-empty", []RolePermission{})
require.NoError(t, err)
require.Nil(t, tuples)
})
t.Run("should deduplicate identical tuples", func(t *testing.T) {
permissions := []RolePermission{
{Action: "folders:read", Kind: "folders", Identifier: "folder1"},
{Action: "folders:read", Kind: "folders", Identifier: "folder1"}, // Duplicate
}
tuples, err := ConvertRolePermissionsToTuples("role-dedup", permissions)
require.NoError(t, err)
// Should only have 1 tuple, not 2
require.Len(t, tuples, 1)
require.Equal(t, "role:role-dedup#assignee", tuples[0].User)
require.Equal(t, "get", tuples[0].Relation)
require.Equal(t, "folder:folder1", tuples[0].Object)
})
}