Edit this page on GitHub

Authorization: Roles, Policies, and Where Attribute Rules Go

Setu-TS has two authorization layers, and they answer different questions.

LayerQuestion it answersContractToken
Roles and permissions“Does this principal hold this role or permission?”IAuthorizationServiceCAPABILITIES.AUTHORIZATION
Policies (this guide)“May this principal do this, to this target?”IAuthorizationPolicyServiceCAPABILITIES.AUTHORIZATION_POLICIES

The role layer is synchronous and has no parameter through which a target can reach a decision, so “the author of this post”, “an approver of an amount under their limit”, or “a member of the document’s team” has no home there. Policies are that home. Both layers ship in @setu-ts/auth-plugin; AuthPlugin registers the policy service whether or not you configure roles.

Does the framework need an attribute (ABAC) engine?

No. Attribute rules are policies you write: a policy is a named set of abilities, each an asynchronous check that receives the principal and the target. ASP.NET Core (IAuthorizationService.AuthorizeAsync with resource handlers), NestJS’s policy layer, and Spring’s PermissionEvaluator make the same choice — none ships an attribute language; each gives you a place to put code. If you prefer an external engine (OpenFGA, Casbin, Cerbos), wrap it in a policy: the check calls the engine, and every entry point below works unchanged.

A role held in one tenant, organisation, or region rather than everywhere is scoped RBAC, built on this layer — see Scoped Roles.

Defining a Policy

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { AuthPlugin, definePolicy, requirePolicy } from '@setu-ts/auth-plugin';

interface Post {
  readonly id: string;
  readonly authorId: string;
  readonly published: boolean;
}

const posts = new Map<string, Post>();

const postPolicy = definePolicy({
  name: 'post',
  abilities: {
    // Requires a signed-in principal: an anonymous request is refused 401
    // before this check runs.
    update: (principal, post: Post | undefined) => post?.authorId === principal.id,
    // Opts in to anonymous principals: called with `null` when nobody is signed in.
    read: {
      anonymous: true,
      check: (principal, post: Post | undefined) =>
        post?.published === true || (principal !== null && post?.authorId === principal.id),
    },
  },
  // Runs first, for a signed-in principal: `true` allows every ability,
  // `undefined` falls through to the ability's check, anything else denies.
  before: (principal) => (principal.roles?.includes('admin') === true ? true : undefined),
});

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    AuthPlugin({ jwt: { secret: 'replace-with-a-secret-of-32-chars!' }, policies: [postPolicy] }),
  ],
});

app.router.patch('/posts/:id', {
  // The third argument is the target: a value, or an extractor called per
  // request. An extractor answering `undefined` (not found) is allowed — the
  // check receives `undefined` and decides.
  middleware: [requirePolicy(postPolicy, 'update', (ctx) => posts.get(ctx.params.id ?? ''))],
  handler: (ctx) => ctx.response.json({ updated: ctx.params.id }),
});

await app.start();

definePolicy infers the ability names from the object, so requirePolicy(postPolicy, 'updaet') is a compile error. It infers the target type from an annotated check parameter (post: Post | undefined above).

The Rules Are Fixed

These are not options. Each is the same for the route guard, the decorator, and the imperative calls, because all four reach one evaluator.

SituationOutcome
The check returns true (or a promise of true)Allowed
The check returns anything else — false, 1, 'yes', {}Denied
The check or before throws or rejectsDenied, and reported once to the logger (never the target)
No principal, and the ability is not anonymousDenied 401, the check is not called
before returns true / undefined / anything elseAllowed / falls through to the check / denied (null denies)
Denied, no principal401 Unauthorized, “Authentication required”
Denied, signed-in principal403 Forbidden, “Insufficient privileges” — the policy is never named
No AuthPlugin registered501 Not Implemented — the route is never served unguarded
A route guard names a policy or ability that is not registeredapp.start() fails, naming the route, policy, and ability
An imperative call names an unknown policy or abilityThe call rejects with UnknownPolicyError, naming it

The refusal bodies are the same ones requireRole writes, in whatever error format you configured.

Two limits are worth knowing. The startup check reads routes registered before start(); a route added afterwards, or a guard added as global middleware, is not checked and fails closed per request instead. And a policy is identified by its name: a guard built from a policy object you forgot to register, while a different policy with the same name is registered, would evaluate that other policy — the startup check (and, for @RequirePolicy, register()) refuses the case where the two disagree on which abilities are anonymous. Whether an ability needs a principal is read from the registered policy on every request, never from the guard’s own object, so an unchecked guard gets this right too. An anonymous request to an ability that needs a principal is refused before your target extractor runs, so it costs no record lookup and learns nothing from it; a guard naming an unregistered policy or ability rejects without running the extractor at all.

Checks Inside a Handler

A declarative guard runs before your handler has loaded anything. When the decision needs the record the handler loads, ask the service directly. can answers a boolean — useful for showing or hiding an edit button — and authorize rejects when denied:

import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthorizationPolicyService, IRequestContext } from '@setu-ts/common';
import { definePolicy } from '@setu-ts/auth-plugin';

interface Invoice {
  readonly amount: number;
  readonly approverLimit: number;
}

const invoicePolicy = definePolicy({
  name: 'invoice',
  abilities: {
    approve: (_principal, invoice: Invoice | undefined) =>
      invoice !== undefined && invoice.amount <= invoice.approverLimit,
  },
});

export async function approve(ctx: IRequestContext, invoice: Invoice): Promise<void> {
  const policies = ctx.services.get<IAuthorizationPolicyService>(
    CAPABILITIES.AUTHORIZATION_POLICIES,
  );
  const canApprove = await policies.can(
    ctx.request.user ?? null,
    invoicePolicy,
    'approve',
    invoice,
  );
  if (canApprove) {
    // render the approve button, or proceed
  }
  // Rejects with AuthorizationDeniedError when denied.
  await policies.authorize(ctx.request.user ?? null, invoicePolicy, 'approve', invoice);
}

AuthorizationDeniedError carries an HTTP status hint, so with errorHandler installed a denied authorize answers exactly the body a guard’s refusal would. Without errorHandler it is an ordinary error and the kernel answers 500.

The Class Form

With @setu-ts/decorator-plugin, a policy can be a class. It is constructed like a controller, so its constructor can take injected dependencies:

import type { IPrincipal } from '@setu-ts/common';
import {
  Ability,
  Controller,
  DecoratorPlugin,
  Get,
  Inject,
  Injectable,
  Patch,
  Policy,
  RequirePolicy,
} from '@setu-ts/decorator-plugin';

interface Doc {
  readonly id: string;
  readonly ownerId: string;
}

@Injectable({ token: 'doc-store' })
class DocStore {
  readonly #docs = new Map<string, Doc>();
  find(id: string): Doc | undefined {
    return this.#docs.get(id);
  }
}

@Policy('doc')
@Inject('doc-store')
class DocPolicy {
  constructor(private readonly docs: DocStore) {}

  @Ability()
  edit(principal: IPrincipal, doc: Doc | undefined): boolean {
    return doc !== undefined && this.docs.find(doc.id)?.ownerId === principal.id;
  }

  @Ability({ anonymous: true })
  view(_principal: IPrincipal | null, _doc: Doc | undefined): boolean {
    return true;
  }
}

@Controller('/docs')
class DocController {
  @Patch('/:id')
  @RequirePolicy(DocPolicy, 'edit', (ctx) => ({ id: ctx.params.id ?? '', ownerId: '' }))
  edit(): { readonly saved: boolean } {
    return { saved: true };
  }

  @Get('/:id')
  @RequirePolicy(DocPolicy, 'view')
  show(): { readonly shown: boolean } {
    return { shown: true };
  }
}

export const decorators = DecoratorPlugin({
  services: [DocStore],
  policies: [DocPolicy],
  controllers: [DocController],
});

A method named before is the policy’s before hook. @RequirePolicy also accepts a definePolicy definition, may be repeated (every @RequirePolicy on a route must allow, top to bottom), and runs after guards and @Roles/@Permissions but before validation — so a target extractor reading the body sees the unvalidated body; prefer route parameters. Registering DecoratorPlugin with @RequirePolicy routes or policies but no AuthPlugin fails at startup, naming the route.

Queues, Messages, and Sockets

There is no policy behaviour for non-HTTP ingress yet: a queue job, a message, or a socket frame carries no principal, and a check that silently evaluated as anonymous would be worse than none. The imperative calls work anywhere, because the principal is an argument you supply — derive it from the payload you trust:

import type { IAuthorizationPolicyService, IPrincipal } from '@setu-ts/common';
import { definePolicy } from '@setu-ts/auth-plugin';

interface ExportJob {
  readonly requestedBy: string;
  readonly accountId: string;
}

const accountPolicy = definePolicy({
  name: 'account',
  abilities: { export: (principal, accountId: string | undefined) => principal.id === accountId },
});

export async function handleExport(
  policies: IAuthorizationPolicyService,
  job: ExportJob,
): Promise<void> {
  const principal: IPrincipal = { id: job.requestedBy };
  await policies.authorize(principal, accountPolicy, 'export', job.accountId);
  // …run the export
}

Scoped Roles

A global role says “Ann is an approver”. A scoped role says “Ann is an approver in the Acme tenant” — or in one organisation, team, or region. Configure scopedRbac beside rbac, and AuthPlugin defines one built-in policy, scoped-rbac, on the same evaluator every policy uses: each catalogue permission and role is an ability of it, and the target is the scope. No new capability and no new evaluator — so the rules table above applies unchanged.

import { scopeFromParam } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';
import { AuthPlugin, requireScopedPermission, requireScopedRole } from '@setu-ts/auth-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    MultiTenancyPlugin({ resolver: 'header' }),
    AuthPlugin({
      jwt: { secret: 'replace-with-a-secret-of-32-chars!' },
      // The catalogue: the only roles and permissions a guard may name.
      rbac: {
        roles: {
          viewer: { permissions: ['invoices:read'] },
          approver: { permissions: ['invoices:approve'], inherits: ['viewer'] },
          'org-admin': { permissions: ['reports:read'] },
        },
      },
      scopedRbac: {
        sources: [
          // Grants listed in configuration. A `null` scope is a global grant.
          {
            kind: 'static',
            grants: [{ subject: 'ann', role: 'approver', scope: { type: 'tenant', id: 'acme' } }],
          },
          // Grants an identity provider put in the token.
          {
            kind: 'claims',
            map: (claims) =>
              Array.isArray(claims.orgAdminOf)
                ? claims.orgAdminOf.map((id) => ({
                    role: 'org-admin',
                    scope: { type: 'organisation', id: String(id) },
                  }))
                : [],
          },
        ],
        // A child tenant inherits its parent's grants. Walked transitively;
        // a cycle, or too deep or too wide a walk, denies.
        inheritsFrom: (scope) =>
          scope.type === 'tenant' && scope.id === 'acme-eu' ? [{ type: 'tenant', id: 'acme' }] : [],
        // `org-admin` counts only when granted in an organisation.
        grantableIn: { 'org-admin': { scopeTypes: ['organisation'] } },
      },
    }),
  ],
});

// The default scope is the resolved request tenant.
app.router.post('/invoices/:id/approve', {
  middleware: [requireScopedPermission('invoices:approve')],
  handler: (ctx) => ctx.response.json({ approved: ctx.params.id }),
});

// Or take it from the route. Several roles are any-of; several permissions all-of.
app.router.get('/orgs/:orgId/reports', {
  middleware: [
    requireScopedRole(['org-admin'], { scope: scopeFromParam('orgId', 'organisation') }),
  ],
  handler: (ctx) => ctx.response.json({ org: ctx.params.orgId }),
});

await app.start();

What a scoped check decides

SituationOutcome
A grant of a role carrying the permission, in the scope or a scope it inheritsAllowed
A global grant (scope: null)Counts in every scope, unless grantableIn restricts the role
A role or permission the principal carries itself (a JWT roles claim, '*')Counts in no scope — set principalAuthorityIsGlobal: true to count it everywhere
A route scope naming a different tenant than the resolved request tenantDenied — a parameter cannot reach outside the caller’s tenant
A parent tenant’s grant, checked in a child that names it in inheritsFromAllowed — child requests inherit parent grants, never the reverse
Any grant source rejects, throws, or exceeds sourceTimeoutMsDenied, and logged with the source name and error class only
More grants, a deeper walk, or more scopes than the configured boundsDenied
A guard naming a permission or role outside the catalogueapp.start() fails, naming the route — a typo is never a silent 403

Grant sources are unioned, and one failing source denies the whole check: a check that silently skipped a source would allow on a partial answer. The wildcard * is never checkable by name.

Grants from the database, and roles a tenant defines

@setu-ts/database-plugin ships both sources over any repository: one row per grant, and one row per (scope, role, permission) for roles a tenant defines at runtime. A custom role bundles catalogue permissions only — a guard names the permission, never the custom role — and resolves against the roles defined in the grant’s own scope, so two tenants defining regional-approver never collide.

import { AuthPlugin } from '@setu-ts/auth-plugin';
import { createDatabaseGrantSource, createDatabaseRoleSource } from '@setu-ts/database-plugin';

export const auth = AuthPlugin({
  rbac: { roles: { approver: { permissions: ['invoices:approve', 'invoices:read'] } } },
  scopedRbac: {
    sources: [{ kind: 'custom', source: createDatabaseGrantSource({ entity: 'role_grants' }) }],
    customRoles: createDatabaseRoleSource({ entity: 'tenant_roles' }),
    // Resolve grants once per 30 s instead of once per request.
    timing: { kind: 'cache', ttlMs: 30_000, maxEntries: 10_000 },
  },
});

When grants are resolved decides how fast a revocation takes effect:

timingGrants are readA revoked grant stops working
'request' (default)Once per request, reused for the rest of itOn the next request
{ kind: 'cache', ttlMs, … }Once per principal per ttlMs, shared in-flightWithin ttlMs
'sign-in' (requires signIn)Once, at sign-in, stored in the auth sessionAt sign-out or when the session expires

'sign-in' stores grants under a private session key, never in the principal’s claims, so a federated token carrying a claim of the same name grants nothing.

The class form, and checks inside a handler

import { scopeFromParam } from '@setu-ts/common';
import { Controller, Get, Post, ScopedPermissions, ScopedRoles } from '@setu-ts/decorator-plugin';

@Controller('/invoices')
@ScopedRoles(['viewer'])
export class InvoiceController {
  @Get('/')
  list(): string[] {
    return [];
  }

  // Method decorators override the class default.
  @Post('/:id/approve')
  @ScopedPermissions(['invoices:approve'])
  approve(): { approved: boolean } {
    return { approved: true };
  }

  @Get('/orgs/:orgId')
  @ScopedRoles(['org-admin'], scopeFromParam('orgId', 'organisation'))
  forOrg(): string[] {
    return [];
  }
}

The decorators answer exactly what the guards answer, and run after @Roles/@Permissions. A name outside the catalogue, or no AuthPlugin({ rbac, scopedRbac }) registered, fails DecoratorPlugin’s register(), naming the route. Inside a handler, ask the policy service directly, and pass the request as context:

import {
  type IAuthorizationPolicyService,
  type IRequestContext,
  SCOPED_RBAC_POLICY,
  scopedPermissionAbility,
} from '@setu-ts/common';

export async function canApprove(
  policies: IAuthorizationPolicyService,
  ctx: IRequestContext,
  tenantId: string,
): Promise<boolean> {
  return await policies.can(
    ctx.request.user ?? null,
    SCOPED_RBAC_POLICY,
    scopedPermissionAbility('invoices:approve'),
    {
      scope: { type: 'tenant', id: tenantId },
      context: ctx,
    },
  );
}

context is what makes the check compare the scope against the request’s resolved tenant. Without it, a check for a tenant other than the one the request resolved to is answered from the grants alone, so a principal holding a role in tenant B passes a check for B made while serving tenant A. A queue job or a scheduled task has no request and so no tenant to compare; leave context out there, and pass the scope the job itself is about.

OpenAPI

requirePolicy and @RequirePolicy carry the same security brand the role guards do, so OpenApiPlugin({ deriveSecurity }) documents a route whose ability requires a signed-in principal as secured. A route whose ability opted in to anonymous principals is documented as public (security: []), because the guard lets an anonymous request through.

The brand describes the guard’s own policy object, because it is fixed when the guard is built. The startup check proves that object agrees with the registered policy for every route present at start(); a route added afterwards from a same-named object that disagrees on anonymous is documented from the object, while access is still decided by the registered policy.