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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user