Changelog
WIPRelease history for @gentleduck/iam. Newest releases at the top.
This page mirrors the package changelog shipped inside @gentleduck/iam at
packages/duck-iam/CHANGELOG.md. That file is the source of truth; this page is
kept in sync with it by hand. Entries run newest first, from 5.10.0 down to the
first published release. Names in each entry are the names that were current in
that release: the Iam* / IAM_* export prefixes only exist from 5.0.0 onward,
so a 2.x entry naming FileAdapter refers to what is called IamFileAdapter today.
5.10.0
Minor Changes
- bde674e: Authorization-correctness audit: behaviour fixes and the documentation defects that were hiding them.
The permission map allowed what can() denied
permissions() evaluated every check against a resource with no attributes,
while keying the answer by resourceId. A caller reads that key as an answer
about that instance, but a rule conditioned on resource.attributes.* could not
fire, so a deny written that way was invisible: read:post:42 came back true
for the very row can() refuses.
IamClient.IPermissionCheck now takes an optional attributes, and each check
is evaluated against the attributes it carries. A check that omits them is
unchanged, and still answers about an instance that has none — now stated in the
reference instead of being inferred from the key.
The route guards had the same hole, and it was not structural. NestJS's
iamNestAccessGuard already accepted a getResourceAttributes; the express and
Hono iamAccessMiddlewares already took attributes synchronously through
getResource. The rest could not be told at all. Express and Hono's iamGuard,
withIamAccess and createIamNextMiddleware now take the same
getResourceAttributes option Nest has; checkIamAccess and the checker
createIamSubjectCan returns take the attributes as an argument. Supplying them
costs a load the guard exists to skip, so it stays opt-in: omit the callback and
the guard remains the coarse gate on type, id and scope, with the
attribute-dependent rule re-checked by can() once the row is in hand. All
eight surfaces are now pinned against one policy so they cannot drift apart.
A dot in the resource type retired a :* deny
resources: ['posts:*'] is documented to cover everything under posts:, and
it did — until the resource type contained a dot anywhere. The three evaluators
each switched to a dot-only matcher whenever either side contained a ., and
that matcher ignores ':*' entirely. So a deny rule covering posts:comment
silently stopped covering posts:comment.reply: adding a dot to a resource
type removed it from every colon-wildcard rule in the policy set.
The policy-target level never switched, so the policy was still selected while its own rule missed — the inconsistency that made it invisible.
All three evaluators (interpreter, compiled table, explain()) now use the
documented matcher, which reads the separator from the pattern and treats one
in the request as data. .* subtree matching, bare-literal behaviour and the
"a : pattern never matches a . type" rule are unchanged and pinned.
matchesResourceHierarchical stays exported as the strict dot-only variant and
is documented as no longer used by the engine.
A route guard named the wrong row, or none
The guards that read the resource id read one path param, id, and could not be
told otherwise. On a nested route that names a different row: /orgs/:id/posts/:postId
guarded as a post sends the org's id as resource.id, so a rule denying a
particular post, or comparing resource.id against what the subject owns, was
answered about the wrong instance. Express and Hono's iamGuard and
withIamAccess now take the getResourceId option Nest already had; the default
is unchanged.
createIamNextMiddleware had no id at all. It matches a path prefix and knows
the rule's resource type, so a rule reading resource.id could not fire and the
request passed — the same fail-open shape as an attribute rule with no
attributes. It now takes getResourceId too, with no default, because only the
rule's own path shape says which segment is the id.
The resolved id is also handed to getResourceAttributes on every guard, so a
loader can fetch the row the check is actually about rather than re-deriving it.
A Server Component check ignored the environment
checkIamAccess and getIamPermissions passed undefined where every other
integration passes a request-derived environment, and neither accepted one. A
policy keyed on environment.ip, environment.userAgent or a custom key read
as a non-match, so a deny that fired in Next middleware was inert in a Server
Component asking the same question. Both now take an environment argument and
forward it.
A wedged database hung every admin call forever
adapterTimeoutMs is documented as a per-adapter-call timeout and was wired
only into the decision path. Against an adapter that never answers, can()
returned false as designed while every engine.admin call — including
admin.listPolicies(), the same adapter method can() bounds — never
settled. An admin route or dashboard hit a wedged database and hung, with no
error, no audit event, and nothing to say whether the write had landed.
All seventeen admin operations now run under the same timeout and reject with the call named. Reads pass the abort signal through, so an adapter that honours it cancels the query. Writes take no signal, so the timeout frees the caller while the write runs on: a rejection means "not confirmed" rather than "not applied", and every admin write is idempotent by id. The transaction-bound admin stays unbounded on purpose — aborting mid-transaction leaves the transaction for the caller to roll back — and that exclusion is now pinned by a test rather than left to be rediscovered.
An admin write over HTTP was recorded as nobody's
The admin routers authenticate a caller and hand it to their own audit hook.
They never handed it to engine.admin, so the engine's onMutation fired with
no actor and the SQL adapters wrote created_by / updated_by as null for
every request-driven write — the columns the drizzle and prisma schemas ship for
exactly this. An operator who wired onMutation instead of onAdminMutation
had no attribution at all, and nothing said so.
All four routers now forward a string authorize answer to engine.admin. An
object answer — a claims object, the documented common case — still names no one
to the engine, because its actor is a string and picking a field would be a
guess; the new getMutationActor option picks it. A value that names no one,
blank or non-string, is discarded rather than written.
admin.import had the same defect one layer down: it stamped its mutation
events with the actor it was given and passed nothing to adapter.savePolicy /
saveRole. It was the only admin write in the package that did. Fixed, and
every engine-side write is now pinned against a recording adapter so a new one
cannot be added without provenance.
preload({ validator: true }) never ran the validator
The flag loaded the lazy validate chunk and then dropped it on the floor: the
module was imported, validatePolicy and validateRole were never called, and
preload() resolved. Since engine.admin is the only thing that validates —
all six adapters call the same write gate, the read path validates nothing — a
row that entered storage another way (a migration, a seed script, a restore, a
direct SQL insert, another service writing the same table) was loaded and
evaluated exactly as stored, and nothing in the package could tell an operator.
Which matters because the two failure modes differ. A condition the evaluator refuses is Indeterminate and the policy denies: loud, fail-closed, visible on the first request. A row that merely never matches — an action carrying a trailing newline from a CSV import, an unreachable resource pattern — just misses, so a deny in that shape silently never fires and the request is allowed.
preload({ validator: true }) now reads the roles too and validates every
stored policy and role, throwing once with the exact number of offending rows
and up to ten named with their reason. Only type: 'error' issues fail the
boot; a BROAD_ALLOW warning does not. Without the flag preload() reads no
roles and runs no validator, so the cost stays opt-in.
A route guard could not name the tenant it was deciding in
The access request has four dimensions — action, resource type, row, scope —
and the scope was the one no guard could derive from the request. iamGuard
(express and hono), withIamAccess and createIamNextMiddleware took only a
fixed scope fixed at mount time, so on the canonical multi-tenant route,
/orgs/:orgId/posts/:postId, every check ran with scope: undefined even
though the tenant was in the path. Only the nest guard could already read one
per request.
Running unscoped is not neutral in either direction. A scoped assignment —
assignRole('u1', 'editor', { scope: 'org-1' }) — only enriches the subject
when the request carries a matching scope, so the grant did not apply and the
guard denied a caller who genuinely held the role. A rule conditioned on
scope is the opposite: the field resolves to null, the condition does not
match, and a deny in that shape silently never fired.
All four now take getScope, matching the extractor the two
iamAccessMiddleware surfaces already had and the shape nest already used. A
fixed scope still wins where both are given, so existing mounts keep their
meaning, and the resolved scope is handed to getResourceAttributes so an
attribute loader can read the row from the right tenant.
A fail-closed deny told the observers nothing
afterEvaluate, onDeny and onMetrics fired for every verdict the evaluator
produced — and for none of the verdicts the engine produced without reaching
it. A malformed subjectId was refused silently, with no hook at all. An
adapter that would not answer fired onError and then denied, unobserved.
permissions() returned an all-false map on a batch load failure after one
onError, and a false per check that threw with only a metric.
These are exactly the denials worth watching. During an adapter outage the
engine refuses every request while iamCreateMetricsAggregator().snapshot()
held total and deny flat — an authorization failure that reads on a
dashboard as a traffic drop rather than a deny spike, and a security log fed by
onDeny that records nothing for the whole window.
Every verdict now reaches the observers. decision.failure names the path:
'input' for a malformed subject id, 'resolution' for a subject or policy
load that threw, 'evaluation' for a throw mid-check. permissions() emits one
per map entry, because there is one verdict per map entry, and
{ telemetry: false } still reports the deny while skipping the metric, as it
already did on the evaluated path. A malformed id does not also fire onError:
it is input, not a fault.
A mistyped scopeCombine widened the role set
policyCombine has a boot-time guard because both evaluators branch on one
literal and fall through to the most permissive option for anything else. The
two scope settings have the identical shape and had no guard at all.
scopeCombine is the sharper of the two, because its fall-through is the
wider branch. A subject holding admin at org-1 and viewer at
org-1.team-a, checked at org-1.team-a under scopeMode: 'hierarchical',
resolves to ['viewer'] with 'override' and to ['admin', 'viewer'] with
anything that is not that exact string — so 'overide' in a config file
silently handed the caller every ancestor scope's roles, and can('delete')
flipped from false to true. scopeMode falls through to 'flat', which is
narrower, but it still means a hierarchical deployment quietly stops honouring
descendant scopes.
Both are now refused at construction with the same message shape as
policyCombine, so a bad value is a failed start rather than a quiet change of
meaning.
A mistyped mode turned every deny into an allow
IConfig.mode was assigned with a bare ?? 'production' and never checked,
while every site that reads it compares this._mode === 'production'. So
'prodution' did not fail, and did not run production either — it selected
development behaviour at all nine sites at once.
The worst of those is check(). In development it answers an IDecision
object instead of a bare boolean, and that object is truthy even when it
denies, so if (await engine.check(...)) admitted every denial. TypeScript
could not catch it: TMode is a type argument that mode does not have to
agree with, so the call site still read as boolean. explain() also became
callable, exposing policy ids, rule ids and condition values in what the
operator believed was production, and the existing
mode: 'production' + policyCombine: 'first-applicable' guard was bypassed,
because the typo is not the string that check compares against.
mode is now refused at construction with the same message shape as
policyCombine, which also closes the first-applicable bypass.
constructor and toString were usable as condition operators
evalCondition dispatched with a bare ops[cond.operator] and then checked
typeof op !== 'function'. ops is an object literal, so every
Object.prototype member is a function on it and passed that check.
constructor returned a boxed object and toString returned the string
"[object Undefined]" — both truthy — so the condition reported as satisfied
without ever comparing anything.
A rule carrying one fired unconditionally: an allow rule granted, and a deny
rule guarded by none was retired. It happened in both modes, because the fast
path's conditionMayThrow had already classified these names correctly — it
uses Object.hasOwn — and handed the policy to the interpreter, which is where
the truthy answer came from.
The write-path validator refuses these names, so only a row that reached
storage another way — a seed, a migration, a direct write — could carry one.
That is the same gap preload({ validator: true }) exists to close.
Dispatch is now own-property only in evalCondition, in the interpreter's
combining-algorithm lookup, and in the public iamEvaluateOperator, which
previously let a policy linter report such a condition as satisfied.
A mistyped deny was outvoted by its own policy's allow
IRule.effect is typed 'allow' | 'deny', and every branch that reads it tests
both names positively. An earlier fix made an unrecognised effect vote for
neither side, which is fail-closed for a policy whose only rule carries it —
nothing matches, so defaultEffect answers — and that is the shape that was
tested.
Give that deny a sibling allow in the same policy and it inverts. Measured, one
deny-overrides policy with an unconditional allow and a second rule spelled
'DENY': can() answered true, against false for 'deny'. The compiled
table agreed, by a different route: its flat model reads a non-allow effect as a
deny, so the cell became DYNAMIC, and the DYNAMIC cell asks the combiners, which
read it as neither.
An unrecognised effect is now Indeterminate rather than an abstention, which is
the same rule already applied to an unanswerable condition and an unknown
combining algorithm. policyHasDenyRule counts anything that is not 'allow',
so the Indeterminate denies instead of casting defaultEffect, and the policy
is reported through onPolicyError naming the offending rule.
The write path refuses these rows, so only a seed, a migration or a direct write can carry one.
A deny with a broken priority was outranked by every allow
first-match and highest-priority rank matched rules by rule.priority.
rulePriority read a priority that was not a finite number — NaN, null,
'urgent', or absent — as 0. That is a real rank, the lowest meaningful one,
so the rule stayed in the ranking and lost to any allow above it.
Measured, a highest-priority policy holding an unconditional allow at priority
5 and a deny carrying 'urgent', -Infinity or no priority at all: can()
answered true in both evaluators, against false for the same deny at
priority 9. Reading it as -Infinity loses the same way.
A priority the engine cannot rank is now Indeterminate, matching the rule
already applied to an unanswerable condition, an unknown combining algorithm and
an unrecognised effect. Both evaluators refuse it at policy level, after the
NotApplicable tests they already share, so they refuse exactly the same
requests; the compiled table's flat model never reads priority, so such a
policy is forced out of it. deny-overrides and allow-overrides do not rank,
and are unaffected.
The write path refuses these rows, so only a seed, a migration or a direct write can carry one.
Production allowed what development denied for a rule with no conditions object
IRule.conditions is required, and evalConditionGroup refuses anything that
is not a condition group. The fast path's conditionMayThrow classifier
disagreed: it read a node that is not an object — undefined, null, a string,
a number — as a leaf that cannot throw. So idx.mayThrow stayed false, the fast
path scanned the policy itself instead of handing it to the interpreter, and
allow-overrides returned on its first unconditional allow without ever
reaching the broken rule.
Measured, an allow-overrides policy with an unconditional allow and a deny
whose conditions was absent, null or a string, made residual by a *
resource: can() answered false in development and true in production.
The same shape nested one level down ({ all: [null] }) behaved the same way.
A non-object node is now a throw site at every depth, so the fast path delegates and both modes refuse identically.
evalConditionGroup also reports it properly. 'all' in group raises a bare
TypeError on a non-object, which is Indeterminate to a caller but arrives
without a duck-iam message — and it fired before the branch written to describe
a non-object could run, so that branch was unreachable. The non-object test now
runs first and throws IamConditionGroupError('unknown-keys') naming what it
saw.
The write path refuses these rows, so only a seed, a migration or a direct write can carry one.
explain() disagreed with the decision it was explaining
explain() reaches its verdict through its own combiner switch and its own
target matching, which makes it a third evaluator — and nothing compared it to
a verdict. Adding that comparison to the 6000-catalog differential found five
disagreements.
Reported allow where can() denied: an unrecognised rule.effect (the
combiner matches 'deny' and 'allow' positively, so a mistyped one voted for
neither) and a non-finite rule.priority (never checked at all). An unknown
combining algorithm fell off the end of the switch and raised
Cannot destructure property 'effect' out of explain() itself, so the
diagnostic crashed on exactly the input it exists to explain.
Reported deny where can() allowed: a throwing condition on a rule that
does not target the request poisoned the whole policy, though ruleApplies
never evaluates such a rule's conditions; and a throwing permission inside the
allow-only RBAC union was not allowed to abstain, though the decision path lets
it.
The policy-level refusals now come from one place and run behind the same
rule-target test the two evaluators share. explain() answers defaultEffect
directly when no rule matched, so the combining algorithm is never consulted
where it has no arm, and the hand-copied policy target matcher is gone in
favour of policyApplies. A non-object condition group is reported by name in
the trace rather than as a bare TypeError.
A role grant retired an explicit deny under first-applicable
policyCombine: 'first-applicable' stops at the first policy that is not
NotApplicable, so whichever policy comes first is the decision. loadAllPolicies
handed over the synthetic __rbac__ policy first — a policy the operator never
wrote, generated from the role definitions.
It is applicable to any action/resource pair any role grants, so it decided
both ways it should not have. A subject holding a role that grants
delete on post got that allow and never reached the ABAC policy denying a
locked post: granting a role widened access past an explicit deny. A subject
holding no such role got the RBAC policy's defaultEffect deny and never
reached an ABAC policy that would have allowed.
The generated policy now goes last, in evaluate and in explain's
decideFinal through one shared helper, so the trace and the decision cannot
name different policies. 'and' and 'allow-overrides' fold every applicable
vote and are unchanged. The order of the stored policies themselves is still the
adapter's row order, as it is for rule order, and no adapter orders
listPolicies.
A mistyped action in a policy rule was reported for roles and not for policies
createIam constrains engine.check to the declared action and resource
unions, so anything a rule names outside them can never be requested.
createIam(...).validateRoles has always reported that for a grant —
UNREACHABLE_TARGET, an error. createIam(...).validatePolicy did not pass the
vocabulary through at all, so the same typo in a policy rule returned
valid: true.
The direction that matters is the deny. A policy that allows '*' on post
and means to deny delete, written actions: ['delet'], answers can('delete', 'post') with true; spelled correctly it answers false. Nothing reported it.
validatePolicy now takes the same optional declared surface and checks
rule.actions, rule.resources, targets.actions, targets.resources and
targets.roles. Patterns are cleared through the engine's own matchesAction
and matchesResource, so a prefix is accepted on exactly the values it would
match at runtime and the action axis's lack of a dot form is honoured. The bare
validatePolicy export is unchanged, as the bare validateRoles is.
A role grant the engine honours was reported as unreachable
The vocabulary pass createIam(...).validateRoles runs matched a grant against
the declared list with includes, so only a literal or '*' could clear it.
validateRoles takes the unconstrained IRole on purpose — it exists for
rows read from a store, a config file or an admin form — and the engine honours
a prefix pattern in a role permission: { action: 'post:*', resource: 'org.*' }
grants post:create on org.team and nothing else.
That grant came back valid: false with two UNREACHABLE_TARGET errors, which
PolicyBuilder-style callers and admin UIs read as a refusal. Each axis is now
cleared by the matcher the engine actually uses — matchesAction,
matchesResource, matchesScope — so 'post:*' and 'org.*' pass, while
'admin.*' on the action axis is still reported (there is no dot form there)
and 'org-1.*' as a scope is still reported (scopes match exactly).
A non-string entry in policy.targets retired the policy, or the whole engine
validatePolicy type-checked every entry of rule.actions and rule.resources
— string, no control characters — and checked targets.actions,
targets.resources and targets.roles only for Array.isArray. Not one entry
inside them was looked at, and the vocabulary pass skipped a list that was not
already all strings, so a malformed target was the one corner nothing read.
Both outcomes are reachable from a policy the validator called valid: true:
- A target the roles axis cannot match retires the policy's denies.
targets.rolesis compared withincludes, so a number — or a role id carrying a stray control character — matches no subject. The policy becomes NotApplicable for everyone and the deny inside it never runs. Measured with a policy that allows'*'and denies a locked resource: withtargets: { roles: ['editor'] }the locked request answersfalse; withroles: [42]it answerstrue. - A target on the action or resource axis takes the process down.
matchesActioncalls.endsWithon the pattern, so a number throws while the compiled table is being built. The engine fails closed and warns, but every request in that process is denied until the row is removed.
Entries are now checked the way rule entries always were, per axis and per
index, so the write path refuses the row: savePolicy reports
targets.roles[0] must be a string. Rows already in a store are not
re-validated, so the runtime behaviour above is pinned by tests rather than
changed.
The published policy schema refused four shapes the validator accepted
POLICY_JSON_SCHEMA is published so operators can gate policies in an admin UI
or in CI, and its contract is one-directional: anything it rejects,
validatePolicy rejects too, so a policy the runtime accepts always validates
there. Four shapes broke that, all of them fields IPolicy and IRule already
type:
| Shape | validatePolicy | schema |
|---|---|---|
description on a policy, not a string | accepted | rejected |
description on a rule, not a string | accepted | rejected |
metadata on a rule, not an object | accepted | rejected |
targets: null | accepted | rejected |
description and metadata were listed in the known-key sets and type-checked
nowhere; targets: null was explicitly skipped. The validator now checks all
four, which is the side that was wrong — no adapter produces any of them (both
drizzle and prisma drop a null column rather than passing it on), and explain()
renders a rule description into the trace it hands an operator.
Behaviour change: savePolicy, admin.import() and PolicyBuilder.build()
now refuse a policy carrying targets: null or a non-string description.
TypeScript callers cannot write any of these shapes; plain-JS and JSON callers
that did were writing a policy the published schema already rejected.
The fuzzer pinning that contract tested it on 147 policies out of 4000
The randomised half of schema-validator-agreement.test.ts generated each slot
independently from its own pool, so almost every policy was invalid for some
unrelated reason and never reached the implication under test — validatePolicy
accepted 147 of 4000. targets was not in the generator at all (one constant,
{ actions: ['read'] }), and description, metadata and priority were
constants too, which is why the four shapes above survived 4000 passes a round.
Its non-vacuity guard asked a different, simpler generator whether it produced accepted policies, so the number it reported said nothing about the generator being pinned.
The generator now builds a valid policy and perturbs one slot (sometimes two)
from a pool covering every optional field, and the guard runs on that same
generator: 1417 of 4000 accepted, against a floor of 1000. Reverting any of the
four fixes above now fails it; before the rewrite, reverting the rule.metadata
fix did not.
Vue's access state shared the permission map the caller still held
createAccessState(map) put the caller's object straight into its ref, and
update(next) assigned next itself. React's AccessProvider and the vanilla
IamAccessClient both copy the map in and both say why in a comment;
createIamPermissionChecker shares it deliberately and documents that. Vue was
the one surface that shared it silently — and it shared in both directions,
since the Ref also hands the stored object back through permissions.value.
So on Vue, and only on Vue, map['delete:post'] = true after construction made
can('delete', 'post') start answering true, with no reactivity triggered:
ref() sees an assignment to .value, not an edit inside the object it holds.
The same write through state.permissions.value did the same thing. The map is
client-side and the server re-checks, so this is a UI-correctness and
cross-binding-consistency bug rather than a bypass — a gate rendered from a map
somebody else could edit.
createAccessState now stores Object.freeze({ ...map }), in the constructor
and in update, which closes both directions: the copy defends the map you
handed in, the freeze defends the one you read back.
The reference doc's ownership matrix was wrong in two more cells while it was
there: it said a write into the map React's AccessProvider hands back changes
can(), but that snapshot has been frozen all along. All four surfaces are now
pinned by one table-shaped test rather than by prose.
A Next route handler's own error was reported as an authorization failure
withIamAccess wrapped the route handler in the same try as the permission
check, so anything the route threw came back through the adapter's onError —
documented as "handles thrown errors during evaluation" — and the client got
the IAM layer's 500 {error:'Internal server error'} instead of whatever the
route meant to answer. Next's own error handling never saw it.
Only half the time, which is the part worth knowing. The call was spelled
return handler(req, ctx), with no await, and an unawaited return inside a
try still routes a synchronous throw to the catch while letting a
rejection past. So the same route error behaved differently depending on
whether the route was declared async.
Hono had this fixed a round earlier, and it was written up then as hono being
"the only one of the five". That was wrong: the check behind it looked for an
awaited downstream call inside the try, and withIamAccess was not awaiting.
handler(req, ctx) now sits outside the try, as await next() does in hono.
cross-adapter.test.ts runs the assertion against both spellings, and a new
express control in the HTTP e2e boots a real express app to show why the one
adapter still calling its downstream inside the try is unaffected: express
dispatches each layer inside a try of its own and turns the throw into
next(err) before it could unwind.
A malformed role grant in the file store turned a deny into an allow
The file adapter's assignment parser dropped a malformed {role, scope?}
entry, reported it through onPolicyError, and answered the read with the
grants that did parse. Losing a grant reads like less authority, so this
looked safe. It is not: a policy's targets.roles names the subject's
grants, and policyApplies drops a policy whose targets no longer
intersect the subject's roles. A deny policy targeting banned stops applying
the moment the banned grant cannot be read, and the allow that the same file
grants through another role carries the decision.
Measured through the engine, with an allow-all-read policy and a
deny-banned policy targeting targets.roles: ['banned']: a subject holding
reader + banned is denied; the same subject whose banned entry is
{role: 'banned', scope: 5} — a number where a string belongs — is allowed.
Same for {role: 42}, for a bare string entry, and for a row that is not an
array at all.
The same file already fails closed for a corrupt attributes row, for the same
stated reason, so the fix is that design applied to assignments: the row moves
into corruptAssignments, getSubjectRoles and getSubjectScopedRoles throw,
and assignRole / revokeRole / updateAssignmentScope refuse — an
incremental edit would write back a row with the unreadable entry silently
gone.
The second half was worse than the read. _serializableState wrote the
parsed assignments back, so the next flush of any kind — an unrelated grant
for another subject — erased the malformed entry from disk permanently, and the
store then looked clean. The raw row is now written back verbatim, as the
corrupt attributes row already was, so the file still holds what an operator
has to repair. deleteRole cannot sweep a row it refuses to read, and now
reports each one it skipped rather than implying the role is gone.
The HTTP adapter answered a subject read with the half of the list that parsed
The same defect as the file store above, at the adapter where the payload is
remote and least trusted. parseHttpSubjectRoles skipped any element that was
not a non-empty string and parseHttpSubjectScopedRoles skipped any element
whose role or scope was not one, both silently, and both returned the rest.
A test pinned that as correct on the premise that "roles are allow-only" — the
premise the file-store finding had already disproved.
Measured through the engine over a fake server, with a deny delete:post
policy at targets.roles: ['contractor'] and a subject holding editor +
contractor: the well-formed list denies, and the same list with the
contractor entry arriving as {role: 'contractor', scope: 'org-1'} — the one
server mistake the endpoint contract explicitly warns about — allows, with
nothing reported. Same for null, 42 and '' in that position. A payload
where every element is malformed already failed closed, because a subject with
no roles has no allow either; only the partial list flipped the verdict, which
is why it survived.
adapters-runtime.md had documented the throwing behaviour as the contract for
both methods for as long as the drop existed. The parsers now match it: the
element that will not parse fails the read, naming its index and its type but
never its value. A row carrying no scope on /scoped-roles throws too — the
two endpoints are disjoint by contract, so that row is the server mixing them
up rather than a global grant. A malformed catalog row from GET /roles is
still dropped and reported, because a policy left targeting it is now reported
in its own right; a dropped grant has no such witness.
A subject's grant of a role nothing defines lost every role it inherited
The grant-side half of the target check above, and the case that one could not
see. resolveEffectiveRoles deliberately keeps a directly assigned role id that
no stored role defines, so the id still matches an ABAC rule naming it and the
grant looks intact. But there is no row to read inherits from, so every role
that id conferred is silently absent — and a deny targeting one of those
retires.
Measured through the engine with a deny delete:post policy at
targets.roles: ['contractor'] and a subject holding staff + editor, where
staff inherits contractor: with the staff row present the subject is denied;
with the staff row missing from the catalogue the subject is allowed, and
nothing is reported — the policy's own target, contractor, is stored and
healthy, so the target check stays quiet. A subject holding contractor
directly is still denied, and one holding only editor is still allowed, which
is what makes the middle row a finding rather than an engine that denies.
Four adapters drop a malformed role row rather than refusing it, on the reasoning that role permissions are allow-only and a role nobody can resolve grants nothing. The first half is true and the second does not follow: the drop is tolerable only because something reports the grants left pointing at nothing. Now something does, de-duplicated per role id — the absent definition is what needs repairing and every holder shares it. Both grant paths report, global and scoped. It changes no verdict.
The premise had been written into five comments and a test-file header across the adapters; those now say what is actually true.
A misspelled action or resource target retired a policy in silence
The third carrier of the same defect. targets.actions and targets.resources
are matched against the request, so an entry naming an operation the policy's
own rules never mention admits nothing and the policy is skipped for every
request — the same retired deny as a targets.roles entry naming no stored
role, which is already reported.
Measured with a deny delete:post rule: targets: {actions: ['delte']} allows,
{resources: ['psot']} allows, and both are silent, while the identical typo in
targets.roles is reported. Spelled correctly, or with no targets at all, the
deny fires.
No catalogue is needed to see it. The engine is never handed the declared
vocabulary — that lives on createIam and reaches only its validatePolicy,
which callers invoke or do not — but a policy's own rules are the vocabulary
that matters: if no pattern in any rule could match a request the targets admit,
the policy cannot fire whatever the catalogue says. Pattern intersection follows
the matchers, so * intersects anything and a prefix form keeps its separator,
leaving post:* and post.* disjoint exactly as matchesResource treats them.
One live rule among dead ones keeps the policy reachable and quiet.
A condition reading a misspelled path silently stopped its deny from firing
One level below the targets: a rule's condition field. resolve answers
null for a path whose root is not subject, resource or environment, and
for one carrying a prototype key at any segment — on every request, for every
input. The rule then never matches, and the deny never fires.
Measured with a deny given a companion allow in the same policy, so the verdict
distinguishes a deny that fired from one that did not:
subject.attributes.banned denies, resource.attributes.sensitive denies, the
same subject unbanned is allowed — and user.banned,
Subject.attributes.banned, subject.__proto__.banned are all allowed,
with nothing reported.
The oracle already existed. isResolvablePath is exported for the validator and
kept in parity with resolve by its own test; it had no caller on the load
path, because validatePolicy is something users invoke and the engine does
not. The engine now runs it over every rule's conditions when policies load,
reporting once per policy, rule and path.
Deliberately not reported: a path that is merely absent, such as
subject.attributes.bannd. Its root is legal and its shape resolvable — whether
the key exists is a fact about the request, and attributes are open-ended. Also
not reported: a $-prefixed value operand, which evalCondition already
reports and answers Indeterminate rather than false.
A rule its own policy's targets excluded could never fire, and the policy looked fine
The mirror of the dead-target check above. Reporting only when no rule matches the targets left the harder case: the policy is reachable, one rule inside it is not. The policy fires, so nothing looks wrong — and the deny in the excluded rule is gone, better hidden than one in a policy that never applies.
Measured with an allow and a deny in the same policy under
targets: {actions: ['delete']}: the deny spelled delete denies, the deny
spelled delte allows, and nothing was reported either way, because the
allow rule kept the policy reachable. Same by resource.
Each excluded rule is now reported on its own, once per policy, rule and dimension. A policy whose rules are all excluded is still reported once at the policy level rather than once per rule. A wildcard target admits everything, and a policy with no targets has nothing to judge its rules against, so both stay quiet.
The test that had to change. The dead-target work had pinned "one live rule
among dead ones keeps the policy reachable" with reported: []. That was the
conservative choice at the time and this round measured it wrong: the policy is
reachable, and the dead rule still needs naming. It now asserts the policy-level
silence and the new per-rule report.
A 0 that switches off a timeout or a cap now says so
adapterTimeoutMs, hookTimeoutMs and maxConcurrentSubjectLoads all read 0
as "off", and each one turned off is a fail-closed mechanism. With a timeout, an
adapter that stops answering makes can() deny; with 0 the check hangs
instead, and never returns. A hook whose promise never settles does the same to
the evaluation. An unbounded subject loader never sheds a cache-miss burst.
The non-finite guard beside them cannot catch this - 0 is legal and
documented. What makes it worth reporting is that Number('') is 0, so an
environment variable that is set but empty lands on the off switch, while an
unset one gives NaN and is refused outright.
Each now warns once per process at construction, naming what stops happening. Nothing changes for the defaults or for any real value.
The Express middleware no longer answers for errors that are not its own
Hono and Next both call the downstream handler outside their try, each with a
comment saying why: a route's own error belongs to the framework, not to the
authorization guard. Express called next() inside it.
Express 4 invokes the next middleware synchronously, so a route that threw
synchronously came back out of next(), was caught by the authorization
middleware and was answered by its onError - a fixed 500 that pre-empts the
app's own error-handling middleware and reports a route failure as an
authorization failure. Both iamAccessMiddleware and iamGuard were affected.
Both now let a route error past. Evaluation errors, a throwing getUserId and
every other failure the guard is responsible for still reach onError
unchanged, and a denial still answers 403 without calling next().
mode: 'development' now announces itself, like the other fail-open setting
defaultEffect: 'allow' has always warned unconditionally at construction, so
that a log search for fail-open configurations finds it. Development mode is the
other one, and the sharper of the two: under fail-open a deny still answers
false, but in development check() and authorize() answer an IDecision,
and an object is truthy even when allowed is false. if (await engine.check(...)) therefore allows every request, denies included. It warned
nothing.
The constructor now warns once per process, naming that consequence rather than
just the mode, and pointing at .allowed and at can(), which stays a boolean
in both modes. Worth knowing because policyCombine: 'first-applicable' is
refused in production, so choosing that combine chooses this mode with it.
setInvalidator no longer leaves an engine attached in one direction only
The invalidator was stored before subscribe returned, so a subscribe that
threw left an engine that publishes its own revocations and applies nobody
else's: stale for the life of the process, fleet-wide, with healthCheck()
reporting nothing about it. It is now stored only after subscribe returns, the
engine is left fully detached, and the error reaches the caller.
dispose() detaches the invalidator as well as releasing the subscription, for
the same reason - an engine that has stopped receiving but still broadcasts is
the worse half of the pair to keep.
Two things subscribe can hand back are now reported instead of absorbed. A
return value that is not a function means the subscription can never be
released, so nothing callable is stored and a warning says so. A teardown that
throws is still caught - it must not mask the reason for shutting down - but is
warned about, because the subscription may still be delivering into an engine
that was already released.
An invalidation the engine cannot apply now drops the caches instead of being ignored
Events arrive from a transport the operator writes, so the engine now
shape-checks what it receives rather than trusting the handler parameter's type:
kind must be one of the four, a subject event must carry a non-empty string
subjectId, and a roles event may carry a non-empty string roleId or none.
Anything that fails that check is applied as {kind:'all'} - every local cache
and the compiled table dropped - and warned about once per distinct reason.
Before, an unrecognised kind was silently ignored, so a newer peer's event
during a rolling deploy left older instances serving the answer that peer had
just said was stale; and an event that was not an object threw out of the
handler, which on an EventEmitter transport is an uncaught exception. A value
that cannot even be inspected, such as a throwing getter or a revoked proxy, is
now a reason rather than a throw.
Note the trade: a transport that delivers malformed messages repeatedly will now drop the caches repeatedly where before it did nothing. The warning names the reason. There is no fleet amplification - every applied branch is local only.
The shipped redis invalidator already validated each decoded event and never reached this path.
An invalidator whose publish fails can no longer take the process down
IInvalidator.publish is operator-supplied code, is allowed to return a
promise, and runs on the revocation path. The engine called it as a bare
void publish(...), so a rejection was unhandled - fatal under Node's default
--unhandled-rejections=throw - and a synchronous throw came back out of
admin.revokeRole as a failure, for a revoke that had already landed and
already cleared the local caches.
Both are now caught, as every other advisory callback in the engine already was,
and reported as one console.warn naming the event kind and stating that this
instance is up to date while other instances keep their caches until their own
TTL expires. No verdict changes, and the local caches are cleared before the
publish either way, so a transport failure degrades to per-instance TTL
staleness rather than to a local one.
The shipped redis invalidator already absorbed and reported its own publish failures, so it never reached this path.
A write that timed out no longer leaves the old answer cached
adapterTimeoutMs bounds how long the caller waits, not how long the store
takes, and an admin write takes IActorOptions, which carries no abort signal.
So a write that times out may well land a moment later. Until now the
invalidation that follows the write was skipped in that case, and the engine
kept serving the old answer for a full cache TTL: revokeRole could time out,
the row could be deleted in the store, and can() would still return true.
Every single-row admin write — savePolicy, deletePolicy, saveRole,
deleteRole, assignRole, revokeRole and both paths of
updateAssignmentScope — now invalidates whether the call resolves or throws.
Dropping a cache entry for a write that did not land costs one re-read; keeping
one for a write that did land is a revocation that never took effect. The batch
methods already worked this way.
hooks.onMutation is deliberately unchanged: it still fires only after a write
resolves, because an audit seam that records writes which may not have happened
is worse than one that misses a write whose outcome was never learned.
A rule that no request can reach is now reported
A rule can be unreachable for reasons wholly inside itself, and until now
nothing said so. An empty actions or resources list matches nothing. An
empty any group is false for every request - while an empty all, an empty
none and {} are all true, so the one spelling that reads like "no
conditions" is the one that retires the rule. in against an empty list can
hold for no value. And a none group holding an item true for every request is
false for every request.
In each case a deny written that way never fires and the policy around it looks
healthy. The engine now reports each unreachable rule once through
hooks.onPolicyError, or one console.warn when no hook is installed. No
verdict changes.
Only falsity that reaches the rule is reported: the walk descends all chains
and stops under any, where a false item is a dead disjunct, and under none,
where a false item helps the rule apply. subset_of and superset_of against
an empty list are left alone because an empty array field satisfies them - a
fact about the request, not the policy.
validatePolicy already rejected the empty lists, but nothing on the load path
calls it, so a seeded row or a migration carried them straight past; the two
condition shapes were unguarded even at write time. Relatedly, a rule with an
empty list inside a policy that has targets was previously reported as one
the policy's targets exclude. That was a misdiagnosis - the rule is dead
whatever the targets say - and it now reports the rule's own list instead.
A deny written against scope does not follow the hierarchy its grant does
Reported and documented, not changed — the behaviour is each operator doing exactly what it says.
In 'hierarchical' mode a grant at org-1 reaches org-1.team-a, because
rolesToPolicy emits any: [eq 'org-1', starts_with 'org-1.'] for it. A rule
an operator writes gets no such help: scope is an ordinary field and there is
no scope_under operator, so each obvious spelling of "this scope and below" is
wrong in a different direction, and two fail open.
Measured with an allow and a deny in one policy, subject granted at org-1:
eq 'org-1' denies the parent and allows org-1.team-a; starts_with 'org-1.' denies the child and allows the parent itself; bare starts_with 'org-1' denies both but also reaches org-10, a different organisation. Only
the two-arm form is right, and it is the one the package already generates
internally.
core-rbac.md now states this where the generated condition is shown, with the
table of all four spellings, and scope-deny-follows-no-hierarchy.test.ts pins
them in both modes so it cannot drift. Whether to add a scope_under operator
is a public-surface decision and is on the open-findings ledger.
A recreated role id handed its permissions to everyone who inherited the old one
deleteRole removes the role and, on every adapter, the grants that named it —
with the reason written into the compliance suite: "an orphan grant would be
held again if a role were recreated under the same id." An inherits edge is
the other place a role id is written down, and nothing swept it. iam_roles
stores inherits as a JSON array, so the SQL foreign key that cascades the
grants cannot reach it either.
Measured on the memory and file stores: u1 holds reader and staff, and
staff has inherits: ['ghost'] where ghost grants write:post.
deleteRole('ghost') correctly takes the write away, but staff.inherits still
reads ['ghost']. An operator then recreates ghost for something unrelated,
granting delete:post — and u1 can delete, a permission nobody granted them.
resolveEffectiveRoles walks the surviving edge into the new role.
The interim protection was resolveEffectiveRoles dropping an inherited id no
role defines, which stops the phantom-allow half. It reads in one direction
only: a role id in subject.roles is also a selector — policy.targets.roles
matches it — so cutting the id retires the deny that was targeting it. Measured
on the same store: a subject reaching banned through staff inherits banned
is denied while banned is defined and allowed once its row is not. Both
directions escalate, so the state must not arise in the first place.
deleteRole now strips the edges as well as the grants, on all six adapters
(the HTTP adapter's reference server included), and the compliance suite pins
both the edge sweep and the end-to-end escalation, so a new adapter cannot miss
it.
The one write path the scope guard did not cover
iamAssertAssignableScope is the adapter-boundary guard that refuses two scope
values: '', because the redis adapter spells "no scope" as '' and would
store it as a global grant, and '*' on a grant, because a scoped assignment is
matched literally — a '*' row answers only a request whose own scope is the
string "*".
assignRole and revokeRole called it. So did drizzle's assignRoleMany and
revokeRoleMany. updateAssignmentScope — the third method that takes a scope
to write, and the only one that takes two — called it on neither end, on all
four adapters that implement it. Its own doc comment listed the two methods it
covered, which is why it read as complete.
Measured on memory, file, prisma and drizzle:
updateAssignmentScope('u1', 'editor', 'org-1', '') and the same with '*'
both returned true and left the subject holding editor under a scope no
request can name. The operator is told the grant moved; it is dead.
Both ends are now guarded — the from as a lookup, so a legacy '*' row can
still be moved off, the to as a grant — and two compliance clauses hold every
adapter to it.
The subject entry outlived the role snapshot it was resolved against
IamLRUCache.set takes a notAfter so a derived entry expires with its input,
and three call sites use it: the RBAC policy expires with the role snapshot, the
merged policies with the older of their two inputs, and the compiled table
stamps itself with the read time of its oldest input rather than Date.now().
The subject entry is the fourth derived value and was not capped that way. It
holds resolveEffectiveRoles(assigned, snapshot) and, for scoped grants, roles
resolved through inherits against the same snapshot, so both halves are only
as fresh as the role graph they were resolved against. The one cap it did take,
the optional getSubjectGrantBoundary, describes the subject's assignment
rows and says nothing about the role graph.
Because each subject is written at its own moment, one resolved late in the
snapshot's life bought a full fresh TTL past it. Measured at the default
cacheTTL: 60, with the staff -> admin inherits edge removed at the store by
another writer: a subject resolved at t=50s still held delete:post at t=61s,
after the engine had re-read the role graph without that edge and was already
answering deny for every subject resolved since; it converged only at t=110s.
The exposure is up to one whole cacheTTL beyond the point the engine itself
knows the role changed, and it needs no adapter feature to reach - any engine
whose roles are written by something other than its own admin facet is in it.
The entry is now capped at min(grantBoundary, roleCache.expiresAt('all')),
which costs nothing when the snapshot is fresh because the two are then the same
instant.
A policy targeting a role nothing defines was skipped in silence
policyApplies matches targets.roles by equality against the request's
effective roles and against nothing else; there is no catalogue lookup. An entry
naming a role no stored row defines therefore matches no subject, and the whole
policy is skipped on every request. A typo is enough.
For a deny policy that is a deny that never fires, which is the one outcome this
engine otherwise refuses to be quiet about - a policy that cannot be read throws
rather than being dropped, a condition that cannot be answered is Indeterminate
rather than false, a policy that throws still votes deny. Measured with every
diagnostic hook attached: a deny delete:post policy targeted at contarctor
instead of contractor let the subject delete, in both modes, with
onPolicyError and onError silent.
The second way in is deleteRole. It sweeps the grants and the inherits edges
that named the role, but it cannot sweep this carrier, and the asymmetry is not
an oversight: targets.roles: [] means unconstrained, so stripping the last
entry would widen the policy from one role to every subject rather than narrow
it to none. There is nothing safe to rewrite and nothing to decide at evaluation
time, since the state is indistinguishable from a policy written before its
role.
So the engine reports it instead: one onPolicyError per (policyId, roleId)
for the engine's lifetime, or one console.warn when no hook is installed. Both
evaluator paths carry the check - the compiled-table build and the interpreter's
loadAllPolicies - because either can be the only one that runs, and the
de-duplication is what keeps development mode, where both run, from reporting
twice. It costs no extra adapter read and changes no verdict.
5.9.0
Minor Changes
8a2f514: Close three fail-open gaps and finish the invalidator log redaction.
RuleBuilder.build()refuses a builder that was never configured. The
defaults are the broadest possible grant — allow on ['*'] x ['*'] with
{all:[]} conditions, which evaluates true — so .rule('x', (r) => { ...forgot... })
silently pushed an unconditional allow-everything rule into the policy.
PolicyBuilder.rule() had claimed for some time that build() already refused
this; it did not. The validator does detect the shape (BROAD_ALLOW) but emits
it as type:'warning', and build() keeps only type:'error', so the verdict
was computed and discarded with no console output and no return channel.
The refusal is deliberately narrower than promoting BROAD_ALLOW to an error:
it targets silence, not breadth. A deliberate broad grant still builds — say
.allow() — and validatePolicy still accepts stored policies carrying one, so
existing policy data is unaffected. desc(), priority() and meta() annotate
a rule rather than shaping it and do not count as configuring it; forScope('*')
does count, because it is an explicit statement about scope even though it
narrows nothing.
BREAKING: defineRule(id).build() and new RuleBuilder(id).build() now
throw unless at least one of allow, deny, on, of, forScope, when or
whenAny was called.
The allowFailOpen gate now covers every public evaluator entry point.
evaluate.public.ts gated iamEvaluate / iamEvaluateFast and asserted its
wrappers were "the only route to the evaluator from outside the package". They
were not: the barrel re-exported the raw single-policy evaluatePolicy /
evaluatePolicyFast straight from ./evaluate, and both reached the package
root, so iamEvaluatePolicy(policy, req, 'allow') answered allowed: true with
no opt-in and no warning. All four are now gated and pinned together by one test,
because a half-gated boundary is what made the original claim false.
BREAKING: iamEvaluatePolicy / iamEvaluatePolicyFast throw on
defaultEffect: 'allow' unless the new trailing allowFailOpen argument is
true, matching iamEvaluate and IamEngine.
explain() and can() agree on condition groups again. evalConditionGroup
distinguishes {} ("no conditions", unconditionally true) from a group carrying
unrecognised keys (false, so a typo cannot turn a conditional allow into an
unconditional one). traceGroup's fallback collapsed both to false, so
explain() reported a denial for a rule can() allowed. The fallback now
delegates to evalConditionGroup rather than reimplementing it — this is the
third time a hand-copy of the decision logic has drifted from it.
The Redis invalidator no longer logs raw tenant-bearing channels. The earlier
redaction covered the two drop-warning sites only; reportSubscribeFailure and
the unsubscribe rejection handler still logged JSON.stringify(channel), so a
NOAUTH, a wrong ACL or a reconnect window wrote the tenant id to stderr. Reaching
it needs no PUBLISH rights at all — a broker hiccup is enough.
The untouched-builder refusal now gates on what the rule became, not on which
methods were called. Three shapes still built the exact allow-everything rule
it was added to refuse. The one that matters is .forScope(...tenantIds) with a
runtime-empty array: forScope set the flag before discovering it had no scope
to apply, so a rule meant to be tenant-restricted built global and passed the
guard. forScope now throws when called with no scopes at all - a restriction
that names nothing can only be a call-site bug, and '*' is how "every scope"
is said out loud. .when(w => w) was the same shape: an empty all group is
.every over nothing, which is true, so it matches every request; an empty
callback no longer counts as configuring the grant. .whenAny(w => w) is left
alone deliberately - .some over nothing is false, so an empty any group
matches nothing and fails closed.
BREAKING: .forScope() with no arguments throws.
A $-sourced matches operand is refused at validate time. evalCondition
returns false for one without looking at the request - correctly, since a
caller-supplied pattern is a ReDoS vector - which makes the condition false for
every request that will ever arrive. validate.libs.ts skipped these with the
comment "Non-string / $-resolved values are caught elsewhere"; nothing caught
them. A deny-when-matches rule written against a request attribute validated
clean, stored clean, and never fired. New code ERR_REGEX_USER_SOURCED, an
error, matching the two sibling pattern checks beside it. The three literal
cases (uncompilable, over-long, catastrophic) were already errors and are
unaffected.
explain() no longer throws where can() denies. evaluate absorbs a rule
that throws as Indeterminate - the policy votes deny if it carries any deny
rule, else casts defaultEffect - while explainEvaluation had no try at
all. On an unknown operator, conditions: {all: null} or a non-array group it
raised out of the caller: a diagnostic failing on precisely the input it exists
to explain, which is also the shape a hand-edited store row produces. The
failure is now recorded on the rule trace as conditionError and the policy
casts the same Indeterminate vote, reusing decision's own policyHasDenyRule
rather than reading the rule list a second time. This is the fourth drift
between these two paths and the third caused by a hand-copy.
Explain.IRuleTrace gains an optional conditionError.
A dangling inherited role id no longer becomes a phantom role.
resolveEffectiveRoles added a role id to the effective set before looking it
up, so an id reached through inherits that no role defines landed in
subject.roles carrying no permissions - and a hand-written ABAC rule testing
subject.roles contains 'ghost' fired on it. The route in is ordinary: delete a
role while another still names it in inherits. deleteRole cascades the
assignments on every adapter, so the direct grant goes; the inherited id did
not. validateRoles already calls this state DANGLING_INHERIT with
type: 'error', so dropping it is not a new opinion about the data. Directly
assigned ids are kept whether or not the catalog defines them - that is a row an
operator wrote, not one derived from one.
BREAKING: getEffectiveRoles and subject.roles no longer contain
inherited role IDs that no stored role defines.
Prisma translates P2003 into the shared unknown-role refusal. An unknown
role produced Foreign key constraint failed on the field: `roleId` off the
driver - naming a column of a schema the operator may not have written, in a
string nothing can branch on - while four adapters raised one shared sentence.
The driver error is preserved as cause. The compliance suite gained the clause
that would have caught it, plus one requiring the refusal not to echo the
caller-supplied role id back into an operator log.
BREAKING: runAdapterCompliance takes a third IComplianceOptions
parameter. delegatesRoleExistence: true waives the wording of the
unknown-role refusal (not the refusal) for an adapter that delegates role
existence to a remote server - the HTTP adapter.
Nest denies a handler whose @IamAuthorize metadata is unreadable. The
guard read the metadata and treated anything falsy as "no decorator present",
so a handler decorated with metadata that did not survive serialisation - or
that arrived as null, 0 or '' - was allowed through with no authorization
call at all. Presence and readability are now separate questions: no decorator
still allows, unreadable metadata routes to onError and denies.
iamOptionalStringField refuses an explicit null. It treated null as
"absent", so {"scope": null} on an admin route meant unscoped - a global
grant - where the caller had written something that reads like "no value". Omit
the field to mean unset.
BREAKING: an admin request body carrying an explicit null for an optional
string field is now a 400 instead of being silently read as absent.
IamValidationError gained statusCode = 400, which is the property Nest's
base exception filter duck-types.
The admin audit trail records a refusal as a refusal. iamWithAdminAudit
set success: true whenever the handler returned normally, and a handler that
returns a 400/403 response object returns normally - so a rejected mutation was
written to the audit log as a successful one. savePolicy / saveRole also
record the target id now, via the new iamAuditIdOf.
The Redis invalidator reports dropped inbound messages. A secret present
on some nodes and not others makes every peer invalidation drop in both
directions for the whole rollout; caches never converge and the failure is stale
allow. The only report was a console.warn coalesced to one line per minute.
New onMessageDropped(reason, channel, suppressed) hook. Separately, the
coalescing latch was keyed on the channel alone, so attacker-driven inbound
drops and publish failures shared one 60s budget - one junk message a minute
suppressed the publish-failure warning for a concurrent broker outage. The two
kinds now have separate keys.
Derived caches inherit their inputs' expiry. IamLRUCache gained
expiresAt(key), and the RBAC-policy, merged-policy and compiled-table caches
now cap their own entries at the oldest input's expiry rather than restarting
the TTL clock. A policy-only invalidation used to re-age the role snapshot,
which was measured serving a stale allow for 118s under a 60s TTL.
IamSubjectsPanel carries the devtools guard itself. package.json exports
every panel individually under ./dt, and the subjects panel - the only one
that writes, calling assignRole / revokeRole / setAttributes - had no
isDevtoolsAllowed anywhere in its path when imported directly.
The admin read routes run the same authorization phase as the writes.
Express, hono and next gated GET /policies and GET /roles on authorize
alone while nest ran the CSRF check too, so an operator whose csrfCheck
carried any part of an authorization decision had it enforced on reads on one
of four adapters, undocumented. All four now run iamRunAdminAuthz. This is
not a new refusal for API clients: iamDefaultCsrfCheck returns true when
there is no Sec-Fetch-Site header at all, which is every non-browser caller.
Nest answers a failed admin mutation the way the other three do. It threw
the handler's own error untouched, so an internal message reached the client -
in one of this package's own tests, a fake SQL string naming a password column.
Express, hono and next all answer a fixed Internal server error. Nest now
throws that, with the original attached as cause so a logger loses nothing,
and includeErrorMessage governs the audit string only, as it does elsewhere.
IamNest.IAdminOptions gained onUnauthorized, onForbidden and onError,
which the other three adapters already had.
BREAKING: a Nest admin handler that throws now surfaces
Internal server error (500) rather than the original error. Read cause for
the original. A validation failure surfaces as 400, as on the other adapters.
One malformed admin request now gets one answer on all four adapters.
POST /subjects/:id/roles with {"roleId": ""} was a 400 on hono, which
hand-rolled its checks inline, and a 500 on express, next and nest, whose shared
validators threw a bare Error that every generic catch routes to onError -
paging an operator for a client's typo and telling the client to retry something
that will never succeed. {"roleId": " "} was a write on all four: the
checks read length === 0, so a blank id became a real grant on a role that
renders as nothing at all in an admin UI, while iamIsNameableActor next to
them had required .trim().length > 0 all along. And hono capped ids at 128
characters where the other three applied no cap and let the engine's 1024
decide.
iamRequireStringField, iamOptionalStringField and iamRequirePathParam now
refuse a blank value and cap at IAM_MAX_ADMIN_FIELD_LENGTH (1024, the engine's
own cap, so the edge refuses exactly what the engine would), and they throw
IamValidationError so all four adapters answer 400 through the branch they
already had. Hono uses them instead of its inline copy.
BREAKING: IamValidationError.kind gained 'request', alongside 'policy'
and 'role'. A whitespace-only roleId, scope or :id is now a 400 instead
of a grant, and a bad admin field is a 400 instead of a 500 on express, next and
nest.
A body that is not JSON is a 400 on hono and next. Both call req.json()
inside the audited handler, so a truncated upload - or a form post carrying a
JSON content-type - raised a SyntaxError out of the handler into the generic
catch, which answered 500 and handed the parse error to onError as though
the package had broken. Express and nest never saw it, because their hosts parse
the body first. New iamReadJsonBody wraps the framework's own parser; the
refusal deliberately does not repeat the parser's message, which quotes the
offending bytes into an operator's log.
Test-suite gaps closed alongside the fixes above (no runtime behaviour
change): the e2e tier now runs in CI - nineteen files and roughly seven hundred
tests previously executed in no workflow at all, because bun run test excludes
**/*.e2e.test.ts and nothing invoked test:e2e. The HTTP e2e sweep gained a
sixth listener that opts into proxy trust, because every environment.ip
assertion in that file compared five integrations that all report undefined,
and deleting the IP derivation outright left the section green. The two verdict
suites now name the verdict they expect rather than only requiring the two
engines to match. And the fast/slow evaluator delegation for throwable policies
is pinned directly: deleting it leaves the 6000-iteration property oracle green,
because on any iteration with a throwable policy the "fast path" is the
interpreter.
And the tier that CI now runs was quietly smaller than it looked. Five e2e
files decided whether to run by shelling out to docker info with a
five-second budget of their own, rather than using the shared thirty-second
probe. On a machine already running the e2e stack the daemon takes longer than
that, so the copies answered "docker is down": the redis adapter suite dropped
twenty-four cases and the invalidation failure-modes suite dropped ten, in a
run that reported 609 passed | 26 skipped and exit 0. Their own reachability
guards read the same wrong answer and agreed the skip was expected. All five
now call the shared probe, which is memoised so the gate and the guard cannot
reach different verdicts - and the guards no longer consult it at all under
CI, where the workflow provisions the backends before vitest starts and
"absent" is a broken runner rather than a reason to be quiet. The two
invalidation suites had no guard at all; the comment above one of them said
"fail loudly, not silently" next to a gate that could only do the opposite.
With the probe fixed the same command collects 706 tests and skips none.
Three harness bugs surfaced by that: assertE2eReachable awaited a
thirty-second probe inside a case with vitest's five-second default, so it
failed as a timeout on exactly the busy daemon it was written for
(scope-prod-parity red on that line with its eleven cases passing beside it);
whilePaused returned as soon as docker unpause did, without waiting for
Postgres to serve again the way whileStopped already did, so the recovery
case read a fail-closed deny as a failure to recover; and the shared compliance
matrix registers ~100 cases with no explicit timeout, which is right against
the in-memory fake and wrong against a container sharing the machine with every
other suite - one round trip took 5.88s and was reported as a failure.
A denylist with an operand typo admitted everyone, and validation was not in
front of it. nin returns true when its operand is not an array, so
allow if tier nin 'banned' - the author meant ['banned'] - satisfies the
guard for every subject it was written to exclude. The operand-type matrix in
the validator refuses that, and the package's own test said the verdict was
"unreachable only because the validator refuses the operand first". It is not:
validatePolicy runs on admin.savePolicy and admin.import, while
loadPolicies does not validate at all. Measured end to end, with no database
and nothing hand-edited - a policy seeded through the documented
IamMemoryAdapter constructor let a banned subject through engine.can. The
same door is an operator's INSERT, a seed script, a migration, or a row
written by a version that predates the rule.
The evaluator now applies the matrix itself and throws IamOperandTypeError
rather than answering, which evaluate absorbs the way it already absorbs an
unknown operator - Indeterminate, reported through onPolicyError, never
false, because answering false retires a deny rule just as quietly. The
table moved to conditions/conditions.libs beside the operators it describes
and the validator imports it, so the write-time check and the read-time check
cannot drift. Because condVal is the resolved operand, this also covers
what the validator cannot see: a $-prefixed reference is skipped there, since
its type is unknowable at authoring time, and lands here as whatever the
request actually carried. BREAKING for anyone whose store holds a policy
the validator would have refused: that policy now denies, loudly, instead of
granting.
The devtools production guard stood in front of one panel out of five.
isDevtoolsAllowed is default-block and well argued - it blocks unless both
NODE_ENV and the engine's own mode give a positive development signal - and
its docblock says it exists so "the policy/role/subject readers cannot leak
into raw-browser bundles (CWE-200 / CWE-489)". Two of those three readers never
called it. package.json publishes ./dt and src/dt/index.ts exports every
panel individually, so importing one directly is a supported thing to
do, and through that route IamPoliciesPanel renders the entire policy corpus
from engine.admin.listPolicies(), IamRolesPanel the whole role catalog, and
IamDecisionInspector answers engine.explain() for any subject, action and
resource typed into it - each blocked through IamDevtools and wide open
through its own export. IamSubjectsPanel had already been given the guard for
exactly this reason; the reasoning was never carried to its siblings.
All four panels that are handed an engine now call the guard themselves, as
IamSubjectsPanel does. IamFlowPanel and IamTraceTree are untouched: they
take a recorder and a result and never reach an engine. The "only panel that
writes" framing is also retired - IamMetricsPanel's Reset button calls
engine.stats.reset(). Guarding is now enforced by a source sweep as well as
by render tests, so a panel added later that reaches for the engine fails until
it carries the guard.
The devtools could not render outside this monorepo at all. ./dt is a
published export, and @gentleduck/registry-ui and @gentleduck/libs were
optional peer dependencies - but six devtools modules imported them
unconditionally, so import '@gentleduck/iam/dt' threw ERR_MODULE_NOT_FOUND
for any consumer who had installed the package and not those two. The modules
that did resolve still rendered wrong: their Tailwind utility classes only name
real CSS if the consumer's Tailwind is configured to scan this package's
dist, and the iam-dt-* rules read var(--card), var(--border) and
var(--foreground) out of the host theme with no fallback. None of this is
visible from inside the monorepo, where both peers are installed and Tailwind
does scan the source, which is why it survived.
The devtools now own their appearance outright. lib/styles.ts carries the
whole visual layer - every colour an --iam-dt-* token it defines itself,
every component a class it ships - injected as a single <style> tag, with a
scoped reset so a host's button {} cannot reach inside. There are two
themes: data-iam-dt-theme pins one and the new theme prop sets it,
defaulting to 'auto', which follows prefers-color-scheme. Tokens are
declared only on the outermost .iam-dt, because each panel carries the class
(each is exported individually and may be its own root) and a nested block
would re-derive the theme and overrule an explicit one. The action, resource,
allow and deny colours were hardcoded hex tuned against a dark ground and sat
at roughly 2:1 on a white one; both palettes now come from a set with worked-out
contrast in each direction. @gentleduck/registry-ui and @gentleduck/libs
are gone from peerDependencies, peerDependenciesMeta and devDependencies,
along with a @gentleduck/variants entry that was listed as an optional peer
without ever being declared or imported.
Two things that were broken rather than merely coupled: a panel imported
directly injected no stylesheet at all, since only the two shells called
ensureStylesInjected - every panel now does, through useIamDevtoolsStyles.
And the accessibility of the overlay: the tab strip is a real tablist with
arrow-key navigation (previously six buttons, five of them unreachable from the
keyboard once the strip claimed the role), Escape closes the panel and returns
focus to the launcher, opening moves focus into it, the resize edge is a
focusable separator that responds to arrow keys, Home and End, Section and
the JSON tree report aria-expanded/aria-controls, Field associates its
label with its control, filter pills report aria-pressed, alerts announce,
icons are hidden from assistive technology, and every animation stops under
prefers-reduced-motion.
Regression tests in dt/__tests__/dt-selfcontained.test.tsx: a source sweep
that fails on any import of the two optional peers, a sweep of the stylesheet
for any var() it does not define itself, a check that the token blocks stay
root-scoped, a cross-check that every iam-dt-* class the components emit has
a rule in the sheet that ships with them, and a render of each panel on its own
asserting it is its own themed root. Each has a positive control, because an
empty sweep satisfies every one of them.
5f38486: Add a second devtools build,
@gentleduck/iam/dt/v2, written on duck-ui../dtand./dt/v2are two implementations of the same six panels with
deliberately opposite dependency contracts, published side by side so a consumer picks the trade rather than inheriting it:
./dt | ./dt/v2 | |
|---|---|---|
| Styling | own stylesheet, iam-dt-* classes, --iam-dt-* tokens | duck-ui components on the host's Tailwind theme |
| Peers | none | @gentleduck/registry-ui, @gentleduck/libs, lucide-react (all optional) |
| Looks like | itself, everywhere | whatever the host app looks like |
./dt is unchanged and stays self-contained — that is the point of keeping it.
Reach for ./dt/v2 when the host already runs duck-ui and Tailwind v4 and you
want the devtool to inherit its theme; it needs one line in the host stylesheet
so Tailwind scans the shipped panels:
@source "../node_modules/@gentleduck/iam/dist/dt/v2";
v2 is a rebuild, not a re-skin. It is assembled from duck-ui's own components
rather than lookalikes — Alert, Avatar, Badge, Button, ButtonGroup,
Card, Empty, Field, Input, InputGroup, Item, Kbd, Label,
Progress, ScrollArea, Separator, Skeleton, Switch, Table, Tabs,
Textarea and Tooltip — because a div with a border is a card only the
devtool knows about, while a Card is one the host's theme can reach. The Flow
log is a real data table, cache and allow rates are real Progress bars, the
verdict filters are real Switches, and every toolbar button carries a
Tooltip. A contract test names that component set and fails if v2 stops using
one of them or hand-rolls a <table> or progress bar instead.
What is deliberately not duck-ui: a new tone system (lib/tone.ts) keeps the
five decision colours on a fixed palette while the chrome floats with the host;
tabs get arrow-key roving focus, because TabsTrigger does roving tabIndex
and no key handling; sections are hand-rolled disclosures inside a Card,
because Collapsible drives its open state through a DOM attribute and renders
closed on the server; and the dockable shell's resize edge is a real
window-splitter separator — pointer drag plus arrow/Home/End,
aria-valuemin/max/now, Escape to close and focus returned to the
launcher — which Separator (an <hr>) cannot be.
Every v2 panel calls isDevtoolsAllowed(engine) itself, because each is
exported individually; a source sweep in the v2 tests fails any future panel
that reads engine. without the guard. A contract test asserts the split from
both sides: v2 really imports duck-ui, is really outside ./dt's
self-containment sweep, and carries none of v1's styling layer.
555fa09: Refuse
'*'as the scope of a role assignment.'*'is this package's spelling of "every scope" on the scope a role or a
permission declares — IPermission.scope is typed TScope | '*', and
matchesScope, scopeCovers and effectiveScopeOf all read it as global. It
means nothing of the kind on a scoped assignment: enrichSubjectWithScopedRoles
compares the stored scope literally, so assignRole(u, r, '*') stored a row that
matched no request, while the write resolved and admin.assignRoles reported
applied: 1. getEffectiveRoles returned [].
That is the same silent success iamAssertNoAssignOptions refuses for a dropped
expiresAt and iamAssertRoleExists refuses for an unknown role id, and
assignRole's own contract already says a grant resolveSubject will drop must
throw rather than read back as success. undefined is how a global assignment is
spelled.
Lookups stay open: revokeRole, revokeRoles, the fromScope end of
updateAssignmentScope and redis's member encoder all accept '*', so rows
written before this change can still be revoked or moved off. They were dead
before it and are dead after it; nothing that worked stops working.
The check also runs in admin's pre-pass, not only in the adapters. The adapter
guard fires inside assignRoles' write loop, which would leave the batch
half-applied — the failure the pre-pass is documented to prevent.
70a90b4: Make
IamMemoryAdapter's constructor agree with its own write methods.A seeded assignment naming a role the init does not declare is now refused, with
the same message assignRole throws. It was accepted, getSubjectRoles
returned the id, and a hand-written ABAC rule keyed on subject.roles fired on
it — an allow from a role that does not exist. resolveEffectiveRoles drops a
dangling inherits id, but a directly assigned role is not that case.
getSubjectAttributes returns a copy rather than the live internal bag, and the
attributes seed copies in. Editing what a read handed back used to rewrite the
store, with no validation and nothing to invalidate a cache from. The other five
adapters already rebuild the bag through iamNarrowAttributes.
This adapter is documented "tests + prototypes only", so neither is a production grant path. Seeds that declare the roles they assign are unaffected.
Report the adapter-compliance suite's optional-method skips honestly. Nineteen
tests in runAdapterCompliance bowed out with a bare return when the adapter
under test does not implement an optional method, reporting as passed; across
the tier that was 59 green ticks asserting nothing. They now report as skipped,
and a new support matrix asserts which adapters implement which optional
methods, so dropping one turns a test red instead of turning five silent.
ebc38e8: Close the two process-global latches, and give React's
usePermissionstherefetchVue's has always had.React's
usePermissionscould not reach its own stale-map reset. The effect
deliberately empties permissions and clears error before each load, for the
sign-out and account-switch cases where the previous subject's grants must not
stay on screen. The only trigger was a change to deps — which defaults to []
and was undocumented — so usePermissions(() => fetchFor(user.id)), the obvious
call, loaded once and then answered can() from the first subject's map
indefinitely. Vue's docblock claimed the two hooks were "the same shape"; they
were not. React's now returns refetch, and carries the monotonic run id Vue
uses, which is required once refetch exists: two loads can be in flight with no
deps change and therefore no effect teardown between them, and a slow earlier one
would otherwise land on top of a fast later one. A rejection is normalised to an
Error rather than declared to be one.
IamAccessClient guarded its map on the way out and not on the way in. The
permissions getter has always returned a copy, because Readonly<…> erases at
runtime and an in-place edit would otherwise grant a permission without going
through update()/merge() — and so without notifying a subscriber. The
constructor and update() stored the caller's reference, which is the identical
hazard unguarded. Both copy now; listeners still receive the caller's own object.
The unsigned-invalidator warning is latched per channel, not per process.
tenantId exists so one process builds one invalidator per tenant, and a single
process-wide boolean meant the second and every later unsigned invalidator
constructed in silence — while the one warning that did fire named no channel, so
an operator who fixed "the" unsigned invalidator had no way to learn the other
forty were still unsigned. The channel is now named in the message, with its
tenant segment redacted the way every other line on that path is.
The broken-error-hook report is latched per hook, not per process. One
engine's transiently broken onPolicyError permanently silenced a different
engine's — a different tenant's — hook failure for the life of the process, and a
hook failure is how an operator learns that policy evaluation is throwing at all.
safeErrorReport now takes the hook and its arguments instead of a
() => hook?.(…) thunk, which gives the latch something stable to key on, keeps
the Error normalisation in one place instead of six, and stops allocating an
Error per throwing policy per request when no hook is wired at all.
e15213c: Bind the
matchesoperator to the operand-type guard it was exempt from.evalConditionthrowsIamOperandTypeErrorfor an operand whose type the
operator cannot compare against, so the condition reads as Indeterminate rather
than as "not met" — false is the permissive answer for a deny rule, and
loadPolicies does not validate, so a seeded or migrated row reaches the
evaluator exactly as authored. OPERAND_TYPES is shared with validate.libs
precisely so write-time and read-time cannot drift.
For matches they had. The operator was dispatched before the guard ran, so a
non-string or absent value fell into evalMatchesOp, failed that function's
own typeof v !== 'string' test, and answered false. A seeded
deny-when-matches rule therefore never denied, and under
defaultEffect: 'allow' the request was allowed outright. The dispatch now sits
below the guard. The refusals matches already had — a $-sourced pattern, an
uncompilable one — are unchanged.
Also: IamLRUCache.entries() used > where get() uses >=, so at an entry's
own expiry millisecond the iterator yielded a value get() refuses. The one
caller evicts rather than serves, so nothing was wrong yet; the signature
promises non-expired entries and now keeps that promise.
And explain() reported ALLOW on a request naming the reserved refusal token.
authorize() and permissions() each refuse that token before consulting any
policy - a '*' rule matches a sentinel string, so without the refusal the
ordinary wildcard admin grant allows exactly the requests the adapters mint the
token for. explain() ran the combine instead and reported what the policies
said, telling an operator investigating a refusal that it was allowed.
explainEvaluation now applies the same predicate, keeps the policy traces, and
carries the same failure: 'input' tag.
5.8.1
Minor Changes
ff6f112: Close five ways the policy builder could emit something other than what the author wrote.
A condition callback that returns a group is no longer discarded.
RuleBuilder.when(), RuleBuilder.whenAny(), RoleBuilder.grantWhen() and
PolicyBuilder.rule() used the builder they passed in and ignored the callback's
return value. The reusable-group idiom returns one, so
.when(() => sharedOwnerOrAdmin()) authored to { all: [] } - and all of
nothing is true, making the rule fire unconditionally: an allow rule granted to
everybody, a deny rule denied everybody. The returned builder is now honoured. A
callback that both chains onto its argument and returns a different builder
throws, because there is no answer to which group was meant.
Built condition groups no longer alias the builder's array. buildAll(),
buildAny() and buildNone() snapshot. Reusing a When after building used to
reach back into rules that were already finished.
When.roles(), When.scopes() and When.resourceType() refuse zero
arguments. They emitted a membership test against an empty list, which can
never match - on a deny rule that removes the guard. Pass at least one value, or
.in(field, list) when the list is computed and may legitimately be empty.
The builders emit absent optional keys, not keys holding undefined.
description, targets and version on policies, description/metadata on
rules, description/inherits/scope/metadata on roles. A key holding
undefined survived in the memory, file and http stores and disappeared through
every JSON- or jsonb-backed one, so the same authored policy read back unequal
depending on where it had been. version in particular is now left out when
unset: the version: 1 default belongs to the store, and a builder that
pre-empted it made "never set" indistinguishable from "set to 1".
Breaking: when()'s type parameters were reordered to
when<TAction, TResource, TRole, TScope, TContext, TActiveResource> so they name
the slots they fill. They previously ran TAction, TResource, TScope, TRole,
which let a scope be passed to .role() and a role to .scope(). Only callers
who pass all four explicitly are affected; inference is unchanged.
3de4298: Make the HTTP boundary's refusals actually refuse.
A wildcard rule turned every unmappable request back into an allow. The
framework adapters map an HTTP request to an (action, resource) pair, and some
requests cannot be mapped: an unmapped method, or a path this layer and the
router downstream would read differently. Both were expressed by handing the
engine a sentinel string - IAM_UNKNOWN_ACTION, IAM_UNKNOWN_RESOURCE -
documented as matching no policy and therefore denying. '*' matches every
string, sentinels included, so any deployment with a wildcard rule
(.on('*').of('*'), the ordinary shape of an admin role) allowed them. The
traversal guard that refuses to resolve /posts/../admin/secret handed the
engine type: 'unknown', an admin was allowed, and express, hono, next and nest
then routed the raw target elsewhere - authorized as one resource, served as
another.
A string cannot carry a denial, so the denial moved into the engine: the token
is reserved, and both authorize() and permissions() refuse it before
consulting any policy. permissions() needed it separately because it does not
route through authorize(). The refusal reports failure: 'input' and fires
onDeny like any other denial. Reserving the token means a resource type or
action genuinely named 'unknown' can no longer be granted; both constants have
always been documented as sentinels that deny.
A literal backslash was not treated as ambiguous, though %5C was. The
WHATWG URL parser rewrites \ to / in a special-scheme URL before resolving
dot segments, so new URL('http://x/posts\..\admin').pathname is /admin:
iamPathIsAmbiguous read the type as posts\..\admin while anything parsing
the target through URL read /admin. The encoded form was already refused;
the plain character - the easier one to send - was not.
The default CSRF check allowed cross-site requests on a capital letter.
iamDefaultCsrfCheck read exactly one spelling of Sec-Fetch-Site out of a
header Record. Node lowercases what it parses, but the predicate is exported and
documented as taking any request-like object, and not finding the header is
indistinguishable from "no header was sent" - which it treats as a non-browser
caller and allows. The lookup is now case-insensitive across all three supported
header shapes, and reads own properties only.
A throwing csrfCheck escaped the admin gate. iamRunAdminAuthz caught a
throwing authorize and reported phase: 'error', but let a throwing
csrfCheck propagate, so whether the request was refused depended on the
framework adapter's outer catch. A predicate that cannot answer has not said
yes: it is now phase: 'forbidden', and authorize is not called.
The admin audit could not name who made a mutation. The gate passed any
truthy actor straight into the audit event, and the documented shape -
authorize: (req) => req.user?.role === 'admin' - returns a boolean. So the
mutation was authorized and the audit trail recorded true as the person who
made it, which attributes it to nobody. A truthy answer still authorizes, as it
always did; a value that names no one is now recorded as no one (actor: undefined), with a one-time notice explaining how to make mutations
attributable. New iamIsNameableActor export.
iamDefaultCsrfCheck's five as casts were replaced with runtime type
predicates while fixing it.
8c131a4: Grant writes now record who made them,
engine.adminemits a typed mutation event for every write, andmodedefaults to'production'.Actor provenance.
IAssignOptionsgainsactor?: string, andrevokeRole/revokeRolesgain a matchingIRevokeOptions.savePolicy,saveRoleandsetSubjectAttributestake a newIActorOptions. Every table in the drizzle and Prisma schemas has declaredcreated_by/updated_byfor as long as it has existed and nothing ever filled them - eight columns per dialect promising a provenance the API could not produce. They are written now:created_byon the row a call inserts,updated_byon the row it overwrites, so an edit records its editor without rewriting the original author. Both are written by spread rather than as an explicit null, so a table that predates the columns is untouched unless the caller names an actor.actoris deliberately outside theASSIGN_OPTION_FIELDSallow-list thatiamAssertNoAssignOptionsguards. DroppingexpiresAtchanges what the store answers; droppingactordoes not, because the event carries it regardless.Mutation events. Wire
hooks.onMutationto receive a closed discriminated union -role.assigned,role.revoked,role.scope-changed,role.saved,role.deleted,policy.saved,policy.deleted,attributes.set- each carryingat, the optionalactor, and the subject/role/scope the write touched.The library owns the event and the actor and ships no history table: retention, redaction and GDPR erasure are compliance decisions the consuming application must own. There is no way to register your own events on this bus; the union stays closed so a
switchontypestays exhaustive across versions.Two details worth knowing:
attributes.setcarries key names and never values, because attribute bags routinely hold personal data and the event is likely headed for a durable log. And underwithTransaction, events buffer alongside invalidations and are emitted only onflush()- a rolled-back grant leaves no history.Breaking:
modenow defaults to'production'. A consumer who never set it was running a production authorization engine that allocated a richDecisionon every call. Three consequences:can()/check()returnbooleanrather thanIDecisionunless you opt in;policyCombine: 'first-applicable'now throws at construction, since the production fast path cannot represent it; and devtools, which refuses a production engine, is now off unless you setmode: 'development'.Explicit type arguments need a fifth. TypeScript uses a type parameter's default rather than inferring it when the argument list is partial, so
new IamEngine<Action, Resource, Role, Scope>({ ..., mode: 'development' })no longer typechecks. Writenew IamEngine<Action, Resource, Role, Scope, 'development'>(...), or drop the explicit arguments and let all five infer.afterEvaluateandonDenynow fire in production too. They were documented and implemented as development-only, which put the audit and alerting hooks in the mode nobody runs in production. The production decision is verdict-only -allowed,effect,duration,timestamp, and areasonthat says so - because the compiled table erases policy identity at compile time and that erasure is the optimisation.policyandruleare absent rather than fabricated. Nothing is allocated unless one of the two hooks is wired.maxConcurrentSubjectLoadsdefaults to512instead of0(unbounded). Only distinct, never-before-cached subjects count toward it, so reaching 512 means a cold-start herd rather than normal traffic. Shedding denies legitimate requests, so the bound is deliberately generous; set0to restore unbounded explicitly.
Also:
IamEngine.setInvalidator(invalidator | null)attaches, replaces or detaches an invalidator after construction, for callers whose Redis client is built after their engine. It unsubscribes the previous one first and validates the shape, so a malformed object fails loudly instead of surfacing later as invalidations that never arrive.IConfig.invalidatorstill works and now routes through it.iamScopeAncestorsandiamScopeCoversare exported. Callers doing scope-aware rank or reach calculations had to reimplement the walk, and any reimplementation drifts from the relation the engine matches with.- The drizzle adapter warns once per process, at construction, when
ops.isNullorops.oris missing. Both omissions are correct but silently slow - withoutor,revokeRoleManydegrades to oneDELETEper row; withoutisNull,updateAssignmentScopefalls back to revoke + assign, two writes with a window where the grant does not exist. Neither can be derived:eq(col, null)is notIS NULL, and there is no way to synthesise anORbuilder fromeqandand.
Production and development now agree on two cases where they did not. Both were found by running the same catalog through a production engine and a development engine and comparing verdicts; both had production granting access development refused, on identical data, with no malformed catalog anywhere.
- A throwing role permission no longer voids unrelated grants.
rolesToPolicyfolds every role permission into one generated__rbac__policy, and the interpreter caught a throwing condition at whole-policy scope - so one rotten permission denied a subject an unconditional grant held through a different role, while the compiled table answered allow from the grant mask before it ever reached the bad condition. A rule that throws inside that generated union now abstains and is reported throughonPolicyError, matching the table. This is safe only there:rolesToPolicyemitseffect: 'allow'and nothing else, so a skipped rule in an allow-only union can cost a subject a grant but can never suppress a denial. An authored policy still fails closed at whole-policy scope, because its vote may have been a deny. - Role permission conditions get the same nesting budget in both modes.
rolesToPolicynests a permission's condition group one level inside the generated rule, so the interpreter reached it at depth 1 while the compiled table, which stores the group raw, started it at 0. Authors got ten usable levels in production and nine in development: at exactlyMAX_CONDITION_DEPTHthe table allowed and the interpreter denied, while depths on either side agreed.validateRolealready validated at depth 1, so the table was the outlier; it now starts there too, via the new exportedIAM_RBAC_CONDITION_DEPTH.
Schema fixes. The three drizzle schemas are three hand-written files describing one logical schema and nothing compared them, so they had drifted:
- MySQL's
iam_roles.inheritswasNOT NULLwith no default, while Postgres and SQLite default it to[]. The same insert succeeded on two dialects and failed on the third. ch_iam_roles_scope_not_blankandch_iam_assignments_scope_not_blankexisted only on Postgres, so a whitespace-only scope was storable on MySQL and SQLite and rejected on Postgres.- MySQL lacked
idx_iam_assignments_subject_scope, leaving the scoped-subject lookup - the hot read - to a subject scan plus a filter. It is added unfiltered there, MySQL having no partial indexes.
A new schema-parity.test.ts compares columns, insert-required columns, indexes, CHECKs and foreign keys across all three dialects, with an explicit allow-list for the handful of entries a dialect genuinely owns alone (Postgres GIN indexes, SQLite's algorithm CHECK standing in for the others' enum type). It also pins src/test/pg-e2e-schema.sql - self-described as a hand-kept mirror, with nothing keeping it honest - against the Postgres schema module.
The Prisma reference schema gains the provenance columns, updated_at on assignments, created_at on subject attributes, and the role / (subject, scope) indexes the drizzle schemas already had. Its header now spells out what Prisma cannot express - CHECK constraints, partial indexes - and, importantly, that @@unique([subjectId, roleId, scope]) does not prevent a duplicate unscoped grant, because SQL unique indexes do not collapse NULLs. The adapter closes that hole with a read-then-write; the comment exists so nobody "simplifies" it back to a bare create.
Breaking: a role must exist before it can be granted. assignRole (and assignRoles) now throws when no role is stored under the given id, on every adapter. This was already true on drizzle and Prisma, whose schemas carry the assignments-to-roles foreign key, and silently accepted on memory, file, redis and HTTP: the row landed, getSubjectRoles returned the id, and resolveSubject dropped it again because no definition resolves — so a typo'd role id reported success and granted nothing. Provisioning code that assigns before saving the role must now save first. The drizzle adapter also translates the driver's foreign-key error into that same refusal, keeping the original as cause; before, an operator saw Failed query: <sql> with the constraint reachable only on .cause.
Breaking: the unique indexes on policy and role name are gone. uq_iam_policies_name and uq_iam_roles_name_scope existed only on the drizzle Postgres schema. Nothing in this package resolves a policy or role by name — id is the key everywhere — so they protected a field no code reads while making a write the adapter contract mandates impossible on Postgres alone. Existing databases keep them until you drop them; nothing in the library depends on either behaviour.
Breaking: iamExtractEnvironment no longer guesses the client IP. It reported one from req.ip, x-forwarded-for or x-real-ip, which meant five HTTP integrations gave five different answers for the same request, and — with nothing in front of the app — let a client set X-Forwarded-For and satisfy an IP-conditioned policy. environment.ip is now undefined unless the app opts in: pass { trustProxy: true } as the second argument, or supply the environment yourself through the integration's getEnvironment. Hono's trustCloudflareHeaders is that opt-in there. The normalisation is unchanged behind the flag.
Breaking: deleting a role now revokes the grants that named it. deleteRole is a cascade on every adapter, matching what fk_iam_assignments_role ON DELETE CASCADE already did on drizzle and Prisma: the same call left getSubjectRoles returning the deleted role on memory, file, redis and HTTP and [] on the SQL adapters. The orphan was not inert — resolveSubject ignored it, but recreating a role under the reused id handed it back to everyone who once held it, with no operator granting anything, and assignRole now refuses to create the very row deleteRole was leaving behind. The redis adapter reaches the assignment sets with a KEYS sweep on that one admin-rate call; a client that does not expose keys gets the role deleted and a report through onPolicyError saying the grants were not. An HTTP server is expected to cascade on DELETE /roles/:id — see the reference server in http-compliance.test.ts.
70a9e4d: A malformed admin request body now answers 400 instead of 500.
engine.admin.savePolicyandsaveRolevalidate before they write, and signalled
a rejection with a bare Error. Every HTTP integration catches whatever a handler
throws and routes it to onError, which answers 500 — so a body the validator
refused, which is the client's mistake, was reported as the server's. The write
was correctly refused either way, so this was never a way past validation; but a
500 tells a caller to retry a request that can never succeed, and hides a client
bug behind an apparent outage.
Rejections are now IamValidationError, carrying kind ('policy' | 'role'),
the validator's issues, and status: 400. It extends Error, so existing
instanceof Error checks and the exact message text still hold.
- express, hono and next answer
400 { error: 'Invalid policy', issues: [...] }and no longer route the failure throughonError. - Nest hands errors to its own exception filter, which only maps
HttpException, and this package does not depend on@nestjs/common. Readstatusin a filter of your own:
if (iamIsValidationError(err))
throw new BadRequestException({
error: `Invalid ${err.kind}`,
issues: err.issues,
});
A genuine server fault is still a 500, and the onAdminMutation audit event
still fires with success: false either way.
3de4298: The Prisma adapter no longer invents keys a stored role does not have.
toRolemapped an absent or nullinheritscolumn toinherits: [], so a role
read back through Prisma was not the role the other adapters returned for the same row — a caller distinguishing "inherits nothing" from "does not declare inheritance" saw the two collapse, and only on this adapter. Absent columns now produce absent keys, matching the memory, Drizzle and Postgres adapters.
If you relied on role.inherits always being an array, read it as
role.inherits ?? [].
Both engine modes now evaluate through the compiled table.
mode: 'production'used the compiled table andmode: 'development'used the interpreter, so a disagreement between the two was invisible until it reached production - and it reached production as an allow against a development run that denied. That single shape accounted for five separate defects in the round-3 audit.The table now produces the verdict in both modes. Development additionally runs the interpreter, because the table cannot explain itself:
CONST_ALLOW/CONST_DENYcells are onekindbyte andallowis a raw bitmask, so policy identity is erased at compile time. The interpreter suppliesreason/policy/rule, the table supplies the verdict, and a disagreement is printed and thrown - a development-time failure instead of a production-only allow.check()returns the sameIDecisionit always did.Development is roughly 2.4x slower than production as a result. That is the trade: the second evaluator is what makes a divergence visible, and it does not run in production.
More roles than the 32-bit grant mask can address now falls back to the interpreter instead of denying every request.
compileTable()throws past 32 roles because role N and role N+32 would otherwise silently share a mask bit. That throw used to reachauthorize()'s catch, so the whole deployment answered deny with no message unless anonErrorhook happened to be wired - a total authorization outage caused by a capacity limit of one representation.The engine now catches that error specifically, warns once, and runs the interpreter, which has no such limit.
preload()resolves instead of throwing andhealthCheck()keepsok: truewhile reportingcompiledTable: { available: false, reason: 'role-limit-exceeded', roleCount, limit }- correct answers, no fast path, and an operator who can see which.Every other compile failure still throws and still denies; a malformed policy is a bug, and answering it with a slower correct path would hide it.
Patch Changes
A policy the compiler cannot lower is now named.
The compiler walks
policy.rulesand each rule'sactions/resourcesdirectly, so a policy missing one of them threw from wherever the walk touched it first -policy.rules is not iterable, with no indication which of a tenant's policies was broken. The interpreter had always isolated a rotten policy and reported its id throughonPolicyError, so moving both modes onto the table would have traded a precise diagnostic for an anonymous one. The shape check now runs before the walk, names the policy and the rule, and is forwarded toonPolicyErrorbefore the error is rethrown. The deny is unchanged.e47efb9: Time-boxed grants stop granting when they expire, not up to a
cacheTTLlater.The subject cache now respects the grant's own window.
IamLRUCachegave every entry the engine's fullcacheTTL(60s by default) with nothing tying it to the bounds the drizzle adapter filters on, so a grant withexpiresAtkept answering allow for up to a minute after it ended, and a grant with a futurestartsAtkept answering deny for up to a minute after it opened. A 30-second break-glass grant was live for sixty.IamLRUCache.settakes an optionalnotAfter, caps the entry at the earlier of the two, stores nothing when the bound has already passed, and expires on>=so it agrees with the adapter's exclusive upper bound.New optional adapter method:
getSubjectGrantBoundary(subjectId, opts?). Returns the earliest futurestartsAtorexpiresAtamong the subject's grants, ornullwhen none has a bound. The engine asks for it alongside the reads it describes — no extra round trip — and caps the cache entry there. It is implemented by the drizzle adapter, the only one that stores the bounds; the other five omit it and continue to refuse the options outright. Custom adapters need no change, and gain the shortened cache by implementing it. A failure in the method costs caching only: the subject is not cached, the answer still comes from the store, and a warning names the method and subject.assignRolerefuses a window that can never be active.startsAt >= expiresAtis an empty interval — the grant is stored and never live, while the call resolves andengine.admin.assignRolesreportsok: true, applied: 1. AnInvalid Datein either field was passed to the driver rather than refused. Both are now rejected at the adapter boundary, onassignRoleand on every row ofassignRoleMany; the error names the fields and never the instants. The shipped pg/mysql/sqlite schemas already carriedch_iam_assignments_starts_before_expires, but the adapter can be pointed at a caller's own table, where the code is the only guard.Three fixes from the audit re-verification sweep.
An error-reporting hook that throws no longer unwinds the evaluation it was
reporting on. onPolicyError and onRuleError were called raw from inside the
catch that implements the Indeterminate contract, so a hook whose metrics
backend was down took the deny vote with it.
The Redis invalidator's drop warning no longer prints the tenant id. The channel carries it, the warning is written on a path any holder of PUBLISH rights can drive, and stderr is the stream that gets shipped to shared aggregators; the tenant segment is now a stable digest instead.
explain() and can() agree at the leaf. The trace evaluated conditions
through the raw operator table, which skips the refusal of matches against a
$-sourced operand, so a trace could report a condition as satisfied that the
engine had refused - showing an operator the opposite of the decision they were
debugging. Leaf verdicts now come from the decision path itself.
5.8.0
Minor Changes
7202aa1: Breaking: scoped permission keys are now prefixed with
@.iamBuildPermissionKeypreviously emittedscope:action:resource[:resourceId], which is ambiguous with the unscopedaction:resource:resourceIdform - a three-segment key could not be parsed back without knowing which shape produced it, so a scope could be read as a resource id and vice versa. Scoped keys now carry an explicit@marker:org-1:manage:billingbecomes@org-1:manage:billingorg-1:update:post:post-42becomes@org-1:update:post:post-42
Unscoped keys are unchanged. A literal leading @ in a segment is escaped as \@, and iamSplitPermissionKey understands the escape. The new iamParsePermissionKey returns { scope, action, resource, resourceId } or null, so consumers no longer have to split keys by hand.
Anything that hardcodes a scoped key string - client-side permission maps, cached can() results keyed by string, fixtures - needs the @ added.
Patch Changes
2e5f5dc: Fix
resolveSubjectmistagging a role reached through cross-scope inheritance with the source assignment's scope instead of the role's own declared scope. A role assigned at one scope that.inherits()a role defined at another scope (e.g. a company-level role inheriting a marketplace-level role) had the inherited role silently invisible at the scope it actually belongs to -enrichSubjectWithScopedRolesfiltered it out because it carried the wrong scope tag, so a policy check at the inherited-into scope only ever saw whatever lower-privilege role was directly assigned there, if any.Each role visited during the inheritance walk is now tagged with its own
IRole.scope, falling back to the assignment row's scope only when the role declares none of its own - a no-op for roles with no cross-scope inheritance.5c96bd5: Fix two places where a rule that was correct in development silently stopped applying in production.
first-match/highest-prioritydisagreed between the two evaluation paths on a priority tie. Both algorithms resolve equal priorities by source order. The interpreter walkspolicy.rulesdirectly and honoured that, butevaluatePolicyFastwalks the rule index, which buckets literal-resource rules separately from wildcard-resource ones and visits the literal bucket first. Adeny read '*'declared before anallow read 'post'at the same priority therefore denied undermode: 'development'and allowed undermode: 'production'- the deny disappeared exactly where it mattered most.Evaluate.IIndexedRulenow carries the rule's index inpolicy.rules, and both tie-break sites in the fast path compare it, so bucket order no longer leaks into the decision.deny-overridesandallow-overrideswere never affected; they are order independent.The
evaluate == evaluateFastproperty oracle covered this shape in principle but drew priorities from 20 values, making ties too rare to hit it. It now draws from 4, so collisions are the common case.The condition-nesting limit was off by one between the validator and the evaluator.
evalConditionGrouprefuses a group atdepth >= MAX_CONDITION_DEPTHand fails closed, whilevalidateConditionGrouponly errored atdepth > MAX_CONDITION_DEPTH. A group nested exactly at the boundary therefore validated cleanly and then never matched. On an allow rule that merely failed closed, but a deny rule at that depth passed validation and silently stopped denying. Both comparisons are now>=, so anything the evaluator will refuse is reported asLIMIT_EXCEEDEDup front.ec678af: Evaluation errors are now Indeterminate and fail closed instead of being silently skipped.
A condition that threw - an unknown operator, a malformed
all/any/nonegroup, a regex rejected for being unsafe - was caught and treated as "policy does not apply", which quietly retired the deny rule that condition was guarding. An error inside a policy or compiled cell that carries a deny rule now vetoes the request. Allow-only policies and RBAC role permissions still skip, since an error there cannot grant anything.Also in this release:
matchesuses a real catastrophic-backtracking detector instead of a naive nested-quantifier regex, and checks the compiled-pattern cache before running it.Unknown condition operators and non-array condition groups throw a prefixed error rather than a raw
TypeError.HTTP method-to-action and pathname normalisation reject
//adminand/%61dminstyle bypasses; unknown methods and unresolvable resources map to explicitIAM_UNKNOWN_ACTION/IAM_UNKNOWN_RESOURCEinstead of a permissive default.User-Agent is capped at 2048 characters before it reaches the environment attributes.
The file adapter writes atomically via tmp-file + rename and serialises concurrent flushes; the redis adapter rejects an empty scope; the drizzle adapter rejects non-finite assignment bounds.
preload()builds the compiled table in production so a compile error surfaces at startup, andhealthCheck()awaits it.pathCacheis no longer exported fromsrc/core/resolve.7a1ce88:
IamEngine.withTransaction(client)binds reads and writes to a transaction you own, andadmingained batch forms that report per-row outcomes.The client is opaque to the library and handed straight back to your adapter. Writes go through
.admin, the same interface asengine.admin, so there is one write surface rather than two. Reads on the bound view run against caches created for that transaction alone: an empty cache always misses through to the transaction-bound adapter, which is what makes a read-after-write inside the transaction correct, and the shared engine keeps answering from its own warm caches, which the transaction never pollutes.Cache invalidations - including the
config.invalidatorfleet broadcast - buffer inpending, de-duplicated, and fire onflush(). A rolled-back grant therefore never evicts another node's cache for a write that did not happen. The bound view drops the invalidator rather than reusing the parent's, so a facade built per transaction cannot leak a subscription; buffered entries broadcast through the parent engine on flush.Also in this release:
admingainedassignRoles,revokeRoles,moveRoleScopesandinvalidateSubjects. Every row is validated before any is written, so a malformed row aborts the batch instead of half-applying it, and each affected subject is invalidated once however many rows named it.Each outcome carries the
rowit answers rather than an id derived from it. A role assignment is identified by a(subject, role, scope)triple of free-form strings, and every encoding of three of those into one key is either ambiguous or unreadable - joining them with a space collided whenever an id contained one, and nothing rejects a space in a subject id. Outcomes stay in input order, so matching by index is exact too.Both role writes are idempotent, so every row is
ok- granting a role a subject already holds is success, matching the single-row method. Outcomes carrychangedfor the finer answer:truewhen the row accounts for a write the statement made,falsewhen it was already in the requested state, and absent where the adapter could not say. It is read off aRETURNINGclause on the write itself, so it costs no extra round trip; MySQL, which has noRETURNING, and adapters that loop thevoid-returning single-row methods leave it off rather than guess.Every write is credited to exactly one row, the first that accounts for it. Listing the same triple twice reports
truethenfalserather than both rows claiming a write the database made once, and revoking a role unscoped alongside a scoped row it already covers credits the wildcard. Neither batch is rejected.The drizzle adapter collapses the writes into one
INSERTand oneDELETE. TheDELETEneedsorin the adapter'sopsand revokes row by row without it.The adapter contract gained an optional
withClient. An adapter that cannot join a transaction makeswithTransactionthrow rather than silently leaving those writes outside it.
5.7.0
Minor Changes
62e3d0b: Add an optional
IConfig.maxConcurrentSubjectLoadscap (default0= unbounded, matchingadapterTimeoutMs's 0-disables convention) to bound the cold-flat herd described inSCALING.mdsection 8.resolveSubjectrejects a new subject load onceinFlight.subjects.sizehits the cap, before touching the adapter - fail-closed load-shed, not a bounded queue, consistent with the engine's existing fail-closed posture. The rejection is a plainErrorwhose message contains"subject load shed", so it surfaces throughcan/check/authorize's existing fail-closedcatch -> onErrorpath with no new wiring.A call that hits the subject cache or joins an already-in-flight load for the same
subject never counts against the cap.
5.6.0
Minor Changes
iam_assignmentsgainsstarts_at,expires_at, andattributescolumns across the pg, mysql, and sqlite drizzle schemas.getSubjectRoles/getSubjectScopedRolesnow filter out assignments outside their[startsAt, expiresAt)window; a row with both NULL behaves exactly as before these columns existed.IScopedRolegains an optionalattributesfield, populated from the new column so
a policy condition can read per-grant data (department, region, whatever the caller
stores) as subject.scopedRoles[].attributes, distinct from the subject's own global
attributes. A corrupted attributes value drops just that field and reports through
onPolicyError; it does not fail the whole role.
ISubjectStore.assignRole gains an optional fourth opts: IamAdapter.IAssignOptions
parameter (startsAt / expiresAt / attributes), implemented by the drizzle adapter.
Purely additive - every other adapter (memory, file, redis, prisma, http) still
satisfies the interface unchanged.
5.5.1
Patch Changes
86b6775: Drop
deletedAtfromiamPolicies,iamRoles,iamAssignments, andiamSubjectAttrs, added in 5.5.0, along withIamDrizzleAdapter's opt-indeletedAt IS NULLread filtering.deletePolicy/deleteRole/revokeRoleare hard-delete by explicit design (a soft-deleted policy/role name couldn't be reused, and a revoked assignment has no reason to be retained), and subject attributes have no delete operation at all - none of these columns would ever have been set by anything in this codebase.ops.isNullstays onIamDrizzleAdapter's config:updateAssignmentScopestill
needs it to match a global (unscoped) assignment correctly, independent of the removed soft-delete filtering.
5.5.0
Minor Changes
2853214: Add
engine.admin.updateAssignmentScope(subjectId, roleId, fromScope, toScope, actor?)to move a role assignment to a different scope in one write instead of revoke + assign.IamAdapter.ISubjectStoregains an optionalupdateAssignmentScope. When an adapter
implements it, the engine uses it directly; when it doesn't (or it returns false
because nothing matched fromScope), the engine transparently falls back to
revoke + assign, so the call always succeeds either way.
Implemented for memory, file, prisma, and drizzle. drizzle additionally needs
ops.isNull configured (matching deletedAt filtering) to match the global/unscoped
case correctly; without it, updateAssignmentScope returns false and the engine falls
back automatically. Not implemented for redis (scope is encoded into the Set member
itself, so there's no cheaper path than remove + add) or http (would need a new
endpoint on the operator's server) - both already work correctly via the fallback.
iamAssignments gains updatedAt / updatedBy in the drizzle schema (pg/mysql/sqlite)
to support this - the only table getting them in this release, since it's now the only
one with a real update path that didn't already have them.
Patch Changes
2853214: Round out audit columns on the drizzle schema (pg/mysql/sqlite):
iamPoliciesandiamRolesgaindeletedAt;iamSubjectAttrsgains thecreatedByit was missing (it already hadupdatedBy) plusdeletedAt.iamAssignments' own audit columns are covered separately, alongside the newupdateAssignmentScopefeature that needs them.IamDrizzleAdapter'sopsconfig gains an optionalisNulloperator. When
provided, listPolicies / getPolicy / listRoles / getRole / getSubjectRoles /
getSubjectScopedRoles / getSubjectAttributes exclude rows with deletedAt set;
omitted (the default, matching every version before this column existed), reads are
unchanged. deletePolicy / deleteRole / revokeRole still hard-delete on purpose -
turning them into soft-deletes would break the unique-name constraint on policies and
roles (a "deleted" name couldn't be reused) and orphan the FK cascade from
iamAssignments. The column is a hook for something outside the adapter to set
(an admin tool, a trigger), not something this adapter writes itself.
2853214: Fix scoped role assignments not resolving inherited roles.
resolveSubjectclosedsubject.rolesoverinheritsbut passedsubject.scopedRolesthrough unresolved, so a condition readingsubject.scopedRolessaw only the directly assigned role and not what it inherits, while the exact same role assigned without a scope resolved correctly. Scoped roles now go through the same inheritance closure.IamClientalso gainsPartialPermissionMap, the typeengine.permissions()
actually returns (only the checked keys, not every possible combination). The React
client's usePermissions / createIamPermissionChecker / IContextValue.permissions
now use it instead of the full PermissionMap, matching what callers really have.
iamBuildPermissionKey is also re-exported from the React entry so a consumer
building a key by hand doesn't need a second import from core.
5.4.2
Patch Changes
- 4d956c8: No functional change. Version bump to resync with the registry after 5.4.1 was published without its git history being committed.
5.4.1
Patch Changes
959a8a4: Restructure the drizzle adapter's schema exports into per-dialect folders, matching
@gentleduck/auth's layout.@gentleduck/iam/adapters/drizzle/schema/{pg,mysql,sqlite}is now
@gentleduck/iam/adapters/drizzle/{pg,mysql,sqlite}. Each folder also exports a
{Pg,Mysql,Sqlite} types namespace (PolicyRow, RoleRow, AssignmentRow, AttrRow)
inferred from that dialect's schema, so a consumer pinned to one dialect no longer needs
to import the adapter's cross-dialect union types to get a concrete row shape.
Update imports from @gentleduck/iam/adapters/drizzle/schema/pg (etc.) to
@gentleduck/iam/adapters/drizzle/pg (etc.).
5.4.0
Minor Changes
39aaa82: Export the factories, and finish the barrels.
The previous release gave every publicly constructed class a factory function, but
several were never exported, so new remained the only reachable spelling for
AnomalyFacet, HijackFacet, WebhookDeliverer, MemoryPasskeyChallengeStore,
AuthMemoryDeviceFingerprintStore, DPoPVerifier, the data-at-rest providers, the
password hashers, and the api-key / magic-link / passkey / saml / passwords impls.
The channels barrel exported one type and nothing else, so every channel had to be
imported by deep path. All six ship from @gentleduck/auth/channels now. The anomaly
barrel likewise omitted both detectors and the fingerprint store, which meant the
detectors could not be registered without reaching past it.
On the IAM side, iamEngine and iamLRUCache are exported alongside their classes.
BREAKING: three aliases in the @gentleduck/auth root now name the factory rather than
the class, so new on them stops compiling.
AuthBackupCodesFacetis nowbackupCodesFacet; the class isBackupCodesFacet.AuthInMemoryEventsis nowinMemoryEvents; the class isInMemoryEvents.
Both classes are exported under their own names, so new BackupCodesFacet(...) and
new InMemoryEvents() are the mechanical fix.
196f52d: Reject a policy whose target names a pair no allow rule covers, instead of warning.
UNREACHABLE_TARGETwas a warning, soPolicyBuilder.build()accepted the policy and
the only symptom was a denial at request time. A denial reads as the permission system working, which is why this cost five separate incidents to recognise: widening a target is one line and widening the rules is another, nothing couples them, and the drift is silent.
It is now an error, so build() throws where the policy is written.
Two supporting fixes:
- The check treated a dimension the target omits as a literal
*, which demanded that every rule be a wildcard. A target naming onlyimpersonatewas reported unreachable because its allow rule named.of('users'). An omitted dimension is one the target does not constrain, so only the dimensions it names are checked. PolicyBuilder.build()dropped the validator's message and reported only the code and path, so every build failure was cryptic. It now includes the message and the policy id.
Also re-enables the two drizzle adapter suites, commented out wholesale in f3f57cb8
"pending rename follow-up" that never landed. IamDrizzle.IConfig had gained
<TDb, TType> in that rename and the suites still referenced it bare. 62 tests back,
and they are not decorative: removing the JSONB shape guard, silencing onPolicyError,
and dropping the WHERE from the single-row lookup are each caught.
BREAKING: a policy with an unreachable target now throws at build time rather than loading with a silent denial.
Patch Changes
Make the not-blank check constraints reject whitespace.
length(trim(x)) > 0only strips ordinary spaces, so a name, subject id, scope or
credential secret consisting of a tab, a newline or a form feed passed the check that
exists to refuse exactly that. Seven constraints were affected:
auth_credentials.secret, iam_assignments.subject_id and .scope, iam_policies.name,
iam_roles.name and .scope, iam_subject_attrs.subject_id.
Each dialect gets the strongest form it has. Postgres and MySQL match a non-whitespace
character (~ '[^[:space:]]' and REGEXP '[^[:space:]]'); SQLite has no regexp operator
built in, so it trims the whitespace set explicitly and compares against the empty string.
All three were verified against a real server for a tab, a newline, a carriage return, a
vertical tab, a form feed and a plain space.
Existing databases need a migration: drop each constraint and add it back in the new form.
Any row already holding a whitespace-only value has to be repaired first, or the
ALTER TABLE is refused.
Close scoped role assignments over
inherits, the way direct assignments already were.resolveSubjectranresolveEffectiveRolesover the roles returned bygetSubjectRoles
and passed getSubjectScopedRoles through untouched. A deployment that scopes every
assignment therefore had an empty subject.roles and a flat scoped set, so a condition
reading subject.roles saw the assigned role and none of the roles it inherits.
The effect was silent and direction-dependent: RBAC permission resolution walks inherits
separately, so a superadmin still had every permission its parents grant, while
w.role(...), w.roles(...) and w.contains('subject.roles', ...) behaved as if the
hierarchy did not exist. The same policy then decided differently depending on whether the
assignment carried a scope, which is not something the API hints at.
Scoped roles now expand through the same closure, each inherited role keeping the scope of the assignment it came from.
5.3.0
Minor Changes
Warn when a policy target names an action/resource pair no rule can allow.
evaluatePolicyfoldsdefaultEffectwhen a policy's target matches and none of its
rules do, and that default is deny. A target therefore widens what a policy refuses,
not only what it inspects: adding a resource to .target({ resources: [...] }) without
an allow rule covering it denies every caller for that resource, and the refusal
surfaces far from the policy that caused it.
validatePolicy now emits an UNREACHABLE_TARGET warning per uncovered pair. It fires
only once the policy contains at least one allow rule, so a purely restrictive policy -
where denying everything the target names is the whole point - is untouched.
Warning rather than error: the behaviour is correct deny-by-default and existing policies that rely on it keep validating.
5.2.0
Minor Changes
bc8a9ea: Prefix the drizzle tables and constraints with
iam_, and let the Nest access guard contribute resource attributes.Renamed tables and constraints. The physical tables move from the
access_* prefix to iam_* (access_policies becomes iam_policies,
access_roles becomes iam_roles), along with every derived pk_, uq_, idx_
and ch_ identifier, in the mysql, pg and sqlite schema builders. This
makes the schema attributable to this package once merged into a host
application's database.
Existing databases need a migration renaming those tables and their constraints. New installations are unaffected.
getResourceAttributes on iamNestAccessGuard. An optional hook that
computes the attributes attached to resource.attributes before
engine.can() runs. It receives the resolved { action, resource } alongside
the request, because the correct attributes are resource-specific: a users
row is its own subject, whereas an iamAssignments row carries its subject in
a column. Passing the resolved pair means callers do not have to re-derive
which case they are in from the raw request.
Known gap: the drizzle adapter and native-attr-shape test suites (47 cases) are temporarily disabled while their mock table references are reworked for the rename.
5.1.0
Minor Changes
- Restructure core into
engine/andconfig/subfolders matching duck-iam patterns. RenamedefineAuthtocreateAuthas primary entry point. ExtractAuthEngineTypesandAuthDefineinto dedicated types files. AddAuthprefix to all public classes.
5.0.1
Patch Changes
- fix: strip redundant iam/auth prefixes from public exports
5.0.0
Major Changes
- Prefix all public exports with package namespace (
Auth*/Iam*/IAM_*/AUTH_*) so the origin is clear at the type level when both packages are imported together. This is a breaking change - all consumers must update import references to the new names.
4.0.0
Major Changes
a5fb285: Rename the policy builder factory to
definePolicy, matchingdefineRuleanddefineRole.BREAKING: the
policy()factory andaccess.policy()method are removed. Use
definePolicy() and access.definePolicy() instead - the builder API is
otherwise unchanged.
3.2.0
Minor Changes
f77fb5a: Harden and type the Drizzle adapter schemas (pg, mysql, sqlite).
- Add
json: 'native' | 'string'adapter option.'native'(default) writes plain objects tojsonb/jsoncolumns so payloads stay queryable;'string'JSON-stringifies for SQLite/text columns. The read path accepts both, so switching is migration-safe. - Type every JSON column with
$type<>()against theAccessControltypes; constrainalgorithmwith a Postgres enum, a MySQL enum, and a SQLite CHECK. - Add CHECK constraints (non-blank name/subject,
version >= 1),created_by/updated_byaudit columns, GIN indexes (pg), partial indexes for scoped rows (pg/sqlite), and aroleIdindex. - Collapse NULL scopes in unique constraints (
NULLS NOT DISTINCTon pg,COALESCE(scope, '')on mysql/sqlite) so duplicate global rows are rejected. - Name every constraint (
pk_,fk_,uq_,idx_,ch_).
Fixes: pg
inheritswastext[]but the shared adapter writes JSON, so it is now- Add
jsonb; the MySQL timestamp default was a static import-time snapshot and is now
per-row CURRENT_TIMESTAMP(3).
Migration note: regenerate migrations with drizzle-kit generate. SQLite users must
pass json: 'string'.
3.1.0
Minor Changes
- e5fc356: Engine structure cleanup + adapter validation hardening.
New exports
parsePolicyRow/parseRoleRowfrom@gentleduck/iam/core/validate. Helpers for custom-adapter authors: take anunknownrow, return the typedAccessControl.IPolicy<...>/AccessControl.IRole<...>when structurally valid, ornullto drop the row. Replaces the pattern of callingvalidatePolicy(row)then castingrow as IPolicy<...>.
Internal refactors (no public API change)
engine.tssplit into five single-purpose modules undercore/engine/:engine.invalidation.ts- cross-instance + in-flight cache invalidationengine.loaders.ts- cache-fronted loaders with single-flight coalescing + adapter timeout + max-row guardsengine.hooks.ts- safe hook calls + metrics emission with throw-swallowingengine.lifecycle.ts- preload / health-check / disposeengine.stats.ts- snapshot / reset / hit-rate aggregation
- File, Redis, Drizzle, and Prisma adapters now route every row-decode path through
parsePolicyRow/parseRoleRowinstead of bareascasts. Prisma'slistPolicies/getPolicy/listRoles/getRolenow actually validate before returning - this was a latent gap. core/explainis now lazy-loaded byengine.explain()via dynamicimport(). Production-mode bundles drop the explain chunk entirely.
Tests
- 50 new direct unit tests for the extracted engine helpers (invalidation, hooks, stats, lifecycle, loaders). The class-method shims are proved to delegate to the extracted free functions, not just rename.
Documentation
AUDIT-RESULTS.mdchecked in. 0 runtime advisories.- The two reported workspace-level vulnerabilities affecting
@gentleduck/iamare both inrole-acl(a benchmark competitor indevDependenciesonly); never installed by consumers.
Migration
None required. All changes are additive or internal.
3.0.1
Patch Changes
1f5ac74: @gentleduck/auth: end-to-end input + tenant + config-time hardening sweep.
- Provider entry-point caps + typeof guards (api-key, magic-link, oauth, passkey,
password, saml). Magic-link
callbackPathvalidated at construction (refuses protocol-relative + CR/LF). OAuthredirectUri+ endpoint URLs validated. SAMLrelayState+hostCR/LF guard. - Facet input caps (flows, sessions, mfa, apikeys, identities, idempotency).
isProviderIdSafeguard insignIn/beginProvider. CAS-claim on recovery + signup. Email canonicalization (trim().toLowerCase()) shared between rate-limit + lookup + stored metadata. - Transport hardening: 4 KB bearer cap, 8 KB DPoP cap, 16 KB cookie-header cap, cookie
name RFC 6265 validation. JWT
signKey.kid+signKey.keyvalidation.Number.isFiniteon iat / nonce / counter rollback.timingSafeEqualonath+nonce. - Adapter parity: memory adapter
findByHashedSecretrespectsctx.tenantId(was searching globally) + usesisRevokedpredicate.upsertinheritstenantIdfrom ctx. Redis adapter caps key length + clamps NaN/huge ttl. SQL adapters parameterize JSONB queries. AuthRoot.strict: refusehttp://baseUrl in production.- Webhooks:
redirect: 'error'SSRF, 1 MiB payload cap, 20-attempt backoff cap, NaN-timestamp rejection. - New
@gentleduck/auth/server/{fastify,koa,nestjs,elysia,grpc}adapters. - New providers: SAML 2.0 SP, Microsoft, Discord, LinkedIn, Sign in with Apple, api-key sign-in.
- New channels: Resend, Twilio, Web Push, AWS SES.
- DPoP (RFC 9449) + OAuth refresh-reuse detection.
- READMEs: parallel structure across both packages, local logo + LICENSE for npm rendering.
@gentleduck/iam: defense-in-depth + adapter hardening + vitest compat shim.
- Provider entry-point caps + typeof guards (api-key, magic-link, oauth, passkey,
password, saml). Magic-link
engine.libs.assertNonEmptyStringParam: enforce 1024-char cap.assertAttributesParam: 256-key + depth-16 caps.engine.permissions(): refuse batches over 1024.engine.can()/check()/explain(): subjectId typeof + length-cap; fail-closed in production.File adapter dicts now
Object.create(null)(prototype-pollution defense).setSubjectAttributes('__proto__', ...)no longer pollutesObject.prototype.HTTP adapter: streaming
readBodyCapped+readJsonCapped<T>so multi-GB remote bodies cannot OOM before slice. ID-length caps. Backoff overflow cap. SSRFredirect: 'error'.Redis invalidator: pre-auth UTF-8 byte-length cap + depth/key-count cap on parsed envelopes.
Hono adapter: body
Reflect.get-parsed with typeof + length guards.Vitest compat shim for bun runtime (
stubGlobal/unstubAllGlobals/describe.runIf); 8 previously-failing devtools tests now pass.
Tests: +42 across both packages, all green. No functional behavior changes beyond defensive guards on hostile input.
3.0.0
Breaking - Engine facet split (cache + stats)
Engine had 16 public methods on one class. Four clusters were visible: evaluation,
cache invalidation, lifecycle, and observability. The cache-invalidation and
observability clusters are folded into two facets so the root surface stays focused.
Evaluation (authorize, can, check, explain, permissions) and lifecycle
(constructor, dispose, preload, healthCheck) stay flat - hot path, single noun.
Migration
Before (<= 2.x) | After (3.0) |
|---|---|
engine.invalidate(opts) | engine.cache.invalidate(opts) |
engine.invalidateSubject(id, opts) | engine.cache.invalidateSubject(id, opts) |
engine.invalidatePolicies(opts) | engine.cache.invalidatePolicies(opts) |
engine.invalidateRoles(id?, opts) | engine.cache.invalidateRoles(id?, opts) |
engine.stats() | engine.stats.get() |
engine.resetStats() | engine.stats.reset() |
engine.flushSharedCaches() (removed) | import { flushSharedCaches } from ... |
Mechanical sed per call site - no behavior change. Codemod:
sed -i -E \
-e 's/(engine[A-Za-z]*)\.invalidate\(/\1.cache.invalidate(/g' \
-e 's/(engine[A-Za-z]*)\.invalidateSubject\(/\1.cache.invalidateSubject(/g' \
-e 's/(engine[A-Za-z]*)\.invalidatePolicies\(/\1.cache.invalidatePolicies(/g' \
-e 's/(engine[A-Za-z]*)\.invalidateRoles\(/\1.cache.invalidateRoles(/g' \
-e 's/(engine[A-Za-z]*)\.stats\(\)/\1.stats.get()/g' \
-e 's/(engine[A-Za-z]*)\.resetStats\(\)/\1.stats.reset()/g' \
src/**/*.ts
Why 3.0 now
- The
flushSharedCachesinstance method was already scheduled for 3.0 removal - it was misleading, because it wiped process-globals, so calling it on one engine affected every other engine in the process. The module-level export is the honest surface and has been the documented one since 2.1. Bundling the deprecation with the facet split means one major version and one migration window. - The flat surface drops from 16 to 9 methods + 2 facet handles. That leaves room for
future facet growth (
engine.cache.prewarm(),engine.stats.subscribe()) without polluting the root.
What did not change
engine.authorize/can/check/explain/permissions- identical signatures and semantics.engine.preload/dispose/healthCheck- unchanged.- Bundle size stable at 41.6 KB (internal refactor, no shape change).
- 948/948 tests pass after the bulk rename.
2.2.0
Architecture debt cleanup + bundle slim
Follow-up to the 2.1.0 security audit. Closes maintenance gaps the cycle surfaced and trims the bundle so the "import everything" headline is no longer the only number.
Architecture
runSingleFlight+runSingleFlightKeyed: 5 copies of the sentinel-compare in-flight pattern inengine.ts(_loadPolicies,_loadRoles,_loadRbacPolicy,_loadAllPolicies,_resolveSubject) collapsed to one helper. Same-class bugs (a missed sentinel in the merger) are now structurally impossible.runAdminAuthz+withAdminAudit: extracted from the 4 server adapters (express / hono / nest / next). The csrf + authorize + try + audit shape lives in one place. Future changes land in one file instead of four.- Per-Engine evaluation caches:
regexandpathcaches threaded end-to-end throughevaluate/evaluateFast/evaluatePolicy/evaluatePolicyFast/matchCandidate/ruleApplies/evalConditionGroup/evalCondition/resolve. Multi-tenant deployments instantiate one Engine per tenant; each owns its own caches and cannot be evicted by hostile-tenant pattern flooding.flushSharedCaches()remains for legacy callers. - Drizzle typed selects: 7
as unknown ascasts at module-edge consolidated into 3 typed helpers (_selectAll,_selectFirst,_selectWhere). Type system is load-bearing again. - Adapter compliance suite at
src/adapters/__compliance__/. Every shipped adapter passes the same 21 scenarios. Caught arevokeRoledrift inMemoryAdapterandFileAdapter(omittingscopeshould remove all matching role rows, not just the unscoped one). - Builder auto-validate:
PolicyBuilder.build()andRoleBuilder.build()runvalidatePolicy/validateRoleand throw on error. Power-users wiring the adapter directly (bypassingengine.admin.savePolicy) see failures where the bug was introduced.
Bundle slim
- Lazy validator:
engine.libs.tsadmin write paths (savePolicy/saveRole/import) nowawait import('../validate')on first call. The 12 KB validator chunk is skipped entirely by read-only services. - Subpath splits:
@gentleduck/iam/core/validate,@gentleduck/iam/core/builder,@gentleduck/iam/core/explain,@gentleduck/iam/core/schemaeach ship as separate entries. Tree-shaking drops them for consumers that don't import the subpath. - Barrel cleanup:
src/index.tsno longer re-exportsFileAdapter,MemoryAdapter, or the validator. Adapter consumers go through subpath imports (@gentleduck/iam/adapters/memory). - Drop 26
@deprecated2.0 to 3.0 type aliases. The.d.tssurface is clean. The deprecation window from 2.0.0 is closed; consumers were warned for two minor versions. - Forensic comments scrubbed: 452 redundant
@authorJSDoc tags and 326 audit-trail reference comments removed from source.
New APIs
flushSharedCachesmodule-level export (@gentleduck/iamand@gentleduck/iam/core). The instance methodEngine#flushSharedCachesis deprecated - it wiped process-globals despite being instance-bound.engine.preload({ validator: true })eagerly loads the lazy validator chunk at boot for operators who want every cost up front.engine.permissions(..., { telemetry: false })opts out of per-checkonMetrics+signalsallocation. Restores 2.0.x throughput on hot UI gates whereauthorize()already captures the metrics signal.escapeHtmlfrom@gentleduck/iam/core/explain. Safe HTML escape for consumers renderingExplain.IResult.summaryinto a debug panel.createEvalCachesfrom@gentleduck/iam/coreconstructs a fresh per-Engine cache pair if a consumer needs to build their own evaluator pipeline.splitPermissionKeyfrom@gentleduck/iam/shared/keysreversesbuildPermissionKeyhonouring escape sequences.
Tests
- 836 to 943 (+107). The +107 is the new adapter compliance matrix applied to 5 adapters.
Stryker mutation testing scaffold
bun run mutation wires Stryker against engine + evaluate + conditions + resolve +
validate + server/generic + all 5 adapters. Not in CI by default (5-15 min runtime);
operators run it on demand or via a scheduled job.
Benchmarks
Measured baselines (2.0.1 from a git worktree clean build, not eyeballed):
| Path | 2.0.1 | 2.1.0 | 2.2.0 |
|---|---|---|---|
evaluatePolicy (conditions) | 1.33 µs | 1.00 µs | 1.00 µs |
engine.can() cached | 4.86 µs | 5.85 µs | 5.18 µs |
engine.permissions() x20 | 20.06 µs | 42.71 µs | 48.08 µs |
| Bundle "import everything" | 38.4 KB | 44.8 KB | 41.3 KB |
| Bundle realistic profile | n/a | n/a | 15-25 KB |
Net 2.0.1 to 2.2.0 bundle delta: +2.9 KB (+7.5%). Earlier docs cited a ~21 KB pre-cycle number - that was estimated from a partial dist, not a clean build. The full security cycle cost ~6 KB raw; the bundle slim cycle recovered ~3 KB; net is +2.9 KB for fail-closed hook contracts, per-Engine caches, default-on CSRF, and lazy validator scaffolding.
engine.permissions(..., { telemetry: false }) cuts the batch path back to ~22 µs for
callers who opt out.
2.1.0
Adversarial security audit cycle (21 rescans, ~60 fix commits)
A second multi-round audit pass after 2.0.0, run by two independent adversarial security-auditor agents plus a silent-failure hunter and a code-smell scanner. 21 rescan cycles produced ~60 fix commits addressing 1 CRITICAL, 7 HIGH, 11 Medium, 12 Low, and 4 Info findings on top of the 2.0.0 hardening. Three consecutive clean rescans (Med+ free) declared the source tree exhausted: "the package is genuinely hard to break."
The change set is mostly backward compatible with three intentional default changes that close trivial auth-bypass / CSRF footguns.
CRITICAL (1)
FileAdapter._loadStateswallowed everyreadFileerror and silently fell back to an empty store. EACCES (permissions drift), EISDIR (path overwritten), EIO (disk corruption) became{policies:{},roles:{},...}, sodefaultEffectdecided every request. WithdefaultEffect:'allow'+allowFailOpenthis is total silent fail-open ("permit everything until restart"); with'deny'it is a total silent outage. OnlyENOENTnow recovers as empty; everything else throws a wrappedError. SEC-054.
HIGH (8)
- HTTP adapter followed fetch redirects without re-validation. The
allowedHosts/ private-IP guard runs once at construction againstbaseUrl, so a 302 to169.254.169.254or10.0.0.5:6379bypassed it._fetchOncenow passesredirect: 'error'. SEC-042. _emitMetricsinvokedonMetricswithout a try/catch. A throwing operator hook escapedauthorize's catch arm and replaced the documented fail-closed deny with a raw error. Wrapped via_safeHookCall; double-wrapped aroundconsole.erroritself. SEC-056.afterEvaluate/onDenyran insideauthorize's main try block; throws caught by the evaluation catch silently rewrote an allow verdict into a fail-closed deny. Trailing hooks now run outside the evaluation try; throws are routed toconsole.errorwithout reshaping the decision. SEC-055.engine.permissions()passedundefinedforonPolicyErrorto the evaluator, so per-policy throws vanished and UI gates silently allowed underdefaultEffect:'allow'. Now forwards the same shimauthorize()uses. SEC-057.- Redis + Drizzle
getSubjectAttributesreturned{}onJSON.parsefailure or a non-object root. ABAC conditions silently flipped to deny with no operator signal. Now throws; the engine routes throughonError+ fail-closed deny. SEC-058. FileAdapterJSON parse failure silently populated_cache = {}. The next_flush()overwrote the recoverable-but-corrupt file - **permanent data destruction triggered by a single transient parse error.** Now throws "store corrupt - refusing to load; restore from backup before retrying". SEC-064.can()/check()invokedthis._hooks.onError?.()unwrapped, so a throwing operatoronErrorpropagated as an unhandled rejection. Now_safeHookCall.- Hono and Next default
getUserIdtrusted the spoofablex-user-idheader. Trivial auth bypass via curl -curl -H 'X-User-Id: admin' ...ranauthorize()under the spoofed identity. Hono: no header fallback. Next: required option, throws at construction without it. SEC-101.
Medium (11)
- The admin write path skipped validation. A hostile admin (or a buggy UI) could persist a
policy the adapter read-side validator silently drops, leaving a tenant with zero
policies so
defaultEffectdecided every request.createAdmin.savePolicy/saveRole/importnow callvalidatePolicy/validateRoleand throw on error. assertValidOrThrowechoed attacker-controlled values (Invalid algorithm "<value>"). An operator who opted intoincludeErrorMessage: trueplus HTTP body echo turned the admin endpoint into a probe oracle. Now emitsINVALID_ALGORITHM at "algorithm"- structural codes only. SEC-052.- Redis migration versus
revokeRolerace._migrateLegacyAssignment's SADD-then-SREM let the migrator resurrect a just-revoked assignment._runSerialisedper-key chain orders writes; revoke now SREMs both encodings. - File
_assertWithinRootran once per adapter, so an attacker swapping the file for a symlink after the first I/O steered subsequent writes. The latch is dropped;realpathre-checks every read and write. _assertWithinRootran outside the load try, so a rejected promise stuck forever in_loadInFlightand every subsequent_loadState()returned the same rejection - a permanent admin DoS until process restart. The restructure clears in-flight viafinallyon any throw. SEC-063.- Admin lockout:
setSubjectAttributescalled the getter first, and the getter now throws on corrupt existing data, so an operator could not overwrite. The setter catches the throw, logs, and treats the existing value as{}. - HTTP adapter
getSubjectRolesforwarded the server response verbatim while other adapters enforce unscoped-only. JSDoc now documents the operator's contract responsibility. - The admin router shipped without CSRF guidance, exposing cookie-auth deployments to
cross-site forms. Optional
csrfCheckadded to all 4 framework adapters; default-on viadefaultCsrfCheck. CAVEAT-2. engine.permissions()had no outer try aroundPromise.all([_resolveSubject, _loadAllPolicies]). An adapter rejection crashed the whole batch withoutonError+ a fail-closed map. Now wrapped in a try; returns an all-deny map keyed by every requested check and invokesonError._loadAllPoliciesmerger had no in-flight sentinel, so a concurrent invalidate mid-load repopulated stale data. Added a_mergedInFlightsentinel.getSubjectRolessemantic drift: file/memory returned unscoped-only; redis/drizzle/prisma returned all collapsed. The same subject resolved differently across backends. Aligned all to unscoped-only; documented inAdapter.ISubjectStore. SEC-059.
Low (15)
- No way to chart the fail-open rate. Added
failOpen: booleantoIMetricsEventplus a counter tocreateMetricsAggregator. Threaded throughevaluate/evaluateFastvia optionalIEvalSignals. - The Redis invalidator v:1 envelope was unwrapped without HMAC verification when
secret: null, so an attacker choseinstanceIdand silenced legitimate cross-instance invalidates. v:1 in unsigned mode is now dropped and warned. permissions()bypassed_emitMetricsentirely, so dashboards charting fail-open missed every batch UI gate. Now emits per check.- File
rootDirwarn fired on every construction, causing log spam that operators filter out. A module-global latch fires it once per process. - The file warn echoed the resolved path, giving a path-existence oracle via log scraping. The path is stripped from the message.
- The Redis invalidator's one-shot per-channel warn latch let an attacker burn the first warn on a benign reason and then flood silently. Replaced with a 60s rate limit plus suppressed-count surfacing. SEC-032.
errorToAuditString(includeMessage=true)returned rawString(err)for non-Errorthrows - an unbounded leak. Now tagged<non-Error <typeof>>, capped at 256 chars, with aJSON.stringifyfallback.- Devtools
localStorageprefix__IAM_DEVTOOLSis now vendor-namespaced__GENTLEDUCK_IAM_DEVTOOLS_V1. _assertWithinRootparent-realpath fallback fired on ANY error, so ELOOP / EACCES bypassed the symlink check via a reconstructed path. Now gated oncode === 'ENOENT'.- The vanilla client listener-throw was totally silent.
console.errorsurfacing added. - The invalidator dropped shape-mismatched inner payloads without
warnDropOnce, so operators saw nothing on sustained schema drift. Routed through warn. - Invalidator
publish()failure was silently swallowed. Added an optionalonPublishError(err, channel)hook plus a rate-limited console fallback. _safeHookCall/_emitMetricscalledconsole.errorunwrapped; a throwing logger (closed stdout, broken pipe) would resurrect the failure. Defensive double-wrap added.dt/lib/flow.tslistenercatch{}was silent.console.erroradded.- Vanilla
extractActionsplit the key on:naively, so resources containing:were mis-tokenised. AddedsplitPermissionKey, which honours the\:and\\escapes frombuildPermissionKey.
Info (4)
createNextMiddlewareJSDoc example demonstrated the unsafe pattern. Replaced with agetServerSessionexample plus a warning.- Only express had a CSRF regression test; hono/next/nest needed parity. Added.
- INFO-A
LRUCacheand EnginemaxPolicies/maxRoles/adapterTimeoutMsaccepted NaN, which silently disabled the bound.Number.isFiniteis now required. - INFO-B
Explain.IResult.summaryis plain text containing attacker-influenced values; consumers rendering it as HTML must escape. JSDoc added.
Deployment hardening (CAVEAT-1/2/3)
- CAVEAT-1:
createRedisInvalidator({ tenantId })auto-prefixes the channel'duck-iam:invalidate:tenant:${tenantId}'. It validatestenantIdagainst/^[A-Za-z0-9_-]{1,64}$/so attacker-controlled tenant slugs cannot inject pub/sub wildcards. - CAVEAT-2: Admin routers are default-on CSRF via
defaultCsrfCheck(aSec-Fetch-Sitecheck).csrfCheck: falseopts out for bearer / mTLS APIs. - CAVEAT-3:
SECURITY.mdadds a 10-section Deployment Hardening Guide covering identity sourcing, admin CSRF, Redis tenancy, multi-tenant cache scoping,defaultEffect:'allow'rationale,explain()output trust and HTML-escape responsibility, the adapter trust model, filerootDir, HTTPallowedHosts, and observability wiring. getCachedRegex/getSegmentsaccept an optional per-instance cache override.clearRegexCache()/clearPathCache()are exported.Engine.flushSharedCaches()is the ergonomic operator API for multi-tenant deployments.
New APIs (additive)
// Engine: flush process-wide regex + path caches (multi-tenant)
engine.flushSharedCaches()
// Server: built-in Sec-Fetch-Site CSRF predicate
import { defaultCsrfCheck } from '@gentleduck/iam/server/generic'
// Admin router CSRF opt-out
adminRouter(engine, { authorize, csrfCheck: false })
// Redis invalidator: per-tenant channel + publish error hook
createRedisInvalidator({
client,
secret,
tenantId: 'acme', // 'duck-iam:invalidate:tenant:acme'
onPublishError: (err, channel) => alert(err),
})
// Metrics: fail-open chartable counter
const m = createMetricsAggregator()
m.snapshot().failOpen // subset of allow attributable to defaultEffect fallback
// Shared keys: escape-aware key parser
import { splitPermissionKey } from '@gentleduck/iam/shared/keys'
// Low-level cache controls
import { clearRegexCache } from '@gentleduck/iam/core/conditions'
import { clearPathCache } from '@gentleduck/iam/core/resolve'
Type additions
EngineTypes.IMetricsEvent.failOpen: booleanMetrics.ISnapshot.failOpen: numberRedisInvalidator.IConfig.tenantId?: stringRedisInvalidator.IConfig.onPublishError?: (err, channel) => voidAdminAudit.IOptions.csrfCheck?: ((req) => boolean) | falseValidate.ValidationCodeextended with'ERR_REGEX_CATASTROPHIC'
Behaviour changes (intentional defaults)
adminRouter/bindAdminRouter/createAdminHandlers/createAdminOperationsenforcedefaultCsrfCheckby default. PasscsrfCheck: falseto restore the old behaviour. Cookie-auth admin UIs get protection without any opt-in. CAVEAT-2.- Hono
accessMiddleware/guardno longer fall back to thex-user-idrequest header. The default is nowc.get('userId'), populated by upstream auth. SEC-101. - Next
withAccessrequiresgetUserIdand throws at construction otherwise, with a message pointing to cookie/JWT-derived identity. SEC-101. FileAdapter.listPoliciesand friends throw on non-ENOENT load failures (was silently empty). SEC-054.FileAdapterthrows on malformed JSON (was silently empty plus permanent file destruction on the next flush). SEC-064.- Redis / Drizzle
getSubjectAttributesthrow on a corrupt blob (was{}). SEC-058. - All 5 adapters'
getSubjectRolesreturn unscoped-only;getSubjectScopedRolesstill surfaces scoped assignments separately. SEC-059. - Engine ctor rejects NaN/Infinity for
maxPolicies/maxRoles/adapterTimeoutMs. INFO-A. LRUCachector rejects NaN/Infinity formaxSize/ttlMs. INFO-A.
Migration
If you used the Hono header default for identity:
// Before
accessMiddleware(engine) // read x-user-id
// After
accessMiddleware(engine, {
getUserId: (c) => (c.get('userId') as string | undefined) ?? null,
})
// And populate c.set('userId', ...) from upstream auth middleware.
If you used the Next withAccess header default:
// Before
withAccess(engine, 'read', 'doc', handler)
// After
withAccess(engine, 'read', 'doc', handler, {
getUserId: async (req) => {
const session = await getServerSession(req)
return session?.user?.id ?? null
},
})
If your admin router is called server-to-server with bearer tokens (no browser involved):
adminRouter(engine, { authorize, csrfCheck: false })
Cookie-auth admin UIs need no changes - the default protects them.
If you relied on getSubjectAttributes returning {} for corrupt rows, wire
setSubjectAttributes (which now recovers automatically) or add a try / catch at your
call site.
If you relied on FileAdapter silently emptying on errors, wire onPolicyError and
handle the thrown error from the read path.
Tests
- 785 to 836 tests (+51).
- 5 consecutive clean Med+ rescans: 010, 011, 012, 014, 017, 019, 020, 021 (intermediate Med+ found and fixed in 015 and 018).
Audit hygiene
- The
audit/directory is gitignored; per-finding markdown reports and per-cyclerescan-NNN.mdreports are tracked locally inaudit/STATE.md.
Stats
- 0 P0/P1/P2/Low open at release time.
- 3 Info-tier residuals (SEC-049 doc IPv6 prefix not publicly routed; SEC-050
architectural per-instance refactor deferred behind the public
flushSharedCacheshelper).
2.0.1
Patch Changes
- 41a45ac: Standardize the README header to match the @duck-md template (centered logo, h1,
tagline, nav, npm badges). Switch docs links from
iam.gentleduck.orgto path-basedgentleduck.org/duck-iam. No runtime code changes.
2.0.0
Breaking
- Type API rewrite: every interface now lives under a per-module namespace
(
AccessControl,Request,Adapter,Primitives,Client,DotPath,EngineTypes,Evaluate,Explain,Validate,Config,Memory,File) with anIprefix. Migration: renamePolicytoAccessControl.IPolicy,DecisiontoAccessControl.IDecision,AccessRequesttoRequest.IAccessRequest, and so on. Interface names carry theIprefix; type aliases stay bare. Adapter.IAdapterread methods accept an optionalIReadOptionswith anAbortSignal. Backwards-compatible for adapters that ignore the parameter; custom adapters should plumb the signal through to their underlying driver where possible.adminRouter(Express) signature changed: it now requires{ authorize: (req) => boolean }as the second argument. Mounting unguarded admin endpoints used to be possible; it is no longer.
Added
policyCombinecross-policy combine ('and'/'allow-overrides'/'first-applicable') configurable viaIConfig.policyCombine, typed asAccessControl.PolicyCombine.hooks.onMetricsprimitive-only telemetry event fired once per evaluation in both modes, payload typed asEngineTypes.IMetricsEvent. Zero overhead when unwired.hooks.onPolicyErrorrouted when a single policy throws during evaluation (fail-skip, not fail-crash).engine.preload()warmsmergedPolicyCacheso the first request after boot is hot.engine.healthCheck()returns{ ok, adapter, cacheHitRate, adapterLatencyMs, lastError? }for/healthz.engine.admin.export()/engine.admin.import(snapshot, { mode })- schema-versioned policy + role snapshots;'merge'and'replace'modes.engine.dispose()releases the cross-instance invalidator subscription.IConfig.adapterTimeoutMs(default 5 s) wraps every adapter read in a timeout that triggersAbortController.abort().IConfig.maxPolicies/maxRolesload-time caps; over-cap throws and routes to a fail-closed deny.IConfig.allowFailOpenrequired to combinemode: 'production'withdefaultEffect: 'allow'.IConfig.invalidator- cross-instance cache-invalidation broadcaster contract.createRedisInvalidatorat@gentleduck/iam/invalidators/redis- pub/sub helper with self-echo filtering.createMetricsAggregatorat@gentleduck/iam/observability/metrics- p50 / p95 / p99 overonMetricsevents.- Hono
bindAdminRouter, Next.jscreateAdminHandlers, **NestJScreateAdminOperations** - all require theauthorizecallback at construction time. - HttpAdapter retry + per-request timeout + circuit-breaker (
retries,backoffMs,timeoutMs,circuitBreakerThreshold,circuitBreakerCooldownMs). - FileAdapter at
@gentleduck/iam/adapters/file- JSON-on-disk store with a pluggableFile.IFSinterface. POLICY_JSON_SCHEMA- Draft 2020-12 JSON schema export for non-TS consumers and editor tooling.engine.stats()/resetStats()- cache hit/miss counters per cache.- Validator semantic checks - emits
UNRESOLVABLE_FIELD,UNRESOLVABLE_VALUE,INHERITANCE_TOO_DEEP,BROAD_ALLOW, andLIMIT_EXCEEDEDcodes. POLICY_LIMITS- DoS bounds (1000 rules per policy, 100 actions per rule, 100 resources per rule, 1000 action x resource cartesian per rule).MAX_INHERITANCE_DEPTH = 32exported fromcore/rbac. The validator errors on chains that exceed it.
Fixed
first-matchcombiner now honorsrule.priorityacross trace, fast, precomputed, and explain paths.engine.explain()populatesDecision.rulefrom the deciding policy's trace.engine.invalidateRoles(roleId?)is scoped - only subjects holding the named role are evicted.setSubjectAttributesdocumented contract is nowmerge, matching every built-in adapter.- Single-flight on
loadPolicies/loadRoles/resolveSubject/loadRbacPolicycoalesces concurrent cold-start adapter calls. Sentinel-compare-on-resolve so a pending load can't write stale data after an invalidate. - NotApplicable semantics: a policy whose
targetsdon't match is skipped by the cross-policy combine, not folded as the default effect. The largest correctness fix in the project's history. - Empty RBAC policy is skipped from the per-request policy set so it doesn't contribute a default-deny under AND combine.
- Fast path matches colon-prefix actions (
'posts:*'), dot-hierarchy resources ('dashboard.*'), and parent-prefix patterns ('org'matching'org:project') consistently with the trace path. evaluatePolicyFastreturnsboolean | null(null = NotApplicable).evaluateFastskips null in every combine mode.- Engine ctor refuses
mode: 'production'+policyCombine: 'first-applicable'. - RBAC rule ids are opaque (
__rbac__#N) - no longer dotted. matchesoperator refuses$-resolved RHS values (ReDoS via user-controlled regex).- HttpAdapter
getPolicy/getRolereturnnullon 404 instead of throwing. - Validator depth bound (
MAX_CONDITION_DEPTH=10) and field-length cap (MAX_FIELD_LENGTH=256). - Regex cache is LRU on hit, not FIFO on insert.
- Synthesised RBAC policy is deep-frozen (every rule + conditions tree).
Number.isFinitepriority check in the validator.
Build / package
sideEffects: falseinpackage.jsonfor tree-shaking../adapters/filesubpath export added.
Tests
- 629 tests across 29 files (up from 309 at 1.7.0).
- A property-based oracle asserts
evaluateis equivalent toevaluateFastover 1000 random policy sets per(combine, defaultEffect)pair. - Bench harness:
evaluate.bench.ts+resolve.bench.ts+ competitor benchmarks.
Dot-path attribute access (When builder)
The When.attr() / When.resourceAttr() / When.env() methods now accept dot-paths into
nested attribute bags. Previously resourceAttr and env required keyof on the raw
object shape (one level deep). Now 'profile.tier' typechecks against
{ profile: { tier: string } } and the value parameter narrows correctly.
New + reorganized in DotPath:
SubjectAttrShape<TContext>- raw subject attribute bag object.ResourceAttrShape<TContext>- raw resource attribute bag object.EnvAttrShape<TContext>- raw environment object.SubjectAttrs<TContext>/ResourceAttrs<TContext>/EnvAttrs<TContext>- now return dot-path string unions (consistent), not raw objects.AttrValueAt<T, P>- walks a dot-path inside an attribute bag to resolve the leaf type.AttrValue<T, P>- rewritten on top ofAttrValueAt, with anAttributeValuefallback.ResolvedResourceAttrPaths<TContext, TResource>- dot-paths into per-resource attribute narrowing.ResolvedResourceAttrs- now returns the resolved attribute SHAPE (object), paired withResolvedResourceAttrPathsfor keys.
When method signatures dropped keyof in favour of these dot-path types. Open attribute
bags (IAnyAttributes via a string index signature) widen to string so the legacy
keyof IAnyAttributes behaviour is preserved for IDefaultContext. The file is
reorganized into 8 labeled sections (context paths, condition adapters, shape extractors,
attribute paths, per-resource narrowing, value resolution, defaults, internal helpers).
Module-local namespaces (added 2.0)
Every bare integration-config interface is now wrapped in a type-only namespace; deprecated
bare aliases are kept for back-compat and will be removed in 3.0.
Http.IConfig(wasIHttpAdapterConfig) -@gentleduck/iam/adapters/httpRedis.ILike+Redis.IConfig(wasRedisLike/RedisAdapterConfig) -@gentleduck/iam/adapters/redisDrizzle.IConfig(wasIDrizzleConfig) -@gentleduck/iam/adapters/drizzleExpress.IOptions+Express.IAdminAuthorize+Express.IAdminRouterOptions(wereIExpressOptions/IAdminAuthorize/IAdminRouterOptions) -@gentleduck/iam/server/expressHono.IOptions+Hono.IAdminAuthorize+Hono.IAdminOptions+Hono.IRouterLike(wereIHonoOptions/IHonoAdminAuthorize/IHonoAdminOptions/IHonoRouterLike) -@gentleduck/iam/server/honoNest.IAuthorizeMeta+Nest.IGuardOptions+Nest.IAdminAuthorize+Nest.IAdminOptions(wereIAuthorizeMeta/INestGuardOptions/INestAdminAuthorize/INestAdminOptions) -@gentleduck/iam/server/nestNext.IWithAccessOptions+Next.IMiddlewareOptions+Next.IAdminAuthorize+Next.IAdminOptions(wereIWithAccessOptions/INextMiddlewareOptions/INextAdminAuthorize/INextAdminOptions) -@gentleduck/iam/server/nextReactClient.IContextValue(wasIContextValue) -@gentleduck/iam/client/reactRedisInvalidator.IPubSubLike+RedisInvalidator.IConfig(wereIRedisPubSubLike/IRedisInvalidatorConfig) -@gentleduck/iam/invalidators/redisMetrics.IAggregator+Metrics.ISnapshot+Metrics.IConfig(wereIMetricsAggregator/IMetricsSnapshot/IMetricsAggregatorConfig) -@gentleduck/iam/observability/metricsAccessControl.OpFn(was bareOpFninconditions.libs.ts)
Every new namespace is type-only (interfaces + type aliases only, no runtime values) so
it compiles to nothing and bundle size stays unchanged. Runtime helpers
(evaluatePolicyFast, ops, regexCache, MAX_*, POLICY_*, every adapter class, every
server factory, every client factory) remain bare module exports so tree-shaking still
works.
Stability
2.0.0 commits to SemVer. The type-API namespace rewrite is load-bearing; no further
public-API renames until 3.0.0. Patch and minor releases stay non-breaking.
2.0.0 - detailed notes
The pre-2.0.0 development entry, kept here because it records the 13-round state of the
work that shipped as 2.0.0. Superseded by the 2.0.0 entry above. It carried an
Unreleased heading for several releases, which put an "unreleased" section in the
middle of shipped history; the heading was corrected without touching its content.
Major refactor: namespaced type API + correctness hardening
A 13-round audit-driven hardening pass plus a full type-API refactor matching the duck-* monorepo convention.
Type API: namespaced + I-prefixed. Every interface now lives under a per-module
namespace (AccessControl, Request, Adapter, Primitives, Client, DotPath,
EngineTypes, Evaluate, Explain, Validate, Config, Memory, File). Interface
names carry an I prefix; type aliases stay bare.
Engine correctness fixes:
first-matchcombiner now honorsrule.priorityacross trace, fast, precomputed, and explain paths.engine.explain()populatesDecision.rulefrom the deciding policy's trace.engine.invalidateRoles(roleId?)is scoped - only subjects holding the named role are evicted.setSubjectAttributescontract is nowmerge, matching every built-in adapter.- Single-flight on
loadPolicies/loadRoles/resolveSubjectcoalesces concurrent cold-start adapter calls. - The
invalidate()family clears in-flight slots + sentinel-compare-on-resolve so a pending load can't write stale data. - NotApplicable semantics: a policy whose
targetsdon't match is skipped by the cross-policy combine, not folded as the default effect. The largest correctness fix in the project's history. - Empty RBAC policy is skipped from the per-request policy set so it doesn't contribute a default-deny under AND combine.
- Fast path matches colon-prefix actions (
'posts:*'), dot-hierarchy resources ('dashboard.*'), and parent-prefix patterns ('org'matching'org:project') consistently with the trace path. evaluatePolicyFastreturnsboolean | null(null = NotApplicable).evaluateFastskips null in every combine mode.- Engine ctor refuses
mode: 'production'+policyCombine: 'first-applicable'. - RBAC rule ids are opaque (
__rbac__#N) - no longer dotted.
New APIs:
AccessControl.PolicyCombine- cross-policy combine strategy ('and'/'allow-overrides'/'first-applicable'). Configurable viaEngine.policyCombine.EngineTypes.IMetricsEvent+onMetricshook - primitive-only telemetry payload fired once per evaluation in both dev and prod modes. Zero overhead when unwired.FileAdapterat@gentleduck/iam/adapters/file- JSON-on-disk store with a pluggableFile.IFSinterface.POLICY_JSON_SCHEMA- Draft 2020-12 JSON schema export for non-TS consumers and editor tooling.Engine.stats()/resetStats()- cache hit/miss counters per cache.- Validator semantic checks - emits
UNRESOLVABLE_FIELD,UNRESOLVABLE_VALUE,INHERITANCE_TOO_DEEP,BROAD_ALLOW, andLIMIT_EXCEEDEDcodes. POLICY_LIMITS- DoS bounds (1000 rules per policy, 100 actions per rule, 100 resources per rule, 1000 action x resource cartesian per rule).MAX_INHERITANCE_DEPTH = 32exported fromcore/rbac. The validator errors on chains that exceed it.
Build / package:
sideEffects: falseinpackage.jsonfor tree-shaking../adapters/filesubpath export added.
Testing:
- 584 tests across 28 files (up from 309 at 1.7.0).
- A property-based oracle asserts
evaluateis equivalent toevaluateFastover 1000 random policy sets per(combine, defaultEffect)pair. - Bench harness:
evaluate.bench.ts+resolve.bench.ts+ competitor benchmarks.
1.7.0
Minor Changes
0e80f84: Add Redis adapter, Drizzle schemas, and full integration test coverage.
New:
RedisAdapterat@gentleduck/iam/adapters/redis. Distributed key/value backend
with idempotent assignRole (set semantics), multi-tenant keyPrefix, and a minimal
RedisLike interface that ioredis, node-redis v4+, and Upstash all satisfy directly.
New: pre-built Drizzle schemas at
@gentleduck/iam/adapters/drizzle/schema/{pg,mysql,sqlite}. Drop-in tables for all three
SQL dialects with the right column types, FK cascade on roleId, a unique index on
(subjectId, roleId, scope), and auto-managed created_at / updated_at. Generate
migrations via drizzle-kit generate.
Test coverage expansion: every adapter, server middleware, and client integration now has dedicated tests. Total test count went from 309 to 498. New test files:
adapters/prisma,adapters/drizzle,adapters/http,adapters/redisserver/express,server/hono,server/nest,server/nextclient/react,client/vue
Optional peer deps added: drizzle-orm, ioredis, redis (all optional).
1.6.2
Patch Changes
- 918b34c: Strip
workspace:*andcatalog:protocol tokens fromdevDependencies/dependencies/peerDependenciesof every public package beforechangeset publish. Previously published artifacts leaked these tokens into npm metadata, which broke strict resolvers (bun, deno) for downstream consumers. Addsscripts/clean-publish.tsand wires it into the rootreleasescript with agit checkoutrestore step so source remains workspace-friendly.
1.6.1
Patch Changes
- Add package README for npm page. Remove special characters from all documentation.
1.6.0
Minor Changes
- Performance:
evaluatePolicyFastnow 2x vs CASL (was 5.2x). Inlined hot path, added a pre-computed results cache for unconditional rules, fixed an empty conditions bug, added a combined action+resource index.
1.5.0
Minor Changes
e682b61: Add optional scope parameter to
grant()for permission-level scopingThe
grant()method now accepts an optional thirdscopeargument:
.grant('update', 'post', 'org-1'). This enables permission-level
scoping directly without needing grantScoped(). The existing
grantScoped(scope, action, resource) method remains available.
Also fixed incorrect first-applicable references in JSDoc comments
to use the correct algorithm names first-match and highest-priority.
1.4.0
Minor Changes
72c449b: Add
FlexibleDollarPathsfor $-value autocomplete and fixAttrValuefor optional propertiesFlexibleDollarPaths<TContext>added directly to method value signatures so the IDE shows$-prefixed autocomplete (e.g.$subject.id) even without a custom contextAttrValuenow stripsundefinedfrom optional properties -yearsExperience?: numbercorrectly resolves tonumberinstead of falling back toAttributeValueStringConditionValueno longer includes(string & {})internally - the flexible string fallback is handled at the method signature level viaFlexibleDollarPaths
1.3.2
Patch Changes
2dd9f8b: feat:
FlexibleDotPathsforDefaultContextautocomplete and strictConditionValuetype safetyDotPathsnow bails tonever(notstring) for string-indexed types, preventing union pollution that killed IDE autocomplete.- New
FlexibleDotPaths<T>detects open-ended attribute bags (likeDefaultContext) and adds(string & {})so known structural paths autocomplete while arbitrary strings are still accepted. Fully typed contexts remain strict. ConditionValuecorrectly restricts non-string value types:env('hour', 'lt', '')now errors whenhourisnumber, instead of accepting anyAttributeValue.
1.3.1
Patch Changes
b62bb5b: fix: prevent
DotPathsfrom recursing into array methods and functionsDotPathsnow treats arrays as leaf paths and skips function-valued properties,
so autocomplete only shows real data properties instead of array methods like
length, push, toString, etc.
1.3.0
Minor Changes
- Add
DollarPathstype for $-variable autocomplete in conditions, refactor core into modular folders, and add JSDoc and inline FAQs to documentation
1.2.0
Minor Changes
- 7fe860f: Add
TContexttype parameter for typed dot-path intellisense and per-resource attribute narrowing. Splittypes.tsinto a modulartypes/directory. Add JSDoc across all source files.
1.1.2
Patch Changes
- 66608fe: Add
publishConfigwith public access for scoped npm package.
1.1.1
Patch Changes
- 37339e8: Fix release workflow to skip redundant CI checks during publish.
1.1.0
Minor Changes
- 29ed55d: Initial release of
@gentleduck/iam: identity and access management utilities.
See also
- Installation - install the current version and its peer dependencies.
- Production checklist - the hardening items several of these releases introduced.
- Troubleshooting - symptoms caused by behaviour that changed in 2.1.0 and 3.0.0.