Skip to main content

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

  • iamFlushSharedCaches instance 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 -> defaultEffect decided 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 accessMiddleware and Next withAccess - both defaulted getUserId to x-user-id request header. curl -H 'X-User-Id: admin' ... ran authorize() under the spoofed identity (SEC-101 HIGH).
  • Decision rewriting via throwing hooks - afterEvaluate/onDeny inside authorize's try caught and turned allow into deny; onMetrics could escape the fail-closed catch entirely (SEC-055/056 HIGH).
  • Silent fail-open in batch checks - permissions() passed undefined for onPolicyError, so a per-policy throw vanished and UI gates silently allowed under defaultEffect:'allow' (SEC-057 HIGH).
  • Silent ABAC denial - Redis/Drizzle getSubjectAttributes returned {} on corruption, conditions silently flipped to deny with no operator signal (SEC-058 HIGH).
  • SSRF via fetch redirect - construction-time allowedHosts validator runs once on baseUrl; redirects to 169.254.169.254 or internal hosts followed silently. Now redirect: 'error' (SEC-042 HIGH).
  • Cross-backend authorization drift - file/memory getSubjectRoles returned 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 _loadInFlight forever; every subsequent _loadState() returned the same rejection. Adapter unusable until process restart (SEC-063).
  • Validator error reflection - assertValidOrThrow echoed Invalid algorithm "<value>"; operator opt-in to includeErrorMessage + 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: boolean
  • Metrics.ISnapshot.failOpen: number
  • IamRedisInvalidator.IConfig.tenantId?: string
  • IamRedisInvalidator.IConfig.onPublishError?: (err, channel) => void
  • IamAdminAudit.IOptions.csrfCheck?: ((req) => boolean) | false
  • IamValidate.ValidationCode extended 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 iamFlushSharedCaches helper).

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-match combiner now honors rule.priority across trace, fast, precomputed, and explain paths.
  • engine.explain() populates Decision.rule from the deciding policy's trace.
  • engine.invalidateRoles(roleId?) is scoped - only subjects holding the named role are evicted.
  • setSubjectAttributes contract is now merge, matching every built-in adapter.
  • Single-flight on loadPolicies / loadRoles / resolveSubject coalesces 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 targets don'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.
  • evaluatePolicyFast returns boolean | null (null = NotApplicable). evaluateFast skips 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 via IamEngine.policyCombine.
  • IamEngineTypes.IMetricsEvent + onMetrics hook - primitive-only telemetry payload fired once per evaluation in both dev and prod modes. Zero overhead when unwired.
  • IamFileAdapter at @gentleduck/iam/adapters/file - JSON-on-disk store with pluggable IamFile.IFS interface.
  • 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_EXCEEDED codes.
  • IAM_POLICY_LIMITS - DoS bounds (1000 rules/policy, 100 actions/rule, 100 resources/rule, 1000 action x resource cartesian/rule).
  • MAX_INHERITANCE_DEPTH = 32 exported from core/rbac. Validator errors on chains that exceed it.

Build / package:

  • sideEffects: false in package.json for tree-shaking.
  • ./adapters/file subpath 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 an AbortController-driven timeout (default 5_000).
  • IConfig.maxPolicies / maxRoles - load-time caps that fail closed.
  • IConfig.allowFailOpen - explicit opt-in required to combine mode: 'production' with defaultEffect: '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).
  • createIamRedisInvalidator at @gentleduck/iam/invalidators/redis - pub/sub helper with self-echo filtering.
  • iamCreateMetricsAggregator at @gentleduck/iam/observability/metrics - p50 / p95 / p99 over onMetrics events.
  • Hono bindAdminRouter, Next.js createAdminHandlers, NestJS createAdminOperations - all require authorize callback.
  • Express adminRouter(engine, { authorize }) - authorize is now required (was optional).

Security fixes:

  • matches operator refuses $-resolved RHS values (ReDoS via user-controlled regex).
  • IamHttpAdapter getPolicy / getRole return null on 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.isFinite priority check in validator.

Testing:

  • 629 tests across 29 files (up from 309 at 1.7.0).
  • Property-based oracle asserts evaluate equivalent to evaluateFast over 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 of AttrValueAt with AttributeValue fallback.
  • ResolvedResourceAttrPaths<TContext, TResource> - dot-paths into per-resource attribute narrowing.
  • ResolvedResourceAttrs returns the resolved attribute SHAPE; pair with ResolvedResourceAttrPaths for 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/http
  • Redis.ILike + Redis.IConfig - @gentleduck/iam/adapters/redis
  • Drizzle.IConfig - @gentleduck/iam/adapters/drizzle
  • Express.IOptions + Express.IAdminAuthorize + Express.IAdminRouterOptions - @gentleduck/iam/server/express
  • IamHono.IOptions + IamHono.IAdminAuthorize + IamHono.IAdminOptions + IamHono.IRouterLike - @gentleduck/iam/server/hono
  • Nest.IAuthorizeMeta + Nest.IGuardOptions + Nest.IAdminAuthorize + Nest.IAdminOptions - @gentleduck/iam/server/nest
  • IamNext.IWithAccessOptions + IamNext.IMiddlewareOptions + IamNext.IAdminAuthorize + IamNext.IAdminOptions - @gentleduck/iam/server/next
  • ReactClient.IContextValue - @gentleduck/iam/client/react
  • IamRedisInvalidator.IPubSubLike + IamRedisInvalidator.IConfig - @gentleduck/iam/invalidators/redis
  • Metrics.IAggregator + Metrics.ISnapshot + Metrics.IConfig - @gentleduck/iam/observability/metrics
  • AccessControl.OpFn moved into the core AccessControl namespace.

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: IamRedisAdapter at @gentleduck/iam/adapters/redis. Distributed key/value backend with idempotent assignRole (set semantics), multi-tenant keyPrefix, and a minimal AuthRedisLike 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, 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/redis

  • server/express, server/hono, server/nest, server/next

  • client/react, client/vue

Optional peer deps added: drizzle-orm, ioredis, redis (all optional).

1.6.2

Patch Changes

  • 918b34c: Strip workspace:* and catalog: protocol tokens from devDependencies/dependencies/peerDependencies of every public package before changeset publish. Previously published artifacts leaked these tokens into npm metadata, which broke strict resolvers (bun, deno) for downstream consumers. Adds scripts/clean-publish.ts and wires it into the root release script with a git checkout restore 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 third scope argument:

.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 (not string) 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 when hour is number, instead of accepting any AttributeValue.

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.