PermissionMap reference
Wire format, key encoding rules, and buildPermissionKey helper for the duck-iam client integrations.
What is a PermissionMap?
A PermissionMap is a flat object — keys encode the permission, values are booleans. It is the wire format every client library consumes.
{
// Format: "action:resource"
"create:post": true,
"delete:post": true,
"manage:team": false,
// Format: "action:resource:resourceId"
"delete:post:abc123": false,
// Format: "scope:action:resource"
"org-1:manage:billing": true,
// Format: "scope:action:resource:resourceId"
"org-1:update:post:post-42": true,
}{
// Format: "action:resource"
"create:post": true,
"delete:post": true,
"manage:team": false,
// Format: "action:resource:resourceId"
"delete:post:abc123": false,
// Format: "scope:action:resource"
"org-1:manage:billing": true,
// Format: "scope:action:resource:resourceId"
"org-1:update:post:post-42": true,
}Generate it on the server with engine.permissions() or the getPermissions helper from @gentleduck/iam/server/next. Missing keys return false (deny by default).
Key formats
| Shape | Format | Example |
|---|---|---|
| Action + resource | action:resource | create:post |
| Action + resource + id | action:resource:resourceId | delete:post:abc123 |
| Scope + action + resource | scope:action:resource | org-1:manage:billing |
| Full | scope:action:resource:resourceId | org-1:update:post:post-42 |
The shape is determined by which fields you pass when generating the map (or calling can() on the client). The client-side can() uses the same encoding — pass a resourceId or scope only when the server included one.
buildPermissionKey
The same key builder powers every client integration:
import { buildPermissionKey } from '@gentleduck/iam'
buildPermissionKey('delete', 'post')
// "delete:post"
buildPermissionKey('update', 'post', 'post-42', 'org-1')
// "org-1:update:post:post-42"import { buildPermissionKey } from '@gentleduck/iam'
buildPermissionKey('delete', 'post')
// "delete:post"
buildPermissionKey('update', 'post', 'post-42', 'org-1')
// "org-1:update:post:post-42"Order: (action, resource, resourceId?, scope?). The serialized key always reorders as scope:action:resource:resourceId so server and client agree.
You rarely call buildPermissionKey() directly — the React, Vue, and vanilla clients call it internally. Reach for it when:
- Writing tests that assert specific keys
- Building custom permission map endpoints
- Inspecting maps in dev tools
Generating a map (server side)
import { engine } from '@/lib/engine'
const permissions = await engine.permissions('user-1', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-42' },
{ action: 'manage', resource: 'billing', scope: 'org-1' },
])import { engine } from '@/lib/engine'
const permissions = await engine.permissions('user-1', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-42' },
{ action: 'manage', resource: 'billing', scope: 'org-1' },
])Result keyed exactly as listed above:
{
"create:post": true,
"delete:post:post-42": false,
"org-1:manage:billing": true
}{
"create:post": true,
"delete:post:post-42": false,
"org-1:manage:billing": true
}Reading a map (client side)
The shape of your can(...) call must match the shape of the key the server generated. Forgetting scope or resourceId results in a missed key and a false return.
client.can('create', 'post') // matches "create:post"
client.can('delete', 'post', 'post-42') // matches "delete:post:post-42"
client.can('manage', 'billing', undefined, 'org-1') // matches "org-1:manage:billing"client.can('create', 'post') // matches "create:post"
client.can('delete', 'post', 'post-42') // matches "delete:post:post-42"
client.can('manage', 'billing', undefined, 'org-1') // matches "org-1:manage:billing"The clients fail closed: if the key is missing, can returns false.
Stale map handling
Permissions change when:
- A user is granted/revoked a role
- A scope changes (joined/left an org)
- A feature flag flips
- An attribute changes (e.g. account verification, plan tier)
The client cannot detect these — refresh the map from the server when you know one happened. See refreshing permissions in the client overview.
Notes & caveats
- Don't mix scopes in one map. Generate per-scope maps when scope materially changes the answer; mixing creates ambiguous keys for "global" lookups.
resourceIdkeys are dense. Don't pre-generate every possible(action, resource, id)combination — generate them lazily for visible records.- Client-side maps are not a security boundary. They drive UI gates only. Always re-check on the server when the action mutates state.