Core overview
How duck-iam folds RBAC roles and ABAC policies into a single evaluation pipeline, plus the vocabulary and the map of the core pages
duck-iam is one access-control engine with two authoring models. Roles (RBAC) are concise for "who can do what"; policies (ABAC) express "under which conditions". They are written differently but evaluated identically: role permissions are compiled into a synthetic policy, that policy joins your hand-written ones, and every check walks the same evaluator. This page fixes the vocabulary and routes you to the page that documents each part.
One evaluator, two authoring models
The two models converge before anything is evaluated, so a role grant and a policy rule are the same kind of object by the time a decision is made.
rolesToPolicy() turns every permission of every role into one allow rule guarded by a subject.roles contains <roleId> condition, and, when the permission or the role that declared it sets a scope that is neither undefined nor '*', a scope condition. The result is a policy with id: '__rbac__' and algorithm: 'allow-overrides'. That policy is prepended to the policies your adapter returns, and from there nothing in the evaluator knows or cares which grant came from a role.
explain() output and IDecision.policy will name __rbac__ for any decision that came from a role. You never author that policy; the engine materialises it from your role definitions and caches it until roles change.The vocabulary
Use these words, and these exported type names. Every page in the duck-iam docs uses them the same way.
| Term | Exported type | What it is |
|---|---|---|
| subject | IamRequest.ISubject | Who is asking. Id, effective roles, scoped roles, attributes. |
| action | plain string | The operation, e.g. read, posts:publish. No verbs are built in. |
| resource | IamRequest.IResource | What is being acted on. Type, optional id, attributes. |
| scope | plain string | Multi-tenant namespace on the request, on a scoped role assignment, on a role, or on a permission. |
| environment | IamRequest.IEnvironment | Request-time context: ip, user agent, timestamp, now, custom fields. |
| request | IamRequest.IAccessRequest | The five above assembled into one object. |
| role | AccessControl.IRole | Named set of permissions, optionally inheriting from other roles. |
| permission | AccessControl.IPermission | One action + resource (+ optional scope, conditions) inside a role. |
| policy | AccessControl.IPolicy | Named set of rules with a combining algorithm and optional targets. |
| rule | AccessControl.IRule | One statement: effect, actions, resources, priority, conditions. |
| target | IPolicy['targets'] | Coarse gate on a whole policy: actions, resources, roles. |
| condition | AccessControl.ICondition | One field / operator / value comparison. |
| condition group | AccessControl.IConditionGroup | all / any / none tree of conditions. |
| effect | AccessControl.Effect | 'allow' or 'deny'. |
| combining algorithm | AccessControl.CombiningAlgorithm | How rules inside one policy are folded. |
| cross-policy combine | AccessControl.PolicyCombine | How per-policy verdicts are merged. |
| decision | AccessControl.IDecision | The result object in development mode. |
| mode | AccessControl.Mode | 'development' or 'production'; defaults to 'production'. |
| explain trace | Explain.IResult | The read-only debugging trace from engine.explain(). |
Every one of these is re-exported from @gentleduck/iam and @gentleduck/iam/core.
import type { AccessControl, IamPrimitives, IamRequest } from '@gentleduck/iam'
Which page do I need
The core section splits along the shape of the question you are asking.
| Page | Covers |
|---|---|
| primitives | Every exported type in the request, policy, and decision vocabulary, field by field. |
| evaluation pipeline | Subject resolution, scoped-role enrichment, policy-set assembly, per-policy evaluation, the decision. |
| rule matching | Action and resource patterns, condition groups, field resolution, every operator's edge semantics. |
| cross-policy combination | NotApplicable semantics, the three policyCombine modes, defaultEffect, defense in depth. |
| roles | Defining roles, inheritance, scoped roles, conditional permissions, the rolesToPolicy output. |
| policies | The policy builder, rules, targets, conditions, nesting, combining algorithms. |
Why hybrid
RBAC alone expresses "editors update posts" but not "editors update posts they own, during business hours, from an allowed region". ABAC alone makes every ordinary grant a hand-written rule, which is verbose for the eighty percent case.
duck-iam lets you keep both:
- Common grants stay roles. Concise, easy to audit, easy to hand to an admin UI through
engine.admin. - Contextual restrictions stay policies. Time windows, ownership, geo-fencing, feature flags, break-glass.
- Both contribute to one decision through the configured cross-policy combine, which defaults to strict AND.
When to use / When not to use
Use the core engine directly when you need a decision inside your own code path: a resolver, a job runner, a service-to-service call. Use it through a server integration when the decision guards an HTTP route, so subject and scope extraction is done for you.
Do not reach for a policy when a role would do. A policy exists to say something a role cannot: a condition on request context, a deny, or a restriction that must apply regardless of who is asking. A policy set of three to five well-named concerns is far easier to audit than twenty overlapping ones.
Do not use engine.explain() on a hot path. It is development-mode only, allocates a full trace, and throws when the engine is in production mode.
Gotchas
Are roles just shorthand for policies?
At evaluation time, yes: rolesToPolicy() materialises them into the __rbac__ policy so roles and ABAC rules run through the same evaluator. You still model them separately, because roles are the better authoring surface for ordinary grants and policies are the better surface for contextual logic.
The __rbac__ policy is omitted when it has no rules
The engine merges [__rbac__, ...adapterPolicies] only when the generated policy has at least one rule. Roles that exist but grant nothing produce an empty rule list, and the policy is dropped rather than joining the combine as a silent participant.
What happens when nothing matches?
defaultEffect decides, and it is 'deny' unless you change it. A policy whose targets do not match is skipped entirely rather than folded in as a default vote - see NotApplicable semantics. Setting defaultEffect: 'allow' additionally requires allowFailOpen: true; the engine constructor throws without it and logs a startup warning even with it.
The cross-policy combine is configurable
Since 2.0.0 the engine takes policyCombine ('and' by default, plus 'allow-overrides' and 'first-applicable'). Older documentation described the AND as fixed engine behaviour; it is not. See cross-policy combination.
scope, environment, or resource attribute?
Put a tenant identifier in scope when it should activate scoped role assignments and scope-restricted permissions - that is the only one of the three the subject resolver reads. Put it in environment or resource.attributes when it is only extra context for conditions and must not change which roles a subject holds.
See also
- Primitives - the exact shape of every type named above.
- Evaluation pipeline - what happens between
engine.can()and a decision. - Cross-policy combination - how per-policy verdicts merge.
- Engine modes - what changes between
developmentandproduction.