Author your role catalogue.
Auth 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 Auth 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 auth.read, waitlist.update, mail.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.read/ 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.
When Auth adds a permission to the system catalogue, every app's system roles pick it up automatically on the next Auth service restart (owner gets every new system permission; admin + member only their fixed defaults). Custom permissions are never auto-bound — not even to owner. After creating one, bind it to roles explicitly (step 4).
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 Auth-admin. Cookie or PAK auth. In the console this is the app'sIdentity → Rolestab — Create role takes a name plus an optional description, and each role row then exposes Permissions, Edit, and Delete. - 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.auth.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.auth.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.auth.productcraft.co/acme/v1/admin/users/648616c8-.../role \
-H 'authorization: Bearer <m2m-access-token>' \
-H 'content-type: application/json' \
-d '{ "role_name": "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. M2M callers only need the role.assign scope — the target role's permission set is not narrowed against the M2M scopes, because you already chose what the credential may do when you scoped it.
The role change takes effect on the next token refresh — the user's current access token still names the old role until it expires (1h). For instant effect, revoke their sessions via POST /v1/apps/:appId/end-users/:userId/sessions/revoke-all — revocation is checked on every request, so existing access tokens die immediately.
7
Create custom permissions
When you need a verb the system catalogue doesn't have.
curl https://api.auth.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
Two enforcement surfaces: Auth's own Consumer API, and your product's routes.
Auth's Consumer API. Flip enforce_app_permissions to true via PATCH /v1/apps/:appId/auth-config (or, in the console, the app's Settings → Auth config tab — the Enforce per-app permissions toggle under Sign-in policy). Until you do, permission checks on Consumer-API routes are pass-through — any valid token for the app can call any admin route, including /admin/users. With the flag on, M2M tokens are checked against their scopes[] and EndUser tokens against their role's resolved permissions. The flag is cached for 60s, so flips take up to a minute to bite.
Your own routes. Resolve the caller's effective permissions with their token via GET /:appSlug/v1/me/permissions — { role, org_role, permissions[] } — and check set membership in your middleware. Cache the response for 60s; that's the same TTL Auth's internal resolver uses, so role edits propagate within a minute either way.
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.auth.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) {
// 409 = already exists; ignore the response either way.
await fetch(`${base}/permissions`, {
method: 'POST', headers, body: JSON.stringify(p),
});
}
// 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 Auth-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.
10
When role changes take effect
Demoting a user doesn't rewrite their existing access token — the new role name only appears in tokens minted after the change.
EndUser access tokens are self-contained 1-hour JWTs that carry the user's role name as a claim. In practice:
- Permissions resolve from the live database, not the token. The guard uses the role name from the token to look up that role's permissions per request (60s cache), so editing what
admincan do reaches everyadmin-token holder within a minute. - Demoting a user is the gap. A user demoted from
admintomemberstill hasadminin their existing access token for up to 1 hour, and the guard will keep returning admin's permissions for it. - For instant demotion, revoke their sessions (
POST /v1/apps/:appId/end-users/:userId/sessions/revoke-all). Session revocation is checked on every request, so the old access token dies immediately and the re-signin mints tokens with the new role. - Suspending a user is also immediate. Setting their status to
suspended(PATCH .../end-users/:userId/status) revokes their sessions and 401s every subsequent request — the guard re-checks account status per call.