Implement BatchCheck method in Authz service with comprehensive unit tests

- Added BatchCheck method to the Authz service, enabling multiple access checks in a single request with optimized batching.
- Implemented request validation, grouping checks by namespace and action to enhance performance.
- Developed extensive unit tests for BatchCheck, covering various scenarios including empty checks, invalid namespaces, and user permission checks.
- Enhanced caching behavior for permissions and integrated folder inheritance checks.
- Updated related test cases to ensure robust validation of the new functionality.
This commit is contained in:
Georges Chaudy
2026-01-06 16:59:24 +01:00
parent 4f3f9ebc04
commit d2c78f5799
7 changed files with 1056 additions and 34 deletions
+144
View File
@@ -186,6 +186,150 @@ func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv
return &authzv1.CheckResponse{Allowed: allowed}, nil
}
// BatchCheck implements authzv1.AuthzServiceServer.BatchCheck
// This performs multiple access checks in a single request with optimized batching.
// 1. Validates the subject once
// 2. Groups checks by (namespace, action) to load permissions once per group
// 3. Reuses the folder tree across checks
func (s *Service) BatchCheck(ctx context.Context, req *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.BatchCheck")
defer span.End()
checks := req.GetChecks()
span.SetAttributes(attribute.Int("check_count", len(checks)))
ctxLogger := s.logger.FromContext(ctx).New(
"subject", req.GetSubject(),
"check_count", len(checks),
)
defer func(start time.Time) {
ctxLogger.Debug("BatchCheck execution time", "duration", time.Since(start).Milliseconds())
}(time.Now())
// Early check for auth info - required for namespace validation
if _, has := types.AuthInfoFrom(ctx); !has {
return nil, status.Error(codes.Internal, "could not get auth info from context")
}
if len(checks) == 0 {
return &authzv1.BatchCheckResponse{
Results: make(map[string]*authzv1.BatchCheckResult),
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}, nil
}
// Validate subject once for all checks
userUID, idType, err := s.validateSubject(ctx, req.GetSubject())
if err != nil {
ctxLogger.Error("invalid subject", "error", err)
// Return all checks as denied with the same error
results := make(map[string]*authzv1.BatchCheckResult, len(checks))
for _, item := range checks {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{
Allowed: false,
Error: err.Error(),
}
}
return &authzv1.BatchCheckResponse{Results: results, Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()}}, nil
}
results := make(map[string]*authzv1.BatchCheckResult, len(checks))
// Group checks by (namespace, action) to batch permission lookups
type checkGroup struct {
namespace types.NamespaceInfo
action string
actionSets []string
items []*authzv1.BatchCheckItem
checkReqs []*checkRequest
}
groups := make(map[string]*checkGroup)
// First pass: validate and group checks
for _, item := range checks {
ns, err := validateNamespace(ctx, item.GetNamespace())
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
action, actionSets, err := s.validateAction(ctx, item.GetGroup(), item.GetResource(), item.GetVerb())
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
// Create the internal check request
checkReq := &checkRequest{
Namespace: ns,
UserUID: userUID,
IdentityType: idType,
Action: action,
ActionSets: actionSets,
Group: item.GetGroup(),
Resource: item.GetResource(),
Verb: item.GetVerb(),
Name: item.GetName(),
ParentFolder: item.GetFolder(),
}
// Group by namespace + action
groupKey := ns.Value + ":" + action
if g, ok := groups[groupKey]; ok {
g.items = append(g.items, item)
g.checkReqs = append(g.checkReqs, checkReq)
} else {
groups[groupKey] = &checkGroup{
namespace: ns,
action: action,
actionSets: actionSets,
items: []*authzv1.BatchCheckItem{item},
checkReqs: []*checkRequest{checkReq},
}
}
}
// Second pass: process each group with shared permissions
for _, group := range groups {
// Set namespace in context for this group (required by store methods)
groupCtx := request.WithNamespace(ctx, group.namespace.Value)
// Try to get cached permissions first, then fall back to store
permissions, err := s.getCachedIdentityPermissions(groupCtx, group.namespace, idType, userUID, group.action)
if err != nil {
// Cache miss - fetch from store
permissions, err = s.getIdentityPermissions(groupCtx, group.namespace, idType, userUID, group.action, group.actionSets)
if err != nil {
ctxLogger.Error("could not get permissions", "namespace", group.namespace.Value, "action", group.action, "error", err)
for _, item := range group.items {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
}
continue
}
}
// Check each item in the group using the shared permissions
for i, item := range group.items {
checkReq := group.checkReqs[i]
allowed, err := s.checkPermission(groupCtx, permissions, checkReq)
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: allowed}
}
}
span.SetAttributes(attribute.Int("groups_processed", len(groups)))
return &authzv1.BatchCheckResponse{
Results: results,
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}, nil
}
func (s *Service) List(ctx context.Context, req *authzv1.ListRequest) (*authzv1.ListResponse, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.List")
defer span.End()
+607
View File
@@ -1829,6 +1829,613 @@ func TestService_CacheList(t *testing.T) {
})
}
func TestService_BatchCheck(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
Audience: []string{"authzservice"},
},
Rest: authn.AccessTokenClaims{Namespace: "org-12"},
})
t.Run("Require auth info", func(t *testing.T) {
s := setupService()
ctx := context.Background()
_, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "could not get auth info")
})
t.Run("Empty checks returns empty results", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Empty(t, resp.Results)
})
type batchCheckTestCase struct {
name string
checks []*authzv1.BatchCheckItem
permissions []accesscontrol.Permission
folders []store.Folder
expectedResults map[string]bool
expectedErrors map[string]bool // true if error expected for this correlation ID
expectGlobalError bool
}
t.Run("Request validation", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should return error for invalid namespace",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for namespace mismatch",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-13",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for unknown group",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "unknown.grafana.app",
Resource: "unknown",
Verb: "get",
Name: "u1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for unknown verb",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "unknown",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
store := &fakeStore{
userID: userID,
userPermissions: tc.permissions,
}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
if tc.expectedErrors[corrID] {
require.NotEmpty(t, result.Error, "expected error for %s", corrID)
}
}
})
}
})
t.Run("User permission checks", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should allow user with permission on single resource",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should deny user without permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
expectedResults: map[string]bool{"check1": false},
},
{
name: "should handle multiple checks with mixed results",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash3",
CorrelationId: "check3",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:read", Scope: "dashboards:uid:dash3"},
},
expectedResults: map[string]bool{
"check1": true,
"check2": false,
"check3": true,
},
},
{
name: "should handle wildcard permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "*", Kind: "*"}},
expectedResults: map[string]bool{"check1": true, "check2": true},
},
{
name: "should handle folder inheritance",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
Folder: "child",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:uid:parent", Kind: "folders", Attribute: "uid", Identifier: "parent"},
},
folders: []store.Folder{
{UID: "parent"},
{UID: "child", ParentUID: strPtr("parent")},
},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should handle action sets",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:admin", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should handle checks across different resources",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "folder.grafana.app",
Resource: "folders",
Verb: "get",
Name: "fold1",
CorrelationId: "check2",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "folders:read", Scope: "folders:uid:fold1"},
},
expectedResults: map[string]bool{"check1": true, "check2": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
store := &fakeStore{
userID: userID,
userPermissions: tc.permissions,
folders: tc.folders,
}
s.store = store
s.permissionStore = store
s.folderStore = store
s.identityStore = &fakeIdentityStore{}
if tc.folders != nil {
s.folderCache.Set(ctx, folderCacheKey("org-12"), newFolderTree(tc.folders))
}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Len(t, resp.Results, len(tc.expectedResults))
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
}
})
}
})
t.Run("Anonymous permission checks", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should allow anonymous with permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should deny anonymous without permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
expectedResults: map[string]bool{"check1": false},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
store := &fakeStore{userPermissions: tc.permissions}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "anonymous:0",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
}
})
}
})
t.Run("Rendering permission checks", func(t *testing.T) {
t.Run("should allow rendering with permission", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "render:0",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.True(t, resp.Results["check1"].Allowed)
})
t.Run("should deny rendering access to another app resources", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "render:0",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "another.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.False(t, resp.Results["check1"].Allowed)
require.NotEmpty(t, resp.Results["check1"].Error)
})
})
t.Run("Invalid subject returns errors for all checks", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
store := &fakeStore{}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "invalid:12",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Len(t, resp.Results, 2)
for _, result := range resp.Results {
require.False(t, result.Allowed)
require.NotEmpty(t, result.Error)
}
})
t.Run("Grouping optimization", func(t *testing.T) {
t.Run("should batch permission lookups for same action", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
fStore := &fakeStore{
userID: userID,
userPermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
},
}
s.store = fStore
s.permissionStore = fStore
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash3",
CorrelationId: "check3",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.True(t, resp.Results["check1"].Allowed)
require.True(t, resp.Results["check2"].Allowed)
require.False(t, resp.Results["check3"].Allowed)
// Verify permissions were fetched only once (1 call for userID + 1 call for basicRole + 1 call for permissions)
require.Equal(t, 3, fStore.calls)
})
})
}
func TestService_CacheBatchCheck(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
Audience: []string{"authzservice"},
},
Rest: authn.AccessTokenClaims{Namespace: "org-12"},
})
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
t.Run("Allow based on cached permissions", func(t *testing.T) {
s := setupService()
s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
s.permCache.Set(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"), map[string]bool{"dashboards:uid:dash1": true})
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.True(t, resp.Results["check1"].Allowed)
})
t.Run("Fallback to database on cache miss", func(t *testing.T) {
s := setupService()
// Populate database but not cache
fStore := &fakeStore{
userID: userID,
userPermissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
}
s.store = fStore
s.permissionStore = fStore
s.identityStore = &fakeIdentityStore{}
s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.True(t, resp.Results["check1"].Allowed)
})
}
func setupService() *Service {
cache := cache.NewLocalCache(cache.Config{Expiry: 5 * time.Minute, CleanupInterval: 5 * time.Minute})
logger := log.New("authz-rbac-service")
@@ -99,14 +99,24 @@ func (s *Server) batchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (
}, nil
}
namespace := r.GetNamespace()
if err := authorize(ctx, namespace, s.cfg); err != nil {
return nil, err
// Group items by namespace
itemsByNamespace := make(map[string][]*authzv1.BatchCheckItem)
for _, item := range items {
ns := item.GetNamespace()
itemsByNamespace[ns] = append(itemsByNamespace[ns], item)
}
store, err := s.getStoreInfo(ctx, namespace)
if err != nil {
return nil, err
// Authorize and get store info for each namespace
stores := make(map[string]*storeInfo)
for namespace := range itemsByNamespace {
if err := authorize(ctx, namespace, s.cfg); err != nil {
return nil, err
}
store, err := s.getStoreInfo(ctx, namespace)
if err != nil {
return nil, err
}
stores[namespace] = store
}
contextuals, err := s.getContextuals(r.GetSubject())
@@ -117,30 +127,26 @@ func (s *Server) batchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (
results := make(map[string]*authzv1.BatchCheckResult, len(items))
subject := r.GetSubject()
// Phase 1: Check GroupResource access (broadest permissions)
// Example: user has "get" on "dashboards" group_resource → all dashboards allowed
s.runGroupResourcePhase(ctx, store, subject, items, contextuals, results)
if len(results) == len(items) {
return s.buildResponse(results), nil
}
// Process each namespace separately
for namespace, nsItems := range itemsByNamespace {
store := stores[namespace]
// Phase 2: Check folder permission inheritance (can_get, can_create, etc. on parent folder)
// Example: user has "can_get" on folder-A → all dashboards in folder-A allowed
s.runFolderPermissionPhase(ctx, store, subject, items, contextuals, results)
if len(results) == len(items) {
return s.buildResponse(results), nil
}
// Phase 1: Check GroupResource access (broadest permissions)
// Example: user has "get" on "dashboards" group_resource → all dashboards allowed
s.runGroupResourcePhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 3: Check folder subresource access (folder_get, folder_create, etc.)
// Example: user has "folder_get" on folder-A → dashboards in folder-A allowed via subresource
s.runFolderSubresourcePhase(ctx, store, subject, items, contextuals, results)
if len(results) == len(items) {
return s.buildResponse(results), nil
}
// Phase 2: Check folder permission inheritance (can_get, can_create, etc. on parent folder)
// Example: user has "can_get" on folder-A → all dashboards in folder-A allowed
s.runFolderPermissionPhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 4: Check direct resource access
// Example: user has "get" directly on dashboard-123
s.runDirectResourcePhase(ctx, store, subject, items, contextuals, results)
// Phase 3: Check folder subresource access (folder_get, folder_create, etc.)
// Example: user has "folder_get" on folder-A → dashboards in folder-A allowed via subresource
s.runFolderSubresourcePhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 4: Check direct resource access
// Example: user has "get" directly on dashboard-123
s.runDirectResourcePhase(ctx, store, subject, nsItems, contextuals, results)
}
// Mark any remaining unresolved items as denied
for _, item := range items {
@@ -15,16 +15,16 @@ func testBatchCheck(t *testing.T, server *Server) {
// Helper to create a batch check request
newReq := func(subject string, items []*authzv1.BatchCheckItem) *authzv1.BatchCheckRequest {
return &authzv1.BatchCheckRequest{
Subject: subject,
Namespace: namespace,
Checks: items,
Subject: subject,
Checks: items,
}
}
// Helper to create a batch check item with correlation ID
// Helper to create a batch check item with correlation ID (uses default namespace)
newItem := func(verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s", group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: namespace,
Verb: verb,
Group: group,
Resource: resource,
@@ -189,4 +189,114 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "13")].Allowed)
})
// Cross-namespace tests
t.Run("cross-namespace: items with explicit namespace should be authorized against their own namespace", func(t *testing.T) {
// Helper to create item with explicit namespace
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:1 has access to dashboard 1 in folder 1 in "default" namespace
// Both items use explicit namespace
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:1",
Checks: []*authzv1.BatchCheckItem{
// Item in default namespace (should be allowed - user:1 has access)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
// Another item in default namespace with different correlation ID
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
})
t.Run("cross-namespace: items from different namespaces in same batch", func(t *testing.T) {
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:2 has group_resource access in "default" namespace
// They should have access in default but not in other-namespace (no tuples there)
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:2",
Checks: []*authzv1.BatchCheckItem{
// Items in default namespace (should be allowed - user:2 has group_resource access)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
// Items in other-namespace (should be denied - no tuples in other-namespace)
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 4)
// Default namespace items should be allowed
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "2", "2")].Allowed)
// Other namespace items should be denied (no permissions in that namespace)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("cross-namespace: mixed results across multiple namespaces", func(t *testing.T) {
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:1 has specific access to dashboard 1 in folder 1
// user:2 would have broader access, but we're testing user:1
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:1",
Checks: []*authzv1.BatchCheckItem{
// Allowed in default namespace
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
// Denied in default namespace (user:1 doesn't have access to dashboard 2)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
// Denied in other-namespace (no tuples)
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "1", "1")].Allowed)
})
}
@@ -588,9 +588,8 @@ func BenchmarkBatchCheck(b *testing.B) {
// Helper to create batch check requests using the new authzv1 API
newBatchCheckReq := func(subject string, items []*authzv1.BatchCheckItem) *authzv1.BatchCheckRequest {
return &authzv1.BatchCheckRequest{
Subject: subject,
Namespace: benchNamespace,
Checks: items,
Subject: subject,
Checks: items,
}
}
@@ -600,6 +599,7 @@ func BenchmarkBatchCheck(b *testing.B) {
for i := 0; i < batchCheckSize && i < len(resources); i++ {
resource := resources[i]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
@@ -617,6 +617,7 @@ func BenchmarkBatchCheck(b *testing.B) {
for _, folder := range folders {
if folderDepths[folder] == depth && len(items) < batchCheckSize {
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
@@ -630,6 +631,7 @@ func BenchmarkBatchCheck(b *testing.B) {
for len(items) < batchCheckSize && len(folders) > 0 {
folder := folders[len(items)%len(folders)]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
@@ -732,6 +734,7 @@ func BenchmarkBatchCheck(b *testing.B) {
for i := 0; i < batchCheckSize; i++ {
folder := data.folders[i%len(data.folders)]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
+61
View File
@@ -152,6 +152,67 @@ func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req c
return resp, nil
}
// BatchCheck implements claims.AccessClient.
func (c authzLimitedClient) BatchCheck(ctx context.Context, id claims.AuthInfo, req claims.BatchCheckRequest) (claims.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "resource.authzLimitedClient.BatchCheck", trace.WithAttributes(
attribute.Int("num_checks", len(req.Checks)),
attribute.Bool("fallback_used", FallbackUsed(ctx)),
))
defer span.End()
if FallbackUsed(ctx) {
span.SetStatus(codes.Error, "BatchCheck not supported with fallback")
return claims.BatchCheckResponse{}, fmt.Errorf("BatchCheck not supported when fallback is used")
}
// Filter checks to only those that require RBAC and validate namespace
rbacChecks := make([]claims.BatchCheckItem, 0, len(req.Checks))
allowedByDefault := make(map[string]bool, len(req.Checks))
for _, check := range req.Checks {
if !claims.NamespaceMatches(id.GetNamespace(), check.Namespace) {
span.SetStatus(codes.Error, "Namespace mismatch")
span.RecordError(claims.ErrNamespaceMismatch)
return claims.BatchCheckResponse{}, claims.ErrNamespaceMismatch
}
if c.IsCompatibleWithRBAC(check.Group, check.Resource) {
rbacChecks = append(rbacChecks, check)
} else {
allowedByDefault[check.CorrelationID] = true
}
}
// If all checks are allowed by default, return early
if len(rbacChecks) == 0 {
results := make(map[string]claims.BatchCheckResult, len(req.Checks))
for _, check := range req.Checks {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: true,
}
}
return claims.BatchCheckResponse{Results: results}, nil
}
// Call the underlying client with RBAC checks
resp, err := c.client.BatchCheck(ctx, id, claims.BatchCheckRequest{Checks: rbacChecks})
if err != nil {
c.logger.FromContext(ctx).Error("BatchCheck failed", "error", err, "num_checks", len(rbacChecks))
span.SetStatus(codes.Error, fmt.Sprintf("batch check failed: %v", err))
span.RecordError(err)
return resp, err
}
// Merge results with allowed-by-default checks
for correlationID := range allowedByDefault {
resp.Results[correlationID] = claims.BatchCheckResult{
Allowed: true,
}
}
return resp, nil
}
// Compile implements claims.AccessClient.
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req claims.ListRequest) (claims.ItemChecker, claims.Zookie, error) {
t := time.Now()
@@ -159,6 +159,97 @@ func TestNamespaceMatching(t *testing.T) {
}
}
func TestAuthzLimitedClient_BatchCheck(t *testing.T) {
mockClient := authlib.FixedAccessClient(true)
client := NewAuthzLimitedClient(mockClient, AuthzOptions{})
t.Run("returns error when fallback is used", func(t *testing.T) {
ctx := WithFallback(context.Background())
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test-dashboard",
},
},
}
_, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "fallback")
})
t.Run("works normally without fallback", func(t *testing.T) {
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test-dashboard",
},
},
}
resp, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
assert.True(t, resp.Results["0"].Allowed)
})
t.Run("returns error on namespace mismatch", func(t *testing.T) {
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-2", // Different namespace
Name: "test-dashboard",
},
},
}
_, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.Error(t, err)
assert.ErrorIs(t, err, authlib.ErrNamespaceMismatch)
})
t.Run("allows non-RBAC resources by default", func(t *testing.T) {
// Use a client that would deny if checked
denyClient := authlib.FixedAccessClient(false)
client := NewAuthzLimitedClient(denyClient, AuthzOptions{})
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "unknown.group",
Resource: "unknown.resource",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test",
},
},
}
resp, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
assert.True(t, resp.Results["0"].Allowed, "non-RBAC resources should be allowed by default")
})
}
// TestNamespaceMatchingFallback tests namespace matching in Check and Compile methods when fallback is used
func TestNamespaceMatchingFallback(t *testing.T) {
// Create a mock client that always returns allowed=true