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)