Changelog
Release history for @gentleduck/iam. Newest releases at the top.
3.0.0
Breaking - Engine facet split (cache + stats)
Engine had 16 public methods on one class. Folded the cache-invalidation and observability clusters into two facets so the root surface stays focused on evaluation. 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.iamFlushSharedCaches() // REMOVED
import { iamFlushSharedCaches } from '@gentleduck/iam'
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
iamFlushSharedCachesinstance method was already scheduled for 3.0 removal - it was misleading (wiped process-globals, affecting every Engine in the process). Bundle the deprecation with the facet split: one major, one migration window.- Flat surface drops from 16 -> 9 methods + 2 facet handles. 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 + semantics.engine.preload / dispose / healthCheck- unchanged.- Bundle size stable at 41.6 KB (internal refactor, no shape change).
- 948/948 tests pass after bulk rename.
2.1.0
Adversarial security audit cycle (21 rescans, ~60 fix commits)
A second multi-round audit pass after 2.0.0, run by independent adversarial security-auditor agents plus a silent-failure hunter and a code-smell scanner. 21 rescan cycles uncovered 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.
What the audit found
The audit was deliberately adversarial. After the 2.0.0 P0/P1 work, prior assumptions were re-attacked from fresh angles. A representative sampling:
- Total silent fail-open in
IamFileAdapter._loadState- EACCES, EISDIR, EIO swallowed -> empty store ->defaultEffectdecided every request. With'allow'that's "permit everything until restart" (SEC-054 CRITICAL). - Permanent data destruction in
IamFileAdapter- a single transient JSON parse failure silently populated_cache = {}, next_flush()overwrote the corrupt-but-recoverable file (SEC-064 HIGH). - Trivial auth bypass via spoofable header in Hono
accessMiddlewareand NextwithAccess- both defaultedgetUserIdtox-user-idrequest header.curl -H 'X-User-Id: admin' ...ran authorize() under the spoofed identity (SEC-101 HIGH). - Decision rewriting via throwing hooks -
afterEvaluate/onDenyinsideauthorize's try caught and turned allow into deny;onMetricscould escape the fail-closed catch entirely (SEC-055/056 HIGH). - Silent fail-open in batch checks -
permissions()passedundefinedforonPolicyError, so a per-policy throw vanished and UI gates silently allowed underdefaultEffect:'allow'(SEC-057 HIGH). - Silent ABAC denial - Redis/Drizzle
getSubjectAttributesreturned{}on corruption, conditions silently flipped to deny with no operator signal (SEC-058 HIGH). - SSRF via fetch redirect - construction-time
allowedHostsvalidator runs once onbaseUrl; redirects to169.254.169.254or internal hosts followed silently. Nowredirect: 'error'(SEC-042 HIGH). - Cross-backend authorization drift - file/memory
getSubjectRolesreturned unscoped-only; redis/drizzle/prisma returned all collapsed. Same subject decided differently across adapters (SEC-059). - Permanent admin DoS - symlink-escape rejection parked the rejected promise in
_loadInFlightforever; every subsequent_loadState()returned the same rejection. Adapter unusable until process restart (SEC-063). - Validator error reflection -
assertValidOrThrowechoedInvalid algorithm "<value>"; operator opt-in toincludeErrorMessage+ body echo turned admin endpoint into a probe oracle (SEC-052). - One-shot warn silencing - Redis invalidator's per-channel warn latch let attacker burn first warn on benign reason then silently flood. Now token-bucket rate-limit + suppressed-count surfacing (SEC-032).
Plus 50+ smaller fixes covering cache races, listener swallow, audit string leakage, redirect SSRF residuals, NaN bound bypass, escape-aware key splitting, devtools namespace, file warn dedupe/redact, file TOCTOU per-I/O, NAT64 prefix, validator non-finite priority, and consistent _safeHookCall wrapping across can/check/permissions/authorize.
Behaviour changes (intentional defaults)
Hono accessMiddleware + guard no longer trust x-user-id. Defaults to c.get('userId') populated by upstream auth. Operators relying on the header must pass getUserId explicitly. SEC-101.
Next withAccess requires getUserId at construction. Throws otherwise with a message pointing to cookie/JWT-derived identity. SEC-101.
Admin routers CSRF-check by default. adminRouter / bindAdminRouter / createAdminHandlers / createAdminOperations all apply iamDefaultCsrfCheck (rejects Sec-Fetch-Site: cross-site|cross-origin). Bearer-token / mTLS APIs opt out via csrfCheck: false. Cookie-auth admin UIs get protection without any opt-in. CAVEAT-2.
IamFileAdapter no longer silently empties on errors. Non-ENOENT readFile errors throw. JSON parse failure throws "store corrupt - refusing to load; restore from backup before retrying" instead of populating _cache = {} and risking permanent overwrite on next flush. SEC-054 / SEC-064.
Redis/Drizzle getSubjectAttributes throws on corrupt blob. Engine routes through onError + fail-closed deny + onMetrics. Was silently {}. SEC-058.
All 5 adapters' getSubjectRoles return unscoped-only. Scoped assignments surface via getSubjectScopedRoles only. Was inconsistent across backends. SEC-059.
Engine ctor + IamLRUCache reject NaN/Infinity bounds. maxPolicies, maxRoles, adapterTimeoutMs, maxSize, ttlMs all require finite numbers. NaN silently disabled the cap before. INFO-A.
New APIs
// Engine: flush process-wide regex + path caches (multi-tenant)
engine.iamFlushSharedCaches()
// Server: built-in Sec-Fetch-Site CSRF predicate
import { iamDefaultCsrfCheck } from '@gentleduck/iam/server/generic'
// Admin router CSRF opt-out
adminRouter(engine, { authorize, csrfCheck: false })
// Redis invalidator: per-tenant channel + publish error hook
createIamRedisInvalidator({
client,
secret,
tenantId: 'acme', // 'duck-iam:invalidate:tenant:acme'
onPublishError: (err, channel) => alert(err),
})
// Metrics: fail-open chartable counter
const m = iamCreateMetricsAggregator()
m.snapshot().failOpen // subset of allow attributable to defaultEffect fallback
// Shared keys: escape-aware key parser
import { iamSplitPermissionKey } 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
IamEngineTypes.IMetricsEvent.failOpen: booleanMetrics.ISnapshot.failOpen: numberIamRedisInvalidator.IConfig.tenantId?: stringIamRedisInvalidator.IConfig.onPublishError?: (err, channel) => voidIamAdminAudit.IOptions.csrfCheck?: ((req) => boolean) | falseIamValidate.ValidationCodeextended with'ERR_REGEX_CATASTROPHIC'
Deployment hardening
SECURITY.md ships a new 10-section Deployment Hardening Guide covering identity sourcing, admin CSRF, Redis tenancy, multi-tenant cache scoping, defaultEffect:'allow' rationale, explain() HTML-escape responsibility, adapter trust model, file rootDir, HTTP allowedHosts, and observability wiring.
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 - 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 IamFileAdapter silently emptying on errors, wire onPolicyError and handle the thrown error from the read path.
Tests
- 785 -> 836 tests (+51).
- 5 clean Med+ rescans cumulative across the cycle.
Stats
- 30+ fix commits in this release on top of the 2.0.0 audit.
- 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 public
iamFlushSharedCacheshelper).
2.0.0
Major refactor: namespaced type API + correctness hardening
18-round audit-driven hardening pass plus a full type-API refactor matching the duck-* monorepo convention. Breaking change: every interface now lives under a per-module namespace with an I prefix.
Type API: namespaced + I-prefixed. Every interface now lives under a per-module namespace (AccessControl, Request, Adapter, Primitives, Client, DotPath, IamEngineTypes, 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. 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. 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 viaIamEngine.policyCombine.IamEngineTypes.IMetricsEvent+onMetricshook - primitive-only telemetry payload fired once per evaluation in both dev and prod modes. Zero overhead when unwired.IamFileAdapterat@gentleduck/iam/adapters/file- JSON-on-disk store with pluggableIamFile.IFSinterface.IAM_POLICY_JSON_SCHEMA- Draft 2020-12 JSON schema export for non-TS consumers and editor tooling.IamEngine.stats()/resetStats()- cache hit/miss counters per cache.- Validator semantic checks - emits
UNRESOLVABLE_FIELD,UNRESOLVABLE_VALUE,INHERITANCE_TOO_DEEP,BROAD_ALLOW,LIMIT_EXCEEDEDcodes. IAM_POLICY_LIMITS- DoS bounds (1000 rules/policy, 100 actions/rule, 100 resources/rule, 1000 action x resource cartesian/rule).MAX_INHERITANCE_DEPTH = 32exported fromcore/rbac. Validator errors on chains that exceed it.
Build / package:
sideEffects: falseinpackage.jsonfor tree-shaking../adapters/filesubpath export added.
New operability surface (added across rounds 14-18):
engine.preload()- warm cache at boot.engine.healthCheck()-{ ok, adapter, cacheHitRate, adapterLatencyMs, lastError? }for/healthz.engine.dispose()- release the cross-instance invalidator subscription.engine.admin.export()/engine.admin.import(snapshot, { mode })- schema-versioned policy + role snapshots.engine.stats()/engine.resetStats()- cache hit / miss counters per cache.IConfig.adapterTimeoutMs- wrap every adapter read in anAbortController-driven timeout (default5_000).IConfig.maxPolicies/maxRoles- load-time caps that fail closed.IConfig.allowFailOpen- explicit opt-in required to combinemode: 'production'withdefaultEffect: 'allow'.IConfig.invalidator- cross-instance cache-invalidation broadcaster contract.hooks.onPolicyError- routed when a single policy throws; fail-skip, not fail-crash.hooks.onMetrics- primitive-only telemetry event fired once per evaluation in both modes.- IamHttpAdapter retry + per-request timeout + circuit-breaker (
retries,backoffMs,timeoutMs,circuitBreakerThreshold,circuitBreakerCooldownMs). createIamRedisInvalidatorat@gentleduck/iam/invalidators/redis- pub/sub helper with self-echo filtering.iamCreateMetricsAggregatorat@gentleduck/iam/observability/metrics- p50 / p95 / p99 overonMetricsevents.- Hono
bindAdminRouter, Next.jscreateAdminHandlers, NestJScreateAdminOperations- all requireauthorizecallback. - Express
adminRouter(engine, { authorize })-authorizeis now required (was optional).
Security fixes:
matchesoperator refuses$-resolved RHS values (ReDoS via user-controlled regex).- IamHttpAdapter
getPolicy/getRolereturnnullon 404 instead of throwing. - Validator depth bound (
MAX_CONDITION_DEPTH=10) and field-length cap (IAM_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 validator.
Testing:
- 629 tests across 29 files (up from 309 at 1.7.0).
- Property-based oracle asserts
evaluateequivalent toevaluateFastover 1000 random policy sets per(combine, defaultEffect)pair. - Bench harness:
evaluate.bench.ts+resolve.bench.ts+ competitor benchmarks.
Dot-path attribute access:
When.attr() / resourceAttr() / env() now accept dot-paths into nested attribute bags. 'profile.tier' typechecks against { profile: { tier: string } } and the value parameter narrows correctly. keyof dropped from resourceAttr and env signatures.
New + reorganized in the DotPath namespace:
SubjectAttrShape/ResourceAttrShape/EnvAttrShape- raw attribute bag objects.SubjectAttrs/ResourceAttrs/EnvAttrs- now return dot-path string unions (consistent), not raw objects.AttrValueAt<T, P>- walks a dot-path inside an attribute bag to the leaf type.AttrValue<T, P>- rewritten on top ofAttrValueAtwithAttributeValuefallback.ResolvedResourceAttrPaths<TContext, TResource>- dot-paths into per-resource attribute narrowing.ResolvedResourceAttrsreturns the resolved attribute SHAPE; pair withResolvedResourceAttrPathsfor keys.
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 remain for back-compat and will be removed in 3.0.
Http.IConfig-@gentleduck/iam/adapters/httpRedis.ILike+Redis.IConfig-@gentleduck/iam/adapters/redisDrizzle.IConfig-@gentleduck/iam/adapters/drizzleExpress.IOptions+Express.IAdminAuthorize+Express.IAdminRouterOptions-@gentleduck/iam/server/expressIamHono.IOptions+IamHono.IAdminAuthorize+IamHono.IAdminOptions+IamHono.IRouterLike-@gentleduck/iam/server/honoNest.IAuthorizeMeta+Nest.IGuardOptions+Nest.IAdminAuthorize+Nest.IAdminOptions-@gentleduck/iam/server/nestIamNext.IWithAccessOptions+IamNext.IMiddlewareOptions+IamNext.IAdminAuthorize+IamNext.IAdminOptions-@gentleduck/iam/server/nextReactClient.IContextValue-@gentleduck/iam/client/reactIamRedisInvalidator.IPubSubLike+IamRedisInvalidator.IConfig-@gentleduck/iam/invalidators/redisMetrics.IAggregator+Metrics.ISnapshot+Metrics.IConfig-@gentleduck/iam/observability/metricsAccessControl.OpFnmoved into the coreAccessControlnamespace.
Every new namespace is type-only (interfaces + type aliases only, no runtime values) so it compiles to nothing and bundle size stays unchanged.
1.7.0
Minor Changes
0e80f84: Add Redis adapter, Drizzle schemas, and full integration test coverage.
New:
IamRedisAdapterat@gentleduck/iam/adapters/redis. Distributed key/value backend with idempotentassignRole(set semantics), multi-tenantkeyPrefix, and a minimalAuthRedisLikeinterface 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 onroleId, unique index on(subjectId, roleId, scope), and auto-managedcreated_at/updated_at. Generate migrations viadrizzle-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: evaluatePolicyFast now 2x vs CASL (was 5.2x). Inlined hot path, added pre-computed results cache for unconditional rules, fixed empty conditions bug, added combined action+resource index.
1.5.0
Minor Changes
e682b61: Add optional scope parameter to grant() for permission-level scoping
The
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 FlexibleDollarPaths for $-value autocomplete and fix AttrValue for optional properties
- FlexibleDollarPaths<TContext> added directly to method value signatures so the IDE shows subject.id) even without a custom context
- AttrValue now strips undefined from optional properties - yearsExperience?: number correctly resolves to number instead of falling back to AttributeValue
- StringConditionValue no longer includes (string & {}) internally - the flexible string fallback is handled at the method signature level via FlexibleDollarPaths
1.3.2
Patch Changes
2dd9f8b: feat: FlexibleDotPaths for DefaultContext autocomplete and strict ConditionValue type safety
- DotPaths now bails to
never(notstring) for string-indexed types, preventing union pollution that killed IDE autocomplete. - New FlexibleDotPaths<T> detects open-ended attribute bags (like DefaultContext) and
adds
(string & {})so known structural paths autocomplete while arbitrary strings are still accepted. Fully typed contexts remain strict. - ConditionValue correctly restricts non-string value types:
env('hour', 'lt', '')now errors whenhourisnumber, instead of accepting any AttributeValue.
- DotPaths now bails to
1.3.1
Patch Changes
b62bb5b: fix: prevent DotPaths from recursing into array methods and functions
DotPaths now 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 DollarPaths type 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 TContext type parameter for typed dot-path intellisense and per-resource attribute narrowing. Split types.ts into modular types/ directory. Add JSDoc across all source files.
1.1.2
Patch Changes
- 66608fe: Add publishConfig with 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.