Implement BatchCheck functionality in LegacyAccessClient and update related proto definitions

- Added BatchCheck method to LegacyAccessClient for handling batch authorization checks.
- Updated proto definitions to remove BatchCheckRequest and BatchCheckResponse messages, replacing them with a new structure.
- Adjusted related client and server implementations to align with the new BatchCheck structure.
- Modified tests to validate the new BatchCheck functionality and ensure proper integration with existing authorization logic.
This commit is contained in:
Georges Chaudy
2026-01-06 16:59:23 +01:00
parent 1465b44d5a
commit 1498970e74
12 changed files with 970 additions and 997 deletions
+96
View File
@@ -167,3 +167,99 @@ func (c *LegacyAccessClient) Compile(ctx context.Context, id claims.AuthInfo, re
return check(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, name))
}, claims.NoopZookie{}, nil
}
func (c *LegacyAccessClient) BatchCheck(ctx context.Context, id claims.AuthInfo, req claims.BatchCheckRequest) (claims.BatchCheckResponse, error) {
ident, ok := id.(identity.Requester)
if !ok {
return claims.BatchCheckResponse{}, errors.New("expected identity.Requester for legacy access control")
}
results := make(map[string]claims.BatchCheckResult, len(req.Checks))
// Cache checkers by action to avoid recreating them for each check
checkerCache := make(map[string]func(scopes ...string) bool)
for _, check := range req.Checks {
opts, ok := c.opts[check.Resource]
if !ok {
// For now w fallback to grafana admin if no options are found for resource.
if ident.GetIsGrafanaAdmin() {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: true}
} else {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: false}
}
continue
}
// Check if verb should be skipped
if opts.Unchecked[check.Verb] {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: true}
continue
}
action, ok := opts.Mapping[check.Verb]
if !ok {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: fmt.Errorf("missing action for %s %s", check.Verb, check.Resource),
}
continue
}
// Get or create cached checker for this action
checker, ok := checkerCache[action]
if !ok {
checker = Checker(ident, action)
checkerCache[action] = checker
}
// Handle list and create verbs (no specific name)
// TODO: Should we allow list/create without name in a BatchCheck request?
if check.Name == "" {
if check.Verb == utils.VerbList || check.Verb == utils.VerbCreate {
// For list/create without name, check if user has the action at all
// TODO: Is this correct for Create?
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: len(ident.GetPermissions()[action]) > 0,
}
} else {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: fmt.Errorf("unhandled authorization: %s %s", check.Group, check.Verb),
}
}
continue
}
// Check with resolver or direct scope
var allowed bool
if opts.Resolver != nil {
ns, err := claims.ParseNamespace(check.Namespace)
if err != nil {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: err,
}
continue
}
scopes, err := opts.Resolver.Resolve(ctx, ns, check.Name)
if err != nil {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: err,
}
continue
}
allowed = checker(scopes...)
} else {
allowed = checker(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, check.Name))
}
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: allowed}
}
return claims.BatchCheckResponse{
Results: results,
Zookie: claims.NoopZookie{},
}, nil
}
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,6 @@ import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
service AuthzExtentionService {
rpc BatchCheck(BatchCheckRequest) returns (BatchCheckResponse);
rpc Read(ReadRequest) returns (ReadResponse);
rpc Write(WriteRequest) returns (WriteResponse);
@@ -231,29 +229,6 @@ message WriteRequest {
message WriteResponse {}
message BatchCheckRequest {
string subject = 1;
string namespace = 2;
repeated BatchCheckItem items = 3;
}
message BatchCheckItem {
string verb = 1;
string group = 2;
string resource = 3;
string name = 4;
string subresource = 5;
string folder = 6;
}
message BatchCheckResponse {
map<string, BatchCheckGroupResource> groups = 1;
}
message BatchCheckGroupResource {
map<string, bool> items = 1;
}
message QueryRequest {
string namespace = 1;
QueryOperation operation = 2;
@@ -19,18 +19,16 @@ import (
const _ = grpc.SupportPackageIsVersion8
const (
AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck"
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate"
AuthzExtentionService_Query_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Query"
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate"
AuthzExtentionService_Query_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Query"
)
// AuthzExtentionServiceClient is the client API for AuthzExtentionService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AuthzExtentionServiceClient interface {
BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error)
Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error)
Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error)
Mutate(ctx context.Context, in *MutateRequest, opts ...grpc.CallOption) (*MutateResponse, error)
@@ -45,16 +43,6 @@ func NewAuthzExtentionServiceClient(cc grpc.ClientConnInterface) AuthzExtentionS
return &authzExtentionServiceClient{cc}
}
func (c *authzExtentionServiceClient) BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchCheckResponse)
err := c.cc.Invoke(ctx, AuthzExtentionService_BatchCheck_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authzExtentionServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReadResponse)
@@ -99,7 +87,6 @@ func (c *authzExtentionServiceClient) Query(ctx context.Context, in *QueryReques
// All implementations should embed UnimplementedAuthzExtentionServiceServer
// for forward compatibility
type AuthzExtentionServiceServer interface {
BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error)
Read(context.Context, *ReadRequest) (*ReadResponse, error)
Write(context.Context, *WriteRequest) (*WriteResponse, error)
Mutate(context.Context, *MutateRequest) (*MutateResponse, error)
@@ -110,9 +97,6 @@ type AuthzExtentionServiceServer interface {
type UnimplementedAuthzExtentionServiceServer struct {
}
func (UnimplementedAuthzExtentionServiceServer) BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchCheck not implemented")
}
func (UnimplementedAuthzExtentionServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Read not implemented")
}
@@ -137,24 +121,6 @@ func RegisterAuthzExtentionServiceServer(s grpc.ServiceRegistrar, srv AuthzExten
s.RegisterService(&AuthzExtentionService_ServiceDesc, srv)
}
func _AuthzExtentionService_BatchCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchCheckRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthzExtentionService_BatchCheck_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, req.(*BatchCheckRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthzExtentionService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReadRequest)
if err := dec(in); err != nil {
@@ -234,10 +200,6 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "authz.extention.v1.AuthzExtentionService",
HandlerType: (*AuthzExtentionServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "BatchCheck",
Handler: _AuthzExtentionService_BatchCheck_Handler,
},
{
MethodName: "Read",
Handler: _AuthzExtentionService_Read_Handler,
-1
View File
@@ -13,7 +13,6 @@ type Client interface {
authlib.AccessClient
Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error)
Write(ctx context.Context, req *authzextv1.WriteRequest) error
BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error)
Mutate(ctx context.Context, req *authzextv1.MutateRequest) error
Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error)
+3 -3
View File
@@ -68,11 +68,11 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error
return err
}
func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check")
func (c *Client) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.BatchCheck")
defer span.End()
return c.authzext.BatchCheck(ctx, req)
return c.authzlibclient.BatchCheck(ctx, id, req)
}
func (c *Client) WriteNew(ctx context.Context, req *authzextv1.WriteRequest) error {
+5 -2
View File
@@ -34,8 +34,11 @@ func (nc NoopClient) Write(ctx context.Context, req *authzextv1.WriteRequest) er
return nil
}
func (nc NoopClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
return nil, nil
func (nc NoopClient) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
return authlib.BatchCheckResponse{
Results: make(map[string]authlib.BatchCheckResult),
Zookie: authlib.NoopZookie{},
}, nil
}
func (nc NoopClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {
@@ -132,3 +132,54 @@ func (c *ShadowClient) Compile(ctx context.Context, id authlib.AuthInfo, req aut
return shadowItemChecker, authlib.NoopZookie{}, err
}
func (c *ShadowClient) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
acResChan := make(chan authlib.BatchCheckResponse, 1)
acErrChan := make(chan error, 1)
go func() {
if c.zanzanaClient == nil {
return
}
zanzanaCtx := context.WithoutCancel(ctx)
zanzanaCtxTimeout, cancel := context.WithTimeout(zanzanaCtx, zanzanaTimeout)
defer cancel()
timer := prometheus.NewTimer(c.metrics.evaluationsSeconds.WithLabelValues("zanzana"))
res, err := c.zanzanaClient.BatchCheck(zanzanaCtxTimeout, id, req)
if err != nil {
c.logger.Error("Failed to run zanzana batch check", "error", err)
}
timer.ObserveDuration()
acRes := <-acResChan
acErr := <-acErrChan
if acErr == nil {
// Compare results for each correlation ID
for corrID, acResult := range acRes.Results {
zanzanaResult, exists := res.Results[corrID]
if !exists {
c.metrics.evaluationStatusTotal.WithLabelValues("error").Inc()
c.logger.Warn("Zanzana batch check missing result", "correlationId", corrID, "user", id.GetUID())
continue
}
if zanzanaResult.Allowed != acResult.Allowed {
c.metrics.evaluationStatusTotal.WithLabelValues("error").Inc()
c.logger.Warn("Zanzana batch check result does not match", "expected", acResult.Allowed, "actual", zanzanaResult.Allowed, "correlationId", corrID, "user", id.GetUID())
} else {
c.metrics.evaluationStatusTotal.WithLabelValues("success").Inc()
}
}
}
}()
timer := prometheus.NewTimer(c.metrics.evaluationsSeconds.WithLabelValues("rbac"))
res, err := c.accessClient.BatchCheck(ctx, id, req)
timer.ObserveDuration()
acResChan <- res
acErrChan <- err
return res, err
}
+1 -2
View File
@@ -10,7 +10,6 @@ import (
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/accesscontrol"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
type typeInfo struct {
@@ -73,7 +72,7 @@ func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo {
return resource
}
func NewResourceInfoFromBatchItem(i *authzextv1.BatchCheckItem) ResourceInfo {
func NewResourceInfoFromBatchItem(i *authzv1.BatchCheckItem) ResourceInfo {
typ, relations := getTypeAndRelations(i.GetGroup(), i.GetResource())
return newResource(
typ,
@@ -2,97 +2,457 @@ package server
import (
"context"
"fmt"
"time"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"google.golang.org/protobuf/types/known/structpb"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
// checkKey represents a unique check to be performed
type checkKey struct {
relation string
object string
}
// batchCheckBuilder encapsulates state for building OpenFGA batch checks
type batchCheckBuilder struct {
subject string
contextuals *openfgav1.ContextualTupleKeys
checks []*openfgav1.BatchCheckItem
checksSeen map[checkKey]bool
checkMapping map[string]checkKey
counter int
}
func newBatchCheckBuilder(subject string, contextuals *openfgav1.ContextualTupleKeys) *batchCheckBuilder {
return &batchCheckBuilder{
subject: subject,
contextuals: contextuals,
checks: make([]*openfgav1.BatchCheckItem, 0),
checksSeen: make(map[checkKey]bool),
checkMapping: make(map[string]checkKey),
counter: 0,
}
}
func (b *batchCheckBuilder) addCheck(relation, object string, context *structpb.Struct) {
if object == "" {
return
}
key := checkKey{relation: relation, object: object}
if b.checksSeen[key] {
return
}
b.checksSeen[key] = true
correlationID := fmt.Sprintf("c%d", b.counter)
b.counter++
b.checks = append(b.checks, &openfgav1.BatchCheckItem{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: b.subject,
Relation: relation,
Object: object,
},
ContextualTuples: b.contextuals,
Context: context,
CorrelationId: correlationID,
})
b.checkMapping[correlationID] = key
}
// BatchCheck implements authzv1.AuthzServiceServer.BatchCheck
// This performs multiple access checks in a single request using OpenFGA's native BatchCheck API.
func (s *Server) BatchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
ctx, span := s.tracer.Start(ctx, "server.BatchCheck")
defer span.End()
if err := authorize(ctx, r.GetNamespace(), s.cfg); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetAttributes(attribute.Int("check_count", len(r.GetChecks())))
batchRes := &authzextv1.BatchCheckResponse{
Groups: make(map[string]*authzextv1.BatchCheckGroupResource),
}
defer func(t time.Time) {
s.metrics.requestDurationSeconds.WithLabelValues("server.BatchCheck", "").Observe(time.Since(t).Seconds())
}(time.Now())
store, err := s.getStoreInfo(ctx, r.GetNamespace())
res, err := s.batchCheck(ctx, r)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform batch check request", "error", err)
return nil, fmt.Errorf("failed to perform batch check request: %w", err)
}
return res, nil
}
func (s *Server) batchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
items := r.GetChecks()
if len(items) == 0 {
return &authzv1.BatchCheckResponse{
Results: make(map[string]*authzv1.BatchCheckResult),
}, nil
}
namespace := r.GetNamespace()
if err := authorize(ctx, namespace, s.cfg); err != nil {
return nil, err
}
store, err := s.getStoreInfo(ctx, namespace)
if err != nil {
return nil, err
}
contextuals, err := s.getContextuals(r.GetSubject())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
groupResourceAccess := make(map[string]bool)
results := make(map[string]*authzv1.BatchCheckResult, len(items))
subject := r.GetSubject()
for _, item := range r.GetItems() {
res, err := s.batchCheckItem(ctx, r, item, contextuals, store, groupResourceAccess)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
// 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
}
// 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 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 4: Check direct resource access
// Example: user has "get" directly on dashboard-123
s.runDirectResourcePhase(ctx, store, subject, items, contextuals, results)
// Mark any remaining unresolved items as denied
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; !resolved {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false}
}
}
return s.buildResponse(results), nil
}
func (s *Server) buildResponse(results map[string]*authzv1.BatchCheckResult) *authzv1.BatchCheckResponse {
return &authzv1.BatchCheckResponse{
Results: results,
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}
}
// runGroupResourcePhase checks if the user has GroupResource-level access.
// This is the broadest permission - if allowed, all items in that group are allowed.
func (s *Server) runGroupResourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
// Group items by their GroupResource
type grInfo struct {
relation string
grIdent string
items []string // correlation IDs
}
groupedItems := make(map[string]*grInfo) // groupResource -> info
for _, item := range items {
relation := common.VerbMapping[item.GetVerb()]
if !common.IsGroupResourceRelation(relation) {
continue
}
groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource(), item.GetSubresource())
if _, ok := batchRes.Groups[groupResource]; !ok {
batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{
Items: make(map[string]bool),
resource := common.NewResourceInfoFromBatchItem(item)
gr := resource.GroupResource()
if _, exists := groupedItems[gr]; !exists {
groupedItems[gr] = &grInfo{
relation: relation,
grIdent: resource.GroupResourceIdent(),
items: make([]string, 0),
}
}
batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed()
groupedItems[gr].items = append(groupedItems[gr].items, item.GetCorrelationId())
}
return batchRes, nil
if len(groupedItems) == 0 {
return
}
// Build batch check for unique GroupResources
builder := newBatchCheckBuilder(subject, contextuals)
grCheckMapping := make(map[string]string) // OpenFGA correlationID -> groupResource
for gr, info := range groupedItems {
correlationID := fmt.Sprintf("gr%d", builder.counter)
builder.counter++
builder.checks = append(builder.checks, &openfgav1.BatchCheckItem{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject,
Relation: info.relation,
Object: info.grIdent,
},
ContextualTuples: contextuals,
CorrelationId: correlationID,
})
grCheckMapping[correlationID] = gr
}
openfgaRes, err := s.openfgaClient.BatchCheck(ctx, &openfgav1.BatchCheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Checks: builder.checks,
})
if err != nil {
s.logger.Warn("Failed to check group resource access", "error", err)
return
}
// Mark all items in allowed GroupResources
for correlationID, result := range openfgaRes.GetResult() {
gr := grCheckMapping[correlationID]
if allowed, ok := result.GetCheckResult().(*openfgav1.BatchCheckSingleResult_Allowed); ok && allowed.Allowed {
for _, itemCorrelationID := range groupedItems[gr].items {
results[itemCorrelationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
func (s *Server) batchCheckItem(
// runFolderPermissionPhase checks folder permission inheritance (can_get, can_create, etc.).
// This applies to folder-based resources like dashboards, panels, etc.
func (s *Server) runFolderPermissionPhase(
ctx context.Context,
r *authzextv1.BatchCheckRequest,
item *authzextv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
store *storeInfo,
groupResourceAccess map[string]bool,
) (*authzv1.CheckResponse, error) {
var (
relation = common.VerbMapping[item.GetVerb()]
resource = common.NewResourceInfoFromBatchItem(item)
groupResource = resource.GroupResource()
)
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string) // checkKey -> correlation IDs
allowed, ok := groupResourceAccess[groupResource]
if !ok {
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, resource, contextuals, store)
if err != nil {
return nil, err
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
allowed = res.GetAllowed()
groupResourceAccess[groupResource] = res.GetAllowed()
resource := common.NewResourceInfoFromBatchItem(item)
folderIdent := resource.FolderIdent()
// Only folder-based generic resources use folder permission inheritance
if !resource.IsGeneric() || folderIdent == "" || !isFolderPermissionBasedResource(resource.GroupResource()) {
continue
}
relation := common.VerbMapping[item.GetVerb()]
rel := common.FolderPermissionRelation(relation)
key := checkKey{relation: rel, object: folderIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(rel, folderIdent, resource.Context())
}
if allowed {
return &authzv1.CheckResponse{Allowed: true}, nil
if len(builder.checks) == 0 {
return
}
if resource.IsGeneric() {
return s.checkGeneric(ctx, r.GetSubject(), relation, resource, contextuals, store)
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed folder permission phase", "error", err)
return
}
return s.checkTyped(ctx, r.GetSubject(), relation, resource, contextuals, store)
// Mark items allowed by folder permissions
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// runFolderSubresourcePhase checks folder subresource access (folder_get, folder_create, etc.).
func (s *Server) runFolderSubresourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string)
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
relation := common.VerbMapping[item.GetVerb()]
var objectIdent string
var subresRel string
if resource.IsGeneric() {
// Generic resources: check subresource on folder
folderIdent := resource.FolderIdent()
if folderIdent == "" {
continue
}
subresRel = common.SubresourceRelation(relation)
if !common.IsSubresourceRelation(subresRel) {
continue
}
objectIdent = folderIdent
} else {
// Typed resources: check subresource on the resource itself
if !resource.HasSubresource() || !resource.IsValidRelation(relation) {
continue
}
objectIdent = resource.ResourceIdent()
if objectIdent == "" {
continue
}
subresRel = common.SubresourceRelation(relation)
}
key := checkKey{relation: subresRel, object: objectIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(subresRel, objectIdent, resource.Context())
}
if len(builder.checks) == 0 {
return
}
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed folder subresource phase", "error", err)
return
}
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// runDirectResourcePhase checks direct resource access.
func (s *Server) runDirectResourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string)
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
relation := common.VerbMapping[item.GetVerb()]
if !resource.IsValidRelation(relation) {
continue
}
resourceIdent := resource.ResourceIdent()
if resourceIdent == "" {
continue
}
// For folders, use the computed permission relation
checkRelation := relation
if resource.Type() == common.TypeFolder {
checkRelation = common.FolderPermissionRelation(relation)
}
key := checkKey{relation: checkRelation, object: resourceIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(checkRelation, resourceIdent, resource.Context())
}
if len(builder.checks) == 0 {
return
}
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed direct resource phase", "error", err)
return
}
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// executeOpenFGABatchChecks executes the OpenFGA batch checks in chunks and returns results
func (s *Server) executeOpenFGABatchChecks(ctx context.Context, store *storeInfo, builder *batchCheckBuilder) (map[checkKey]bool, error) {
const maxChecksPerBatch = 50
checkResults := make(map[checkKey]bool)
for i := 0; i < len(builder.checks); i += maxChecksPerBatch {
end := i + maxChecksPerBatch
if end > len(builder.checks) {
end = len(builder.checks)
}
openfgaRes, err := s.openfgaClient.BatchCheck(ctx, &openfgav1.BatchCheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Checks: builder.checks[i:end],
})
if err != nil {
return nil, fmt.Errorf("failed to perform OpenFGA batch check: %w", err)
}
// Process results
for correlationID, result := range openfgaRes.GetResult() {
key, ok := builder.checkMapping[correlationID]
if !ok {
continue
}
if allowed, ok := result.GetCheckResult().(*openfgav1.BatchCheckSingleResult_Allowed); ok {
checkResults[key] = allowed.Allowed
}
}
}
return checkResults, nil
}
@@ -1,193 +1,192 @@
package server
import (
"fmt"
"testing"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/utils"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func testBatchCheck(t *testing.T, server *Server) {
newReq := func(subject, verb, group, resource, subresource string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest {
for i, item := range items {
items[i] = &authzextv1.BatchCheckItem{
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: item.GetName(),
Folder: item.GetFolder(),
}
}
return &authzextv1.BatchCheckRequest{
Namespace: namespace,
// Helper to create a batch check request
newReq := func(subject string, items []*authzv1.BatchCheckItem) *authzv1.BatchCheckRequest {
return &authzv1.BatchCheckRequest{
Subject: subject,
Items: items,
Namespace: namespace,
Checks: items,
}
}
// Helper to create a batch check item with correlation ID
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{
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:1", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:1", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.Len(t, res.Results, 2)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through group_resource", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:2", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
}))
require.NoError(t, err)
assert.Len(t, res.Groups[groupResource].Items, 2)
require.Len(t, res.Results, 2)
// user:2 has group_resource access, so both should be allowed
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:3", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:3", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.Len(t, res.Results, 2)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:4", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "3"},
{Name: "3", Folder: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:4", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "3", "2"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "3"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 3)
require.Len(t, res.Results, 3)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.True(t, res.Groups[groupResource].Items["2"])
assert.False(t, res.Groups[groupResource].Items["3"])
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "3", "2")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "3")].Allowed)
})
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:5", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:5", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.Len(t, res.Results, 2)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("user:6 should be able to read folder 1", func(t *testing.T) {
groupResource := common.FormatGroupResource(folderGroup, folderResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:6", utils.VerbGet, folderGroup, folderResource, "", []*authzextv1.BatchCheckItem{
{Name: "1"},
{Name: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:6", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "1"),
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "2"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.Len(t, res.Results, 2)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "2")].Allowed)
})
t.Run("user:7 should be able to read folder {1,2} through group_resource access", func(t *testing.T) {
groupResource := common.FormatGroupResource(folderGroup, folderResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, "", []*authzextv1.BatchCheckItem{
{Name: "1"},
{Name: "2"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:7", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "1"),
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "2"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["1"])
require.True(t, res.Groups[groupResource].Items["2"])
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "2")].Allowed)
})
t.Run("user:8 should be able to read all resoruce:dashboard.grafana.app/dashboards in folder 6 through folder 5", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:8", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "20", Folder: "6"},
t.Run("user:8 should be able to read all resource:dashboard.grafana.app/dashboards in folder 6 through folder 5", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:8", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "6", "20"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["20"])
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "20")].Allowed)
})
t.Run("user:9 should be able to create dashboards in folder 6 through folder 5", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:9", utils.VerbCreate, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "20", Folder: "6"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:9", []*authzv1.BatchCheckItem{
newItem(utils.VerbCreate, dashboardGroup, dashboardResource, "", "6", "10"),
newItem(utils.VerbCreate, dashboardGroup, dashboardResource, "", "6", "20"),
}))
require.NoError(t, err)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["20"])
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "20")].Allowed)
})
t.Run("user:10 should be able to get dashboard status for 10 and 11", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:10", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:10", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
}))
require.NoError(t, err)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 3)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.False(t, res.Groups[groupResource].Items["12"])
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
})
t.Run("user:11 should be able to get dashboard status for 10, 11 and 12 through group_resource", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:11", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:11", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
}))
require.NoError(t, err)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 3)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.True(t, res.Groups[groupResource].Items["12"])
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
})
t.Run("user:12 should be able to get dashboard status in folder 5 and 6", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:12", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "5"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
{Name: "13", Folder: "1"},
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:12", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "5", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "1", "13"),
}))
require.NoError(t, err)
require.Len(t, res.Groups[groupResource].Items, 4)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.True(t, res.Groups[groupResource].Items["12"])
require.False(t, res.Groups[groupResource].Items["13"])
require.Len(t, res.Results, 4)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "5", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
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)
})
}
@@ -15,7 +15,6 @@ import (
"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"
@@ -37,14 +36,14 @@ const (
// Timeout for List operations
listTimeout = 30 * time.Second
// BenchmarkBatchCheck measures the performance of BatchCheck requests with 50 items per batch.
batchCheckSize = 50
// 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
@@ -338,6 +337,14 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) {
}
cfg := setting.NewCfg()
cfg.ZanzanaServer.CacheSettings.CheckCacheLimit = 100000 // Cache check results
cfg.ZanzanaServer.CacheSettings.CheckQueryCacheEnabled = true // Cache check subproblems
cfg.ZanzanaServer.CacheSettings.CheckIteratorCacheEnabled = true // Cache DB iterators for checks
cfg.ZanzanaServer.CacheSettings.CheckIteratorCacheMaxResults = 10000 // Max results per iterator
cfg.ZanzanaServer.CacheSettings.SharedIteratorEnabled = true // Share iterators across concurrent checks
cfg.ZanzanaServer.CacheSettings.SharedIteratorLimit = 10000 // Max shared iterators
testStore := sqlstore.NewTestStore(b, sqlstore.WithCfg(cfg))
openFGAStore, err := store.NewEmbeddedStore(cfg, testStore, log.NewNopLogger())
@@ -573,58 +580,62 @@ func BenchmarkCheck(b *testing.B) {
})
}
// BenchmarkBatchCheck measures the performance of BatchCheck requests
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,
// 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,
Items: items,
Namespace: benchNamespace,
Checks: 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)
createBatchItems := func(resources []string, resourceFolders map[string]string) []*authzv1.BatchCheckItem {
items := make([]*authzv1.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],
items = append(items, &authzv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: resource,
Folder: resourceFolders[resource],
CorrelationId: fmt.Sprintf("item-%d", i),
})
}
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)
createFolderBatchItems := func(folders []string, depth int, folderDepths map[string]int) []*authzv1.BatchCheckItem {
items := make([]*authzv1.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,
items = append(items, &authzv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-in-%s", folder),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", len(items)),
})
}
}
// 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,
items = append(items, &authzv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", len(items)),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", len(items)),
})
}
return items
@@ -636,6 +647,7 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with group_resource permission - should have access to everything
user := data.users[0]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has group_resource permission (all access)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -643,7 +655,7 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Groups
_ = res.Results
}
})
@@ -651,6 +663,7 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with folder permission on shallow folder
user := data.users[usersPerPattern]
items := createFolderBatchItems(data.folders, 1, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth 1", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -658,7 +671,7 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Groups
_ = res.Results
}
})
@@ -666,6 +679,7 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with folder permission on mid-depth folder
user := data.users[2*usersPerPattern]
items := createFolderBatchItems(data.folders, 4, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth 4", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -673,22 +687,7 @@ func BenchmarkBatchCheck(b *testing.B) {
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
_ = res.Results
}
})
@@ -696,6 +695,7 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with direct resource permission
user := data.users[4*usersPerPattern]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has direct resource permission", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -703,22 +703,7 @@ func BenchmarkBatchCheck(b *testing.B) {
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
_ = res.Results
}
})
@@ -726,6 +711,7 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with no permissions - tests denial path
user := data.users[len(data.users)-1]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has NO permissions (denial case)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -733,24 +719,28 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Groups
_ = res.Results
}
})
b.Run("MixedFolders", func(b *testing.B) {
// Batch of items across different folder depths
user := data.users[usersPerPattern]
items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize)
b.Run("MixedAccess", func(b *testing.B) {
// Create items from different folders - user has access to some but not all
user := data.users[3*usersPerPattern] // folder-scoped resource permission
items := make([]*authzv1.BatchCheckItem, 0, batchCheckSize)
// Mix of accessible and inaccessible resources
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,
items = append(items, &authzv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", i),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", i),
})
}
b.Logf("Testing BatchCheck with %d items, user has mixed access (some allowed, some denied)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -758,9 +748,31 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Groups
_ = res.Results
}
})
// Test BatchCheck at various folder depths
for depth := 0; depth <= data.maxDepth; depth++ {
depth := depth // capture for closure
if len(data.foldersByDepth[depth]) == 0 {
continue
}
b.Run(fmt.Sprintf("ByDepth/Depth%d", depth), func(b *testing.B) {
user := fmt.Sprintf("user:depth-%d-access", depth)
items := createFolderBatchItems(data.folders, depth, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth %d", len(items), depth)
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items))
if err != nil {
b.Fatal(err)
}
_ = res.Results
}
})
}
}
// BenchmarkList measures the performance of List requests (Compile equivalent)