diff --git a/pkg/registry/apis/iam/hooks.go b/pkg/registry/apis/iam/hooks.go index f572711512b..aac644dcfc3 100644 --- a/pkg/registry/apis/iam/hooks.go +++ b/pkg/registry/apis/iam/hooks.go @@ -12,8 +12,10 @@ import ( "k8s.io/apimachinery/pkg/runtime" 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 ( @@ -100,17 +102,17 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r return } + rp, ok := obj.(*iamv0.ResourcePermission) + if !ok { + return + } + // Grab a ticket to write to Zanzana // This limits the amount of concurrent writes to Zanzana wait := time.Now() b.zTickets <- true hooksWaitHistogram.Observe(time.Since(wait).Seconds()) // Record wait time - rp, ok := obj.(*iamv0.ResourcePermission) - if !ok { - return - } - go func(rp *iamv0.ResourcePermission) { defer func() { // Release the ticket after write is done @@ -168,3 +170,127 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(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.Observe(time.Since(wait).Seconds()) + + go func() { + defer func() { + <-b.zTickets + }() + + tuples, err := convertRolePermissionsToTuples(roleUID, permissions) + if err != nil { + b.logger.Error("failed to convert role permissions to tuples", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "err", err, + "permissionsCnt", len(permissions), + ) + return + } + + // Avoid writing if there are no valid tuples + if len(tuples) == 0 { + b.logger.Debug("no valid tuples to write for role", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "permissionsCnt", len(permissions), + ) + return + } + + b.logger.Debug("writing role permissions to zanzana", + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(tuples), + "permissionsCnt", len(permissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: tuples, + }, + }) + if err != nil { + b.logger.Error("failed to write role permissions to zanzana", + "err", err, + "namespace", namespace, + "roleUID", roleUID, + "roleType", roleType, + "tuplesCnt", len(tuples), + ) + } + }() +} diff --git a/pkg/registry/apis/iam/hooks_test.go b/pkg/registry/apis/iam/hooks_test.go index d292aa823a2..9c783171560 100644 --- a/pkg/registry/apis/iam/hooks_test.go +++ b/pkg/registry/apis/iam/hooks_test.go @@ -23,14 +23,34 @@ func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) err return f.writeCallback(ctx, req) } -func TestAfterResourcePermissionCreate(t *testing.T) { - t.Skip("Need to fix its flaky behavior in CI") - - b := &IdentityAccessManagementAPIBuilder{ - logger: log.NewNopLogger(), - zTickets: make(chan bool, 1), +func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, 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 tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs) + } } +} + +func TestAfterResourcePermissionCreate(t *testing.T) { t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + t.Cleanup(func() { + <-b.zTickets + }) + folderPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "org-2", @@ -51,16 +71,13 @@ func TestAfterResourcePermissionCreate(t *testing.T) { require.NotNil(t, req.Writes) require.Len(t, req.Writes.TupleKeys, 2) require.Equal(t, "org-2", req.Namespace) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:u1", Relation: "view", Object: "folder:fold1"}, - ) - require.Equal( - t, - req.Writes.TupleKeys[1], - &v1.TupleKey{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"}, - ) + + expectedTuples := []*v1.TupleKey{ + {User: "user:u1", Relation: "view", Object: "folder:fold1"}, + {User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"}, + } + + requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples) return nil } @@ -68,10 +85,15 @@ func TestAfterResourcePermissionCreate(t *testing.T) { b.AfterResourcePermissionCreate(&folderPerm, nil) }) - // Wait for the ticket to be released - <-b.zTickets - 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 + }) + dashPerm := iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", @@ -95,26 +117,18 @@ func TestAfterResourcePermissionCreate(t *testing.T) { require.Len(t, req.Writes.TupleKeys, 2) require.Equal(t, "default", req.Namespace) - tuple1 := req.Writes.TupleKeys[0] - require.NotNil(t, tuple1.Condition) - require.Equal(t, "group_filter", tuple1.Condition.Name) - tuple1.Condition = nil - require.Equal( - t, - tuple1, - &v1.TupleKey{User: "service-account:sa1", Relation: "view", Object: object}, - ) + // Verify all tuples have the group_filter condition + for _, tuple := range req.Writes.TupleKeys { + require.NotNil(t, tuple.Condition, "Condition should not be nil for tuple %+v", tuple) + require.Equal(t, "group_filter", tuple.Condition.Name) + } - tuple2 := req.Writes.TupleKeys[1] - require.NotNil(t, tuple2.Condition) - require.Equal(t, "group_filter", tuple2.Condition.Name) - tuple2.Condition = nil - require.Equal( - t, - tuple2, - &v1.TupleKey{User: "team:team1", Relation: "edit", Object: object}, - ) + expectedTuples := []*v1.TupleKey{ + {User: "service-account:sa1", Relation: "view", Object: object}, + {User: "team:team1", Relation: "edit", Object: object}, + } + requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples) return nil } @@ -122,3 +136,300 @@ func TestAfterResourcePermissionCreate(t *testing.T) { b.AfterResourcePermissionCreate(&dashPerm, nil) }) } + +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) + }) +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 16c7c3fb160..ed0222d01bd 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -263,12 +263,20 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } + if enableZanzanaSync { + b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana") + coreRoleStore.AfterCreate = b.AfterRoleCreate + } storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore roleStore, err := NewLocalStore(iamv0.RoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.rolesStorage) if err != nil { return err } + if enableZanzanaSync { + b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana") + roleStore.AfterCreate = b.AfterRoleCreate + } storage[iamv0.RoleInfo.StoragePath()] = roleStore roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index 92a1604259e..28ebd1edb02 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -185,7 +185,7 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector // For resource actions on folders we need to merge the tuples into one with combined subresources. if zanzana.IsFolderResourceTuple(tuple) { - key := tupleStringWithoutCondition(tuple) + key := zanzana.TupleStringWithoutCondition(tuple) if t, ok := tuples[tuple.Object][key]; ok { zanzana.MergeFolderResourceTuples(t, tuple) } else { @@ -202,14 +202,6 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector } } -func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string { - c := tuple.Condition - tuple.Condition = nil - s := tuple.String() - tuple.Condition = c - return s -} - // basicRoleBindingsCollector collects role bindings for basic roles func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { @@ -371,36 +363,41 @@ func rolePermissionsCollector(store db.DB) legacyTupleCollector { return nil, err } + // Group permissions by role UID + rolePermissionsMap := make(map[string][]zanzana.RolePermission) + for _, p := range permissions { + rolePermissionsMap[p.RoleUID] = append(rolePermissionsMap[p.RoleUID], zanzana.RolePermission{ + Action: p.Action, + Kind: p.Kind, + Identifier: p.Identifier, + }) + } + tuples := make(map[string]map[string]*openfgav1.TupleKey) - for _, p := range permissions { - tuple, ok := zanzana.TranslateToResourceTuple( - zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee), - p.Action, - p.Kind, - p.Identifier, - ) - if !ok { + // Convert permissions for each role using the shared utility + for roleUID, perms := range rolePermissionsMap { + roleTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, perms) + if err != nil { + reconcilerLogger.Warn("Failed to convert role permissions to tuples", "roleUID", roleUID, "err", err) continue } - if tuples[tuple.Object] == nil { - tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) - } - - // For resource actions on folders we need to merge the tuples into one with combined subresources. - if zanzana.IsFolderResourceTuple(tuple) { - key := tupleStringWithoutCondition(tuple) - if t, ok := tuples[tuple.Object][key]; ok { - zanzana.MergeFolderResourceTuples(t, tuple) - } else { - tuples[tuple.Object][key] = tuple + // Add tuples to the result map + for _, tuple := range roleTuples { + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) } - continue + // Use the appropriate key based on whether it's a folder resource tuple + var key string + if zanzana.IsFolderResourceTuple(tuple) { + key = zanzana.TupleStringWithoutCondition(tuple) + } else { + key = tuple.String() + } + tuples[tuple.Object][key] = tuple } - - tuples[tuple.Object][tuple.String()] = tuple } return tuples, nil @@ -482,7 +479,7 @@ func zanzanaCollector(relations []string) zanzanaTupleCollector { } for _, t := range tuples { if zanzana.IsFolderResourceTuple(t.Key) { - out[tupleStringWithoutCondition(t.Key)] = t.Key + out[zanzana.TupleStringWithoutCondition(t.Key)] = t.Key } else { out[t.Key.String()] = t.Key } diff --git a/pkg/services/accesscontrol/dualwrite/collectors_test.go b/pkg/services/accesscontrol/dualwrite/collectors_test.go new file mode 100644 index 00000000000..2c0ac88a689 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/collectors_test.go @@ -0,0 +1,1074 @@ +package dualwrite + +import ( + "context" + "fmt" + "testing" + "time" + + authlib "github.com/grafana/authlib/types" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/services/team/teamimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +type testEnv struct { + sql *sqlstore.SQLStore + db db.DB + cfg *setting.Cfg + userSvc user.Service + teamSvc team.Service + orgSvc org.Service + folderSvc folder.Service + ctx context.Context +} + +func setupTestEnv(t *testing.T) *testEnv { + t.Helper() + + sql, cfg := db.InitTestDBWithCfg(t) + cfg.AutoAssignOrg = true + cfg.AutoAssignOrgRole = "Viewer" + cfg.AutoAssignOrgId = 1 + + teamService, err := teamimpl.ProvideService(sql, cfg, tracing.InitializeTracerForTest()) + require.NoError(t, err) + + orgService, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) + require.NoError(t, err) + + userService, err := userimpl.ProvideService( + sql, orgService, cfg, teamService, localcache.ProvideService(), tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + + // Create test org + orgID, err := orgService.GetOrCreate(context.Background(), "test") + require.NoError(t, err) + require.Equal(t, int64(1), orgID) + + return &testEnv{ + sql: sql, + db: sql, + cfg: cfg, + userSvc: userService, + teamSvc: teamService, + orgSvc: orgService, + folderSvc: nil, // Set per test if needed + ctx: context.Background(), + } +} + +func createUser(t *testing.T, env *testEnv, login string, orgID int64, isServiceAccount bool) *user.User { + t.Helper() + + u, err := env.userSvc.Create(env.ctx, &user.CreateUserCommand{ + Login: login, + OrgID: orgID, + IsServiceAccount: isServiceAccount, + }) + require.NoError(t, err) + return u +} + +func createTeam(t *testing.T, env *testEnv, name string, orgID int64) team.Team { + t.Helper() + + tm, err := env.teamSvc.CreateTeam(env.ctx, &team.CreateTeamCommand{ + Name: name, + OrgID: orgID, + }) + require.NoError(t, err) + return tm +} + +func addTeamMember(t *testing.T, env *testEnv, userID, orgID, teamID int64, permission int) { + t.Helper() + + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + now := time.Now() + _, err := sess.Exec("INSERT INTO team_member (org_id, team_id, user_id, permission, created, updated) VALUES (?, ?, ?, ?, ?, ?)", + orgID, teamID, userID, permission, now, now) + return err + }) + require.NoError(t, err) +} + +func createRole(t *testing.T, env *testEnv, name string, orgID int64) string { + t.Helper() + + var roleUID string + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + uid := fmt.Sprintf("role-%s-%d", name, orgID) + now := time.Now() + _, err := sess.Exec("INSERT INTO role (uid, name, org_id, version, created, updated) VALUES (?, ?, ?, ?, ?, ?)", + uid, name, orgID, 1, now, now) + roleUID = uid + return err + }) + require.NoError(t, err) + return roleUID +} + +func addUserRole(t *testing.T, env *testEnv, userID int64, roleUID string, orgID int64) { + t.Helper() + + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + var roleID int64 + _, err := sess.SQL("SELECT id FROM role WHERE uid = ?", roleUID).Get(&roleID) + if err != nil { + return err + } + _, err = sess.Exec("INSERT INTO user_role (org_id, user_id, role_id, created) VALUES (?, ?, ?, ?)", + orgID, userID, roleID, time.Now()) + return err + }) + require.NoError(t, err) +} + +func addTeamRole(t *testing.T, env *testEnv, teamID int64, roleUID string, orgID int64) { + t.Helper() + + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + var roleID int64 + _, err := sess.SQL("SELECT id FROM role WHERE uid = ?", roleUID).Get(&roleID) + if err != nil { + return err + } + _, err = sess.Exec("INSERT INTO team_role (org_id, team_id, role_id, created) VALUES (?, ?, ?, ?)", + orgID, teamID, roleID, time.Now()) + return err + }) + require.NoError(t, err) +} + +func addPermission(t *testing.T, env *testEnv, roleUID, action, kind, identifier string) { + t.Helper() + + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + var roleID int64 + _, err := sess.SQL("SELECT id FROM role WHERE uid = ?", roleUID).Get(&roleID) + if err != nil { + return err + } + now := time.Now() + _, err = sess.Exec("INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + roleID, action, fmt.Sprintf("%s:uid:%s", kind, identifier), kind, "uid", identifier, now, now) + return err + }) + require.NoError(t, err) +} + +func addBuiltinRole(t *testing.T, env *testEnv, roleUID, builtinRole string, orgID int64) { + t.Helper() + + err := env.db.WithDbSession(env.ctx, func(sess *db.Session) error { + var roleID int64 + _, err := sess.SQL("SELECT id FROM role WHERE uid = ?", roleUID).Get(&roleID) + if err != nil { + return err + } + now := time.Now() + _, err = sess.Exec("INSERT INTO builtin_role (role_id, org_id, role, created, updated) VALUES (?, ?, ?, ?, ?)", + roleID, orgID, builtinRole, now, now) + return err + }) + require.NoError(t, err) +} + +// Mock zanzana client for testing +type mockZanzanaClient struct { + mock.Mock +} + +func (m *mockZanzanaClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*authzextv1.ReadResponse), args.Error(1) +} + +func (m *mockZanzanaClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +func (m *mockZanzanaClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*authzextv1.BatchCheckResponse), args.Error(1) +} + +// authlib.AccessClient methods +func (m *mockZanzanaClient) Check(ctx context.Context, id authlib.AuthInfo, req authlib.CheckRequest, folder string) (authlib.CheckResponse, error) { + args := m.Called(ctx, id, req, folder) + return args.Get(0).(authlib.CheckResponse), args.Error(1) +} + +func (m *mockZanzanaClient) Compile(ctx context.Context, id authlib.AuthInfo, req authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) { + args := m.Called(ctx, id, req) + if args.Get(0) == nil { + return nil, nil, args.Error(2) + } + return args.Get(0).(authlib.ItemChecker), args.Get(1).(authlib.Zookie), args.Error(2) +} + +func TestIntegrationTeamMembershipCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect team members with member permission", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + team1 := createTeam(t, env, "team1", 1) + addTeamMember(t, env, user1.ID, 1, team1.ID, 0) // 0 = member permission + + collector := teamMembershipCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 1) + + teamObject := zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, "") + require.Contains(t, tuples, teamObject) + + teamTuples := tuples[teamObject] + require.Len(t, teamTuples, 1) + + var tuple *openfgav1.TupleKey + for _, t := range teamTuples { + tuple = t + break + } + + assert.Equal(t, zanzana.NewTupleEntry(zanzana.TypeUser, user1.UID, ""), tuple.User) + assert.Equal(t, zanzana.RelationTeamMember, tuple.Relation) + assert.Equal(t, teamObject, tuple.Object) + }) + + t.Run("should collect team admins with admin permission", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + team1 := createTeam(t, env, "team1", 1) + addTeamMember(t, env, user1.ID, 1, team1.ID, 4) // 4 = admin permission + + collector := teamMembershipCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 1) + + teamObject := zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, "") + teamTuples := tuples[teamObject] + + var tuple *openfgav1.TupleKey + for _, t := range teamTuples { + tuple = t + break + } + + assert.Equal(t, zanzana.RelationTeamAdmin, tuple.Relation) + }) + + t.Run("should collect multiple team members", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + user2 := createUser(t, env, "user2", 1, false) + team1 := createTeam(t, env, "team1", 1) + addTeamMember(t, env, user1.ID, 1, team1.ID, 0) + addTeamMember(t, env, user2.ID, 1, team1.ID, 4) + + collector := teamMembershipCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + teamObject := zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, "") + teamTuples := tuples[teamObject] + require.Len(t, teamTuples, 2) + }) + + t.Run("should return empty for no team memberships", func(t *testing.T) { + env := setupTestEnv(t) + + collector := teamMembershipCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Empty(t, tuples) + }) + + t.Run("should filter by org ID", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + team1 := createTeam(t, env, "team1", 1) + addTeamMember(t, env, user1.ID, 1, team1.ID, 0) + + // Collect for org 1 only + collector := teamMembershipCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 1) + + teamObject := zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, "") + require.Contains(t, tuples, teamObject) + }) +} + +func TestIntegrationFolderTreeCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect folder parent relationships", func(t *testing.T) { + env := setupTestEnv(t) + + fakeFolderSvc := foldertest.NewFakeService() + fakeFolderSvc.ExpectedFolders = []*folder.Folder{ + {UID: "child1", ParentUID: "parent1", OrgID: 1}, + {UID: "child2", ParentUID: "parent1", OrgID: 1}, + } + + collector := folderTreeCollector(fakeFolderSvc) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 2) + + child1Object := zanzana.NewTupleEntry(zanzana.TypeFolder, "child1", "") + require.Contains(t, tuples, child1Object) + + child1Tuples := tuples[child1Object] + require.Len(t, child1Tuples, 1) + + var tuple *openfgav1.TupleKey + for _, t := range child1Tuples { + tuple = t + break + } + + assert.Equal(t, child1Object, tuple.Object) + assert.Equal(t, zanzana.RelationParent, tuple.Relation) + assert.Equal(t, zanzana.NewTupleEntry(zanzana.TypeFolder, "parent1", ""), tuple.User) + }) + + t.Run("should skip folders without parents", func(t *testing.T) { + env := setupTestEnv(t) + + fakeFolderSvc := foldertest.NewFakeService() + fakeFolderSvc.ExpectedFolders = []*folder.Folder{ + {UID: "root1", ParentUID: "", OrgID: 1}, + {UID: "child1", ParentUID: "root1", OrgID: 1}, + } + + collector := folderTreeCollector(fakeFolderSvc) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 1) // Only child1 should be collected + + child1Object := zanzana.NewTupleEntry(zanzana.TypeFolder, "child1", "") + require.Contains(t, tuples, child1Object) + }) + + t.Run("should handle pagination", func(t *testing.T) { + env := setupTestEnv(t) + + fakeFolderSvc := foldertest.NewFakeService() + + // Create 250 folders to test pagination + var folders []*folder.Folder + for i := 1; i <= 250; i++ { + folders = append(folders, &folder.Folder{ + UID: fmt.Sprintf("child%d", i), + ParentUID: "parent1", + OrgID: 1, + }) + } + + // Mock GetFolders to return pages + fakeFolderSvc.ExpectedFolders = folders + + collector := folderTreeCollector(fakeFolderSvc) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Len(t, tuples, 250) + }) + + t.Run("should return empty for no folders", func(t *testing.T) { + env := setupTestEnv(t) + + fakeFolderSvc := foldertest.NewFakeService() + fakeFolderSvc.ExpectedFolders = []*folder.Folder{} + + collector := folderTreeCollector(fakeFolderSvc) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Empty(t, tuples) + }) + + t.Run("should handle folder service error", func(t *testing.T) { + env := setupTestEnv(t) + + fakeFolderSvc := foldertest.NewFakeService() + fakeFolderSvc.ExpectedError = fmt.Errorf("folder service error") + + collector := folderTreeCollector(fakeFolderSvc) + _, err := collector(env.ctx, 1) + + require.Error(t, err) + }) +} + +func TestIntegrationBasicRoleBindingsCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect basic role bindings for regular users", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + + // Update org role + err := env.orgSvc.UpdateOrgUser(env.ctx, &org.UpdateOrgUserCommand{ + OrgID: 1, + UserID: user1.ID, + Role: org.RoleAdmin, + }) + require.NoError(t, err) + + collector := basicRoleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + + roleObject := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole("Admin"), "") + require.Contains(t, tuples, roleObject) + + roleTuples := tuples[roleObject] + require.NotEmpty(t, roleTuples) + + var found bool + for _, tuple := range roleTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeUser, user1.UID, "") { + assert.Equal(t, zanzana.RelationAssignee, tuple.Relation) + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("should collect basic role bindings for service accounts", func(t *testing.T) { + env := setupTestEnv(t) + + sa := createUser(t, env, "sa1", 1, true) + + collector := basicRoleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + // Find service account tuple + var found bool + for _, roleTuples := range tuples { + for _, tuple := range roleTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeServiceAccount, sa.UID, "") { + found = true + break + } + } + } + assert.True(t, found) + }) + + t.Run("should collect multiple users with different roles", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + user2 := createUser(t, env, "user2", 1, false) + + err := env.orgSvc.UpdateOrgUser(env.ctx, &org.UpdateOrgUserCommand{ + OrgID: 1, + UserID: user1.ID, + Role: org.RoleAdmin, + }) + require.NoError(t, err) + + err = env.orgSvc.UpdateOrgUser(env.ctx, &org.UpdateOrgUserCommand{ + OrgID: 1, + UserID: user2.ID, + Role: org.RoleEditor, + }) + require.NoError(t, err) + + collector := basicRoleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + adminRole := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole("Admin"), "") + editorRole := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole("Editor"), "") + + require.Contains(t, tuples, adminRole) + require.Contains(t, tuples, editorRole) + }) +} + +func TestIntegrationManagedPermissionsCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect managed permissions for users", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + roleUID := createRole(t, env, "managed:dash:u1:perm", 1) + addUserRole(t, env, user1.ID, roleUID, 1) + addPermission(t, env, roleUID, "dashboards:read", "dashboards", "uid1") + + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + }) + + t.Run("should collect managed permissions for service accounts", func(t *testing.T) { + env := setupTestEnv(t) + + sa := createUser(t, env, "sa1", 1, true) + roleUID := createRole(t, env, "managed:dash:u1:perm", 1) + addUserRole(t, env, sa.ID, roleUID, 1) + addPermission(t, env, roleUID, "dashboards:read", "dashboards", "uid1") + + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + + // Verify service account subject + var found bool + for _, objTuples := range tuples { + for _, tuple := range objTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeServiceAccount, sa.UID, "") { + found = true + break + } + } + } + assert.True(t, found, "Should find service account tuple") + }) + + t.Run("should collect managed permissions for teams", func(t *testing.T) { + env := setupTestEnv(t) + + team1 := createTeam(t, env, "team1", 1) + roleUID := createRole(t, env, "managed:dash:u1:perm", 1) + addTeamRole(t, env, team1.ID, roleUID, 1) + addPermission(t, env, roleUID, "dashboards:read", "dashboards", "uid1") + + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + + // Verify team subject + var found bool + for _, objTuples := range tuples { + for _, tuple := range objTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, zanzana.RelationTeamMember) { + found = true + break + } + } + } + assert.True(t, found, "Should find team tuple") + }) + + t.Run("should collect managed permissions for basic roles", func(t *testing.T) { + env := setupTestEnv(t) + + roleUID := createRole(t, env, "managed:dash:u1:perm", 1) + addBuiltinRole(t, env, roleUID, "Admin", 1) + addPermission(t, env, roleUID, "dashboards:read", "dashboards", "uid1") + + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + }) + + t.Run("should filter by kind", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + roleUID1 := createRole(t, env, "managed:dash:u1:perm", 1) + roleUID2 := createRole(t, env, "managed:fld:u2:perm", 1) + addUserRole(t, env, user1.ID, roleUID1, 1) + addUserRole(t, env, user1.ID, roleUID2, 1) + addPermission(t, env, roleUID1, "dashboards:read", "dashboards", "uid1") + addPermission(t, env, roleUID2, "folders:read", "folders", "uid2") + + // Collect only dashboards + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + // Should only have dashboard tuples + for _, objTuples := range tuples { + for _, tuple := range objTuples { + assert.NotContains(t, tuple.Object, "folders") + } + } + }) + + t.Run("should return empty for no managed permissions", func(t *testing.T) { + env := setupTestEnv(t) + + collector := managedPermissionsCollector(env.db, "dashboards") + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Empty(t, tuples) + }) +} + +func TestIntegrationRoleBindingsCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect user role bindings", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + roleUID := createRole(t, env, "custom-role", 1) + addUserRole(t, env, user1.ID, roleUID, 1) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + + roleObject := zanzana.NewTupleEntry(zanzana.TypeRole, roleUID, "") + require.Contains(t, tuples, roleObject) + + roleTuples := tuples[roleObject] + require.NotEmpty(t, roleTuples) + + var found bool + for _, tuple := range roleTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeUser, user1.UID, "") && + tuple.Relation == zanzana.RelationAssignee { + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("should collect team role bindings", func(t *testing.T) { + env := setupTestEnv(t) + + team1 := createTeam(t, env, "team1", 1) + roleUID := createRole(t, env, "custom-role", 1) + addTeamRole(t, env, team1.ID, roleUID, 1) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + + roleObject := zanzana.NewTupleEntry(zanzana.TypeRole, roleUID, "") + roleTuples := tuples[roleObject] + + var found bool + for _, tuple := range roleTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeTeam, team1.UID, zanzana.RelationTeamMember) { + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("should collect service account role bindings", func(t *testing.T) { + env := setupTestEnv(t) + + sa := createUser(t, env, "sa1", 1, true) + roleUID := createRole(t, env, "custom-role", 1) + addUserRole(t, env, sa.ID, roleUID, 1) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + var found bool + for _, roleTuples := range tuples { + for _, tuple := range roleTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeServiceAccount, sa.UID, "") { + found = true + break + } + } + } + assert.True(t, found) + }) + + t.Run("should filter out managed roles", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + customRole := createRole(t, env, "custom-role", 1) + managedRole := createRole(t, env, "managed:dash:perm", 1) + addUserRole(t, env, user1.ID, customRole, 1) + addUserRole(t, env, user1.ID, managedRole, 1) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + // Should have custom role + customRoleObject := zanzana.NewTupleEntry(zanzana.TypeRole, customRole, "") + require.Contains(t, tuples, customRoleObject) + + // Should NOT have managed role + managedRoleObject := zanzana.NewTupleEntry(zanzana.TypeRole, managedRole, "") + require.NotContains(t, tuples, managedRoleObject) + }) + + t.Run("should include global roles (org_id=0)", func(t *testing.T) { + env := setupTestEnv(t) + + user1 := createUser(t, env, "user1", 1, false) + globalRole := createRole(t, env, "global-role", 0) + addUserRole(t, env, user1.ID, globalRole, 0) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + globalRoleObject := zanzana.NewTupleEntry(zanzana.TypeRole, globalRole, "") + require.Contains(t, tuples, globalRoleObject) + }) + + t.Run("should return empty for no role bindings", func(t *testing.T) { + env := setupTestEnv(t) + + collector := roleBindingsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Empty(t, tuples) + }) +} + +func TestIntegrationRolePermissionsCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should collect role permissions", func(t *testing.T) { + env := setupTestEnv(t) + + roleUID := createRole(t, env, "custom-role", 1) + addPermission(t, env, roleUID, "dashboards:read", "dashboards", "uid1") + + collector := rolePermissionsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + }) + + t.Run("should filter out managed roles", func(t *testing.T) { + env := setupTestEnv(t) + + customRole := createRole(t, env, "custom-role", 1) + managedRole := createRole(t, env, "managed:dash:perm", 1) + addPermission(t, env, customRole, "dashboards:read", "dashboards", "uid1") + addPermission(t, env, managedRole, "dashboards:read", "dashboards", "uid2") + + collector := rolePermissionsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + // Should only have custom role permissions + foundCustom := false + foundManaged := false + for _, objTuples := range tuples { + for _, tuple := range objTuples { + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeRole, customRole, zanzana.RelationAssignee) { + foundCustom = true + } + if tuple.User == zanzana.NewTupleEntry(zanzana.TypeRole, managedRole, zanzana.RelationAssignee) { + foundManaged = true + } + } + } + assert.True(t, foundCustom, "Should find custom role permissions") + assert.False(t, foundManaged, "Should not find managed role permissions") + }) + + t.Run("should include global role permissions (org_id=0)", func(t *testing.T) { + env := setupTestEnv(t) + + globalRole := createRole(t, env, "global-role", 0) + addPermission(t, env, globalRole, "dashboards:read", "dashboards", "uid1") + + collector := rolePermissionsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.NotEmpty(t, tuples) + }) + + t.Run("should return empty for no permissions", func(t *testing.T) { + env := setupTestEnv(t) + + collector := rolePermissionsCollector(env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + require.Empty(t, tuples) + }) +} + +func TestIntegrationAnonymousRoleBindingsCollector(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + t.Run("should return object with empty tuples when org doesn't match", func(t *testing.T) { + env := setupTestEnv(t) + + cfg := setting.NewCfg() + cfg.Anonymous.Enabled = true + cfg.Anonymous.OrgName = "different-org" + cfg.Anonymous.OrgRole = "Viewer" + + collector := anonymousRoleBindingsCollector(cfg, env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + // Should have object set but no tuples + roleObject := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole("Viewer"), "") + require.Contains(t, tuples, roleObject) + + roleTuples := tuples[roleObject] + require.Empty(t, roleTuples) + }) + + t.Run("should handle non-existent org gracefully", func(t *testing.T) { + env := setupTestEnv(t) + + cfg := setting.NewCfg() + cfg.Anonymous.Enabled = true + cfg.Anonymous.OrgName = "non-existent-org" + cfg.Anonymous.OrgRole = "Viewer" + + collector := anonymousRoleBindingsCollector(cfg, env.db) + tuples, err := collector(env.ctx, 1) + + require.NoError(t, err) + + roleObject := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole("Viewer"), "") + require.Contains(t, tuples, roleObject) + + roleTuples := tuples[roleObject] + require.Empty(t, roleTuples) + }) +} + +func TestZanzanaCollector(t *testing.T) { + t.Run("should collect tuples for single relation", func(t *testing.T) { + mockClient := new(mockZanzanaClient) + + tuples := []*authzextv1.Tuple{ + { + Key: &authzextv1.TupleKey{ + User: "user:user1", + Relation: "member", + Object: "team:team1", + }, + }, + } + + mockClient.On("Read", mock.Anything, mock.MatchedBy(func(req *authzextv1.ReadRequest) bool { + return req.TupleKey.Object == "team:team1" && req.TupleKey.Relation == "member" + })).Return(&authzextv1.ReadResponse{ + Tuples: tuples, + ContinuationToken: "", + }, nil) + + collector := zanzanaCollector([]string{"member"}) + result, err := collector(context.Background(), mockClient, "team:team1", "org:1") + + require.NoError(t, err) + require.Len(t, result, 1) + + mockClient.AssertExpectations(t) + }) + + t.Run("should collect tuples for multiple relations", func(t *testing.T) { + mockClient := new(mockZanzanaClient) + + memberTuples := []*authzextv1.Tuple{ + { + Key: &authzextv1.TupleKey{ + User: "user:user1", + Relation: "member", + Object: "team:team1", + }, + }, + } + + adminTuples := []*authzextv1.Tuple{ + { + Key: &authzextv1.TupleKey{ + User: "user:user2", + Relation: "admin", + Object: "team:team1", + }, + }, + } + + mockClient.On("Read", mock.Anything, mock.MatchedBy(func(req *authzextv1.ReadRequest) bool { + return req.TupleKey.Relation == "member" + })).Return(&authzextv1.ReadResponse{ + Tuples: memberTuples, + ContinuationToken: "", + }, nil) + + mockClient.On("Read", mock.Anything, mock.MatchedBy(func(req *authzextv1.ReadRequest) bool { + return req.TupleKey.Relation == "admin" + })).Return(&authzextv1.ReadResponse{ + Tuples: adminTuples, + ContinuationToken: "", + }, nil) + + collector := zanzanaCollector([]string{"member", "admin"}) + result, err := collector(context.Background(), mockClient, "team:team1", "org:1") + + require.NoError(t, err) + require.Len(t, result, 2) + + mockClient.AssertExpectations(t) + }) + + t.Run("should handle pagination with continuation token", func(t *testing.T) { + mockClient := new(mockZanzanaClient) + + firstPage := []*authzextv1.Tuple{ + { + Key: &authzextv1.TupleKey{ + User: "user:user1", + Relation: "member", + Object: "team:team1", + }, + }, + } + + secondPage := []*authzextv1.Tuple{ + { + Key: &authzextv1.TupleKey{ + User: "user:user2", + Relation: "member", + Object: "team:team1", + }, + }, + } + + // First call returns continuation token + mockClient.On("Read", mock.Anything, mock.MatchedBy(func(req *authzextv1.ReadRequest) bool { + return req.ContinuationToken == "" + })).Return(&authzextv1.ReadResponse{ + Tuples: firstPage, + ContinuationToken: "token1", + }, nil).Once() + + // Second call with continuation token + mockClient.On("Read", mock.Anything, mock.MatchedBy(func(req *authzextv1.ReadRequest) bool { + return req.ContinuationToken == "token1" + })).Return(&authzextv1.ReadResponse{ + Tuples: secondPage, + ContinuationToken: "", + }, nil).Once() + + collector := zanzanaCollector([]string{"member"}) + result, err := collector(context.Background(), mockClient, "team:team1", "org:1") + + require.NoError(t, err) + require.Len(t, result, 2) + + mockClient.AssertExpectations(t) + }) + + t.Run("should return empty for no tuples", func(t *testing.T) { + mockClient := new(mockZanzanaClient) + + mockClient.On("Read", mock.Anything, mock.Anything).Return(&authzextv1.ReadResponse{ + Tuples: []*authzextv1.Tuple{}, + ContinuationToken: "", + }, nil) + + collector := zanzanaCollector([]string{"member"}) + result, err := collector(context.Background(), mockClient, "team:team1", "org:1") + + require.NoError(t, err) + require.Empty(t, result) + + mockClient.AssertExpectations(t) + }) + + t.Run("should handle client error", func(t *testing.T) { + mockClient := new(mockZanzanaClient) + + mockClient.On("Read", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("client error")) + + collector := zanzanaCollector([]string{"member"}) + _, err := collector(context.Background(), mockClient, "team:team1", "org:1") + + require.Error(t, err) + + mockClient.AssertExpectations(t) + }) +} diff --git a/pkg/services/authz/zanzana/tuple_helpers.go b/pkg/services/authz/zanzana/tuple_helpers.go new file mode 100644 index 00000000000..fece8723f9e --- /dev/null +++ b/pkg/services/authz/zanzana/tuple_helpers.go @@ -0,0 +1,81 @@ +package zanzana + +import ( + "github.com/grafana/grafana/pkg/infra/log" + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +// TupleStringWithoutCondition returns the string representation of a tuple without its condition. +// This is useful for deduplicating tuples that have the same user, relation, and object +// but different conditions that need to be merged. +func TupleStringWithoutCondition(tuple *openfgav1.TupleKey) string { + c := tuple.Condition + tuple.Condition = nil + s := tuple.String() + tuple.Condition = c + return s +} + +// RolePermission represents a permission that can be converted to a Zanzana tuple. +type RolePermission struct { + Action string + Kind string + Identifier string +} + +// ConvertRolePermissionsToTuples converts role permissions to Zanzana tuples with proper merging. +// It handles: +// - Translation of RBAC action/kind/identifier to Zanzana tuples +// - Special handling for folder resource tuples (which need to be merged) +// - Deduplication of tuples +// +// Returns a slice of tuples ready to be written to Zanzana, or nil if no valid tuples could be created. +func ConvertRolePermissionsToTuples(roleUID string, permissions []RolePermission) ([]*openfgav1.TupleKey, error) { + if len(permissions) == 0 { + return nil, nil + } + + // Subject for role permissions: role:{uid}#assignee + subject := NewTupleEntry(TypeRole, roleUID, RelationAssignee) + + // Use a map to track tuples, with special handling for folder resource tuples + tupleMap := make(map[string]*openfgav1.TupleKey) + folderResourceTuples := make(map[string]*openfgav1.TupleKey) // key is tuple without condition + + for _, perm := range permissions { + // Convert RBAC action/kind to Zanzana tuple + tuple, ok := TranslateToResourceTuple(subject, perm.Action, perm.Kind, perm.Identifier) + if !ok { + // Skip permissions that can't be translated + log.New("zanzana").Debug("skipping permission that can't be translated", "permission", perm) + continue + } + + // Handle folder resource tuples specially - they need to be merged + if IsFolderResourceTuple(tuple) { + // Create a key without the condition for deduplication + key := TupleStringWithoutCondition(tuple) + if existing, exists := folderResourceTuples[key]; exists { + // Merge this tuple with the existing one + MergeFolderResourceTuples(existing, tuple) + } else { + folderResourceTuples[key] = tuple + } + continue + } + + // For non-folder resource tuples, just add to the map + tupleMap[tuple.String()] = tuple + } + + // Collect all tuples + tuples := make([]*openfgav1.TupleKey, 0, len(tupleMap)+len(folderResourceTuples)) + for _, t := range tupleMap { + tuples = append(tuples, t) + } + for _, t := range folderResourceTuples { + tuples = append(tuples, t) + } + + return tuples, nil +} diff --git a/pkg/services/authz/zanzana/tuple_helpers_test.go b/pkg/services/authz/zanzana/tuple_helpers_test.go new file mode 100644 index 00000000000..4da5490911a --- /dev/null +++ b/pkg/services/authz/zanzana/tuple_helpers_test.go @@ -0,0 +1,145 @@ +package zanzana + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestTupleStringWithoutCondition(t *testing.T) { + tuple := &openfgav1.TupleKey{ + User: "user:123", + Relation: "view", + Object: "folder:abc", + Condition: &openfgav1.RelationshipCondition{ + Name: "group_filter", + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "group_resource": structpb.NewStringValue("dashboards.grafana.app/dashboards"), + }, + }, + }, + } + + result := TupleStringWithoutCondition(tuple) + + // Verify the condition is preserved + require.NotNil(t, tuple.Condition) + require.Equal(t, "group_filter", tuple.Condition.Name) + + // Verify the string doesn't include the condition + require.NotContains(t, result, "group_filter") + require.Contains(t, result, "user:123") + require.Contains(t, result, "view") + require.Contains(t, result, "folder:abc") +} + +func TestConvertRolePermissionsToTuples(t *testing.T) { + t.Run("should convert folder permissions", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "folders:read", Kind: "folders", Identifier: "folder1"}, + {Action: "folders:write", Kind: "folders", Identifier: "folder1"}, + } + + tuples, err := ConvertRolePermissionsToTuples("role-123", permissions) + require.NoError(t, err) + require.Len(t, tuples, 2) + + // Verify all tuples have the correct subject + for _, tuple := range tuples { + require.Equal(t, "role:role-123#assignee", tuple.User) + require.Equal(t, "folder:folder1", tuple.Object) + } + + // Verify relations are correct + relations := []string{tuples[0].Relation, tuples[1].Relation} + require.Contains(t, relations, "get") + require.Contains(t, relations, "update") + }) + + t.Run("should convert dashboard permissions", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "dashboards:read", Kind: "dashboards", Identifier: "dash1"}, + {Action: "dashboards:delete", Kind: "dashboards", Identifier: "dash1"}, + } + + tuples, err := ConvertRolePermissionsToTuples("role-456", permissions) + require.NoError(t, err) + require.Len(t, tuples, 2) + + // Verify all tuples have the correct subject + for _, tuple := range tuples { + require.Equal(t, "role:role-456#assignee", tuple.User) + require.Contains(t, tuple.Object, "resource:") + require.Contains(t, tuple.Object, "dashboard") + } + }) + + t.Run("should handle wildcard scopes", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "folders:read", Kind: "folders", Identifier: "*"}, + } + + tuples, err := ConvertRolePermissionsToTuples("role-789", permissions) + require.NoError(t, err) + require.Len(t, tuples, 1) + + tuple := tuples[0] + require.Equal(t, "role:role-789#assignee", tuple.User) + require.Contains(t, tuple.Object, "group_resource:") + }) + + t.Run("should skip untranslatable permissions", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "folders:read", Kind: "folders", Identifier: "folder1"}, + {Action: "unknown:action", Kind: "unknown", Identifier: "something"}, + } + + tuples, err := ConvertRolePermissionsToTuples("role-abc", permissions) + require.NoError(t, err) + // Only the valid permission should be converted + require.Len(t, tuples, 1) + require.Equal(t, "role:role-abc#assignee", tuples[0].User) + require.Equal(t, "folder:folder1", tuples[0].Object) + }) + + t.Run("should merge folder resource tuples", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "dashboards:read", Kind: "folders", Identifier: "parent-folder"}, + {Action: "dashboards:write", Kind: "folders", Identifier: "parent-folder"}, + } + + tuples, err := ConvertRolePermissionsToTuples("role-merge", permissions) + require.NoError(t, err) + // Folder resource tuples should be merged + require.Greater(t, len(tuples), 0) + + // All tuples should have the correct subject + for _, tuple := range tuples { + require.Equal(t, "role:role-merge#assignee", tuple.User) + } + }) + + t.Run("should return nil for empty permissions", func(t *testing.T) { + tuples, err := ConvertRolePermissionsToTuples("role-empty", []RolePermission{}) + require.NoError(t, err) + require.Nil(t, tuples) + }) + + t.Run("should deduplicate identical tuples", func(t *testing.T) { + permissions := []RolePermission{ + {Action: "folders:read", Kind: "folders", Identifier: "folder1"}, + {Action: "folders:read", Kind: "folders", Identifier: "folder1"}, // Duplicate + } + + tuples, err := ConvertRolePermissionsToTuples("role-dedup", permissions) + require.NoError(t, err) + // Should only have 1 tuple, not 2 + require.Len(t, tuples, 1) + require.Equal(t, "role:role-dedup#assignee", tuples[0].User) + require.Equal(t, "get", tuples[0].Relation) + require.Equal(t, "folder:folder1", tuples[0].Object) + }) +}