Skip to main content

Layered policy example

A working blog setup - roles plus three policies covering all four combining algorithms, nesting, and $-variables, with real decisions and explain traces

This page assembles everything in the policies section into one runnable setup: three roles, three hand-written policies, and the synthetic RBAC policy the engine generates. Read building policies, rules, and conditions first if any single call is unfamiliar.

What the setup enforces

Four layers decide each request, and under the default policyCombine: 'and' every applicable layer must allow.

Loading diagram...

Each box owns one concern and uses a different combining algorithm, chosen for what that concern needs:

  • __rbac__ is generated by rolesToPolicy() with allow-overrides, so any role that grants the action is enough.
  • content-guardrails is deny-overrides: a single guardrail firing must beat every allow in the same policy.
  • business-hours is first-match: an ordered list where an explicit break-glass rule outranks the off-hours deny.
  • tenant-isolation is highest-priority: two tiers, with the cross-tenant deny ranked above the same-tenant allow.

The typed configuration

import { createIam } from '@gentleduck/iam'

const access = createIam({
  actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
  resources: ['post', 'comment'] as const,
  scopes: ['org-alpha', 'org-beta'] as const,
  roles: ['viewer', 'editor', 'admin'] as const,
})

createIam() fixes TAction, TResource, TScope, and TRole for every builder it returns, so a typo in on(), of(), forScope(), or role() is a compile error. Options table on access config.

Roles

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

const editor = access
  .defineRole('editor')
  .name('Editor')
  .inherits('viewer')
  .grantCRUD('post')
  .grant('publish', 'post')
  .build()

const admin = access.defineRole('admin').name('Admin').inherits('editor').grantAll('*').build()

inherits() closes over the parent's permissions at evaluation time, so an editor holds the viewer's reads without repeating them. See role inheritance.

Policy 1: content guardrails (deny-overrides)

This is where the nesting and $-variable work lives.

const guardrails = access
  .definePolicy('content-guardrails')
  .name('Content Guardrails')
  .algorithm('deny-overrides')
  .rule('block-suspended', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .when((w) => w.attr('status', 'in', ['suspended', 'banned'])),
  )
  .rule('delete-owner-or-admin', (r) =>
    r
      .deny()
      .on('delete')
      .of('post')
      .when((w) => w.not((n) => n.or((o) => o.isOwner().role('admin')))),
  )
  .rule('cross-department-edit', (r) =>
    r
      .deny()
      .on('update', 'publish')
      .of('post')
      .when((w) =>
        w.not((n) =>
          n
            .role('admin')
            .and((a) =>
              a
                .check('resource.attributes.department', 'eq', '$subject.attributes.department')
                .resourceAttr('locked', 'neq', true),
            ),
        ),
      ),
  )
  .rule('allow-otherwise', (r) => r.allow().on('*').of('*'))
  .build()

Three things to read carefully:

  • delete-owner-or-admin is NOT (owner OR admin). not() is NOR, so the deny fires when the subject is neither the owner nor an admin. isOwner() compiles to resource.attributes.ownerId eq '$subject.id'.
  • cross-department-edit is NOT (admin OR (same department AND not locked)). The nested and() inside the not() group is the second alternative; see nesting for the exact object this builds.
  • allow-otherwise is the catch-all that keeps this deny-only policy from voting defaultEffect on every request it does not object to. Without it, a plain read matches the block-suspended rule's */* shape, no rule's conditions hold, and the policy votes deny through the fallback - denying reads for everyone. See combining algorithms.

Under deny-overrides the catch-all is safe: any matched deny still wins over it, regardless of order.

Policy 2: business hours (first-match)

const businessHours = access
  .definePolicy('business-hours')
  .name('Business Hours')
  .target({ actions: ['create', 'update', 'delete', 'publish'] })
  .algorithm('first-match')
  .rule('break-glass', (r) =>
    r
      .allow()
      .on('*')
      .of('*')
      .priority(100)
      .when((w) => w.role('admin').env('breakGlass', 'eq', true)),
  )
  .rule('deny-off-hours', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .priority(50)
      .when((w) => w.or((o) => o.env('hour', 'lt', 9).env('hour', 'gte', 17))),
  )
  .rule('allow-in-hours', (r) => r.allow().on('*').of('*').priority(10))
  .build()

target({ actions }) makes the whole policy NotApplicable for read, so reads never consult it - the explain trace below shows that as targets don't match. The three priorities are explicit and distinct, which is what makes first-match read top-to-bottom. See targets and combining algorithms.

Policy 3: tenant isolation (highest-priority)

const tenant = access
  .definePolicy('tenant-isolation')
  .name('Tenant Isolation')
  .algorithm('highest-priority')
  .rule('same-tenant', (r) =>
    r
      .allow()
      .on('*')
      .of('*')
      .priority(10)
      .when((w) => w.check('resource.attributes.orgId', 'eq', '$scope')),
  )
  .rule('cross-tenant-deny', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .priority(20)
      .when((w) => w.check('resource.attributes.orgId', 'neq', '$scope')),
  )
  .build()

$scope resolves to request.scope, or null when the request has none. Exactly one of the two rules can match for a given request, so the priorities are documentation rather than a real tie-break here - but they make the intent explicit if a third rule is ever added.

Wiring the engine

import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'

const adapter = new IamMemoryAdapter({
  roles: [viewer, editor, admin],
  policies: [guardrails, businessHours, tenant],
  assignments: { 'u-ed': ['editor'], 'u-admin': ['admin'], 'u-sus': ['editor'] },
  attributes: {
    'u-ed': { status: 'active', department: 'eng' },
    'u-admin': { status: 'active', department: 'ops' },
    'u-sus': { status: 'suspended', department: 'eng' },
  },
})

const engine = access.createEngine({
  adapter,
  defaultEffect: 'deny',
  mode: 'development',
})

IamMemoryAdapter is for tests and prototypes; swap in a persistent adapter for anything real. To load the same objects into a running engine instead of seeding the adapter, use engine.admin.saveRole(role) and engine.admin.savePolicy(policy), which also invalidate the relevant caches. See adapters and admin API.

Decisions

const post = {
  type: 'post' as const,
  id: 'p-42',
  attributes: { ownerId: 'u-ed', department: 'eng', orgId: 'org-alpha', locked: false },
}

await engine.can('u-ed', 'update', post, { hour: 14 }, 'org-alpha')

Every request below carries scope org-alpha unless the row says otherwise, and every post carries orgId: 'org-alpha'.

#SubjectRequestResultDecided by
1u-ed (editor)update post, hour: 14, org-alphaallowevery layer allowed
2u-edupdate post, hour: 22, org-alphadenybusiness-hours / deny-off-hours
3u-admin (admin)update post, hour: 22, breakGlass: trueallowbreak-glass (p=100) outranks deny-off-hours (p=50)
4u-edread post, hour: 22, org-alphaallowbusiness-hours is NotApplicable for read
5u-edupdate post, hour: 14, scope org-betadenytenant-isolation / cross-tenant-deny
6u-eddelete a post owned by u-xdenycontent-guardrails / delete-owner-or-admin
7u-eddelete its own post, hour: 14allowisOwner() held, so the guardrail did not fire
8u-sus (suspended editor)read post, hour: 14denycontent-guardrails / block-suspended
9u-edupdate a post with locked: truedenycontent-guardrails / cross-department-edit

Row 4 is the payoff of target({ actions }) on business-hours: reads are untouched by the hours policy even at 22:00. Row 9 shows the nested and() doing its work - the subject is in the right department but the resource is locked, so the second alternative inside the not() group fails and the deny fires.

One request end to end

Row 2 - an editor updating an in-department post at 22:00 - passes through every layer before a deny short-circuits the combine.

Loading diagram...

The last two steps are the and combine's short-circuit: evaluate() returns on the first applicable deny, so tenant-isolation is never evaluated for this request. That is a performance property, not a semantic one - the answer would be the same either way. engine.explain() does evaluate every policy, which is why its trace below shows all four.

The same requests under explain()

engine.explain() is available in development mode only and returns an Explain.IResult. Its summary field is a plain-text trace. Real output for row 2:

DENIED: "u-ed" attempting update on post [scope: org-alpha]
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: Allowed by rule "__rbac__#6" (1/17 rules matched)
  content-guardrails [deny-overrides]: Allowed by rule "allow-otherwise" (1/4 rules matched)
  business-hours [first-match]: First match: rule "deny-off-hours" (deny) (2/3 rules matched)
  tenant-isolation [highest-priority]: Highest priority: rule "same-tenant" (p=10) (1/2 rules matched)
  Result: First match: rule "deny-off-hours" (deny)

Row 1, the same request at 14:00:

ALLOWED: "u-ed" attempting update on post [scope: org-alpha]
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: Allowed by rule "__rbac__#6" (1/17 rules matched)
  content-guardrails [deny-overrides]: Allowed by rule "allow-otherwise" (1/4 rules matched)
  business-hours [first-match]: First match: rule "allow-in-hours" (allow) (1/3 rules matched)
  tenant-isolation [highest-priority]: Highest priority: rule "same-tenant" (p=10) (1/2 rules matched)
  Result: Highest priority: rule "same-tenant" (p=10)

Row 4, a read at 22:00:

ALLOWED: "u-ed" attempting read on post [scope: org-alpha]
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: Allowed by rule "__rbac__#0" (3/17 rules matched)
  content-guardrails [deny-overrides]: Allowed by rule "allow-otherwise" (1/4 rules matched)
  business-hours: targets don't match (deny)
  tenant-isolation [highest-priority]: Highest priority: rule "same-tenant" (p=10) (1/2 rules matched)
  Result: Highest priority: rule "same-tenant" (p=10)

How to read a trace:

  • Roles: is the effective set after inheritance - editor plus the inherited viewer. Scoped roles, when any applied, are listed after a + scoped: marker.
  • __rbac__#6 is the synthetic rule id rolesToPolicy() generated; ids are a monotonic __rbac__#N counter, and 17 is the total number of permission rules across all three roles. The rule's description names the role and permission it came from.
  • business-hours: targets don't match (deny) is a NotApplicable policy. The (deny) in parentheses is the placeholder effect carried on the decision object, not a vote - the combine skips it.
  • Result: is the cross-policy verdict. On an allow it is the reason from the last applicable allowing policy, which is why row 1 and row 4 both name tenant-isolation rather than the RBAC policy.

Field-by-field breakdown of Explain.IResult on explain and debug.

Adapting this to production

Swap the adapter

Replace IamMemoryAdapter with a persistent one and keep the role and policy objects exactly as they are - they are plain data. See adapters.

Switch the mode

mode: 'production' builds a compiled table and returns plain booleans instead of IDecision objects; explain() throws. Keep a development engine in staging for the traces above. See engine modes.

Keep priorities explicit

Give every rule in a first-match or highest-priority policy a distinct priority. Both modes break ties by source order, but a policy that reads top-to-bottom by accident is one reordering away from a different verdict.

Add presence guards

A $-reference that resolves to nothing takes its whole policy out of the decision. Audit each $-comparison against optional data and put an exists check ahead of it, so the rule declines to match instead of refusing to answer.

Watch the fallback

Any deny-only policy needs a catch-all allow, a narrowing target, or an intentional defaultEffect. PolicyBuilder.build() catches the target-shaped version with UNREACHABLE_TARGET, not the rest.

See also