Author your role catalogue.
Heimdall ships three system roles per app — owner, admin, member. Most products outgrow that within a quarter. This chapter shows how to add custom roles, bind permissions, and keep the catalogue in sync from your code.
1
The per-app permission catalogue
Every Heimdall app sees the same flat list of system permissions out of the box. They're named <resource>.<action> with dots:
user.create user.read user.update user.delete user.list
role.create role.read role.update role.delete role.assign role.revoke
permission.create permission.read permission.delete
session.revoke
token.createOn top of those, you can mint custom permissions for verbs that only make sense inside your product — invoice.refund, dashboard.export, billing.read. Custom permissions live in the same table with app_id = your-app-id; system ones have app_id NULL.
Don't confuse these with workspace policy actions. The strings heimdall.read, rally.update, envoi.template.send also use dots and look similar — but those gate your workspace owner's ability to manage ProductCraft (e.g. mint a PAK). The per-app catalogue described here gates your end-users' ability to act inside your app. Two systems, two storage layers, never collide.
2
System roles
Three roles are seeded when an app is created:
- owner — every permission in
ALL_APP_PERMISSIONS. Cannot be deleted. - admin — user management (read / list / update) + role assign / revoke. Not role create / update / delete — admins can't mutate the role graph, only assign within it.
- member —
user.read+role.read. Read-only. The signup default.
New permissions added to the catalogue (either via system updates or your own custom entries) get bound to owner automatically the next time the heimdall-api pod boots — SystemRolePermissionSyncBootstrap walks every owner row and adds the missing grants. Admin + member stay unchanged unless you bind explicitly.
3
Create a custom role
Two lanes do this. Pick the one matching your actor:
- Workspace owner (console, PAK from CI):
POST /v1/apps/<appId>/roleson Heimdall-admin. Cookie or PAK auth (since #172). - Customer-product backend (M2M token):
POST /<appSlug>/v1/admin/roleson the Consumer admin lane. M2M JWT auth, gated byrole.create.
Both endpoints take the same DTO and create the same row. The difference is who's holding the token.
curl https://api.heimdall.productcraft.co/acme/v1/admin/roles \
-H 'authorization: Bearer <m2m-access-token>' \
-H 'content-type: application/json' \
-d '{
"name": "billing-admin",
"description": "Manages invoices + payment methods. Cannot manage users."
}'New roles start with no permissions. The next step binds them.
4
Bind permissions to a role
PUT replaces the full permission set on the role. The body is a flat array of <resource>.<action> strings — system + custom permissions both accepted.
curl -X PUT https://api.heimdall.productcraft.co/acme/v1/admin/roles/billing-admin/permissions \
-H 'authorization: Bearer <m2m-access-token>' \
-H 'content-type: application/json' \
-d '{
"permissions": [
"user.read",
"invoice.read",
"invoice.create",
"invoice.refund",
"billing.read"
]
}'5
Caller-narrowing — the safety net
The PUT endpoint enforces a critical rule: the caller cannot grant a permission they don't themselves hold.
If your M2M token's scopes[] doesn't include invoice.refund, the call above 403s with Cannot grant actions you don't have: invoice.refund. Same rule for EndUser admins — their effective permission set (from app_membership.role) must be a superset of the grant.
This prevents an “assign yourself” privilege escalation. A user holding only role.update can't use it to rewrite the owner role into [admin, role.assign, *] and then assign themselves to it.
6
Assign a role to a user
curl -X PATCH https://api.heimdall.productcraft.co/acme/v1/admin/users/648616c8-.../role \
-H 'authorization: Bearer <m2m-access-token>' \
-H 'content-type: application/json' \
-d '{ "roleName": "billing-admin" }'For EndUser admin callers, the same caller-narrowing applies: assigning user X to role billing-admin fails if the caller doesn't themselves hold every verb on billing-admin. For M2M callers, the caller's scope set IS the cap — if the M2M was issued with role.assign but not invoice.refund, the assignment is refused.
The role change takes effect on the next token refresh — the user's current access token still names the old role until it expires (default 1h). For instant effect, also revoke their sessions via POST /v1/apps/:appId/end-users/:userId/sessions/revoke-all.
7
Create custom permissions
When you need a verb the system catalogue doesn't have.
curl https://api.heimdall.productcraft.co/acme/v1/admin/permissions \
-H 'authorization: Bearer <m2m-access-token>' \
-H 'content-type: application/json' \
-d '{
"resource": "invoice",
"action": "refund",
"description": "Mark an invoice as refunded"
}'Resource + action each match ^[a-z][a-z0-9_-]{1,47}$. Conflicts with system permissions (same resource.action) return 409.
After creating, bind to roles via the PUT roles/:name/permissions from step 4.
8
Enforce permissions at runtime
Once roles are bound, your routes need to actually check them. Two pieces — turn on enforcement, then mark routes.
On the auth-config page (Step 2 in Chapter 2), flip enforce_app_permissions to true. Without this flag, @RequireAppPermission annotations are pass-through — any valid token can hit any route.
In your own NestJS service (the one verifying Heimdall tokens), the gate is:
@Controller('invoices')
export class InvoiceController {
@Post(':id/refund')
@RequireAppPermission('invoice.refund')
async refund(@Param('id') id: string) { ... }
}If you're not using NestJS, your middleware grabsreq.user.permissions (resolved from GET /me/permissions) and checks set membership. Cache the response for 60s — that's the same TTL Heimdall's internal RolePermissionResolver uses.
9
Sync the catalogue from CI
The whole point of having an M2M-callable role + permission API: idempotent setup from your deploy pipeline.
Pattern your customers like dispute.markets use this for:
async function syncRoleCatalogue(appSlug: string, m2mToken: string) {
const headers = {
authorization: `Bearer ${m2mToken}`,
'content-type': 'application/json',
};
const base = `https://api.heimdall.productcraft.co/${appSlug}/v1/admin`;
// 1. Ensure custom permissions exist (idempotent — 409 on dup is fine)
const customPerms = [
{ resource: 'invoice', action: 'read', description: 'View invoices' },
{ resource: 'invoice', action: 'create', description: 'Create invoices' },
{ resource: 'invoice', action: 'refund', description: 'Mark invoice refunded' },
];
for (const p of customPerms) {
await fetch(`${base}/permissions`, {
method: 'POST', headers, body: JSON.stringify(p),
}).catch(() => {}); // 409 = already exists, skip silently
}
// 2. Upsert custom roles
const existing = await fetch(`${base}/roles`, { headers })
.then(r => r.json());
const desired = [
{ name: 'billing-admin', description: 'Invoices + refunds.' },
{ name: 'support', description: 'Read everything; mutate nothing.' },
];
for (const r of desired) {
if (!existing.data.find((x: any) => x.name === r.name)) {
await fetch(`${base}/roles`, {
method: 'POST', headers, body: JSON.stringify(r),
});
}
}
// 3. Bind permissions to roles
await fetch(`${base}/roles/billing-admin/permissions`, {
method: 'PUT', headers, body: JSON.stringify({
permissions: ['user.read', 'invoice.read', 'invoice.create', 'invoice.refund'],
}),
});
await fetch(`${base}/roles/support/permissions`, {
method: 'PUT', headers, body: JSON.stringify({
permissions: ['user.read', 'user.list', 'invoice.read'],
}),
});
}Run on every deploy. Idempotent. The M2M token your CI uses needs: permission.create, role.create, role.update, role.read, plus every permission it's granting (so the caller-narrowing check passes).
Alternative: PAK on Heimdall-admin. If you prefer one workspace-level credential to manage all apps in your workspace, hit /v1/apps/:appId/roles with a pcft_live_* PAK. Same idempotent shape, different lane. Use this when your IaC handles multiple apps (staging + prod + dev) from one place.
6
When role changes take effect
Demoting a user in the console doesn't invalidate their existing access token — the new role applies on the next token refresh.
Heimdall's EndUser access tokens are 1-hour-default self-contained JWTs that carry the user's role name as a claim. When you change a user's role (in the console, via the admin lane, or via the workspace API), the new role lands in the database immediately but existing access tokens still name the old role until they expire or the user refreshes.
What this means in practice:
- Permissions resolve from the live database, not the token. The per-app permission guard uses the role name from the token to look up the role's permissions per request, so if you change what the role
admincan do, every request with a token claimingadminpicks up the new perms on the next call. - Demoting a user is the gap. A user demoted from
admintomemberstill hasadminin their existing access token. For up to the access-token TTL (1 hour by default; configurable per app viaauth_config.access_token_ttl_seconds), the per-app guard will return admin's permissions for that user's requests. - If you need instant demotion, force a refresh on the affected user — either log them out from your product UI (clearing your local session and redirecting through
/auth/signin) or callPOST /:appSlug/v1/auth/refreshfrom your backend with the user's refresh token and discard the old access token. The reissued token will carry the new role. - Suspending a user is immediate. Setting
account.status = 'suspended'causes every subsequent request (including with an otherwise-valid token) to 401 because the per-app guard re-checks status on every call. Use this for the "kick them out now" case rather than waiting for the demote to propagate.
If 1 hour is too long for your product, lower access_token_ttl_seconds in your app's auth_config — the trade-off is more frequent refresh calls (each costs one DB query for session lookup + a fresh signing). 5-15 minutes is typical for high-stakes products.