diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index 7f0faff1f18..38f38fb90a7 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -58,6 +58,13 @@ const ( RelationGetPermissions string = "get_permissions" RelationSetPermissions string = "set_permissions" + RelationCanGet string = "can_get" + RelationCanCreate string = "can_create" + RelationCanUpdate string = "can_update" + RelationCanDelete string = "can_delete" + RelationCanGetPermissions string = "can_get_permissions" + RelationCanSetPermissions string = "can_set_permissions" + RelationSubresourceSetView string = "resource_" + RelationSetView RelationSubresourceSetEdit string = "resource_" + RelationSetEdit RelationSubresourceSetAdmin string = "resource_" + RelationSetAdmin @@ -134,6 +141,26 @@ var RelationToVerbMapping = map[string]string{ RelationSetPermissions: utils.VerbSetPermissions, } +// FolderPermissionRelation returns the optimized folder relation for permission management. +func FolderPermissionRelation(relation string) string { + switch relation { + case RelationGet: + return RelationCanGet + case RelationCreate: + return RelationCanCreate + case RelationUpdate: + return RelationCanUpdate + case RelationDelete: + return RelationCanDelete + case RelationGetPermissions: + return RelationCanGetPermissions + case RelationSetPermissions: + return RelationCanSetPermissions + default: + return relation + } +} + func IsGroupResourceRelation(relation string) bool { return isValidRelation(relation, RelationsGroupResource) } diff --git a/pkg/services/authz/zanzana/schema/schema_folder.fga b/pkg/services/authz/zanzana/schema/schema_folder.fga index b9b0a842de9..c55d1312f53 100644 --- a/pkg/services/authz/zanzana/schema/schema_folder.fga +++ b/pkg/services/authz/zanzana/schema/schema_folder.fga @@ -4,15 +4,21 @@ type folder relations define parent: [folder] - # Action sets - define view: [user, service-account, team#member, role#assignee] or edit or view from parent - define edit: [user, service-account, team#member, role#assignee] or admin or edit from parent + # Permission levels define admin: [user, service-account, team#member, role#assignee] or admin from parent + define edit: [user, service-account, team#member, role#assignee] or edit from parent + define view: [user, service-account, team#member, role#assignee] or view from parent + define get: [user, service-account, team#member, role#assignee] or get from parent + define create: [user, service-account, team#member, role#assignee] or create from parent + define update: [user, service-account, team#member, role#assignee] or update from parent + define delete: [user, service-account, team#member, role#assignee] or delete from parent + define get_permissions: [user, service-account, team#member, role#assignee] or get_permissions from parent + define set_permissions: [user, service-account, team#member, role#assignee] or set_permissions from parent - define get: [user, service-account, team#member, role#assignee] or view or get from parent - define create: [user, service-account, team#member, role#assignee] or edit or create from parent - define update: [user, service-account, team#member, role#assignee] or edit or update from parent - define delete: [user, service-account, team#member, role#assignee] or edit or delete from parent - - define get_permissions: [user, service-account, team#member, role#assignee] or admin or get_permissions from parent - define set_permissions: [user, service-account, team#member, role#assignee] or admin or set_permissions from parent + # Computed actions + define can_get: admin or edit or view or get + define can_create: admin or edit or create + define can_update: admin or edit or update + define can_delete: admin or edit or delete + define can_get_permissions: admin or get_permissions + define can_set_permissions: admin or set_permissions diff --git a/pkg/services/authz/zanzana/server/server_bench_test.go b/pkg/services/authz/zanzana/server/server_bench_test.go new file mode 100644 index 00000000000..98ec58560b0 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_bench_test.go @@ -0,0 +1,947 @@ +package server + +import ( + "context" + "fmt" + "math/rand" + "testing" + "time" + + authzv1 "github.com/grafana/authlib/authz/proto/v1" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/log" + "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/common" + "github.com/grafana/grafana/pkg/services/authz/zanzana/store" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + benchNamespace = "default" + + // Folder tree parameters + foldersPerLevel = 3 + folderDepth = 7 + + // Other data generation parameters + numResources = 50000 + numUsers = 1000 + numTeams = 100 + + // Timeout for List operations + listTimeout = 30 * time.Second + + // Resource type constants for benchmarks + benchDashboardGroup = "dashboard.grafana.app" + benchDashboardResource = "dashboards" + benchFolderGroup = "folder.grafana.app" + benchFolderResource = "folders" + + // BenchmarkBatchCheck measures the performance of BatchCheck requests with 50 items per batch. + batchCheckSize = 50 +) + +// benchmarkData holds all the generated test data for benchmarks +type benchmarkData struct { + folders []string // folder UIDs + folderDepths map[string]int // folder UID -> depth level + folderParents map[string]string // folder UID -> parent UID + folderDescendants map[string]int // folder UID -> number of descendants (including self) + foldersByDepth [][]string // folders grouped by depth level + resources []string // resource names + resourceFolders map[string]string // resource name -> folder UID + users []string // user identifiers (e.g., "user:1") + teams []string // team identifiers (e.g., "team:1") + + // Pre-computed test scenarios + deepestFolder string // folder at max depth for worst-case tests + midDepthFolder string // folder at depth/2 + shallowFolder string // folder at depth 1 + rootFolder string // root level folder (depth 0) + largestRootFolder string // root folder with most descendants + largestRootDescCount int // number of descendants in largestRootFolder + maxDepth int // maximum depth in the tree +} + +// generateFolderHierarchy creates a balanced tree of folders. +// Each folder has `childrenPerFolder` children, up to `depth` levels deep. +func generateFolderHierarchy(childrenPerFolder, depth int) ([]*openfgav1.TupleKey, *benchmarkData) { + // Calculate total folders: childrenPerFolder + childrenPerFolder^2 + ... + childrenPerFolder^(depth+1) + totalFolders := 0 + levelSize := childrenPerFolder + for d := 0; d <= depth; d++ { + totalFolders += levelSize + levelSize *= childrenPerFolder + } + + data := &benchmarkData{ + folders: make([]string, 0, totalFolders), + folderDepths: make(map[string]int), + folderParents: make(map[string]string), + folderDescendants: make(map[string]int), + } + tuples := make([]*openfgav1.TupleKey, 0, totalFolders) + + folderIdx := 0 + + // Track folders at each level for parent assignment + levelFolders := make([][]string, depth+1) + for i := range levelFolders { + levelFolders[i] = make([]string, 0) + } + + // Create root level folders (depth 0) + for i := 0; i < childrenPerFolder; i++ { + folderUID := fmt.Sprintf("folder-%d", folderIdx) + data.folders = append(data.folders, folderUID) + data.folderDepths[folderUID] = 0 + levelFolders[0] = append(levelFolders[0], folderUID) + folderIdx++ + } + + // Create folders at each subsequent depth level + for d := 1; d <= depth; d++ { + parentFolders := levelFolders[d-1] + + // Each parent gets exactly childrenPerFolder children + for _, parentUID := range parentFolders { + for j := 0; j < childrenPerFolder; j++ { + folderUID := fmt.Sprintf("folder-%d", folderIdx) + + data.folders = append(data.folders, folderUID) + data.folderDepths[folderUID] = d + data.folderParents[folderUID] = parentUID + levelFolders[d] = append(levelFolders[d], folderUID) + + // Create parent relationship tuple + tuples = append(tuples, common.NewFolderParentTuple(folderUID, parentUID)) + folderIdx++ + } + } + } + + // Set reference folders for different depth scenarios + data.rootFolder = levelFolders[0][0] + data.shallowFolder = levelFolders[0][0] + if len(levelFolders[1]) > 0 { + data.shallowFolder = levelFolders[1][0] + } + midDepth := depth / 2 + if len(levelFolders[midDepth]) > 0 { + data.midDepthFolder = levelFolders[midDepth][0] + } + // Deepest folder + if len(levelFolders[depth]) > 0 { + data.deepestFolder = levelFolders[depth][0] + } + + // Calculate descendant counts for each folder (bottom-up) + // Initialize all folders with count of 1 (self) + for _, folder := range data.folders { + data.folderDescendants[folder] = 1 + } + // Process folders from deepest to shallowest, accumulating descendant counts + for d := depth; d >= 0; d-- { + for _, folder := range levelFolders[d] { + if parent, hasParent := data.folderParents[folder]; hasParent { + data.folderDescendants[parent] += data.folderDescendants[folder] + } + } + } + + // Find root folder with most descendants + for _, rootFolder := range levelFolders[0] { + count := data.folderDescendants[rootFolder] + if count > data.largestRootDescCount { + data.largestRootDescCount = count + data.largestRootFolder = rootFolder + } + } + + // Store folders by depth for depth-based testing + data.foldersByDepth = levelFolders + data.maxDepth = depth + + return tuples, data +} + +// generateResources creates resources distributed across folders +func generateResources(data *benchmarkData, numResources int) []*openfgav1.TupleKey { + data.resources = make([]string, numResources) + data.resourceFolders = make(map[string]string, numResources) + + // Distribute resources across folders + for i := 0; i < numResources; i++ { + resourceName := fmt.Sprintf("resource-%d", i) + folderIdx := i % len(data.folders) + folderUID := data.folders[folderIdx] + + data.resources[i] = resourceName + data.resourceFolders[resourceName] = folderUID + } + + // Note: We don't create tuples for resources themselves, + // permissions are assigned to users/teams on folders or directly on resources + return nil +} + +// generateUsers creates user identifiers +func generateUsers(data *benchmarkData, numUsers int) { + data.users = make([]string, numUsers) + for i := 0; i < numUsers; i++ { + data.users[i] = fmt.Sprintf("user:%d", i) + } +} + +// generateTeams creates team identifiers +func generateTeams(data *benchmarkData, numTeams int) { + data.teams = make([]string, numTeams) + for i := 0; i < numTeams; i++ { + data.teams[i] = fmt.Sprintf("team:%d", i) + } +} + +// generatePermissionTuples creates various permission assignments for benchmarking. +// Users are distributed across 7 patterns: global, root folder, mid-depth folder, +// folder-scoped resource, direct resource, team-based, and no permissions. +const numPermissionPatterns = 7 + +func generatePermissionTuples(data *benchmarkData) []*openfgav1.TupleKey { + tuples := make([]*openfgav1.TupleKey, 0) + + // Distribute users across different permission patterns + usersPerPattern := len(data.users) / numPermissionPatterns + + // Pattern 1: Users with GroupResource permission (all access) + // Users 0 to usersPerPattern-1 + for i := 0; i < usersPerPattern; i++ { + tuples = append(tuples, common.NewGroupResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + )) + } + + // Pattern 2: Users with folder-level permission on root folders + // Users usersPerPattern to 2*usersPerPattern-1 + for i := usersPerPattern; i < 2*usersPerPattern; i++ { + folderIdx := (i - usersPerPattern) % len(data.folders) + // Only assign to root-level folders for this pattern + for j := folderIdx; j < len(data.folders); j++ { + if data.folderDepths[data.folders[j]] == 0 { + tuples = append(tuples, common.NewFolderTuple( + data.users[i], + common.RelationSetView, + data.folders[j], + )) + break + } + } + } + + // Pattern 3: Users with folder-level permission on mid-depth folders + // Use relative depth range: 1/3 to 2/3 of max depth + // Use "view" relation which grants get through the optimized schema + minMidDepth := data.maxDepth / 3 + maxMidDepth := 2 * data.maxDepth / 3 + if maxMidDepth < minMidDepth { + maxMidDepth = minMidDepth + } + // Collect folders in the mid-depth range + var midDepthFolders []string + for d := minMidDepth; d <= maxMidDepth; d++ { + if d < len(data.foldersByDepth) { + midDepthFolders = append(midDepthFolders, data.foldersByDepth[d]...) + } + } + // Fall back to root folders if no mid-depth folders exist + if len(midDepthFolders) == 0 { + midDepthFolders = data.foldersByDepth[0] + } + for i := 2 * usersPerPattern; i < 3*usersPerPattern; i++ { + folderIdx := (i - 2*usersPerPattern) % len(midDepthFolders) + tuples = append(tuples, common.NewFolderTuple( + data.users[i], + common.RelationSetView, + midDepthFolders[folderIdx], + )) + } + + // Pattern 4: Users with folder-scoped resource permission + for i := 3 * usersPerPattern; i < 4*usersPerPattern; i++ { + folderIdx := (i - 3*usersPerPattern) % len(data.folders) + tuples = append(tuples, common.NewFolderResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + data.folders[folderIdx], + )) + } + + // Pattern 5: Users with direct resource permission + for i := 4 * usersPerPattern; i < 5*usersPerPattern; i++ { + resourceIdx := (i - 4*usersPerPattern) % len(data.resources) + tuples = append(tuples, common.NewResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + data.resources[resourceIdx], + )) + } + + // Pattern 6: Team memberships and team permissions + // First, add users to teams + for i := 5 * usersPerPattern; i < 6*usersPerPattern && i < len(data.users); i++ { + teamIdx := (i - 5*usersPerPattern) % len(data.teams) + tuples = append(tuples, common.NewTypedTuple( + common.TypeTeam, + data.users[i], + common.RelationTeamMember, + fmt.Sprintf("%d", teamIdx), + )) + } + // Then, give teams folder permissions + // Use "view" relation which grants get through the optimized schema + for i := 0; i < len(data.teams); i++ { + folderIdx := i % len(data.folders) + teamMember := fmt.Sprintf("team:%d#member", i) + tuples = append(tuples, common.NewFolderTuple( + teamMember, + common.RelationSetView, + data.folders[folderIdx], + )) + } + + // Pattern 7: Users with no permissions (remaining users) + // These users don't get any tuples - they're for testing denial cases + + return tuples +} + +// setupBenchmarkServer creates a server with the benchmark data loaded +func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) { + b.Helper() + if testing.Short() { + b.Skip("skipping benchmark in short mode") + } + + cfg := setting.NewCfg() + testStore := sqlstore.NewTestStore(b, sqlstore.WithCfg(cfg)) + + openFGAStore, err := store.NewEmbeddedStore(cfg, testStore, log.NewNopLogger()) + require.NoError(b, err) + + openfga, err := NewOpenFGAServer(cfg.ZanzanaServer, openFGAStore) + require.NoError(b, err) + + srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService(), prometheus.NewRegistry()) + require.NoError(b, err) + + // Generate test data + b.Log("Generating folder hierarchy...") + folderTuples, data := generateFolderHierarchy(foldersPerLevel, folderDepth) + + b.Log("Generating resources...") + generateResources(data, numResources) + + b.Log("Generating users...") + generateUsers(data, numUsers) + + b.Log("Generating teams...") + generateTeams(data, numTeams) + + b.Log("Generating permission tuples...") + permTuples := generatePermissionTuples(data) + + // Add special user with permission on largest root folder (for >1000 folder test) + // Use "view" relation which grants get through the optimized schema + largeRootUserTuple := common.NewFolderTuple( + "user:large-root-access", + common.RelationSetView, + data.largestRootFolder, + ) + permTuples = append(permTuples, largeRootUserTuple) + + // Add users with permissions at each depth level for depth-based testing + // Use "view" relation which grants get through the optimized schema + for depth := 0; depth <= data.maxDepth; depth++ { + if len(data.foldersByDepth[depth]) == 0 { + continue + } + folder := data.foldersByDepth[depth][0] + user := fmt.Sprintf("user:depth-%d-access", depth) + permTuples = append(permTuples, common.NewFolderTuple(user, common.RelationSetView, folder)) + } + + // Combine all tuples + allTuples := append(folderTuples, permTuples...) + + b.Logf("Total tuples to write: %d", len(allTuples)) + + // Get store info + ctx := newContextWithNamespace() + storeInf, err := srv.getStoreInfo(ctx, benchNamespace) + require.NoError(b, err) + + // Write tuples in batches (OpenFGA limits to 100 per write) + batchSize := 100 + for i := 0; i < len(allTuples); i += batchSize { + end := i + batchSize + if end > len(allTuples) { + end = len(allTuples) + } + batch := allTuples[i:end] + + _, err = srv.openfga.Write(ctx, &openfgav1.WriteRequest{ + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, + Writes: &openfgav1.WriteRequestWrites{ + TupleKeys: batch, + OnDuplicate: "ignore", + }, + }) + require.NoError(b, err) + + if (i/batchSize)%100 == 0 { + b.Logf("Written %d/%d tuples", end, len(allTuples)) + } + } + + b.Logf("Benchmark data setup complete: %d folders, %d resources, %d users, %d teams", + len(data.folders), len(data.resources), len(data.users), len(data.teams)) + b.Logf("Largest root folder: %s with %d descendants", data.largestRootFolder, data.largestRootDescCount) + + return srv, data +} + +// BenchmarkCheck measures the performance of Check requests +func BenchmarkCheck(b *testing.B) { + srv, data := setupBenchmarkServer(b) + ctx := newContextWithNamespace() + + // Helper to create check requests + newCheckReq := func(subject, verb, group, resource, folder, name string) *authzv1.CheckRequest { + return &authzv1.CheckRequest{ + Namespace: benchNamespace, + Subject: subject, + Verb: verb, + Group: group, + Resource: resource, + Folder: folder, + Name: name, + } + } + + usersPerPattern := len(data.users) / 7 + + b.Run("GroupResourceDirect", func(b *testing.B) { + // User with group_resource permission - should have access to everything + user := data.users[0] // First user has GroupResource permission + resource := data.resources[rand.Intn(len(data.resources))] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + if !res.GetAllowed() { + b.Fatal("expected access to be allowed") + } + } + }) + + // Test folder inheritance at each depth level (0 to maxDepth) + // User has permission on ROOT folder (depth 0), we check access at each deeper level + rootUser := "user:depth-0-access" // has view permission on root folder + for depth := 0; depth <= data.maxDepth; depth++ { + depth := depth // capture for closure + if len(data.foldersByDepth[depth]) == 0 { + continue + } + b.Run(fmt.Sprintf("FolderInheritance/Depth%d", depth), func(b *testing.B) { + resource := data.resources[0] + folder := data.foldersByDepth[depth][0] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(rootUser, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + } + + b.Run("FolderResourceScoped", func(b *testing.B) { + // User with folder-scoped resource permission + user := data.users[3*usersPerPattern] + folderIdx := 0 + folder := data.folders[folderIdx] + resource := data.resources[folderIdx] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("DirectResource", func(b *testing.B) { + // User with direct resource permission + user := data.users[4*usersPerPattern] + resourceIdx := 0 + resource := data.resources[resourceIdx] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("TeamMembership", func(b *testing.B) { + // User who is a team member, team has folder permission + user := data.users[5*usersPerPattern] + teamIdx := 0 + folderIdx := teamIdx % len(data.folders) + folder := data.folders[folderIdx] + resource := data.resources[folderIdx%len(data.resources)] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - tests denial path + user := data.users[len(data.users)-1] // Last user has no permissions + resource := data.resources[0] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + if res.GetAllowed() { + b.Fatal("expected access to be denied") + } + } + }) + + b.Run("FolderCheck", func(b *testing.B) { + // Direct folder access check + user := data.users[usersPerPattern] + folder := data.rootFolder + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource, "", folder)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) +} + +func BenchmarkBatchCheck(b *testing.B) { + srv, data := setupBenchmarkServer(b) + ctx := newContextWithNamespace() + + // Helper to create batch check requests + newBatchCheckReq := func(subject string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest { + return &authzextv1.BatchCheckRequest{ + Namespace: benchNamespace, + Subject: subject, + Items: items, + } + } + + // Helper to create batch items for resources in folders + createBatchItems := func(resources []string, resourceFolders map[string]string) []*authzextv1.BatchCheckItem { + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for i := 0; i < batchCheckSize && i < len(resources); i++ { + resource := resources[i] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: resource, + Folder: resourceFolders[resource], + }) + } + return items + } + + // Helper to create batch items for folders at a specific depth + createFolderBatchItems := func(folders []string, depth int, folderDepths map[string]int) []*authzextv1.BatchCheckItem { + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for _, folder := range folders { + if folderDepths[folder] == depth && len(items) < batchCheckSize { + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-in-%s", folder), + Folder: folder, + }) + } + } + // Fill remaining slots if needed + for len(items) < batchCheckSize && len(folders) > 0 { + folder := folders[len(items)%len(folders)] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-%d", len(items)), + Folder: folder, + }) + } + return items + } + + usersPerPattern := len(data.users) / numPermissionPatterns + + b.Run("GroupResourceDirect", func(b *testing.B) { + // User with group_resource permission - should have access to everything + user := data.users[0] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth1", func(b *testing.B) { + // User with folder permission on shallow folder + user := data.users[usersPerPattern] + items := createFolderBatchItems(data.folders, 1, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth4", func(b *testing.B) { + // User with folder permission on mid-depth folder + user := data.users[2*usersPerPattern] + items := createFolderBatchItems(data.folders, 4, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth7", func(b *testing.B) { + // Check access on deepest folders (worst case for inheritance traversal) + user := data.users[usersPerPattern] + items := createFolderBatchItems(data.folders, data.maxDepth, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("DirectResource", func(b *testing.B) { + // User with direct resource permission + user := data.users[4*usersPerPattern] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("TeamMembership", func(b *testing.B) { + // User who is a team member, team has folder permission + user := data.users[5*usersPerPattern] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - tests denial path + user := data.users[len(data.users)-1] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("MixedFolders", func(b *testing.B) { + // Batch of items across different folder depths + user := data.users[usersPerPattern] + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for i := 0; i < batchCheckSize; i++ { + folder := data.folders[i%len(data.folders)] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-%d", i), + Folder: folder, + }) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) +} + +// BenchmarkList measures the performance of List requests (Compile equivalent) +func BenchmarkList(b *testing.B) { + srv, data := setupBenchmarkServer(b) + baseCtx := newContextWithNamespace() + + // Helper to create list requests + newListReq := func(subject, verb, group, resource string) *authzv1.ListRequest { + return &authzv1.ListRequest{ + Namespace: benchNamespace, + Subject: subject, + Verb: verb, + Group: group, + Resource: resource, + } + } + + // Helper to create context with timeout + ctxWithTimeout := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(baseCtx, listTimeout) + } + + usersPerPattern := len(data.users) / 7 + + b.Run("AllAccess", func(b *testing.B) { + // User with group_resource permission - should return All=true quickly + user := data.users[0] + b.Logf("Test: User with group_resource permission (access to ALL dashboards)") + b.Logf("Expected: All=true returned immediately without ListObjects call") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if !res.GetAll() { + b.Fatal("expected All=true for user with group_resource permission") + } + } + }) + + b.Run("FolderScoped", func(b *testing.B) { + // User with folder permissions - should return folder list + user := data.users[usersPerPattern] + b.Logf("Test: User with direct folder permission on a single folder") + b.Logf("Expected: Returns list of folders user has access to") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("DirectResources", func(b *testing.B) { + // User with direct resource permissions - should return items list + user := data.users[4*usersPerPattern] + b.Logf("Test: User with direct permission on specific resources") + b.Logf("Expected: Returns list of specific resources user has access to") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - should return empty results + user := data.users[len(data.users)-1] + b.Logf("Test: User with NO permissions (denial case)") + b.Logf("Expected: Empty results") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("LargeRootFolder", func(b *testing.B) { + // User with access to root folder that has many descendants + user := "user:large-root-access" + b.Logf("Test: User with permission on ROOT folder (folder-0)") + b.Logf("Root folder %s has %d total descendants", data.largestRootFolder, data.largestRootDescCount) + b.Logf("Expected: ListObjects should return folders through inheritance") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + start := time.Now() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + elapsed := time.Since(start) + cancel() + if err != nil { + b.Fatalf("Error after %v: %v", elapsed, err) + } + if i == 0 { + b.Logf("Result: %d folders returned in %v (descendants: %d)", + len(res.GetItems()), elapsed, data.largestRootDescCount) + } + } + }) + + // Test List at various folder depths to find breaking point + b.Run("ByDepth", func(b *testing.B) { + b.Logf("Testing List performance at various folder depths (timeout: %v)", listTimeout) + b.Logf("Tree structure: %d folders per level, %d max depth", foldersPerLevel, data.maxDepth) + + for depth := 0; depth <= data.maxDepth; depth++ { + if len(data.foldersByDepth[depth]) == 0 { + continue + } + + folder := data.foldersByDepth[depth][0] + descendants := data.folderDescendants[folder] + user := fmt.Sprintf("user:depth-%d-access", depth) + + b.Run(fmt.Sprintf("Depth%d_%dDescendants", depth, descendants), func(b *testing.B) { + b.Logf("Test: User with permission on folder at depth %d", depth) + b.Logf("Folder: %s, Descendants: %d", folder, descendants) + + // First, do a single timed run to report + ctx, cancel := ctxWithTimeout() + start := time.Now() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + elapsed := time.Since(start) + cancel() + + if err != nil { + b.Logf("FAILED after %v: %v", elapsed, err) + if elapsed >= listTimeout { + b.Logf("TIMEOUT: List took longer than %v", listTimeout) + } + b.Skip("Skipping benchmark iterations due to error") + return + } + + b.Logf("Result: %d folders in %v", len(res.GetItems()), elapsed) + + if elapsed > 5*time.Second { + b.Logf("WARNING: Single List took %v, skipping benchmark iterations", elapsed) + b.Skip("Too slow for benchmark iterations") + return + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + _, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + } + }) + } + }) +} diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index 916c84e5c0a..2f49641f17f 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -126,8 +126,14 @@ func (s *Server) checkTyped(ctx context.Context, subject, relation string, resou return &authzv1.CheckResponse{Allowed: false}, nil } + // Use optimized folder permission relations for permission management + checkRelation := relation + if resource.Type() == common.TypeFolder { + checkRelation = common.FolderPermissionRelation(relation) + } + // Check if subject has direct access to resource - res, err := s.openfgaCheck(ctx, store, subject, relation, resourceIdent, contextuals, nil) + res, err := s.openfgaCheck(ctx, store, subject, checkRelation, resourceIdent, contextuals, nil) if err != nil { return nil, err } @@ -143,14 +149,15 @@ func (s *Server) checkGeneric(ctx context.Context, subject, relation string, res defer span.End() var ( - folderIdent = resource.FolderIdent() - resourceCtx = resource.Context() - folderRelation = common.SubresourceRelation(relation) + folderIdent = resource.FolderIdent() + resourceCtx = resource.Context() + folderRelation = common.SubresourceRelation(relation) + folderCheckRelation = common.FolderPermissionRelation(relation) ) if folderIdent != "" && isFolderPermissionBasedResource(resource.GroupResource()) { // Check if resource inherits permissions from the folder (like dashboards in a folder) - res, err := s.openfgaCheck(ctx, store, subject, relation, folderIdent, contextuals, resourceCtx) + res, err := s.openfgaCheck(ctx, store, subject, folderCheckRelation, folderIdent, contextuals, resourceCtx) if err != nil { return nil, err } diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index 216e8df933e..9734f186d2a 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -85,6 +85,12 @@ func (s *Server) listTyped(ctx context.Context, subject, relation string, resour resourceCtx = resource.Context() ) + // Use optimized folder permission relations for permission management + listRelation := relation + if resource.Type() == common.TypeFolder { + listRelation = common.FolderPermissionRelation(relation) + } + var items []string if resource.HasSubresource() && common.IsSubresourceRelation(subresourceRelation) { // List requested subresources @@ -110,7 +116,7 @@ func (s *Server) listTyped(ctx context.Context, subject, relation string, resour StoreId: store.ID, AuthorizationModelId: store.ModelID, Type: resource.Type(), - Relation: relation, + Relation: listRelation, User: subject, ContextualTuples: contextuals, }) @@ -129,8 +135,9 @@ func (s *Server) listGeneric(ctx context.Context, subject, relation string, reso defer span.End() var ( - folderRelation = common.SubresourceRelation(relation) - resourceCtx = resource.Context() + folderRelation = common.SubresourceRelation(relation) + folderListRelation = common.FolderPermissionRelation(relation) // Optimized for permission management + resourceCtx = resource.Context() ) // 1. List all folders subject has access to resource type in @@ -159,7 +166,7 @@ func (s *Server) listGeneric(ctx context.Context, subject, relation string, reso StoreId: store.ID, AuthorizationModelId: store.ModelID, Type: common.TypeFolder, - Relation: relation, + Relation: folderListRelation, User: subject, Context: resourceCtx, ContextualTuples: contextuals,