Authorization engine (Casbin)
When a satellite's rules outgrow flat 'resource:action' codes — hierarchies, wildcards, per-resource policy — add a policy engine as your app's own dependency and enforce against your own tables. It stays an SCS decision: your repo, your dependency, your database. This is the distilled shape the platform's apps use; Backend → Authorization is the simpler guards path and where you decide you need this. It enforces against the token's userId + organizationId (never a client-supplied id), and your admin role passes everything.
One thing to settle before you write a line: what that admin role is called. The scaffold's seed uses org-admin (hyphen); HRM — the app this page distills — uses org_admin (underscore). Neither is more correct, but the wildcard below is an exact string match, so pick one and use the same spelling in your seed, your buildLines, and the decorator.
Five files under one folder:
api/config/
└─ rbac_model.conf # the Casbin model — RBAC + domains. NOT under src/
api/src/authz/
├─ permissions.ts # your codes, 'resource:action'
├─ policy.adapter.ts # loads one org's policy from your tables, cached
├─ rbac.service.ts # one enforcer per org — rolesFor / can
└─ authorize.decorator.ts # the @authorize({ permissions: [...] }) decoratorKeep the .conf out of src/, or the container won't boot
api/tsconfig.json is rootDir: ./src → outDir: ./dist, and tsc emits only the JavaScript it compiles. A rbac_model.conf sitting in src/authz/ is never copied, so dist/authz/rbac_model.conf does not exist. bun run src/index.ts in dev finds it; bun run dist/index.js in the image does not — and because the model is read at module load, the API dies on startup rather than on the first authorized request.
Put it in api/config/ and resolve from the working directory, which is what HRM does:
private readonly modelPath = path.resolve(process.cwd(), 'config/rbac_model.conf')Then make sure the Dockerfile carries it: COPY --from=builder /app/config ./config.
An RBAC-with-domains model where the domain is the org — so policy is multi-tenant, and a * policy is the admin wildcard:
# api/config/rbac_model.conf — sub=role/user, dom=org, obj=resource, act=action
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act)Policy loads through a per-org adapter that reads only that org's rows and caches the built lines, so a cold request doesn't rescan the DB:
// api/src/authz/policy.adapter.ts
import { Adapter, Model, Helper } from 'casbin'
import type { CacheService } from '../services/cache.service'
const TTL = 300 // seconds a built policy stays cached
export class PolicyAdapter implements Adapter {
private key = `casbin:policy:${this.orgId}`
constructor(
private cache: CacheService,
private orgId: string,
/** Your Drizzle read → Casbin lines: 'p,<role>,<org>,<res>,<act>' and 'g,<userId>,<role>,<org>'. */
private build: (orgId: string) => Promise<string[]>,
) {}
async loadPolicy(model: Model): Promise<void> {
let lines = await this.cache.get<string[]>(this.key)
if (!lines) { lines = await this.build(this.orgId); await this.cache.set(this.key, lines, TTL) }
for (const line of lines) Helper.loadPolicyLine(line, model)
}
/** Call after a role's grants change so the next request rebuilds. */
async invalidate(): Promise<void> { await this.cache.delete(this.key) }
// Read-only: grants live in your tables, not in Casbin.
async savePolicy() { return true }
async addPolicy() {}
async removePolicy() {}
async removeFilteredPolicy() {}
}Build the lines from your own tables — the scaffold's flat permissions.code is already 'resource:action':
async function buildLines(orgId: string): Promise<string[]> {
const { rolePerms, userRoles } = await readOrgPolicy(orgId) // your Drizzle query, scoped to orgId
const lines = rolePerms.map(({ role, code }) => `p,${role},${orgId},${code.replace(':', ',')}`)
for (const { userId, role } of userRoles) lines.push(`g,${userId},${role},${orgId}`)
return lines
}Then one enforcer per org, wired to the adapter and cached in-process:
// api/src/authz/rbac.service.ts
import { newEnforcer, type Enforcer } from 'casbin'
import { PolicyAdapter } from './policy.adapter'
import path from 'node:path'
// A path, not a string read at import time — see the warning above.
const MODEL_PATH = path.resolve(process.cwd(), 'config/rbac_model.conf')
const enforcers = new Map<string, Enforcer>()
async function enforcerFor(orgId: string): Promise<Enforcer> {
let e = enforcers.get(orgId)
if (!e) {
// `cache` is the scaffold's Redis-backed CacheService (see below).
e = await newEnforcer(MODEL_PATH, new PolicyAdapter(cache, orgId, buildLines))
enforcers.set(orgId, e)
}
return e
}
export const rbac = {
rolesFor: (u: string, o: string) => enforcerFor(o).then((e) => e.getRolesForUserInDomain(u, o)),
can: (u: string, o: string, obj: string, act: string) => enforcerFor(o).then((e) => e.enforce(u, o, obj, act)),
}The cache is the scaffold's Redis-backed CacheService; call adapter.invalidate() (or clear the casbin:policy:<org> key) when an org's grants change. No Redis? Cache the lines to a .json file, or drop the adapter for Casbin's built-in FileAdapter over a policy .csv.
The decorator resolves the caller's roles once, short-circuits for org-admin, then checks the permissions ('resource:action'):
// api/src/authz/authorize.decorator.ts
import type { Context, Next } from 'hono'
import { Authentication } from '@venizia/ignis'
import { rbac } from './rbac.service'
export interface AuthorizeOptions {
permissions?: string[] // 'resource:action' — any listed one passes
roles?: string[] // any listed role passes
}
export function authorize(opts: AuthorizeOptions = {}) {
return (_t: any, _k: string, d: PropertyDescriptor) => {
const handler = d.value
d.value = async function (c: Context, next?: Next) {
const user = c.get(Authentication.CURRENT_USER as any) as any
const userId = String(user?.userId ?? user?.sub ?? '')
const orgId = String(user?.organizationId ?? '')
if (!userId || !orgId) return c.json({ error: 'Unauthorized' }, 401)
const roles = await rbac.rolesFor(userId, orgId)
const ok =
roles.includes('org-admin') || // admin wildcard
(opts.roles?.some(r => roles.includes(r)) ?? false) ||
(!!opts.permissions?.length && (await Promise.all(
opts.permissions.map((p) => {
const [obj, act] = p.split(':')
return rbac.can(userId, orgId, obj, act)
}),
)).some(Boolean))
if (!ok) return c.json({ error: 'Forbidden' }, 403)
return handler.call(this, c, next)
}
return d
}
}This is the distilled shape. The hrm app runs the battle-tested version — a DB-backed policy adapter, a first-login enforcer refresh, and a federated-id fallback — if you need to go deeper.
Common snags
| Symptom | Fix |
|---|---|
Every request 403s, even for an admin | The short-circuit checks the exact role name org-admin — make sure that's the role your grants (buildLines) emit. |
| A grant change doesn't take effect | The per-org policy is cached (TTL) — call adapter.invalidate() or clear casbin:policy:<org> after changing grants. |
Boot fails / cache errors with no Redis | CacheService needs Redis — cache the built lines to a .json file, or use Casbin's FileAdapter over a policy .csv. |
| A valid permission never passes | Its 'resource:action' code isn't in the org's lines — check buildLines emits p,<role>,<org>,<res>,<act> for it. |
| A code with two colons silently mismatches | code.replace(':', ',') and p.split(':') both assume exactly one — events:read:own becomes a three-part split whose tail is dropped. Keep codes to resource:action. |
| Container crash-loops on boot, fine in dev | The Casbin model is under src/ and never reached dist/. Move it to api/config/ and copy it in the Dockerfile. |
See also
- Backend → Authorization — the simple guards path, and when to reach for an engine.
- Working with the Shell → The guards — the three inputs (who · roles · permissions) this builds on.