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.
Each box owns one concern and uses a different combining algorithm, chosen for what that concern needs:
__rbac__is generated byrolesToPolicy()withallow-overrides, so any role that grants the action is enough.content-guardrailsisdeny-overrides: a single guardrail firing must beat every allow in the same policy.business-hoursisfirst-match: an ordered list where an explicit break-glass rule outranks the off-hours deny.tenant-isolationishighest-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-adminisNOT (owner OR admin).not()is NOR, so the deny fires when the subject is neither the owner nor an admin.isOwner()compiles toresource.attributes.ownerId eq '$subject.id'.cross-department-editisNOT (admin OR (same department AND not locked)). The nestedand()inside thenot()group is the second alternative; see nesting for the exact object this builds.allow-otherwiseis the catch-all that keeps this deny-only policy from votingdefaultEffecton every request it does not object to. Without it, a plainreadmatches theblock-suspendedrule's*/*shape, no rule's conditions hold, and the policy votesdenythrough 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.
$scope is on the operand side, so on a request with no scope it resolves to null and both rules throw IamOperandTypeError. The engine reports each through hooks.onPolicyError and tenant-isolation becomes Indeterminate - which, because it carries cross-tenant-deny, is a deny vote. Every request in the table below therefore carries a scope. That is the deliberate answer, and it is stricter than the alternative it replaced: comparing null to null made same-tenant match and allowed cross-tenant access outright. If you want unscoped requests to pass this layer rather than be denied by it, put .exists('scope') ahead of each comparison so the rules decline to match. See $-variable references.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'.
| # | Subject | Request | Result | Decided by |
|---|---|---|---|---|
| 1 | u-ed (editor) | update post, hour: 14, org-alpha | allow | every layer allowed |
| 2 | u-ed | update post, hour: 22, org-alpha | deny | business-hours / deny-off-hours |
| 3 | u-admin (admin) | update post, hour: 22, breakGlass: true | allow | break-glass (p=100) outranks deny-off-hours (p=50) |
| 4 | u-ed | read post, hour: 22, org-alpha | allow | business-hours is NotApplicable for read |
| 5 | u-ed | update post, hour: 14, scope org-beta | deny | tenant-isolation / cross-tenant-deny |
| 6 | u-ed | delete a post owned by u-x | deny | content-guardrails / delete-owner-or-admin |
| 7 | u-ed | delete its own post, hour: 14 | allow | isOwner() held, so the guardrail did not fire |
| 8 | u-sus (suspended editor) | read post, hour: 14 | deny | content-guardrails / block-suspended |
| 9 | u-ed | update a post with locked: true | deny | content-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.
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 -editorplus the inheritedviewer. Scoped roles, when any applied, are listed after a+ scoped:marker.__rbac__#6is the synthetic rule idrolesToPolicy()generated; ids are a monotonic__rbac__#Ncounter, and17is the total number of permission rules across all three roles. The rule'sdescriptionnames 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 nametenant-isolationrather 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
- Policies overview - when a policy is the right tool
- Combining algorithms - all four algorithms in detail
- Nesting and/or/not - the condition trees used above
$-variable references -isOwner(),$scope, and missing-path semantics- Cross-policy combining -
policyCombineand the short-circuit - Explain and debug - the full trace object