Skip to content
Secure servicesDocs 1.3.x

Authorization ​

createAuthzInterceptor enforces access control after authentication using code-based rules and an optional fallback callback. Use createProtoAuthzInterceptor when policy is declared in proto options.

Declarative Rules ​

Define rules as an ordered list. The first rule whose method pattern and requirements both match wins. A rule whose role or scope requirements fail is skipped; it does not deny the request by itself.

typescript
import { createAuthzInterceptor } from '@connectum/auth';

const authz = createAuthzInterceptor({
  defaultPolicy: 'deny',
  rules: [
    { name: 'admin-only', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
    { name: 'write-scope', methods: ['data.v1.DataService/Write'], requires: { scopes: ['write'] }, effect: 'allow' },
  ],
});

Rule Fields ​

FieldTypeDescription
namestringRule name for logging and debugging
methodsreadonly string[]Exact method, Service/*, or *
requires{ roles?, scopes? }Required roles and/or scopes
effect'allow' | 'deny'What to do when the rule matches. Exactly these two lowercase strings; any other value ('DENY', 'block') throws a TypeError when the interceptor is created

Matching Semantics ​

  • Roles use any-of semantics -- the user needs at least one of the listed roles.
  • Scopes use all-of semantics -- the user needs all listed scopes.
  • Rules without requires match all authenticated users for the named methods.
  • An empty or omitted list places no restriction on that dimension: requires: { roles: [] } does not restrict roles, and requires: {} matches any authenticated caller, exactly like a rule without requires. A rule restricts a dimension only when its list has at least one entry.
  • This interceptor requires an AuthContext before evaluating any rule. A rule named public does not bypass authentication. Put intentionally public methods in both authentication and authorization skipMethods lists.

Method Patterns ​

PatternDescription
'public.v1.PublicService/*'All methods of the service
'*'All services and methods (allowed in rule methods and in internalMethods)
'admin.v1.AdminService/DeleteUser'Exact method match

Partial wildcards such as Write* or admin.v1.*/* are not supported by auth interceptors. List exact methods or use a complete Service/* pattern. A pattern that is not one of the three forms (a partial wildcard, a bare service name, a leading slash) throws a TypeError that names the option, the rule and the pattern when the interceptor is created, in skipMethods, in a rule's methods and in internalMethods; it used to be accepted and never matched, so a deny rule written that way did nothing.

The catch-all '*' is not accepted in skipMethods. createAuthInterceptor, createJwtAuthInterceptor, createSessionAuthInterceptor, createGatewayAuthInterceptor and createAuthzInterceptor throw when it is created with skipMethods: ['*'], because the option would switch the interceptor off for every method without saying so. To turn an interceptor off, do not register it; to skip a whole service, list 'pkg.v1.Service/*'. See Auth option validation.

Programmatic Callback ​

For complex logic that can not be expressed as rules, add an authorize callback. It is invoked only when no rule matches:

typescript
const authz = createAuthzInterceptor({
  defaultPolicy: 'deny',
  rules: [],
  authorize: (context, req) => context.roles.includes('superadmin'),
});

If authorize returns (or resolves to) exactly true, the request is allowed. Any other result fails the request with Code.PermissionDenied, including when defaultPolicy is allow: false, but also undefined and a truthy value that is not a boolean, such as the string 'false' that (ctx) => ctx.claims.isAdmin returns for a string claim. Write the comparison out, for example ctx.claims.isAdmin === true. The default policy applies only when no rule matches and no callback is configured.

defaultPolicy is 'allow' or 'deny' and defaults to 'deny' when omitted. Any other value ('Deny', '', null) throws a TypeError when the interceptor is created, so a typo cannot open the methods that no rule matches. See Auth authorization and JWT validation when upgrading.

Proto-Based Authorization ​

For defining authorization rules directly in .proto files using custom options, see the dedicated Proto-Based Authorization page. Proto options are read at runtime by createProtoAuthzInterceptor() and take priority over programmatic rules.

Interceptor Chain Position ​

Auth and authz interceptors must be placed after errorHandler and before resilience interceptors:

This ensures:

  • Authentication errors are properly formatted by errorHandler
  • Unauthenticated requests are rejected before consuming resilience resources
  • Auth context is available to all downstream interceptors
typescript
const server = createServer({
  services: [routes],
  interceptors: [
    createErrorHandlerInterceptor(),
    jwtAuth,   // immediately after errorHandler
    authz,     // immediately after authentication
    ...createDefaultInterceptors({ errorHandler: false }),
  ],
});

Full Example ​

typescript
import { createServer } from '@connectum/core';
import { createDefaultInterceptors, createErrorHandlerInterceptor } from '@connectum/interceptors';
import { createJwtAuthInterceptor, createAuthzInterceptor } from '@connectum/auth';

const publicMethods = ['public.v1.PublicService/*'];

const jwtAuth = createJwtAuthInterceptor({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  issuer: 'https://auth.example.com/',
  skipMethods: publicMethods,
});

const authz = createAuthzInterceptor({
  defaultPolicy: 'deny',
  skipMethods: publicMethods,
  rules: [
    { name: 'admin-only', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
    { name: 'write-scope', methods: ['data.v1.DataService/Write'], requires: { scopes: ['write'] }, effect: 'allow' },
  ],
  authorize: (context, req) => {
    // Fallback: superadmins can do anything
    return context.roles.includes('superadmin');
  },
});

const server = createServer({
  services: [routes],
  // Recommended order: errorHandler -> AUTH -> AUTHZ -> rest.
  interceptors: [
    createErrorHandlerInterceptor(),
    jwtAuth,
    authz,
    ...createDefaultInterceptors({ errorHandler: false }),
  ],
});

await server.start();

Verify ​

Call the admin method with an admin role and without it, then repeat without credentials. For the example above, the admin succeeds, a caller with neither admin nor the fallback superadmin role receives Code.PermissionDenied, and the unauthenticated caller receives Code.Unauthenticated. Call a public method without credentials to check that both skip lists agree.

If an allow rule is never reached, check the exact service/method names and its requirements. If a later fallback unexpectedly grants access, remember that failing a rule's requirements skips that rule rather than denying immediately.