From eeddc8cd1802790f8d7497b1c4df3916363dfeec Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 3 Nov 2025 15:39:20 +0100 Subject: [PATCH] Zanzana: Add team binding hooks (#113274) add team binding hooks --- pkg/registry/apis/iam/register.go | 8 + pkg/registry/apis/iam/team_binding_hooks.go | 348 ++++++++ .../apis/iam/team_binding_hooks_test.go | 749 ++++++++++++++++++ 3 files changed, 1105 insertions(+) create mode 100644 pkg/registry/apis/iam/team_binding_hooks.go create mode 100644 pkg/registry/apis/iam/team_binding_hooks_test.go diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index cc63171fea3..1356ea49261 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -224,6 +224,14 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } + // Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks + if enableZanzanaSync { + b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana") + teamBindingStore.AfterCreate = b.AfterTeamBindingCreate + teamBindingStore.AfterDelete = b.AfterTeamBindingDelete + teamBindingStore.BeginUpdate = b.BeginTeamBindingUpdate + } + storage[teamBindingResource.StoragePath()] = teamBindingDW } diff --git a/pkg/registry/apis/iam/team_binding_hooks.go b/pkg/registry/apis/iam/team_binding_hooks.go new file mode 100644 index 00000000000..c43c3a91ab1 --- /dev/null +++ b/pkg/registry/apis/iam/team_binding_hooks.go @@ -0,0 +1,348 @@ +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" +) + +// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format +// TeamBinding represents a user's membership in a team with a specific permission level +func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) { + if tb.Spec.Subject.Name == "" { + return nil, errEmptyName + } + + if tb.Spec.TeamRef.Name == "" { + return nil, errEmptyName + } + + // Map permission to relation + var relation string + switch tb.Spec.Permission { + case iamv0.TeamBindingTeamPermissionAdmin: + relation = zanzana.RelationTeamAdmin + case iamv0.TeamBindingTeamPermissionMember: + relation = zanzana.RelationTeamMember + default: + // Default to member if unknown permission + relation = zanzana.RelationTeamMember + } + + // Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID} + tuple := &v1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""), + Relation: relation, + Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""), + } + + return tuple, nil +} + +// AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + tb, ok := obj.(*iamv0.TeamBinding) + if !ok { + b.logger.Error("failed to convert object to TeamBinding type", "object", obj) + return + } + + resourceType := "teambinding" + operation := "create" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(tb *iamv0.TeamBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple, err := convertTeamBindingToTuple(tb) + if err != nil { + b.logger.Error("failed to convert team binding to tuple", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "err", err, + ) + status = "failure" + return + } + + b.logger.Debug("writing team binding to zanzana", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: tb.Namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{tuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to write team binding to zanzana", + "err", err, + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + ) + } else { + // Record successful tuple write + hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc() + } + }(tb.DeepCopy()) // Pass a copy of the object +} + +// BeginTeamBindingUpdate is a pre-update hook that prepares zanzana updates +// It converts old and new team bindings to tuples and performs the zanzana write after K8s update succeeds +func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract team bindings from both old and new objects + oldTB, ok := oldObj.(*iamv0.TeamBinding) + if !ok { + return nil, nil + } + + newTB, ok := obj.(*iamv0.TeamBinding) + if !ok { + return nil, nil + } + + // Convert old team binding to tuple for deletion + var oldTuple *v1.TupleKey + var oldErr error + if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" { + oldTuple, oldErr = convertTeamBindingToTuple(oldTB) + if oldErr != nil { + b.logger.Error("failed to convert old team binding to tuple", + "namespace", oldTB.Namespace, + "name", oldTB.Name, + "err", oldErr, + ) + } + } + + // Convert new team binding to tuple for writing + var newTuple *v1.TupleKey + var newErr error + if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" { + newTuple, newErr = convertTeamBindingToTuple(newTB) + if newErr != nil { + b.logger.Error("failed to convert new team binding to tuple", + "namespace", newTB.Namespace, + "name", newTB.Name, + "err", newErr, + ) + } + } + + // 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 + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("teambinding", "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues("teambinding", "update", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues("teambinding", "update", status).Inc() + }() + + b.logger.Debug("updating team binding in zanzana", + "namespace", newTB.Namespace, + "name", newTB.Name, + "oldSubject", oldTB.Spec.Subject.Name, + "newSubject", newTB.Spec.Subject.Name, + "oldTeamRef", oldTB.Spec.TeamRef.Name, + "newTeamRef", newTB.Spec.TeamRef.Name, + "oldPermission", oldTB.Spec.Permission, + "newPermission", newTB.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + // Prepare write request + req := &v1.WriteRequest{ + Namespace: newTB.Namespace, + } + + // Add delete for old tuple + if oldTuple != nil && oldErr == nil { + deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple}) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: deleteTuple, + } + b.logger.Debug("deleting existing team binding from zanzana", + "namespace", newTB.Namespace, + "subject", oldTB.Spec.Subject.Name, + "teamRef", oldTB.Spec.TeamRef.Name, + ) + } + + // Add write for new tuple + if newTuple != nil && newErr == nil { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{newTuple}, + } + b.logger.Debug("writing new team binding to zanzana", + "namespace", newTB.Namespace, + "subject", newTB.Spec.Subject.Name, + "teamRef", newTB.Spec.TeamRef.Name, + ) + } + + // 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 team binding in zanzana", + "err", err, + "namespace", newTB.Namespace, + "name", newTB.Name, + ) + } else { + // Record successful tuple operations + if oldTuple != nil && oldErr == nil { + hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() + } + if newTuple != nil && newErr == nil { + hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() + } + } + } else { + b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + } + }() + }, nil +} + +// AfterTeamBindingDelete is a post-delete hook that removes the team binding from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + tb, ok := obj.(*iamv0.TeamBinding) + if !ok { + b.logger.Error("failed to convert object to TeamBinding type", "object", obj) + return + } + + resourceType := "teambinding" + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(tb *iamv0.TeamBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple, err := convertTeamBindingToTuple(tb) + if err != nil { + b.logger.Error("failed to convert team binding to tuple for deletion", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "err", err, + ) + status = "failure" + return + } + + // Convert tuple to TupleKeyWithoutCondition for deletion + deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple}) + + b.logger.Debug("deleting team binding from zanzana", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: tb.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: deleteTuple, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to delete team binding from zanzana", + "err", err, + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + ) + } else { + // Record successful tuple deletion + hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc() + } + }(tb.DeepCopy()) // Pass a copy of the object +} diff --git a/pkg/registry/apis/iam/team_binding_hooks_test.go b/pkg/registry/apis/iam/team_binding_hooks_test.go new file mode 100644 index 00000000000..869060bcacb --- /dev/null +++ b/pkg/registry/apis/iam/team_binding_hooks_test.go @@ -0,0 +1,749 @@ +package iam + +import ( + "context" + "sync" + "testing" + "time" + + 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 TestAfterTeamBindingCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + External: false, + }, + } + + testMemberBinding := 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) + require.Nil(t, req.Deletes) + + expectedTuple := &v1.TupleKey{ + User: "user:user-1", + Relation: "member", + Object: "team:team-1", + } + + actualTuple := req.Writes.TupleKeys[0] + require.Equal(t, expectedTuple.User, actualTuple.User) + require.Equal(t, expectedTuple.Relation, actualTuple.Relation) + require.Equal(t, expectedTuple.Object, actualTuple.Object) + require.Nil(t, actualTuple.Condition) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding} + b.AfterTeamBindingCreate(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for team binding with admin permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + External: true, + }, + } + + testAdminBinding := 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) + require.Nil(t, req.Deletes) + + expectedTuple := &v1.TupleKey{ + User: "user:user-2", + Relation: "admin", + Object: "team:team-2", + } + + actualTuple := req.Writes.TupleKeys[0] + require.Equal(t, expectedTuple.User, actualTuple.User) + require.Equal(t, expectedTuple.Relation, actualTuple.Relation) + require.Equal(t, expectedTuple.Object, actualTuple.Object) + require.Nil(t, actualTuple.Condition) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding} + b.AfterTeamBindingCreate(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-3", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-3", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterTeamBindingCreate(&teamBinding, nil) + }) + + t.Run("should handle conversion error gracefully", func(t *testing.T) { + // TeamBinding with empty subject name should fail conversion + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", // Empty name should cause error + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-4", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + // Should not be called due to conversion error + require.Fail(t, "Write should not be called when conversion fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.AfterTeamBindingCreate(&teamBinding, nil) + // Wait a bit to ensure the goroutine has time to process + // The goroutine will complete but won't call the write callback + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when conversion fails") + }) +} + +func TestBeginTeamBindingUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when permission changes from member to admin", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + testPermissionUpdate := 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 member permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + // Should write new admin permission + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should update zanzana entry when user changes", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + testUserUpdate := 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 user binding + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + // Should write new user binding + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should update zanzana entry when team changes", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + testTeamUpdate := 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 team binding + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, + ) + + // Should write new team binding + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should not write to zanzana when update fails", func(t *testing.T) { + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error { + // Should not be called when success=false + require.Fail(t, "Write should not be called when update fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call finish function with success=false + finishFunc(context.Background(), false) + // No wait needed since write should not be called + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-5", + Namespace: "org-5", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-5", + Namespace: "org-5", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + finishFunc, err := builder.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when zClient is nil + }) +} + +func TestAfterTeamBindingDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + External: false, + }, + } + + testMemberDelete := 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) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete} + b.AfterTeamBindingDelete(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for team binding with admin permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + External: true, + }, + } + + testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", 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) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete} + b.AfterTeamBindingDelete(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-3", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-3", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterTeamBindingDelete(&teamBinding, nil) + }) + + t.Run("should handle conversion error gracefully", func(t *testing.T) { + // TeamBinding with empty team ref name should fail conversion + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-4", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "", // Empty name should cause error + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + // Should not be called due to conversion error + require.Fail(t, "Write should not be called when conversion fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.AfterTeamBindingDelete(&teamBinding, nil) + // Wait a bit to ensure the goroutine has time to process + // The goroutine will complete but won't call the write callback + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when conversion fails") + }) +} + +func TestConvertTeamBindingToTuple(t *testing.T) { + t.Run("should convert member permission correctly", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + require.Equal(t, "user:user-1", tuple.User) + require.Equal(t, "member", tuple.Relation) + require.Equal(t, "team:team-1", tuple.Object) + require.Nil(t, tuple.Condition) + }) + + t.Run("should convert admin permission correctly", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + require.Equal(t, "user:user-2", tuple.User) + require.Equal(t, "admin", tuple.Relation) + require.Equal(t, "team:team-2", tuple.Object) + require.Nil(t, tuple.Condition) + }) + + t.Run("should return error for empty subject name", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.Error(t, err) + require.Nil(t, tuple) + require.Equal(t, errEmptyName, err) + }) + + t.Run("should return error for empty team ref name", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.Error(t, err) + require.Nil(t, tuple) + require.Equal(t, errEmptyName, err) + }) + + t.Run("should default to member for unknown permission", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: "unknown", // Invalid permission + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + // Should default to member relation + require.Equal(t, "member", tuple.Relation) + }) +}