Skip to content

Authorization Usage

Task-oriented examples for the Authorization component. See the Overview for initial setup and the API Reference for every option and class.

Securing routes

Imperative route. Add authorize to defineRoute()'s configs, next to authenticate.

typescript
import { BaseRestController, Authentication, AuthorizationActions } from '@venizia/ignis';

class ArticleController extends BaseRestController {
  binding() {
    this.defineRoute({
      configs: {
        path: '/{id}',
        method: 'delete',
        authenticate: { strategies: [Authentication.STRATEGY_JWT] },
        authorize: {
          action: AuthorizationActions.DELETE,
          resource: 'Article',
          conditions: { ownerId: 'currentUser' }, // available to voters/custom enforcers - the built-in Casbin enforcer ignores it
        },
        responses: jsonResponse({ description: 'Deleted article', schema: ArticleSchema }),
      },
      handler: async context => {
        const { id } = context.req.valid('param');
        return context.json(await this.articleService.deleteById({ id }));
      },
    });
  }
}

Decorator-based route. Same authorize field inside @get/@post/etc. configs.

typescript
import { controller, get, post, AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';

@controller({ path: '/articles' })
class ArticleController extends BaseRestController {
  @post({
    configs: {
      path: '/',
      authenticate: { strategies: [Authentication.STRATEGY_JWT] },
      authorize: {
        action: AuthorizationActions.CREATE,
        resource: 'Article',
        allowedRoles: ['editor', AuthorizationRoles.ADMIN.identifier],
      },
      request: { body: jsonContent({ schema: CreateArticleSchema }) },
      responses: jsonResponse({ description: 'Created article', schema: ArticleSchema }),
    },
  })
  async create(opts: { context: TRouteContext }) {
    // Runs if the user has 'create:Article' permission OR the 'editor'/'900_admin' role
  }
}

Require multiple specs. Pass an array - each spec becomes its own middleware, and all must pass.

typescript
authorize: [
  { action: AuthorizationActions.UPDATE, resource: 'User' },
  { action: AuthorizationActions.UPDATE, resource: 'Admin' },
]

NOTE

When several specs run on the same route, rules are built once (via enforcer.buildRules()) and cached on Authorization.RULES for the rest of the request - the second spec reuses them instead of rebuilding.

gRPC route. Same authorize field, inside RPC metadata - AbstractGrpcController.buildRpcMiddlewares() injects it in the same order as REST (authenticate, then authorize).

typescript
@unary({
  configs: {
    name: 'deleteUser',
    authenticate: { strategies: ['jwt'] },
    authorize: { action: AuthorizationActions.DELETE, resource: 'user' },
  },
})
async deleteUser(opts: { request: DeleteUserRequest; context: TRouteContext }) { ... }

See gRPC Controllers for the full decorator/defineRoute gRPC surface.

Using the standalone authorize() function

authorize({ spec, enforcerName? }) returns a plain Hono MiddlewareHandler - use it outside route configs, e.g. wiring a Hono sub-app directly.

typescript
import { authorize, authenticate, Authentication, AuthorizationActions } from '@venizia/ignis';

app.delete(
  '/articles/:id',
  authenticate({ strategies: [Authentication.STRATEGY_JWT] }),
  authorize({ spec: { action: AuthorizationActions.DELETE, resource: 'Article' } }),
  c => c.json({ deleted: true }),
);

Target a specific enforcer. Omit enforcerName to use the first one registered.

typescript
authorize({
  spec: { action: AuthorizationActions.READ, resource: 'Report' },
  enforcerName: 'my-custom',
});

Voters

Voters run before the enforcer (pipeline step 4) and are evaluated in order - the first non-ABSTAIN decision wins.

DecisionEffect
AuthorizationDecisions.ALLOWGrants access immediately - skips remaining voters and the enforcer
AuthorizationDecisions.DENYDenies access immediately - throws 403
AuthorizationDecisions.ABSTAINNo opinion - falls through to the next voter, then the enforcer
typescript
import { AuthorizationActions, AuthorizationDecisions, TAuthorizationVoter } from '@venizia/ignis';

const ownerVoter: TAuthorizationVoter = async ({ user, action, resource, context }) => {
  if (action !== AuthorizationActions.UPDATE && action !== AuthorizationActions.DELETE) {
    return AuthorizationDecisions.ABSTAIN;
  }

  const article = await articleService.findById({ id: context.req.param('id') });
  if (!article) {
    return AuthorizationDecisions.ABSTAIN;
  }

  return article.authorId === user.userId ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.ABSTAIN;
};

authorize: {
  action: AuthorizationActions.UPDATE,
  resource: 'Article',
  voters: [ownerVoter, adminOverrideVoter], // sequential; first non-ABSTAIN wins
}

TIP

Default to ABSTAIN when a voter has no strong opinion. Only return DENY when the request should be blocked regardless of any other check - it short-circuits everything, including a later ALLOW voter.

Role-based shortcuts

Global bypass. Roles in alwaysAllowRoles (bound via IAuthorizeOptions) skip all authorization checks on every route.

typescript
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
  defaultDecision: 'deny',
  alwaysAllowRoles: [AuthorizationRoles.SUPER_ADMIN.identifier, 'system'],
});

Per-route bypass. Roles in a spec's allowedRoles skip the enforcer for that route only.

typescript
authorize: { action: AuthorizationActions.DELETE, resource: 'Article', allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'] }

Role formats accepted. extractUserRoles() reads user.roles and normalizes every entry to a string, trying identifier first, then name, then String(id).

user.roles shapeExtracted as
['admin', 'user']Used as-is
[{ id: 1, identifier: '900_admin', priority: 900 }]'900_admin' (preferred - matches AuthorizationRole.identifier)
[{ id: 1, name: 'admin' }]'admin'
[{ id: 1 }]'1'

CRUD factory integration

Apply to every generated route. Set authorize at the controller level in defineCrudController.

typescript
import { AuthorizationActions } from '@venizia/ignis';

ControllerFactory.defineCrudController({
  entity: Article,
  repository: { name: 'ArticleRepository' },
  controller: { name: 'ArticleController', basePath: '/articles' },
  authenticate: { strategies: [Authentication.STRATEGY_JWT] },
  authorize: { action: AuthorizationActions.READ, resource: 'Article' },
});

Override per endpoint. routes.<endpoint>.authorize takes precedence over the controller-level spec.

typescript
routes: {
  find: { authenticate: { skip: true } },                                   // public read - skips BOTH checks
  count: { authorize: { skip: true } },                                     // authenticated, no authorization
  create: { authorize: { action: AuthorizationActions.CREATE, resource: 'Article' } },
  deleteById: {
    authorize: {
      action: AuthorizationActions.DELETE,
      resource: 'Article',
      allowedRoles: [AuthorizationRoles.ADMIN.identifier],
    },
  },
}

Priority, in order:

  1. authenticate: { skip: true } - skips both authentication AND authorization
  2. authorize: { skip: true } - skips authorization only (authentication still runs)
  3. Per-route authorize spec - overrides the controller-level spec
  4. No per-route config - inherits the controller-level authorize

Per-route config is typed TRouteAuthorizeConfig = { skip: true } | IAuthorizationSpec | IAuthorizationSpec[]. See the API Reference for the full resolver signature.

Dynamic skip authorization

Set Authorization.SKIP_AUTHORIZATION from an earlier middleware to bypass authorization for that one request (pipeline step 1) - useful for trusted service-to-service calls.

typescript
import { Authorization } from '@venizia/ignis';
import { createMiddleware } from 'hono/factory';

const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
  if (c.req.header('X-Internal-Service') === 'trusted-key') {
    c.set(Authorization.SKIP_AUTHORIZATION, true);
  }
  return next();
});

Accessing context variables

typescript
import { Authorization, Authentication } from '@venizia/ignis';

const user = c.get(Authentication.CURRENT_USER);           // IAuthUser
const rules = c.get(Authorization.RULES);                  // unknown - shape depends on the enforcer
const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION);  // boolean
const domain = c.get(Authorization.DOMAIN);                // "<Type>_<id>" | "SYSTEM_WIDE" - set only when domain scoping is in play

c.set(Authorization.SKIP_AUTHORIZATION, true);
c.set(Authorization.RULES, null); // force a rebuild later in the SAME request

TIP

Rules are cached per-request only - every new HTTP request starts with an empty cache. c.set(Authorization.RULES, null) only matters if you need to force a rebuild mid-request (e.g. after mutating the user's roles inline).

Custom enforcer

Implement IAuthorizationEnforcer to replace or supplement Casbin.

typescript
import {
  IAuthorizationEnforcer, IAuthorizationRequest, IAuthUser, TAuthorizationDecision,
  AuthorizationDecisions, TContext,
} from '@venizia/ignis';
import { BaseHelper, ValueOrPromise } from '@venizia/ignis-helpers';
import { Env } from 'hono';

type MyRules = Map<string, Set<string>>;

class MyCustomEnforcer extends BaseHelper implements IAuthorizationEnforcer<Env, string, string, MyRules> {
  name = 'my-custom';

  constructor() {
    super({ scope: MyCustomEnforcer.name });
  }

  async configure(): Promise<void> {
    // One-time initialization - called by the registry on first resolveEnforcer()
  }

  async buildRules(opts: { user: { principalType: string } & IAuthUser; context: TContext }): Promise<MyRules> {
    return new Map(); // build from DB, config, etc.
  }

  async evaluate(opts: { rules: MyRules; request: IAuthorizationRequest; context: TContext }): Promise<TAuthorizationDecision> {
    const actions = opts.rules.get(opts.request.resource);
    return actions?.has(opts.request.action) ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.DENY;
  }
}

Register it the same way as the Casbin enforcer, with type: AuthorizationEnforcerTypes.CUSTOM:

typescript
AuthorizationEnforcerRegistry.getInstance().register({
  container: this,
  enforcers: [{
    enforcer: MyCustomEnforcer,
    name: 'my-custom',
    type: AuthorizationEnforcerTypes.CUSTOM,
    options: { /* your enforcer-specific options, if any */ },
  }],
});

NOTE

A custom enforcer can inject its own options via @inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') }) in the constructor, exactly like CasbinAuthorizationEnforcer does.

Custom filtered adapter

Use the ready-made ScopedCasbinAdapter for most apps - it reads one edge table and needs no subclassing. Write a custom adapter only when your storage model differs.

BaseFilteredAdapter provides the datasource/connector plumbing, isFiltered(), the no-op write methods, and a loadLines helper. A subclass implements only loadFilteredPolicy - query your store for ONE principal's policies and turn them into casbin lines.

typescript
import { BaseFilteredAdapter, ICasbinPolicyFilter, type ICasbinPolicySource } from '@venizia/ignis';
import type { Model } from 'casbin';

class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
  constructor(opts: { dataSource: ICasbinPolicySource }) {
    super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
  }

  async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
    const { type, id } = filter.principal;

    // 1. Query your store for THIS principal's policies (this.connector is provided by the base).
    // 2. Build casbin lines as plain strings, e.g. `p, User_${id}, Order, read, allow` or `g, User_${id}, Role_42, *`.
    const lines: string[] = await this.buildLinesFor({ type, id });

    // 3. Load them into the model with the base helper.
    await this.loadLines({ model, lines });
  }

  private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
    return []; // ...your queries via this.connector...
  }
}

There are no template-method query hooks or line formatters on the base - a subclass owns its own queries and line construction. See ScopedCasbinAdapter for a full reference implementation (role closure, structural trees, soft-delete, schema-qualified SQL).

AuthorizationRole comparison

AuthorizationRole gives priority-based role comparison; identifiers are {paddedPriority}{delimiter}{name}.

typescript
import { AuthorizationRole, AuthorizationRoles } from '@venizia/ignis';

AuthorizationRoles.SUPER_ADMIN.identifier;  // '999_super-admin'
AuthorizationRoles.ADMIN.identifier;        // '900_admin'

AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true (999 > 900)
AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER });         // true (1 < 10)

const moderator = AuthorizationRole.build({ name: 'moderator', priority: 500 });
moderator.identifier; // '500_moderator'

const customRole = AuthorizationRole.build({ name: 'editor', priority: 100, delimiter: '-' });
customRole.identifier; // '100-editor'

Model-Based Resource References

Instead of hardcoding resource strings, declare authorize.principal on the model - @model auto-populates the static AUTHORIZATION_SUBJECT from it.

typescript
import { BasePostgresEntity, model, generateIdColumnDefs } from '@venizia/ignis';
import { pgTable, text } from 'drizzle-orm/pg-core';

@model({
  type: 'entity',
  settings: { authorize: { principal: 'article' } },
})
export class Article extends BasePostgresEntity<typeof Article.schema> {
  static override schema = pgTable('Article', {
    ...generateIdColumnDefs({ id: { dataType: 'string' } }),
    title: text('title').notNull(),
  });
}

// Article.AUTHORIZATION_SUBJECT === 'article'
typescript
import { AuthorizationActions } from '@venizia/ignis';
import { Article } from '../models/entities/article.model';

authorize: {
  action: AuthorizationActions.READ,
  resource: Article.AUTHORIZATION_SUBJECT, // instead of the string literal 'article'
}

Query every declared principal at runtime, e.g. to seed Casbin policies.

typescript
import { MetadataRegistry } from '@venizia/ignis';

const registry = MetadataRegistry.getInstance();

registry.getAuthorizeModelPrincipals({ format: 'array' });
// ['article', 'user', 'configuration']

registry.getAuthorizeModelPrincipals({ format: 'record' });
// { Article: 'article', User: 'user', Configuration: 'configuration' }

registry.getAuthorizeModelSettings({ format: 'array' });
// [{ name: 'Article', authorize: { principal: 'article' }, entry: IModelRegistryEntry }]

TIP

Declaring authorize.principal on the model makes it the single source of truth for the model's authorization subject, eliminating string duplication across route configs and policy setup. See the Persistent Models guide for the full @model settings surface.

RBAC with domains (multi-tenant)

For apps where a user holds roles scoped to specific tenants (and sometimes globally), Casbin's RBAC with domains pattern keeps a user's materialized policy linear (memberships + permissions) instead of a permissions x tenants cross-product - 30 tenants x 700 permissions is ~730 lines instead of ~21,000.

There are two ways to get there:

ApproachWhen
Scoped model (recommended)isScoped: true + ScopedCasbinAdapter + CASBIN_RBAC_DOMAIN_SCOPED_MODEL; supply the request domain per route via spec.domain (or a global domainResolver). The enforcer registers its own matchers - no domainMatching or normalizePayloadFn needed.
Manual flat model (lower-level)Keep a flat g + p model and register domainMatching + normalizePayloadFn yourself. See The flat model below.

Register the enforcer with isScoped: true (see the Overview), then tell each route where to read its domain from. IAuthorizationSpec.domain accepts a declarative source or a resolver function.

typescript
import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';

// (a) Declarative - read the domain id from a request param/header/query/context var:
authorize({
  spec: { action: 'read', resource: 'Order', domain: { from: 'param', key: 'merchantId', type: 'Merchant' } }, // -> "Merchant_<param>"
});

// (b) Resolver - compute { type, id } yourself (return null -> SYSTEM_WIDE):
authorize({
  spec: {
    action: 'read',
    resource: 'Order',
    domain: ({ context }) => {
      const merchantId = resolveActiveMerchant({ context });
      return merchantId ? { type: 'Merchant', id: merchantId } : null;
    },
  },
});
  • Precedence (resolveRequestDomain): spec.domain (resolver, then declarative) -> the global IAuthorizeOptions.domainResolver -> SYSTEM_WIDE.
  • Where it lands. The resolved value is stashed on Authorization.DOMAIN and passed to the enforcer as request.domain.
  • No domain = super-admin scope. A route with no domain source at all enforces SYSTEM_WIDE in scoped mode.

Global fallback - apply the same resolver to every route that doesn't set spec.domain:

typescript
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
  defaultDecision: 'deny',
  domainResolver: ({ context }) => {
    const id = resolveActiveMerchant({ context });
    return id ? { type: 'Merchant', id } : null;
  },
});

The flat model

ini
[request_definition]
r = sub, dom, obj, act

[policy_definition]
p = sub, dom, obj, act, eft

[role_definition]
g = _, _, _

[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))

[matchers]
m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
  • g = _, _, _ - the membership relation is domain-aware (subject, role, domain).
  • g(r.sub, p.sub, r.dom) - the registered domain matching function decides whether the request domain matches the stored membership domain (this is what makes * a wildcard).
  • keyMatch(r.dom, p.dom) - a built-in matcher (no registration needed) that lets a permission with p.dom = "*" match any request domain.

Register the domain matching function. Pass domainMatching in the enforcer options - it registers once during configure().

typescript
import {
  AuthorizationEnforcerRegistry, AuthorizationEnforcerTypes, CasbinAuthorizationEnforcer,
  CasbinDomainMatchingFunctions, CasbinEnforcerModelDrivers, type ICasbinEnforcerOptions,
} from '@venizia/ignis';

AuthorizationEnforcerRegistry.getInstance().register({
  container: this,
  enforcers: [{
    enforcer: CasbinAuthorizationEnforcer,
    name: 'casbin',
    type: AuthorizationEnforcerTypes.CASBIN,
    options: {
      model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: theFlatModelConfAbove }, // your own .conf text - not a framework export
      adapter,
      cached,
      // For a domain model, normalizePayloadFn MUST always return a `domain`.
      normalizePayloadFn: ({ user, action, resource, context }) => ({
        subject: `User_${user.userId}`,
        domain: `Merchant_${resolveActiveMerchant({ context })}`,
        resource,
        action,
      }),
      domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
    } satisfies ICasbinEnforcerOptions,
  }],
});

NOTE

domainMatching is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc) internally - never call it directly.

Choosing the matching function. keyMatch is the safe default for opaque domain identifiers like Merchant_<uuid> - it treats only * as special and never splits on / or :, so it cannot accidentally match one tenant against another. Use the others only for structured path-style domains.

FunctionAdds
CasbinDomainMatchingFunctions.KEY_MATCHNothing beyond * (recommended default)
CasbinDomainMatchingFunctions.KEY_MATCH_2URL-path :param segments
CasbinDomainMatchingFunctions.KEY_MATCH_3{param} segments
CasbinDomainMatchingFunctions.KEY_MATCH_4{param} segments with repeated-name equality checks
CasbinDomainMatchingFunctions.REGEX_MATCHFull regular-expression matching on the stored value

Policy lines and outcomes, given the flat model above with keyMatch registered on g, request enforceSync(subject, domain, resource, action):

CasePolicy linesRequestOutcome
Scoped role (owner in a tenant)g, User_u, Role_owner, Merchant_A
p, Role_owner, *, Material.find, read, allow
(User_u, Merchant_A, Material.find, read)allow
Scoped role - isolation(same as above)(User_u, Merchant_B, Material.find, read)deny (g domain doesn't match)
Multi-tenant roleg, User_u, Role_owner, Merchant_A
g, User_u, Role_owner, Merchant_B
p, Role_owner, *, Material.find, read, allow
(User_u, Merchant_B, Material.find, read)allow (one g line per tenant; a single p line)
Global role (e.g. guest/onboarding)g, User_u, Role_guest, *
p, Role_guest, *, Organizer.onBoarding, create, allow
(User_u, Merchant_anything, Organizer.onBoarding, create)allow (wildcard g domain)
Direct user permission (scoped)p, User_u, Merchant_A, Report.read, read, allow(User_u, Merchant_A, Report.read, read)allow (reflexive g(u,u,dom) + keyMatch)
Direct user permission - isolation(same as above)(User_u, Merchant_B, Report.read, read)deny
Deny overridep, Role_x, *, Secret.read, read, deny
p, Role_y, *, Secret.read, read, allow
any domain where the user has both rolesdeny (!some(p.eft == deny))

IMPORTANT

The matching function is applied as fn(requestDomain, policyDomain) - the wildcard belongs on the stored side. Store only * or exact domain values (never Merchant_*) to keep tenant isolation guaranteed.

Misconfiguration is caught early. If roleDefinition is not declared under [role_definition] in the model, configure() throws at boot - Casbin would otherwise silently register the function as a no-op, leaving wildcard domains permanently unmatched (global roles silently denied).

typescript
// model declares `g` only
domainMatching: { roleDefinition: 'g2', fn: CasbinDomainMatchingFunctions.KEY_MATCH };
// throws: Role definition "g2" is not declared in the Casbin model. Declare it under
// [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.

See also