From 2e1704b56f01bb3eb72e05aa3172791116619bee Mon Sep 17 00:00:00 2001 From: Jo Date: Thu, 23 Oct 2025 09:47:39 +0200 Subject: [PATCH] Access: Add AfterCreate hooks for Roles/Core Roles (#112666) As part of migrating Grafana's authorization system to Zanzana (OpenFGA), we need to ensure that role permissions defined in the IAM API are automatically synced to the authorization backend. Without this sync, roles created through the API would not be enforced by Zanzana, creating an inconsistency between defined permissions and actual authorization decisions. This is a critical piece of the dual-write pattern during the migration to Zanzana, ensuring that: Role permissions are immediately available for authorization checks The legacy RBAC system and new Zanzana system remain in sync Users experience consistent permission enforcement regardless of which backend is queried safe to revert --- pkg/registry/apis/iam/hooks.go | 136 ++- pkg/registry/apis/iam/hooks_test.go | 385 +++++- pkg/registry/apis/iam/register.go | 8 + .../accesscontrol/dualwrite/collectors.go | 61 +- .../dualwrite/collectors_test.go | 1074 +++++++++++++++++ pkg/services/authz/zanzana/tuple_helpers.go | 81 ++ .../authz/zanzana/tuple_helpers_test.go | 145 +++ 7 files changed, 1816 insertions(+), 74 deletions(-) create mode 100644 pkg/services/accesscontrol/dualwrite/collectors_test.go create mode 100644 pkg/services/authz/zanzana/tuple_helpers.go create mode 100644 pkg/services/authz/zanzana/tuple_helpers_test.go 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) + }) +}