mirror of https://github.com/grafana/grafana.git
				
				
				
			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 f8e953ca09.
* 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
			
			
This commit is contained in:
		
							parent
							
								
									edef69fdc8
								
							
						
					
					
						commit
						d216d75fbb
					
				| 
						 | 
				
			
			@ -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=
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										1
									
								
								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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										2
									
								
								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=
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 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) {
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	b := &IdentityAccessManagementAPIBuilder{
 | 
			
		||||
		logger:   log.NewNopLogger(),
 | 
			
		||||
		zTickets: make(chan bool, 1),
 | 
			
		||||
	}
 | 
			
		||||
		t.Cleanup(func() {
 | 
			
		||||
			<-b.zTickets
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
	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()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue