Skip to content

Authorization Getting Started ​

Every authorization question IGNIS answers - can this user do that, here - comes down to one thing: can you reach an allow edge by walking a graph from the user. This page builds that picture first, then seeds one real grant and watches one route go from 401 to 403 to 200.

The graph, not the rule table ​

Forget rules for a moment. IGNIS authorization state is one graph. The nodes are four kinds of thing: User, Role, Permission, Domain. Every row in the single PolicyDefinition table is one edge between two nodes, and its variant column names what kind of edge it is - a role assignment, a domain membership, a permission grant, or one of three "inherits" edges that let one node stand in for a family of others.

A request is a tuple: who is asking (subject), where (domain), on what (resource), to do what (action). Casbin decides a request by walking the graph along all four of those axes at once. If it can reach an allow edge on every axis, and no deny edge also matches, the request passes. Nothing is precomputed - the walk runs fresh on every request, over the edges that user actually holds.

So what do g, g2, g3, g4, g5 mean? Each one names the walk along one axis of that tuple - not a separate language to memorize.

RelationAxisWhat it walks
gsubjectUser holds Role, Role inherits Role
g2domain (membership)User belongs to Domain
g3domain (nesting)Domain contains Domain
g4resourceResource covers Resource
g5actionAction covers Action

p is not a walk - it is the edge you are trying to reach. A p line is a grant: an action, an effect (allow or deny), and a domain, attached to a User or a Role.

That is the whole model. The rest of this page makes it concrete: real tables, one real grant, one real request.

Before you touch authorization ​

Authorization runs after authentication and only ever reads what authentication already put on the request. It needs two things from your authenticated user: principalType (which node type the user is - almost always 'user') and a userId. Neither is a dedicated field on IAuthUser; both are plain properties your authentication layer adds to the token payload.

Read the Authentication component's entity column helpers before continuing. They define the User, Role, Permission, and PolicyDefinition columns this page builds on, and skipping them is the single most common reason authorization "does nothing": without user.principalType, the enforcer throws a 400 one step before it would even reach a 403.

The three tables ​

Authorization reads three tables directly: Role, Permission, and PolicyDefinition. It never reads User - a request only ever carries principalType and userId, not a database row.

Role, via extraRoleColumns():

ColumnTypeMeaning
identifiertext, uniqueThe casbin role name, for example 900_admin
nametextHuman-readable label
priorityintegerHigher outranks lower; backs AuthorizationRole comparisons
statustextRole lifecycle, defaults to RoleStatuses.ACTIVATED
descriptiontext, nullableOptional

Permission, via extraPermissionColumns():

ColumnTypeMeaning
codetext, uniqueThe resource string a route's authorize.resource matches, for example configuration
subjecttextGroups permissions by resource family, for example Order
methodtextFor example GET; used by subset grants
actiontextFor example read
scopetextFor example global
parentIdtext or integerOptional resource nesting

PolicyDefinition, via extraPolicyDefinitionColumns({ idType: 'string' }) - the edge table:

ColumnTypeMeaning
varianttextWhich of the seven edge kinds this row is
subjectType, subjectIdtextThe edge's source node, for example user + a user id
targetType, targetIdtextThe edge's destination node
actiontext, nullableSet only on grant rows
effecttext, nullableallow or deny, set only on grant rows
domainTypetext, nullableThe domain node's type, or the SYSTEM_WIDE scope literal - see the note below
domainIdtext, nullableThe domain node's bare id. Follows idType, so integer when you leave it at the default
metadatajsonb, nullableOnly subset ("custom") grants use it

variant must be one of exactly seven values by default, owned by AuthorizationPolicyVariants: grant, assign_role, role_inherits, join_domain, domain_inherits, resource_inherits, action_inherits. Nothing validates this column on read. A typo or a wrong value does not error - the row just never matches any query, and the grant it was meant to carry silently does not exist. An application storing its own edge type in the same table declares it via extraPolicyDefinitionColumns({ extraVariants: [...] }) - see the API reference.

The domain is two columns, and that is where most hand-written rows go wrong. domain_type holds the node type, for example Organization. domain_id holds the bare id, for example 3fa85f64-5717-4562-b3fc-2c963f66afa6. The casbin token <Type>_<id> is never stored: ScopedCasbinAdapter assembles it in SQL every time it reads the row. Write a token into domain_type and every domain-scoped check for that row fails.

Only three pairs are legal, and policyDefinitionDomainShapeCheck() returns them as CHECK predicate text for your migration:

domain_typedomain_idMeans
nullnullANY_MEMBER - every domain the subject belongs to
SYSTEM_WIDEnullSystem-wide, bypassing membership
A domain type, for example OrganizationThe domain row's idThat one domain

The next section shows the one way to avoid all of these mistakes at once.

Register the component ​

This assumes a Postgres datasource is already registered - see DataSources if it is not yet. Adjust the import path and binding key to your own datasource class name.

typescript
import {
  AuthorizeBindingKeys, AuthorizeComponent, AuthorizationDecisions, AuthorizationEnforcerRegistry,
  AuthorizationEnforcerTypes, CasbinAuthorizationEnforcer, CasbinEnforcerModelDrivers,
  ScopedCasbinAdapter, CASBIN_RBAC_DOMAIN_SCOPED_MODEL, BaseApplication, IAuthorizeOptions,
} from '@venizia/ignis';
import { PostgresDataSource } from './datasources/postgres.datasource';
import { Organization, Permission, PolicyDefinition, Role } from './models/entities';

export class Application extends BaseApplication {
  preConfigure() {
    const dataSource = this.get<PostgresDataSource>({ key: 'datasources.PostgresDataSource' });

    this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
      defaultDecision: AuthorizationDecisions.DENY,
      alwaysAllowRoles: ['999_super-admin'],
    });

    this.component(AuthorizeComponent);

    const adapter = new ScopedCasbinAdapter({
      dataSource,
      entities: {
        policyDefinition: { tableName: PolicyDefinition.name },
        permission: { tableName: Permission.name },
        principals: { user: 'user', role: Role.name },
        domainTypes: [Organization.name],
      },
    });

    AuthorizationEnforcerRegistry.getInstance().register({
      container: this,
      enforcers: [{
        enforcer: CasbinAuthorizationEnforcer,
        name: 'casbin',
        type: AuthorizationEnforcerTypes.CASBIN,
        options: {
          model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_DOMAIN_SCOPED_MODEL },
          isScoped: true,
          adapter,
          cached: { use: false },
        },
      }],
    });
  }
}

entities.principals.user must equal the exact principalType string your authentication layer puts on the token - 'user' here. entities.principals.role must equal your Role entity's .name; it builds the casbin prefix for role-scoped lines (Role_<id>). cached.use: false re-reads policy rows on every request - fine while you are getting this working. The RBAC with domains guide covers adding a Redis cache once it matters.

Seed exactly one grant ​

Build every PolicyDefinition row through AuthorizationPolicyBuilder. Never write variant, domainType or domainId by hand. The builder cannot produce a wrong variant, and it splits a typed { type, id } domain into the two columns for you, so the row always lands on one of the three legal pairs above.

A grant needs three things to already exist: the user (from sign-up), one Organization row (the tenant), and one Permission row - say code: 'configuration', action: 'read'. Seed those however you already seed reference data. Then seed the grant itself:

typescript
import { randomUUID } from 'crypto';
import { Pool } from 'pg';
import { AuthorizationActions, AuthorizationDecisions, AuthorizationPolicyBuilder } from '@venizia/ignis';

const pool = new Pool({ /* your connection */ });

const grant = AuthorizationPolicyBuilder.grant({
  subject: { type: 'user', id: userId },
  permission: { type: 'Permission', id: permissionId },
  action: AuthorizationActions.READ,
  effect: AuthorizationDecisions.ALLOW,
  domain: { type: 'Organization', id: organizationId },
});

await pool.query(
  `INSERT INTO "PolicyDefinition"
     (id, variant, subject_type, subject_id, target_type, target_id, action, effect, domain_type, domain_id)
   VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
  [randomUUID(), grant.variant, grant.subjectType, grant.subjectId, grant.targetType, grant.targetId, grant.action, grant.effect, grant.domainType, grant.domainId],
);

That one row is enough - no role, no role assignment. The scoped model's role, domain, resource, and action axes all fall back to a self-link when a request matches a stored value exactly, so a grant made directly to the user's own subject clears every axis on its own. grant.domainType now reads Organization and grant.domainId the organization's bare id; the adapter pairs them into Organization_<organizationId> when it reads the row back.

examples/vert/scripts/seed-user-policies.ts runs this same builder call for seven personas at once, some with a role assignment added and some without - read it once you need more than one grant.

Protect one route ​

typescript
// Inside a controller's binding()
const READ_CONFIGURATIONS_CONFIG = {
  path: '/authz-example/configurations',
  method: 'get',
  authenticate: { strategies: [Authentication.STRATEGY_JWT] },
  authorize: {
    action: AuthorizationActions.READ,
    resource: 'configuration',
  },
  responses: jsonResponse({ description: 'Configurations', schema: ConfigurationsSchema }),
} as const;

this.defineRoute({
  configs: READ_CONFIGURATIONS_CONFIG,
  handler: context => context.json({ data: ['app.name=My App'] }, HTTP.ResultCodes.RS_2.Ok),
});

This authorize has no domain, so the enforcer falls back to the domainResolver in IAuthorizeOptions. Add one that reads the organization straight off the signed-in user:

typescript
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
  defaultDecision: AuthorizationDecisions.DENY,
  alwaysAllowRoles: ['999_super-admin'],
  // Scoped RBAC: the request domain is the authenticated user's organization.
  domainResolver: ({ context }) => {
    const organizationId = context.get(Authentication.CURRENT_USER)?.organizationId;
    return typeof organizationId === 'string'
      ? { type: Organization.name, id: organizationId }
      : null;
  },
});

organizationId is not a built-in IAuthUser field. It is a plain property your sign-in puts on the token payload, the same way principalType and userId are (see Before you touch authorization). Now sign in as usual (see Authentication Usage) and call the route with three different tokens:

bash
# No token at all
curl -i http://localhost:3000/api/authz-example/configurations
# -> 401: no authenticated user

# A signed-in user with zero PolicyDefinition rows
curl -i http://localhost:3000/api/authz-example/configurations \
  -H "Authorization: Bearer $TOKEN_WITHOUT_GRANT"
# -> 403: authenticated, but nothing to reach an allow edge

# The user from the previous section - their token already carries their organization's id
curl -i http://localhost:3000/api/authz-example/configurations \
  -H "Authorization: Bearer $TOKEN_WITH_GRANT"
# -> 200

examples/vert runs this exact scenario for real, with real ports and real tokens: examples/vert/scripts/seed-authz-test-data.ts and examples/vert/scripts/seed-user-policies.ts seed seven personas, and examples/vert/scripts/test-authorization.sh curls all of them - cases C1, C2, and C3 are the three requests above.

What just happened ​

The 200 request walked the seven-step pipeline like this:

  1. Authorization.SKIP_AUTHORIZATION was not set - continue.
  2. Authentication.CURRENT_USER was present, with principalType: 'user' - continue.
  3. No alwaysAllowRoles or allowedRoles matched - continue.
  4. No voters were registered - continue.
  5. The casbin enforcer resolved, the only one registered.
  6. domainResolver read organizationId off the signed-in user's token and resolved it to Organization_<id>. ScopedCasbinAdapter read the user's own rows from PolicyDefinition and built one p line from the grant you seeded.
  7. enforcer.evaluate() matched that line on every axis - g and g3 by self-link, since the grant names the user and the domain directly; objectMatch and g5 by exact string equality on configuration and read - and returned allow.

The 403 request stopped at the same step 7, on the same enforcer, with no line to match: that user's PolicyDefinition rows were empty, so the rules built in step 6 had nothing in them. The 401 request never got past step 2 - there was no user to check anything against.

Where to go next ​

  • Usage & Examples - securing gRPC routes, voters, role shortcuts, CRUD factory integration, and domain scoping with a global domainResolver
  • API Reference - every option, binding key, and enforcer internal
  • Error Reference - what each error means and how to fix it
  • examples/vert/src/controllers/authorization-example/ - the full controller these routes are drawn from