Chapter 1: your first permission check
Define a role, seed a memory adapter, create an engine, and answer your first authorization question in about twenty lines
DocDuck starts with one question: may Alice read a document? This chapter builds the smallest thing that can answer it - a role, an adapter, an engine - and then takes the answer apart.
Learning goals
- Define a role with
defineRoleand understand whatbuild()returns. - Seed
IamMemoryAdapterwith roles and assignments. - Construct an
IamEngineand read its defaults. - Tell
engine.canandengine.checkapart, and read a decision. - Know why an unmatched action is denied.
The three moving parts
Everything in duck-iam sits between a subject (who is asking), a request (what they want), and an adapter (where the roles and policies live).
The engine owns no data. It asks the adapter for Alice's roles, turns those roles into policy, evaluates the request against that policy, and returns a decision. Swap IamMemoryAdapter for Prisma or Drizzle later and nothing else in this diagram changes.
Step by step
Define a role
Create src/roles.ts:
import { defineRole } from '@gentleduck/iam'
export const viewer = defineRole('viewer')
.name('Viewer')
.desc('Read-only access to documents and teams')
.grant('read', 'document')
.grant('read', 'team')
.build()
export const roles = [viewer]
defineRole(id) returns a RoleBuilder. Chain .grant(action, resource) for each
permission, then .build(). The result is a plain AccessControl.IRole record - no
methods, no hidden state, safe to serialise into a database or send over HTTP.
.build() runs the role validator and throws if the role is malformed, so a typo
fails where you wrote it rather than at the first request.
Seed an adapter
The engine reads everything through an adapter. IamMemoryAdapter keeps it all in Maps.
import { IamEngine } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { roles } from './roles'
export const adapter = new IamMemoryAdapter({
roles,
assignments: {
alice: ['viewer'],
},
})
export const engine = new IamEngine({ adapter, mode: 'development' })
assignments maps a subject ID to the role IDs it holds. A subject can hold several:
alice: ['viewer', 'commenter']. The adapter also accepts policies (chapter 3) and
attributes (a per-subject attribute bag).
mode defaults to 'production', which returns bare booleans and skips the reason
strings. Every chapter through 7 sets 'development' explicitly, because the course
reads decisions and traces. Chapter 8 switches it back.
Ask the question
Create src/main.ts:
import { engine } from './access'
async function main() {
const canRead = await engine.can('alice', 'read', {
type: 'document',
attributes: {},
})
console.log('alice can read a document:', canRead)
// alice can read a document: true
const canDelete = await engine.can('alice', 'delete', {
type: 'document',
attributes: {},
})
console.log('alice can delete a document:', canDelete)
// alice can delete a document: false
}
void main()
Run it:
npx tsx src/main.ts
npx tsx src/main.ts
can versus check
| Method | Returns in development | Returns in production | Use it when |
|---|---|---|---|
engine.can(...) | boolean | boolean | yes or no is the whole answer |
engine.check(...) | AccessControl.IDecision | boolean | you want the reason, the deciding rule, and timing |
Both take the same five arguments:
engine.can(subjectId, action, resource, environment?, scope?): Promise<boolean>
engine.check(subjectId, action, resource, environment?, scope?): Promise<AccessControl.ModeResult<TMode>>
environment arrives in chapter 3 and scope in chapter 5. Add the decision to src/main.ts:
const decision = await engine.check('alice', 'read', { type: 'document', attributes: {} })
console.log(decision.allowed) // true
console.log(decision.effect) // 'allow'
console.log(decision.policy) // '__rbac__'
console.log(decision.reason) // Allowed by rule "__rbac__#0"
console.log(decision.duration) // 0.12 (milliseconds)
console.log(decision.timestamp) // 1772668800000
can returns false and check returns a synthesized deny when subjectId is not a non-empty string of at most 1024 characters, and again when the adapter throws while resolving the subject. The onError hook fires (chapter 4); the caller never sees a rejected promise or an accidental allow.What just happened
- Resolve the subject. The engine calls
adapter.getSubjectRoles,adapter.getSubjectAttributes, andadapter.listRolesin parallel, then closes the assigned roles over theirinheritschain (chapter 2). The result is anIamRequest.ISubject:id,roles,attributes, and optionalscopedRoles. It is cached per subject. - Convert roles to policy.
rolesToPolicy()turns every permission of every role into an ABAC rule inside one synthetic policy withid: '__rbac__',name: 'RBAC Policies', andalgorithm: 'allow-overrides'. - Evaluate. The request runs against every policy - the synthetic one plus anything the adapter stores. Rules whose action and resource match are collected and combined by the policy's algorithm.
- Decide.
readondocumentmatches; the decision is allow.deletematches nothing, so the engine falls back todefaultEffect, which is'deny'.
The synthetic __rbac__ policy
Roles do not run directly. This is what your viewer role becomes:
{
id: '__rbac__',
name: 'RBAC Policies',
description: 'Auto-generated from role definitions',
algorithm: 'allow-overrides',
rules: [
{
id: '__rbac__#0',
effect: 'allow',
description: 'Viewer: read on document',
priority: 10,
actions: ['read'],
resources: ['document'],
conditions: { all: [{ field: 'subject.roles', operator: 'contains', value: 'viewer' }] },
},
{
id: '__rbac__#1',
effect: 'allow',
description: 'Viewer: read on team',
priority: 10,
actions: ['read'],
resources: ['team'],
conditions: { all: [{ field: 'subject.roles', operator: 'contains', value: 'viewer' }] },
},
],
}
Rule IDs are a monotonic counter, __rbac__#0, __rbac__#1, and so on - not derived from the role or action names, because those can contain dots and would produce ambiguous IDs. That ID is what you see in decision.reason.
RBAC and ABAC are the same machine. A role is shorthand for a policy.
The decision object
interface IDecision {
readonly allowed: boolean
readonly effect: 'allow' | 'deny'
readonly rule?: AccessControl.IRule
readonly policy?: string
readonly reason: string
readonly duration: number
readonly timestamp: number
readonly applicable?: boolean
readonly failure?: 'input' | 'resolution' | 'evaluation'
}
| Field | Meaning |
|---|---|
allowed | The verdict. |
effect | The same verdict as 'allow' or 'deny'. |
rule | The rule that decided, when one did. Absent when the default effect fired. |
policy | The ID of the deciding policy, for example '__rbac__'. |
reason | Human-readable, and stable enough to assert on in tests: Allowed by rule "X", Denied by rule "X", No matching rules. Defaulted to deny. |
duration | Evaluation time in milliseconds, from performance.now(). |
timestamp | Date.now() at the moment of the decision. |
applicable | false marks a policy that had nothing to say - its targets did not match, or none of its rules covered this action and resource. The cross-policy combiner skips those instead of counting them as a vote. |
failure | Set when the deny was synthesized rather than decided: 'input' for a malformed subjectId, 'resolution' when the adapter failed, 'evaluation' when the pipeline threw. |
The resource object
interface IResource<TResource extends string = string> {
readonly type: TResource
readonly id?: string
readonly attributes: Readonly<IamPrimitives.Attributes>
}
// Type only - enough for role checks
{ type: 'document', attributes: {} }
// A specific instance
{ type: 'document', id: 'doc-1', attributes: {} }
// With metadata that conditions can read (chapter 3)
{
type: 'document',
id: 'doc-1',
attributes: { ownerId: 'alice', teamId: 'team-acme', status: 'published' },
}
type is matched against rule resources. id identifies the instance and shows up in batch permission keys (chapter 4). attributes is where conditions look; {} means "no metadata", and a condition reading a missing field resolves to null.
IamPrimitives.Attributes is Record<string, AttributeValue>, where AttributeValue is a scalar (string | number | boolean | null), an array of scalars, or a flat record of scalars. Nested objects are not part of the model - flatten them before passing them in.Engine configuration
new IamEngine({ adapter }) accepts one options object. Only adapter is required.
| Option | Type | Default | Meaning |
|---|---|---|---|
adapter | IamAdapter.IAdapter | required | Where policies, roles, and subject data come from. |
mode | 'development' or 'production' | 'production' | Development returns decisions and enables explain; production returns booleans and uses the compiled table. |
defaultEffect | 'allow' or 'deny' | 'deny' | What to return when nothing matched. |
cacheTTL | number | 60 | Cache lifetime in seconds. 0 disables caching. |
maxCacheSize | number | 1000 | Subject cache capacity, LRU eviction. |
policyCombine | 'and', 'allow-overrides', 'first-applicable' | 'and' | How decisions from separate policies merge (chapter 3). |
hooks | IamEngineTypes.IHooks | {} | Lifecycle hooks (chapter 4). |
maxPolicies | number | 10000 | Ceiling on policies loaded from the adapter. |
maxRoles | number | 10000 | Ceiling on roles loaded from the adapter. |
adapterTimeoutMs | number | 5000 | Per-adapter-call timeout. 0 disables it. |
allowFailOpen | boolean | false | Required opt-in for defaultEffect: 'allow'. |
scopeMode / scopeCombine | see chapter 5 | 'flat' / 'union' | Scoped-role matching. |
invalidator | IamEngineTypes.IInvalidator | none | Cross-instance cache invalidation. |
maxConcurrentSubjectLoads | number | 512 | Cap on concurrent cold subject loads. 0 restores unbounded. |
allowFailOpen: true, and even then it logs a startup warning. A fail-open authorization engine turns a buggy condition or an adapter blip into an unintended grant. Leave it on 'deny'.Try it
- Give Alice a second role. Add
commentertosrc/roles.tswith.grant('create', 'comment'), put it in therolesarray, and change the assignment toalice: ['viewer', 'commenter']. Confirmengine.can('alice', 'create', { type: 'comment', attributes: {} })istrue. - Check an unknown subject:
engine.can('mallory', 'read', { type: 'document', attributes: {} }). The adapter returns no roles, nothing matches, and the answer isfalse- unknown subjects are denied without any special handling. - Print
decision.rule?.idfor the allowed read and confirm it is__rbac__#0. Then reorder the two.grant()calls inviewerand watch the ID change - rule IDs are positional, so never persist them as identifiers.
Where we are
docduck/
src/
roles.ts - role definitions
access.ts - adapter + engine
main.ts - the script you run
Chapter 2 turns that single role into a hierarchy.
See also
- Role definition - the full
RoleBuilderreference - Memory adapter - every seed option
- Engine methods -
can,check,authorize, and the rest - Primitives -
ISubject,IResource,IDecision,Attributes - Chapter 2: role hierarchies