From d216d75fbb14cc9e3d0665f7e83e50c47380db5a Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 27 Oct 2025 18:20:59 +0100 Subject: [PATCH] Access: Add CoreRole/Role Delete/Update hooks for OpenFGA (#112839) * Add delete and update hooks for roles/core roles no need to capture non reference types small cleanup on vars * fix ticket priming in hooks * fix ticket priming in hooks * Revert "fix ticket priming in hooks" This reverts commit f8e953ca09cd0356f1822effdb3ccbe4ec6d2f6f. * use old testing blocks * protect runtime obj in go func * update test for correctness * separate files for test correctness. fix leaking goroutines in go tests * go workspace fixes * attribute owner * clean up go mod --- apps/iam/go.sum | 2 + go.mod | 1 + go.sum | 2 + pkg/extensions/enterprise_imports.go | 1 + pkg/registry/apis/iam/register.go | 8 +- ...{hooks.go => resource_permission_hooks.go} | 128 +-- ...t.go => resource_permission_hooks_test.go} | 380 +------ pkg/registry/apis/iam/role_hooks.go | 413 ++++++++ pkg/registry/apis/iam/role_hooks_test.go | 952 ++++++++++++++++++ 9 files changed, 1428 insertions(+), 459 deletions(-) rename pkg/registry/apis/iam/{hooks.go => resource_permission_hooks.go} (78%) rename pkg/registry/apis/iam/{hooks_test.go => resource_permission_hooks_test.go} (56%) create mode 100644 pkg/registry/apis/iam/role_hooks.go create mode 100644 pkg/registry/apis/iam/role_hooks_test.go diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 156abe868d7..812bacc1933 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -860,6 +860,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= +github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= diff --git a/go.mod b/go.mod index 4d7ec58ef44..aa63907b29f 100644 --- a/go.mod +++ b/go.mod @@ -239,6 +239,7 @@ require ( github.com/grafana/grafana/apps/alerting/rules v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/correlations v0.0.0 // @grafana/datapro github.com/grafana/grafana/apps/dashboard v0.0.0 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad + github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage github.com/grafana/grafana/apps/iam v0.0.0 // @grafana/identity-access-team github.com/grafana/grafana/apps/investigations v0.0.0 // @fcjack @matryer diff --git a/go.sum b/go.sum index ccf6b889d50..c37d8583801 100644 --- a/go.sum +++ b/go.sum @@ -1641,6 +1641,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= +github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 2dbdad4a9d6..9c052688d8f 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -57,4 +57,5 @@ import ( _ "github.com/grafana/tempo/pkg/traceql" _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" + _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" ) diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 1d1e74730f4..8c328e9963d 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -277,8 +277,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } if enableZanzanaSync { - b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana") + b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") coreRoleStore.AfterCreate = b.AfterRoleCreate + coreRoleStore.AfterDelete = b.AfterRoleDelete + coreRoleStore.BeginUpdate = b.BeginRoleUpdate } storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore @@ -287,8 +289,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } if enableZanzanaSync { - b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana") + b.logger.Info("Enabling hooks for Role to sync to Zanzana") roleStore.AfterCreate = b.AfterRoleCreate + roleStore.AfterDelete = b.AfterRoleDelete + roleStore.BeginUpdate = b.BeginRoleUpdate } storage[iamv0.RoleInfo.StoragePath()] = roleStore diff --git a/pkg/registry/apis/iam/hooks.go b/pkg/registry/apis/iam/resource_permission_hooks.go similarity index 78% rename from pkg/registry/apis/iam/hooks.go rename to pkg/registry/apis/iam/resource_permission_hooks.go index 42b2ffe604b..1de5bb9f110 100644 --- a/pkg/registry/apis/iam/hooks.go +++ b/pkg/registry/apis/iam/resource_permission_hooks.go @@ -13,10 +13,8 @@ import ( "k8s.io/apiserver/pkg/registry/generic/registry" 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 ( @@ -37,7 +35,7 @@ func toZanzanaSubject(kind iamv0.ResourcePermissionSpecPermissionKind, name stri case iamv0.ResourcePermissionSpecPermissionKindServiceAccount: return zanzana.NewTupleEntry(zanzana.TypeServiceAccount, name, ""), nil case iamv0.ResourcePermissionSpecPermissionKindTeam: - return zanzana.NewTupleEntry(zanzana.TypeTeam, name, ""), nil + return zanzana.NewTupleEntry(zanzana.TypeTeam, name, zanzana.RelationTeamMember), nil case iamv0.ResourcePermissionSpecPermissionKindBasicRole: basicRole := zanzana.TranslateBasicRole(name) if basicRole == "" { @@ -442,127 +440,3 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionDelete(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.WithLabelValues("role", "create").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), - ) - } - }() -} diff --git a/pkg/registry/apis/iam/hooks_test.go b/pkg/registry/apis/iam/resource_permission_hooks_test.go similarity index 56% rename from pkg/registry/apis/iam/hooks_test.go rename to pkg/registry/apis/iam/resource_permission_hooks_test.go index c19e45ce0d9..ff696020fc1 100644 --- a/pkg/registry/apis/iam/hooks_test.go +++ b/pkg/registry/apis/iam/resource_permission_hooks_test.go @@ -2,6 +2,7 @@ package iam import ( "context" + "sync" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,11 +20,6 @@ type FakeZanzanaClient struct { readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error) } -// Write implements zanzana.Client. -func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error { - return f.writeCallback(ctx, req) -} - // Read implements zanzana.Client. func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1.ReadResponse, error) { if f.readCallback != nil { @@ -32,6 +28,11 @@ func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1. return &v1.ReadResponse{}, nil } +// Write implements zanzana.Client. +func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error { + return f.writeCallback(ctx, req) +} + func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) { t.Helper() for _, exp := range expected { @@ -50,16 +51,32 @@ func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.Tupl } } -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), +func requireDeleteTuplesMatch(t *testing.T, actual []*v1.TupleKeyWithoutCondition, expected []*v1.TupleKeyWithoutCondition, 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 + } } - t.Cleanup(func() { - <-b.zTickets - }) + if !found { + require.Fail(t, "Expected delete tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs) + } + } +} +func TestAfterResourcePermissionCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) { + wg.Add(1) folderPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "org-2", @@ -76,6 +93,7 @@ func TestAfterResourcePermissionCreate(t *testing.T) { } testFolderEntries := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() require.NotNil(t, req) require.NotNil(t, req.Writes) require.Len(t, req.Writes.TupleKeys, 2) @@ -92,17 +110,11 @@ func TestAfterResourcePermissionCreate(t *testing.T) { b.zClient = &FakeZanzanaClient{writeCallback: testFolderEntries} b.AfterResourcePermissionCreate(&folderPerm, nil) + wg.Wait() }) 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 - }) - + wg.Add(1) dashPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", @@ -119,6 +131,7 @@ func TestAfterResourcePermissionCreate(t *testing.T) { } testDashEntries := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() object := "resource:dashboard.grafana.app/dashboards/dash1" require.NotNil(t, req) @@ -134,7 +147,7 @@ func TestAfterResourcePermissionCreate(t *testing.T) { expectedTuples := []*v1.TupleKey{ {User: "service-account:sa1", Relation: "view", Object: object}, - {User: "team:team1", Relation: "edit", Object: object}, + {User: "team:team1#member", Relation: "edit", Object: object}, } requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples) @@ -144,15 +157,17 @@ func TestAfterResourcePermissionCreate(t *testing.T) { b.zClient = &FakeZanzanaClient{writeCallback: testDashEntries} b.AfterResourcePermissionCreate(&dashPerm, nil) }) + wg.Wait() } func TestBeginResourcePermissionUpdate(t *testing.T) { + var wg sync.WaitGroup b := &IdentityAccessManagementAPIBuilder{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } - t.Run("should update zanzana entries for folder resource permissions", func(t *testing.T) { + wg.Add(1) oldFolderPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "org-2", @@ -183,6 +198,7 @@ func TestBeginResourcePermissionUpdate(t *testing.T) { } testFolderWrite := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) @@ -218,10 +234,9 @@ func TestBeginResourcePermissionUpdate(t *testing.T) { finishFunc(context.Background(), true) }) - // Wait for the ticket to be released - <-b.zTickets - + wg.Wait() t.Run("should update zanzana entries for dashboard resource permissions", func(t *testing.T) { + wg.Add(1) oldDashPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", @@ -253,6 +268,7 @@ func TestBeginResourcePermissionUpdate(t *testing.T) { object := "resource:dashboard.grafana.app/dashboards/dash1" testDashWrite := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() require.NotNil(t, req) require.Equal(t, "default", req.Namespace) @@ -291,16 +307,18 @@ func TestBeginResourcePermissionUpdate(t *testing.T) { // Call the finish function with success=true to trigger the zanzana write finishFunc(context.Background(), true) + wg.Wait() }) } func TestAfterResourcePermissionDelete(t *testing.T) { + var wg sync.WaitGroup b := &IdentityAccessManagementAPIBuilder{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } - t.Run("should delete zanzana entries for folder resource permissions", func(t *testing.T) { + wg.Add(1) folderPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "org-2", @@ -317,6 +335,7 @@ func TestAfterResourcePermissionDelete(t *testing.T) { } testFolderDelete := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) @@ -340,12 +359,11 @@ func TestAfterResourcePermissionDelete(t *testing.T) { b.zClient = &FakeZanzanaClient{writeCallback: testFolderDelete} b.AfterResourcePermissionDelete(&folderPerm, nil) + wg.Wait() }) - // Wait for the ticket to be released - <-b.zTickets - t.Run("should delete zanzana entries for dashboard resource permissions", func(t *testing.T) { + wg.Add(1) dashPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", @@ -362,6 +380,7 @@ func TestAfterResourcePermissionDelete(t *testing.T) { } testDashDelete := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() object := "resource:dashboard.grafana.app/dashboards/dash1" require.NotNil(t, req) @@ -388,305 +407,6 @@ func TestAfterResourcePermissionDelete(t *testing.T) { b.zClient = &FakeZanzanaClient{writeCallback: testDashDelete} b.AfterResourcePermissionDelete(&dashPerm, nil) - }) - - // Wait for the ticket to be released - <-b.zTickets -} - -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) + wg.Wait() }) } diff --git a/pkg/registry/apis/iam/role_hooks.go b/pkg/registry/apis/iam/role_hooks.go new file mode 100644 index 00000000000..9c4271c94aa --- /dev/null +++ b/pkg/registry/apis/iam/role_hooks.go @@ -0,0 +1,413 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + 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" +) + +// 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 + } + + var rType string + var rt *iamv0.CoreRole + + if coreRole, ok := obj.(*iamv0.CoreRole); ok { + rt = coreRole.DeepCopy() + rType = "coreRole" + } else if regRole, ok := obj.(*iamv0.Role); ok { + regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions)) + for i, p := range regRole.Spec.Permissions { + regRolePermissions[i] = iamv0.CoreRolespecPermission(p) + } + rt = &iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: regRole.Name, + Namespace: regRole.Namespace, + }, + Spec: iamv0.CoreRoleSpec{ + Permissions: regRolePermissions, + }, + } + rType = "role" + } else { + // Not a supported role type + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(rType, "create").Observe(time.Since(wait).Seconds()) + + go func(role *iamv0.CoreRole, roleType string) { + start := time.Now() + status := "success" + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues(rType, "create", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(rType, "create", status).Inc() + }() + + tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions) + if err != nil { + b.logger.Error("failed to convert role permissions to tuples", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "err", err, + "permissionsCnt", len(role.Spec.Permissions), + ) + status = "failure" + return + } + + // Avoid writing if there are no valid tuples + if len(tuples) == 0 { + b.logger.Debug("no valid tuples to write for role", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "permissionsCnt", len(role.Spec.Permissions), + ) + status = "failure" + return + } + + b.logger.Debug("writing role permissions to zanzana", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "tuplesCnt", len(tuples), + "permissionsCnt", len(role.Spec.Permissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: role.Namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: tuples, + }, + }) + if err != nil { + b.logger.Error("failed to write role permissions to zanzana", + "err", err, + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "tuplesCnt", len(tuples), + ) + status = "failure" + return + } + + // Record successful tuple writes + hooksTuplesCounter.WithLabelValues(rType, "create", "write").Add(float64(len(tuples))) + }(rt.DeepCopy(), rType) +} + +// AfterRoleDelete is a post-delete hook that removes the role permissions from Zanzana (openFGA) +// It handles both Role and CoreRole types +func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + var rType string + var rt *iamv0.CoreRole + + // Try CoreRole first + if coreRole, ok := obj.(*iamv0.CoreRole); ok { + rt = coreRole.DeepCopy() + rType = "coreRole" + } else if regRole, ok := obj.(*iamv0.Role); ok { + regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions)) + for i, p := range regRole.Spec.Permissions { + regRolePermissions[i] = iamv0.CoreRolespecPermission(p) + } + rt = &iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: regRole.Name, + Namespace: regRole.Namespace, + }, + Spec: iamv0.CoreRoleSpec{ + Permissions: regRolePermissions, + }, + } + rType = "role" + } else { + // Not a supported role type + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time + + go func(role *iamv0.CoreRole, roleType string) { + defer func() { + <-b.zTickets + }() + + b.logger.Debug("deleting role permissions from zanzana", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "permissionsCnt", len(role.Spec.Permissions), + ) + + tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions) + if err != nil { + b.logger.Error("failed to convert role permissions to tuples for deletion", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "err", err, + "permissionsCnt", len(role.Spec.Permissions), + ) + return + } + + // Avoid deleting if there are no valid tuples + if len(tuples) == 0 { + b.logger.Debug("no valid tuples to delete for role", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "permissionsCnt", len(role.Spec.Permissions), + ) + return + } + + // Convert tuples to TupleKeyWithoutCondition for deletion + deleteTuples := toTupleKeysWithoutCondition(tuples) + + b.logger.Debug("deleting role permissions from zanzana", + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "tuplesCnt", len(deleteTuples), + "permissionsCnt", len(role.Spec.Permissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: role.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + }, + }) + if err != nil { + b.logger.Error("failed to delete role permissions from zanzana", + "err", err, + "namespace", role.Namespace, + "roleUID", role.Name, + "roleType", roleType, + "tuplesCnt", len(deleteTuples), + ) + } + }(rt.DeepCopy(), rType) +} + +// beginRoleUpdate is a pre-update hook that prepares zanzana updates +// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds +// It handles both Role and CoreRole types +func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + var oldRole, newRole *iamv0.CoreRole + var roleType string + + if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok { // Try CoreRole first + oldRole = oldCoreRole.DeepCopy() + newCoreRole, ok := obj.(*iamv0.CoreRole) + if !ok { + return nil, nil + } + newRole = newCoreRole.DeepCopy() + roleType = "coreRole" + } else if oldRegRole, ok := oldObj.(*iamv0.Role); ok { // Try Role + oldRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(oldRegRole.Spec.Permissions)) + for i, p := range oldRegRole.Spec.Permissions { + oldRegRolePermissions[i] = iamv0.CoreRolespecPermission(p) + } + oldRole = &iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: oldRegRole.Name, + Namespace: oldRegRole.Namespace, + }, + Spec: iamv0.CoreRoleSpec{ + Permissions: oldRegRolePermissions, + }, + } + newRegRole, ok := obj.(*iamv0.Role) + if !ok { + return nil, nil + } + newRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(newRegRole.Spec.Permissions)) + for i, p := range newRegRole.Spec.Permissions { + newRegRolePermissions[i] = iamv0.CoreRolespecPermission(p) + } + newRole = &iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: newRegRole.Name, + Namespace: newRegRole.Namespace, + }, + Spec: iamv0.CoreRoleSpec{ + Permissions: newRegRolePermissions, + }, + } + roleType = "role" + } else { + // Not a supported role type + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + // Update failed, don't write to zanzana + return + } + + // Grab a ticket to write to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time + + go func(old *iamv0.CoreRole, new *iamv0.CoreRole) { + defer func() { + <-b.zTickets + }() + roleUID, namespace := old.Name, old.Namespace + oldPermissions, newPermissions := old.Spec.Permissions, new.Spec.Permissions + + // Convert old permissions to tuples for deletion + var oldTuples []*v1.TupleKey + if len(oldPermissions) > 0 { + var err error + oldTuples, err = convertRolePermissionsToTuples(roleUID, oldPermissions) + if err != nil { + b.logger.Error("failed to convert old role permissions to tuples", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "err", err, + ) + } + } + + // Convert new permissions to tuples for writing + newTuples, err := convertRolePermissionsToTuples(roleUID, newPermissions) + if err != nil { + b.logger.Error("failed to convert new role permissions to tuples", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "err", err, + ) + return + } + + b.logger.Debug("updating role permissions in zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "oldPermissionsCnt", len(oldPermissions), + "newPermissionsCnt", len(newPermissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + // Prepare write request + req := &v1.WriteRequest{ + Namespace: namespace, + } + + // Add deletes for old tuples + if len(oldTuples) > 0 { + deleteTuples := toTupleKeysWithoutCondition(oldTuples) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + } + b.logger.Debug("deleting existing role permissions from zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(deleteTuples), + ) + } + + // Add writes for new tuples + if len(newTuples) > 0 { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: newTuples, + } + b.logger.Debug("writing new role permissions to zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(newTuples), + ) + } + + // Only make the request if there are deletes or writes + if req.Deletes != nil || req.Writes != nil { + err = b.zClient.Write(ctx, req) + if err != nil { + b.logger.Error("failed to update role permissions in zanzana", + "err", err, + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + ) + } + } + }(oldRole.DeepCopy(), newRole.DeepCopy()) + }, nil +} diff --git a/pkg/registry/apis/iam/role_hooks_test.go b/pkg/registry/apis/iam/role_hooks_test.go new file mode 100644 index 00000000000..282576b6ec7 --- /dev/null +++ b/pkg/registry/apis/iam/role_hooks_test.go @@ -0,0 +1,952 @@ +package iam + +import ( + "context" + "sync" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/stretchr/testify/require" +) + +func TestAfterCoreRoleCreate(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) + + t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) + + t.Run("should handle wildcard scopes", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) + + t.Run("should skip untranslatable permissions", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) +} + +func TestAfterRoleCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) + + t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) + + t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) { + wg.Add(1) + 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 { + defer wg.Done() + 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) + wg.Wait() + }) +} + +func TestBeginCoreRoleUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should update zanzana entries when permissions change", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-role-uid", + Namespace: "org-1", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Test Role", + Permissions: []iamv0.CoreRolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + {Action: "folders:write", Scope: "folders:uid:folder1"}, + }, + }, + } + + newRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-role-uid", + Namespace: "org-1", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Test Role Updated", + Permissions: []iamv0.CoreRolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder2"}, + {Action: "folders:delete", Scope: "folders:uid:folder2"}, + }, + }, + } + + testUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Verify deletes (old permissions) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + + expectedDeletes := []*v1.TupleKeyWithoutCondition{ + {User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"}, + {User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"}, + } + requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes) + + // Verify writes (new permissions) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 2) + + expectedWrites := []*v1.TupleKey{ + {User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder2"}, + {User: "role:test-role-uid#assignee", Relation: "delete", Object: "folder:folder2"}, + } + requireTuplesMatch(t, req.Writes.TupleKeys, expectedWrites) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testUpdate} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should handle adding new permissions", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expand-role-uid", + Namespace: "org-2", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Expand Role", + Permissions: []iamv0.CoreRolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + }, + }, + } + + newRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expand-role-uid", + Namespace: "org-2", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Expand Role", + Permissions: []iamv0.CoreRolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + {Action: "folders:write", Scope: "folders:uid:folder1"}, + {Action: "folders:delete", Scope: "folders:uid:folder1"}, + }, + }, + } + + testExpand := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should delete old permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + + // Should write all new permissions + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 3) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testExpand} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should handle removing all permissions", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clear-role-uid", + Namespace: "org-3", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Clear Role", + Permissions: []iamv0.CoreRolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + {Action: "folders:write", Scope: "folders:uid:folder1"}, + }, + }, + } + + newRole := iamv0.CoreRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clear-role-uid", + Namespace: "org-3", + }, + Spec: iamv0.CoreRoleSpec{ + Title: "Clear Role", + Permissions: []iamv0.CoreRolespecPermission{}, + }, + } + + testClear := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + // Should delete old permissions + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + + // Should have no writes + require.Nil(t, req.Writes) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testClear} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) +} + +func TestBeginRoleUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should update zanzana entries when permissions change", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "custom-role-uid", + Namespace: "org-1", + }, + Spec: iamv0.RoleSpec{ + Title: "Custom Role", + Permissions: []iamv0.RolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + {Action: "folders:write", Scope: "folders:uid:folder1"}, + }, + }, + } + + newRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "custom-role-uid", + Namespace: "org-1", + }, + Spec: iamv0.RoleSpec{ + Title: "Custom Role Updated", + Permissions: []iamv0.RolespecPermission{ + {Action: "dashboards:read", Scope: "dashboards:uid:dash1"}, + {Action: "dashboards:write", Scope: "dashboards:uid:dash1"}, + }, + }, + } + + testUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Verify deletes (old permissions) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + + expectedDeletes := []*v1.TupleKeyWithoutCondition{ + {User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder1"}, + {User: "role:custom-role-uid#assignee", Relation: "update", Object: "folder:folder1"}, + } + requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes) + + // Verify writes (new permissions) - dashboards use resource type + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 2) + + // All writes should be for dashboards + for _, tuple := range req.Writes.TupleKeys { + require.Equal(t, "role:custom-role-uid#assignee", tuple.User) + require.Contains(t, tuple.Object, "resource:") + require.Contains(t, tuple.Object, "dashboard") + } + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testUpdate} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should handle completely new permission set", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "swap-role-uid", + Namespace: "default", + }, + Spec: iamv0.RoleSpec{ + Title: "Swap Role", + Permissions: []iamv0.RolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + }, + }, + } + + newRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "swap-role-uid", + Namespace: "default", + }, + Spec: iamv0.RoleSpec{ + Title: "Swap Role", + Permissions: []iamv0.RolespecPermission{ + {Action: "folders:write", Scope: "folders:uid:folder2"}, + {Action: "folders:delete", Scope: "folders:uid:folder2"}, + }, + }, + } + + testSwap := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "default", req.Namespace) + + // Should delete old permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal(t, "role:swap-role-uid#assignee", req.Deletes.TupleKeys[0].User) + require.Equal(t, "folder:folder1", req.Deletes.TupleKeys[0].Object) + + // Should write new permissions + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 2) + for _, tuple := range req.Writes.TupleKeys { + require.Equal(t, "role:swap-role-uid#assignee", tuple.User) + require.Equal(t, "folder:folder2", tuple.Object) + } + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testSwap} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should handle adding permissions to empty role", func(t *testing.T) { + wg.Add(1) + oldRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-role-uid", + Namespace: "org-2", + }, + Spec: iamv0.RoleSpec{ + Title: "Empty Role", + Permissions: []iamv0.RolespecPermission{}, + }, + } + + newRole := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-role-uid", + Namespace: "org-2", + }, + Spec: iamv0.RoleSpec{ + Title: "Empty Role", + Permissions: []iamv0.RolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + }, + }, + } + + testAddToEmpty := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should have no deletes + require.Nil(t, req.Deletes) + + // Should write new permission + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "role:empty-role-uid#assignee", req.Writes.TupleKeys[0].User) + require.Equal(t, "folder:folder1", req.Writes.TupleKeys[0].Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + wg.Wait() + }) +} + +func TestAfterCoreRoleDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should delete zanzana entries for core role with folder permissions", func(t *testing.T) { + wg.Add(1) + 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"}, + }, + }, + } + + testCoreRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Equal(t, "org-1", req.Namespace) + + expectedDeletes := []*v1.TupleKeyWithoutCondition{ + {User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"}, + {User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"}, + } + + requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes} + b.AfterRoleDelete(&coreRole, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entries for core role with dashboard permissions", func(t *testing.T) { + wg.Add(1) + 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"}, + }, + }, + } + + testDashboardRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Equal(t, "default", req.Namespace) + + // Check all deletes have the correct subject + for _, tuple := range req.Deletes.TupleKeys { + require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User) + require.Contains(t, tuple.Object, "resource:") + } + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes} + b.AfterRoleDelete(&coreRole, nil) + wg.Wait() + }) + + t.Run("should handle wildcard scopes on delete", func(t *testing.T) { + wg.Add(1) + 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:*"}, + }, + }, + } + + testWildcardDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + + tuple := req.Deletes.TupleKeys[0] + require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User) + require.Contains(t, tuple.Object, "group_resource:") + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes} + b.AfterRoleDelete(&coreRole, nil) + wg.Wait() + }) + + t.Run("should skip untranslatable permissions on delete", func(t *testing.T) { + wg.Add(1) + 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 + }, + }, + } + + testMixedDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + // Should only delete 1 tuple (the untranslatable one should be skipped) + require.Len(t, req.Deletes.TupleKeys, 1) + + tuple := req.Deletes.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: testMixedDeletes} + b.AfterRoleDelete(&coreRole, nil) + wg.Wait() + }) +} + +func TestAfterRoleDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Run("should delete zanzana entries for role with folder permissions", func(t *testing.T) { + wg.Add(1) + 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"}, + }, + }, + } + + testRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Equal(t, "org-3", req.Namespace) + + expectedDeletes := []*v1.TupleKeyWithoutCondition{ + {User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"}, + {User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"}, + } + + requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes} + b.AfterRoleDelete(&role, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entries for role with dashboard permissions", func(t *testing.T) { + wg.Add(1) + 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"}, + }, + }, + } + + testDashRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Equal(t, "default", req.Namespace) + + // Check all deletes have the correct subject + for _, tuple := range req.Deletes.TupleKeys { + require.Equal(t, "role:dash-role-uid#assignee", tuple.User) + require.Contains(t, tuple.Object, "resource:") + } + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes} + b.AfterRoleDelete(&role, nil) + wg.Wait() + }) + + t.Run("should handle multiple permissions on delete", func(t *testing.T) { + wg.Add(1) + role := iamv0.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-role-uid", + Namespace: "org-1", + }, + Spec: iamv0.RoleSpec{ + Title: "Multi Permission Role", + Permissions: []iamv0.RolespecPermission{ + {Action: "folders:read", Scope: "folders:uid:folder1"}, + {Action: "folders:write", Scope: "folders:uid:folder1"}, + {Action: "folders:delete", Scope: "folders:uid:folder1"}, + }, + }, + } + + testMultiDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 3) + + // All should be for the same role and folder + for _, tuple := range req.Deletes.TupleKeys { + require.Equal(t, "role:multi-role-uid#assignee", tuple.User) + require.Equal(t, "folder:folder1", tuple.Object) + } + + // Check all expected relations are present + relations := make(map[string]bool) + for _, tuple := range req.Deletes.TupleKeys { + relations[tuple.Relation] = true + } + require.True(t, relations["get"], "Expected 'get' relation") + require.True(t, relations["update"], "Expected 'update' relation") + require.True(t, relations["delete"], "Expected 'delete' relation") + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes} + b.AfterRoleDelete(&role, nil) + wg.Wait() + }) +}