Skip to main content

Cookbook

Common authorization patterns as copy-pasteable recipes - owners, public-vs-private, role hierarchies, multi-tenant, time-bound access.

A collection of common patterns. Each recipe is self-contained: copy the snippet, swap the actions / resources for yours, and ship.

Owners can edit their own resources

import { definePolicy, rule, when } from '@gentleduck/iam'

const ownersPolicy = definePolicy('owners', {
  combine: 'allow-overrides',
  rules: [
    rule({
      effect: 'allow',
      target: { actions: ['update', 'delete'], resources: ['post', 'comment'] },
      condition: when().equals('subject.id', '$resource.attributes.ownerId'),
    }),
  ],
})

Pair with a role for reading anyone's post:

const viewer = access.defineRole('viewer').grantRead('post', 'comment').build()

A user with viewer role can read anyone's post but only update / delete their own.

Public-vs-private resources

const publicReadPolicy = definePolicy('public-read', {
  combine: 'allow-overrides',
  rules: [
    // Anyone can read public posts.
    rule({
      effect: 'allow',
      target: { actions: 'read', resources: 'post' },
      condition: when().equals('$resource.attributes.visibility', 'public'),
    }),
    // Only the owner can read private ones.
    rule({
      effect: 'allow',
      target: { actions: 'read', resources: 'post' },
      condition: when()
        .equals('$resource.attributes.visibility', 'private')
        .equals('subject.id', '$resource.attributes.ownerId'),
    }),
  ],
})

Role hierarchy

const viewer = access.defineRole('viewer')
  .grantRead('post', 'comment')
  .build()

const editor = access.defineRole('editor')
  .inherits(viewer)
  .grant('create', 'post')
  .grant('update', 'post')
  .build()

const admin = access.defineRole('admin')
  .inherits(editor)
  .grant('delete', 'post')
  .grant('manage', 'user')
  .build()

admin inherits everything editor and viewer can do.

Multi-tenant: role per organization

const subject: AccessControl.ISubject = {
  id: 'user-1',
  tenantId: 'tenant-1',
  // Scoped assignments: this user is admin in org-1 and viewer in org-2.
  scopedRoles: {
    'org:org-1': ['admin'],
    'org:org-2': ['viewer'],
  },
}

engine.can({ subject, action: 'delete', resource: 'post', scope: 'org:org-1' })
// -> true (admin in this org)

engine.can({ subject, action: 'delete', resource: 'post', scope: 'org:org-2' })
// -> false (viewer can't delete)

See Scoped roles ->.

Time-bound access

const businessHoursPolicy = definePolicy('business-hours', {
  combine: 'allow-overrides',
  rules: [
    rule({
      effect: 'allow',
      target: { actions: 'export', resources: 'report' },
      condition: when()
        .gte('context.timestamp', '$businessHoursStart')
        .lt('context.timestamp', '$businessHoursEnd'),
    }),
  ],
})

engine.can({
  subject,
  action: 'export',
  resource: 'report',
  context: {
    timestamp: Date.now(),
    businessHoursStart: 9 * 60 * 60 * 1000,
    businessHoursEnd: 17 * 60 * 60 * 1000,
  },
})

Require MFA for high-risk actions

When you bridge with duck-auth, the subject carries the session's AAL:

const mfaPolicy = definePolicy('mfa-required', {
  combine: 'deny-overrides',
  rules: [
    // Deny by default for sensitive actions...
    rule({
      effect: 'deny',
      target: { actions: ['delete', 'export'], resources: 'user' },
    }),
    // ...unless the session is AAL2.
    rule({
      effect: 'allow',
      target: { actions: ['delete', 'export'], resources: 'user' },
      condition: when().equals('subject.attributes.aal', 'aal2'),
    }),
  ],
})

The deny is the safety net; the allow lifts it only when the caller is authenticated at AAL2.

Field-level permissions (column-grained)

const fieldsPolicy = definePolicy('fields', {
  combine: 'first-match',
  rules: [
    // Editors can edit everything except billing fields.
    rule({
      effect: 'allow',
      target: { actions: 'update', resources: 'user' },
      condition: when()
        .equals('subject.roles', 'editor', { mode: 'includes' })
        .not(when().intersect('context.fieldsChanged', ['billing.cardNumber', 'billing.address'])),
    }),
    // Admins can edit billing.
    rule({
      effect: 'allow',
      target: { actions: 'update', resources: 'user' },
      condition: when().equals('subject.roles', 'admin', { mode: 'includes' }),
    }),
  ],
})

const decision = engine.can({
  subject,
  action: 'update',
  resource: 'user',
  context: { fieldsChanged: ['billing.address'] },
})

Pass the context.fieldsChanged from your handler before performing the update.

Audit every deny

const engine = createEngine({
  ...config,
  onDeny(decision) {
    auditLog.write({
      ts: Date.now(),
      subjectId: decision.subject.id,
      action: decision.action,
      resource: decision.resource,
      reason: decision.reason,
    })
  },
})

The hook fires on every deny - including denies under the explicit-deny combining algorithm - so you have a full audit trail of attempted forbidden actions.

Soft launching a new policy

When deploying a new policy that you're not sure about, run it in shadow mode alongside the production policy:

const engine = createEngine({ ...config })
const shadowEngine = createEngine({ ...config, policies: [...config.policies, newPolicy] })

app.post('/api/resource', (req, res) => {
  const ctx = await auth.resolveSession(req)
  const subject = projectToSubject(ctx.identity, ctx.session)
  const args = { subject, action: 'update', resource: 'resource' }

  // Real decision drives behaviour.
  const realDecision = engine.can(args)

  // Shadow decision drives logging only.
  const shadowDecision = shadowEngine.can(args)
  if (realDecision !== shadowDecision) {
    log.warn({ event: 'shadow-mismatch', args, real: realDecision, shadow: shadowDecision })
  }

  if (!realDecision) return res.status(403).end()
  // ...
})

After a few days with no mismatches, promote the shadow engine to the real one.

See also