diff --git a/pkg/registry/apis/iam/hooks.go b/pkg/registry/apis/iam/hooks.go index aac644dcfc3..16dddb8ae1a 100644 --- a/pkg/registry/apis/iam/hooks.go +++ b/pkg/registry/apis/iam/hooks.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" 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" @@ -96,6 +97,20 @@ func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResour return key, nil } +// toTupleKeysWithoutCondition converts v1.TupleKey to v1.TupleKeyWithoutCondition +// by stripping the condition field, which is required for delete operations +func toTupleKeysWithoutCondition(tuples []*v1.TupleKey) []*v1.TupleKeyWithoutCondition { + result := make([]*v1.TupleKeyWithoutCondition, len(tuples)) + for i, t := range tuples { + result[i] = &v1.TupleKeyWithoutCondition{ + User: t.User, + Relation: t.Relation, + Object: t.Object, + } + } + return result +} + // AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA) func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) { if b.zClient == nil { @@ -217,7 +232,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, // Deep copy permissions to avoid race conditions permissions = make([]iamv0.CoreRolespecPermission, len(coreRole.Spec.Permissions)) copy(permissions, coreRole.Spec.Permissions) - roleType = "core role" + roleType = "coreRole" } else if role, ok := obj.(*iamv0.Role); ok { // Try Role roleUID = role.Name @@ -294,3 +309,266 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, } }() } + +// 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 + } + + // 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 + permissions = coreRole.Spec.Permissions + roleType = "coreRole" + } else if role, ok := obj.(*iamv0.Role); ok { + // Try Role + roleUID = role.Name + namespace = role.Namespace + + // Convert permissions + 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 + }() + + b.logger.Debug("deleting role permissions from zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "permissionsCnt", len(permissions), + ) + + tuples, err := convertRolePermissionsToTuples(roleUID, permissions) + if err != nil { + b.logger.Error("failed to convert role permissions to tuples for deletion", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "err", err, + "permissionsCnt", len(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", namespace, + "roleUID", roleUID, + "roleType", roleType, + "permissionsCnt", len(permissions), + ) + return + } + + // Convert tuples to TupleKeyWithoutCondition for deletion + deleteTuples := toTupleKeysWithoutCondition(tuples) + + b.logger.Debug("deleting role permissions from zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(deleteTuples), + "permissionsCnt", len(permissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + }, + }) + if err != nil { + b.logger.Error("failed to delete role permissions from zanzana", + "err", err, + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(deleteTuples), + ) + } + }() +} + +// 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 + } + + // Extract permissions based on the object type + var roleUID, namespace string + var oldPermissions, newPermissions []iamv0.CoreRolespecPermission + var roleType string + + // Try CoreRole first + if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok { + newCoreRole, ok := obj.(*iamv0.CoreRole) + if !ok { + return nil, nil + } + roleUID = newCoreRole.Name + namespace = newCoreRole.Namespace + oldPermissions = oldCoreRole.Spec.Permissions + newPermissions = newCoreRole.Spec.Permissions + roleType = "coreRole" + } else if oldRole, ok := oldObj.(*iamv0.Role); ok { + // Try Role + newRole, ok := obj.(*iamv0.Role) + if !ok { + return nil, nil + } + roleUID = newRole.Name + namespace = newRole.Namespace + + // Convert old permissions + oldPermissions = make([]iamv0.CoreRolespecPermission, len(oldRole.Spec.Permissions)) + for i, p := range oldRole.Spec.Permissions { + oldPermissions[i] = iamv0.CoreRolespecPermission(p) + } + + // Convert new permissions + newPermissions = make([]iamv0.CoreRolespecPermission, len(newRole.Spec.Permissions)) + for i, p := range newRole.Spec.Permissions { + newPermissions[i] = iamv0.CoreRolespecPermission(p) + } + roleType = "role" + } else { + // Not a supported role type + return nil, nil + } + + // 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 nil, err + } + + // 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 + } + + // Prime the ticket channel if empty, then grab a ticket (receive) to avoid test hangs + select { + case b.zTickets <- true: + default: + } + // Grab a ticket to write to Zanzana + wait := time.Now() + <-b.zTickets + hooksWaitHistogram.Observe(time.Since(wait).Seconds()) + + go func() { + defer func() { + b.zTickets <- true + }() + + 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, + ) + } + } + }() + }, nil +} diff --git a/pkg/registry/apis/iam/hooks_test.go b/pkg/registry/apis/iam/hooks_test.go index 9c783171560..dcfdffae181 100644 --- a/pkg/registry/apis/iam/hooks_test.go +++ b/pkg/registry/apis/iam/hooks_test.go @@ -15,9 +15,18 @@ import ( type FakeZanzanaClient struct { zanzana.Client + readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error) writeCallback func(context.Context, *v1.WriteRequest) error } +// Read implements zanzana.Client. +func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1.ReadResponse, error) { + if f.readCallback != nil { + return f.readCallback(ctx, req) + } + return &v1.ReadResponse{}, nil +} + // Write implements zanzana.Client. func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error { return f.writeCallback(ctx, req) @@ -41,6 +50,24 @@ func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.Tupl } } +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 + } + } + if !found { + require.Fail(t, "Expected delete 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{ @@ -433,3 +460,713 @@ func TestAfterRoleCreate(t *testing.T) { b.AfterRoleCreate(&role, nil) }) } + +func TestAfterCoreRoleDelete(t *testing.T) { + t.Run("should delete 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"}, + }, + }, + } + + testCoreRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) + + t.Run("should delete 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"}, + }, + }, + } + + testDashboardRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) + + t.Run("should handle wildcard scopes on delete", 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:*"}, + }, + }, + } + + testWildcardDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) + + t.Run("should skip untranslatable permissions on delete", 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 + }, + }, + } + + testMixedDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) +} + +func TestAfterRoleDelete(t *testing.T) { + t.Run("should delete 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"}, + }, + }, + } + + testRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) + + t.Run("should delete 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"}, + }, + }, + } + + testDashRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error { + 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) + }) + + t.Run("should handle multiple permissions on delete", 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: "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 { + 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) + }) +} + +func TestAfterCoreRoleUpdate(t *testing.T) { + t.Run("should update zanzana entries when permissions change", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) + + t.Run("should handle adding new permissions", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) + + t.Run("should handle removing all permissions", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) +} + +func TestAfterRoleUpdate(t *testing.T) { + t.Run("should update zanzana entries when permissions change", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) + + t.Run("should handle completely new permission set", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) + + t.Run("should handle adding permissions to empty role", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + + 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 { + 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) + }) +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index ed0222d01bd..cf29eca0d18 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -264,8 +264,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 @@ -274,8 +276,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