From a862faee39a83df9f4ac41e651f4c3156d7ecbf2 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sat, 23 May 2026 11:28:25 -0700 Subject: [PATCH] Wire batch authorize into server APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authz.NewBatchAuthorizeFunc — the batch counterpart to the existing AuthorizeFunc — together with WithBatchAttr, WithBatchSkipAssumptionCheck, and WithBatchDryRun options. It maps the new batch errors (mixed organization, empty batch, unsupported resource type) to GraphQL Invalid responses, and reuses the existing mappings for ErrAssumptionRequired / ErrInsufficientPermissions / ErrResourceNotFound. Plumb the new function into the Connect and Console resolvers and add Resolver.AuthorizeBatch to the MCP resolver with equivalent error mapping for tool callers. Signed-off-by: Bryan Frimin --- pkg/server/api/authz/authorization.go | 86 +++++++++++++++++++- pkg/server/api/connect/v1/graphql_handler.go | 11 +-- pkg/server/api/connect/v1/resolver.go | 11 +-- pkg/server/api/mcp/v1/resolver.go | 44 ++++++++++ 4 files changed, 140 insertions(+), 12 deletions(-) diff --git a/pkg/server/api/authz/authorization.go b/pkg/server/api/authz/authorization.go index 2b1a73f9d..c57949f64 100644 --- a/pkg/server/api/authz/authorization.go +++ b/pkg/server/api/authz/authorization.go @@ -27,8 +27,10 @@ import ( ) type ( - AuthorizeFuncOption func(*iam.AuthorizeParams) - AuthorizeFunc func(context.Context, gid.GID, string, ...AuthorizeFuncOption) (*coredata.Scope, error) + AuthorizeFuncOption func(*iam.AuthorizeParams) + AuthorizeFunc func(context.Context, gid.GID, string, ...AuthorizeFuncOption) (*coredata.Scope, error) + BatchAuthorizeFuncOption func(*iam.AuthorizeBatchParams) + BatchAuthorizeFunc func(context.Context, string, []gid.GID, ...BatchAuthorizeFuncOption) (*coredata.Scope, error) ) func WithAttr(key, value string) AuthorizeFuncOption { @@ -51,6 +53,24 @@ func WithDryRun() AuthorizeFuncOption { } } +func WithBatchAttr(key, value string) BatchAuthorizeFuncOption { + return func(params *iam.AuthorizeBatchParams) { + params.ResourceAttributes[key] = value + } +} + +func WithBatchSkipAssumptionCheck() BatchAuthorizeFuncOption { + return func(params *iam.AuthorizeBatchParams) { + params.SkipAssumptionCheck = true + } +} + +func WithBatchDryRun() BatchAuthorizeFuncOption { + return func(params *iam.AuthorizeBatchParams) { + params.DryRun = true + } +} + func NewAuthorizeFunc( svc *iam.Service, logger *log.Logger, @@ -100,3 +120,65 @@ func NewAuthorizeFunc( return scope, nil } } + +func NewBatchAuthorizeFunc( + svc *iam.Service, + logger *log.Logger, +) BatchAuthorizeFunc { + return func( + ctx context.Context, + action string, + objectIDs []gid.GID, + options ...BatchAuthorizeFuncOption, + ) (*coredata.Scope, error) { + identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) + + params := iam.AuthorizeBatchParams{ + Principal: identity.ID, + Action: action, + Resources: objectIDs, + ResourceAttributes: make(map[string]string), + } + if session != nil { + params.Session = &session.ID + } + + for _, option := range options { + option(¶ms) + } + + scope, err := svc.Authorizer.AuthorizeBatch(ctx, params) + if err != nil { + if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok { + return nil, gqlutils.AssumptionRequired(ctx, err) + } + + if _, ok := errors.AsType[*iam.ErrInsufficientPermissions](err); ok { + return nil, gqlutils.Forbidden(ctx, err) + } + + if _, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + if _, ok := errors.AsType[*iam.ErrEmptyResourceBatch](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + if _, ok := errors.AsType[*iam.ErrBatchAuthorizationUnsupportedResourceType](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFoundf(ctx, "resource not found") + } + + logger.ErrorCtx(ctx, "cannot batch authorize", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return scope, nil + } +} diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index 179697389..edd43be54 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -31,11 +31,12 @@ import ( func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler { config := schema.Config{ Resolvers: &Resolver{ - authorize: authz.NewAuthorizeFunc(svc, logger), - logger: logger, - iam: svc, - baseURL: baseURL, - sessionCookie: authn.NewCookie(&cookieConfig), + authorize: authz.NewAuthorizeFunc(svc, logger), + batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger), + logger: logger, + iam: svc, + baseURL: baseURL, + sessionCookie: authn.NewCookie(&cookieConfig), }, Directives: schema.DirectiveRoot{ Session: session.Directive, diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 288c6f9e8..3fe661153 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -48,11 +48,12 @@ import ( type ( Resolver struct { - authorize authz.AuthorizeFunc - logger *log.Logger - iam *iam.Service - baseURL *baseurl.BaseURL - sessionCookie *authn.Cookie + authorize authz.AuthorizeFunc + batchAuthorize authz.BatchAuthorizeFunc + logger *log.Logger + iam *iam.Service + baseURL *baseurl.BaseURL + sessionCookie *authn.Cookie } ) diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index 3831f3cad..edc0410b5 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -88,3 +88,47 @@ func (r *Resolver) Authorize(ctx context.Context, entityID gid.GID, action iam.A return nil, fmt.Errorf("internal server error") } + +func (r *Resolver) AuthorizeBatch(ctx context.Context, entityIDs []gid.GID, action iam.Action) (*coredata.Scope, error) { + identity := authn.IdentityFromContext(ctx) + + scope, err := r.iamSvc.Authorizer.AuthorizeBatch( + ctx, + iam.AuthorizeBatchParams{ + Principal: identity.ID, + Resources: entityIDs, + Action: action, + }, + ) + if err == nil { + return scope, nil + } + + if _, ok := errors.AsType[*iam.ErrInsufficientPermissions](err); ok { + return nil, fmt.Errorf("permission denied") + } + + if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok { + return nil, fmt.Errorf("assumption required") + } + + if _, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err); ok { + return nil, fmt.Errorf("mixed-organization batch") + } + + if _, ok := errors.AsType[*iam.ErrEmptyResourceBatch](err); ok { + return nil, fmt.Errorf("empty resource batch") + } + + if _, ok := errors.AsType[*iam.ErrBatchAuthorizationUnsupportedResourceType](err); ok { + return nil, fmt.Errorf("batch authorization unsupported for resource type") + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, fmt.Errorf("resource not found") + } + + r.logger.ErrorCtx(ctx, "cannot batch authorize MCP request", log.Error(err)) + + return nil, fmt.Errorf("internal server error") +}