Heimdall guides
03 · Roles + permissions

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:

System permissions (app_id IS NULL)
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.create

On 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>/roles on Heimdall-admin. Cookie or PAK auth (since #172).
  • Customer-product backend (M2M token): POST /<appSlug>/v1/admin/roles on the Consumer admin lane. M2M JWT auth, gated by role.create.

Both endpoints take the same DTO and create the same row. The difference is who's holding the token.

POST /<app_slug>/v1/admin/roles (Consumer admin, M2M 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.

PUT /<appSlug>/v1/admin/roles/billing-admin/permissions
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

PATCH /<appSlug>/v1/admin/users/:userId/role
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.

POST /<appSlug>/v1/admin/permissions
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:

Your service's invoice controller
@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:

On-boot role sync (TypeScript)
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 admin can do, every request with a token claiming admin picks up the new perms on the next call.
  • Demoting a user is the gap. A user demoted from admin to member still has admin in their existing access token. For up to the access-token TTL (1 hour by default; configurable per app via auth_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 call POST /:appSlug/v1/auth/refresh from 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.