Skip to main content

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

ShapeFormatExample
Action + resourceaction:resourcecreate:post
Action + resource + idaction:resource:resourceIddelete:post:abc123
Scope + action + resourcescope:action:resourceorg-1:manage:billing
Fullscope:action:resource:resourceIdorg-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.
  • resourceId keys 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.