diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index bb722e48670..cc63171fea3 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -238,6 +238,13 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana") + store.AfterCreate = b.AfterUserCreate + store.BeginUpdate = b.BeginUserUpdate + store.AfterDelete = b.AfterUserDelete + } + dw, err := opts.DualWriteBuilder(userResource.GroupResource(), legacyStore, store) if err != nil { return err diff --git a/pkg/registry/apis/iam/user_org_hooks.go b/pkg/registry/apis/iam/user_org_hooks.go new file mode 100644 index 00000000000..380d98ceac2 --- /dev/null +++ b/pkg/registry/apis/iam/user_org_hooks.go @@ -0,0 +1,303 @@ +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" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +// createUserBasicRoleTuple creates a tuple for a user's basic role assignment +func createUserBasicRoleTuple(userUID, orgRole string) *v1.TupleKey { + if orgRole == "" { + return nil + } + + basicRole := zanzana.TranslateBasicRole(orgRole) + if basicRole == "" { + return nil + } + + return &v1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, userUID, ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), + } +} + +// AfterUserCreate is a post-create hook that writes the user's basic role assignment to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + user, ok := obj.(*iamv0.User) + if !ok { + b.logger.Error("failed to convert object to User type", "object", obj) + return + } + + resourceType := "user" + operation := "create" + + // Skip if user has no role assigned + if user.Spec.Role == "" { + b.logger.Debug("user has no role assigned, skipping basic role sync", + "namespace", user.Namespace, + "userUID", user.Name, + ) + return + } + + // Grab a ticket to write to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(u *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) + if tuple == nil { + b.logger.Warn("failed to create user basic role tuple", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + status = "failure" + return + } + + b.logger.Debug("writing user basic role to zanzana", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err := b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: u.Namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{tuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to write user basic role to zanzana", + "err", err, + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + } else { + hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc() + } + }(user.DeepCopy()) +} + +// BeginUserUpdate is a pre-update hook that gets called on user updates +// It compares old and new roles and performs the zanzana write after K8s update succeeds +func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + oldUser, ok := oldObj.(*iamv0.User) + if !ok { + return nil, nil + } + + newUser, ok := obj.(*iamv0.User) + if !ok { + return nil, nil + } + + // If role hasn't changed, no need to update + if oldUser.Spec.Role == newUser.Spec.Role { + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("user", "update").Observe(time.Since(wait).Seconds()) + + go func(old, new *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues("user", "update", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues("user", "update", status).Inc() + }() + + b.logger.Debug("updating user basic role in zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "oldRole", old.Spec.Role, + "newRole", new.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + req := &v1.WriteRequest{ + Namespace: new.Namespace, + } + + // Delete old role tuple if it existed + if old.Spec.Role != "" { + oldTuple := createUserBasicRoleTuple(old.Name, old.Spec.Role) + if oldTuple != nil { + deleteTuple := tupleToTupleKeyWithoutCondition(oldTuple) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, + } + b.logger.Debug("deleting old user basic role from zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "role", old.Spec.Role, + ) + } + } + + // Write new role tuple if it exists + if new.Spec.Role != "" { + newTuple := createUserBasicRoleTuple(new.Name, new.Spec.Role) + if newTuple != nil { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{newTuple}, + } + b.logger.Debug("writing new user basic role to zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "role", new.Spec.Role, + ) + } + } + + // Only make the request if there are deletes or writes + if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { + err := b.zClient.Write(ctx, req) + if err != nil { + status = "failure" + b.logger.Error("failed to update user basic role in zanzana", + "err", err, + "namespace", new.Namespace, + "userUID", new.Name, + ) + } else { + if req.Deletes != nil && len(req.Deletes.TupleKeys) > 0 { + hooksTuplesCounter.WithLabelValues("user", "update", "delete").Inc() + } + if req.Writes != nil && len(req.Writes.TupleKeys) > 0 { + hooksTuplesCounter.WithLabelValues("user", "update", "write").Inc() + } + } + } else { + b.logger.Debug("no tuples to update in zanzana", "namespace", new.Namespace) + } + }(oldUser.DeepCopy(), newUser.DeepCopy()) + }, nil +} + +// AfterUserDelete is a post-delete hook that removes the user's basic role assignment from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + user, ok := obj.(*iamv0.User) + if !ok { + b.logger.Error("failed to convert object to User type", "object", obj) + return + } + + resourceType := "user" + operation := "delete" + + // Skip if user had no role assigned + if user.Spec.Role == "" { + b.logger.Debug("user had no role assigned, skipping basic role sync", + "namespace", user.Namespace, + "userUID", user.Name, + ) + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(u *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) + if tuple == nil { + b.logger.Warn("failed to create user basic role tuple for deletion", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + status = "failure" + return + } + + deleteTuple := tupleToTupleKeyWithoutCondition(tuple) + + b.logger.Debug("deleting user basic role from zanzana", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err := b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: u.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to delete user basic role from zanzana", + "err", err, + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + } else { + hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc() + } + }(user.DeepCopy()) +} diff --git a/pkg/registry/apis/iam/user_org_hooks_test.go b/pkg/registry/apis/iam/user_org_hooks_test.go new file mode 100644 index 00000000000..516b66e380f --- /dev/null +++ b/pkg/registry/apis/iam/user_org_hooks_test.go @@ -0,0 +1,609 @@ +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 TestAfterUserCreate(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for user with Admin role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "df2p421det1q8c", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testAdminRole := 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) + require.Equal(t, "org-1", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:df2p421det1q8c", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_admin", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for user with Editor role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user123", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + testEditorRole := 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) + require.Equal(t, "org-2", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:user123", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_editor", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testEditorRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for user with Viewer role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "viewer456", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + testViewerRole := 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) + require.Equal(t, "org-3", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:viewer456", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_viewer", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testViewerRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should skip when user has no role", func(t *testing.T) { + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "norole789", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + // Should not call zanzana client + b.zClient = nil + b.AfterUserCreate(&user, nil) + // If we get here without panic, the test passes + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + // Should return early without calling zanzana + builder.AfterUserCreate(&user, nil) + // If we get here without panic, the test passes + }) +} + +func TestBeginUserUpdate(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when role changes from Viewer to Admin", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testRoleChange := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should delete old role + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:testuser", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_viewer", deleteTuple.Object) + + // Should write new role + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + writeTuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:testuser", writeTuple.User) + require.Equal(t, "assignee", writeTuple.Relation) + require.Equal(t, "role:basic_admin", writeTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testRoleChange} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should delete old role when new role is empty", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser2", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser2", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + testRemoveRole := 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 role + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:testuser2", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_editor", deleteTuple.Object) + + // Should not write new role + require.Nil(t, req.Writes) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testRemoveRole} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should be able to add a new role when old role was empty", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser3", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser3", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testAddRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + // Should not delete old role (was empty) + require.Nil(t, req.Deletes) + + // Should write new role + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + writeTuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:testuser3", writeTuple.User) + require.Equal(t, "assignee", writeTuple.Relation) + require.Equal(t, "role:basic_admin", writeTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAddRole} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should skip update when role hasn't changed", func(t *testing.T) { + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser4", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser4", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when no update needed + }) + + t.Run("should not call zanzana when update fails", func(t *testing.T) { + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser5", + Namespace: "org-5", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser5", + Namespace: "org-5", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + callCount := 0 + testNoCall := func(ctx context.Context, req *v1.WriteRequest) error { + callCount++ + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoCall} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call with success=false - should not trigger zanzana write + finishFunc(context.Background(), false) + require.Equal(t, 0, callCount, "zanzana should not be called when update fails") + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + finishFunc, err := builder.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when zClient is nil + }) +} + +func TestAfterUserDelete(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for user with Admin role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "df2p421det1q8c", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testDeleteAdmin := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Nil(t, req.Writes) + + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:df2p421det1q8c", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_admin", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteAdmin} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for user with Editor role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "editor123", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + testDeleteEditor := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:editor123", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_editor", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteEditor} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for user with Viewer role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "viewer456", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + testDeleteViewer := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:viewer456", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_viewer", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteViewer} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should skip when user has no role", func(t *testing.T) { + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "norole789", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + // Should not call zanzana client + b.zClient = nil + b.AfterUserDelete(&user, nil) + // If we get here without panic, the test passes + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + // Should return early without calling zanzana + builder.AfterUserDelete(&user, nil) + // If we get here without panic, the test passes + }) +} + +func TestCreateUserBasicRoleTuple(t *testing.T) { + t.Run("should create tuple for Admin role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "Admin") + require.NotNil(t, tuple) + require.Equal(t, "user:user123", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_admin", tuple.Object) + }) + + t.Run("should create tuple for Editor role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user456", "Editor") + require.NotNil(t, tuple) + require.Equal(t, "user:user456", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_editor", tuple.Object) + }) + + t.Run("should create tuple for Viewer role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user789", "Viewer") + require.NotNil(t, tuple) + require.Equal(t, "user:user789", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_viewer", tuple.Object) + }) + + t.Run("should create tuple for None role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user000", "None") + require.NotNil(t, tuple) + require.Equal(t, "user:user000", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_none", tuple.Object) + }) + + t.Run("should return nil for empty role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "") + require.Nil(t, tuple) + }) + + t.Run("should return nil for invalid role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "InvalidRole") + require.Nil(t, tuple) + }) +}