Wire batch authorize into server APIs

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 <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-23 11:28:25 -07:00
parent b39d880c0f
commit a862faee39
4 changed files with 140 additions and 12 deletions

View File

@@ -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(&params)
}
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
}
}

View File

@@ -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,

View File

@@ -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
}
)

View File

@@ -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")
}