feat: Implement Cerbos policy importer, attribute schema enforcement, and async loader - #8
Merged
Merged
Conversation
New @alexify/kerberos/cerbos subpath (strategic track P1): turns an existing Cerbos policy repository into Kerberos policies in-process, with zero dependencies. - src/cerbos/yaml.js — parser for the YAML subset Cerbos policies are written in (block/flow/quoted/block-scalar forms, comments, --- streams); anchors, aliases, tags, directives, multi-line plain scalars and tab indentation throw. Verified differentially against the reference `yaml` package over the whole conformance corpus plus edge-case tables (test/CerbosYaml.test.js). - src/cerbos/cel.js — CEL lexer + recursive-descent parser for the full expression grammar (raw/triple-quoted strings, hex/uint/double literals, // comments); bytes literals, message construction and leading-dot names rejected at parse with offsets. - src/cerbos/translate.js — celToExpr: CEL AST → JS for the safe $expr interpreter (documented jsep setup). Timestamps are epoch-ms numbers (Date.parse/Date.now) so comparison/equality/arithmetic stay numeric; Go-style duration literals constant-fold to ms; has() → typeof … !== "undefined" (explicit null is present, as in CEL); in → .includes (fail-loud on maps); replace → split/join (CEL replaces every occurrence); UTC date accessors incl. the zero-based getDayOfMonth; int-literal division truncates. Macros, matches(), Cerbos extension functions, globals/runtime/auxData throw named errors. Every translation is tested semantically through the codec. - src/cerbos/importer.js — importCerbosPolicies: structural document mapper for all four policy kinds; skips disabled: true, accepts SCOPE_PERMISSIONS_OVERRIDE_PARENT, validates effects, refuses unknown keys at every level; the only opt-out is drop: ['schemas']. - conformance/importer.test.js — the whole PDP-pinned corpus re-run through the public importer: all 52 decision and 4 plan expectations hold for importer-loaded policies (real YAML parsing + CEL translation instead of the shared-subset passthrough). - Wiring: package.json exports/files, cerbos.d.ts (+ tsd + export parity), pnpm size entry (10.7 KB min+gzip, browser-clean), README section, docs guide page + nav + exports/installation tables, CHANGELOG, CLAUDE.md, conformance README/DIVERGENCES cross-refs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resource policies now accept a Cerbos-shaped `schemas:` block
(principalSchema/resourceSchema refs + ignoreWhen.actions globs, all
three validation backends extended), enforced through the new `schemas`
engine option (src/attributeSchemas.js):
- definitions map refs to validators: JSON Schema (compiled with the
`ajv` option), Zod-like schemas, or plain validator functions — all
normalized at construction;
- enforcement 'reject' (default when the option is set) denies every
action of an invalid request — a principal policy cannot rescue it —
with reason 'invalid-attributes' and Cerbos-shaped validationErrors
({ path, message, source }) on the checkResources result, never gated
on includeMeta; 'warn' reports without changing decisions; 'none' or
an absent option leaves policy schema refs inert (Cerbos's own
unconfigured default);
- ignoreWhen skips validation only when EVERY requested action matches;
with scoped policies the most specific chain entry declaring schemas
wins; a ref missing from definitions throws KerberosValidationError
regardless of onError;
- wired into BOTH evaluation drivers before principal evaluation, with
the resolved resource chain threaded into the decision walk so
enforcement never adds a second chain lookup; validationErrors also
reach audit entries;
- the Cerbos importer now translates schemas: blocks verbatim
(drop: ['schemas'] still discards them);
- types (ResourcePolicyAttributeSchemas, AttributeValidationError,
KerberosAttributeSchemasOptions, the 'invalid-attributes' reason),
README/guides/DIVERGENCES/CHANGELOG updated; 21 new unit tests incl.
sync/async driver parity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…undles
New Node-only @alexify/kerberos/loader subpath (strategic track P1):
- loadPolicyDirectory / loadPolicyFile read policy-as-code repositories:
Kerberos serialized JSON and Cerbos YAML/JSON mix freely (`.yaml` and
apiVersion-carrying JSON route through the /cerbos importer; the
`cerbos` option forces or disables that routing), deterministic sorted
order, `_`-prefixed and hidden entries skipped, `_schemas/**.json`
surfaced keyed both bare and `cerbos:///…` for the engine's
schemas.definitions option, and an optional codec deserializes
documents straight into constructor inputs.
- createPolicyBundle / writePolicyBundle / loadPolicyBundle implement
GitOps bundle artifacts: `version` is the SHA-256 of the canonical
sorted-key JSON of { policies, derivedRoles } (byte-reproducible with
createdAt: null), recomputed and verified on load — tampered,
truncated or foreign-stamped bundles throw; live (deserialized)
policies are rejected at bundling since functions would silently
stringify away.
- The one documented exception to the src/ platform-neutral rule:
src/loader/index.js uses node:fs/path/crypto directly, and browser
bundlers substitute src/loader/browser.js (identical surface, throwing
stubs) via the package `browser` map + exports condition.
- Typed KerberosLoaderError (carries `file`); loader.d.ts + tsd +
export-parity; fixtures-based tests incl. an end-to-end engine build
with _schemas wiring and bundle tamper detection; README/guide/docs
nav/CHANGELOG/CLAUDE.md updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d line
New published binary (package.json bin, strategic track P1):
- `kerberos test <policiesDir> <testsDir>` runs Cerbos-TestSuite-format
suites (*_test.yaml/json: named principal/resource fixtures + expected
effects) against a /loader-loaded policy directory, so a pure policy
repository tests itself in CI with zero engineering glue. jsep and its
documented plugins resolve from the CALLER's project (createRequire on
cwd) with an actionable install hint when { $expr } policies need them;
--schemas reject|warn wires _schemas/ into attribute-schema
enforcement (ajv resolved the same way); --json emits a structured
report; the runner refuses expectation features it does not check
(e.g. outputs) instead of silently passing. Exit codes: 0/1/2.
- `kerberos bundle <dir> --out <file> [--reproducible]` bakes the
hash-stamped bundle artifact from the /loader subpath.
- Tested by spawning the real binary: passing/failing suites, --json,
load-bearing --schemas flag (same suite passes only with enforcement),
refused expectation keys, bundle verification, byte-stable
--reproducible output. lint/format now cover bin/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Strategic track P1 "published comparative benchmarks + fuzzing": - test/Fuzz.test.js — deterministic (mulberry32-seeded) mutation fuzzing of the security-sensitive surfaces, running as part of `pnpm test` (FUZZ_ITERATIONS cranks it): the $expr codec must throw typed errors only, never leak functions, never pollute Object.prototype; every expression celToExpr emits must compile under the documented jsep setup; the YAML parser and RelationResolver throw typed errors only. The suite already paid for itself: a 20k-iteration run caught @jsep-plugin/new emitting a malformed callee-less NewExpression node for `new R.attr.x`, which escaped the codec as a raw TypeError — the validator/evaluators now guard the missing callee and reject it as KerberosExprError (regression-pinned). - bench/compare.js (`pnpm bench:compare`) — Kerberos vs @casl/ability vs casbin on one shared RBAC+ownership scenario, with allow/deny sanity cross-checks so the three implementations provably encode the same rules; scripts/size-compare.js (`pnpm size:compare`) — browser min+gzip comparison (casbin does not bundle for the browser at all). Honest numbers and their caveats published in docs/guide/benchmarks.md and the README (CASL's prebuilt check is faster because it does dramatically less; @cerbos/embedded and OPA-WASM excluded because their bundles cannot be built from open tooling). Docs benchmark table synced with the README's current numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Strategic track P1 "verified ORM-adapter compat":
- New main-entry export `toCerbosQueryPlan` (src/planning/sdk.js):
converts planResources output from the HTTP-API operand encoding
Kerberos emits ({ variable } / { expression: { operator, operands } })
into the flattened @cerbos/core SDK encoding ({ name } /
{ operator, operands }) the official Cerbos ORM adapters consume —
plan kinds are byte-identical, so this is the one hop needed.
Kerberos-only operators follow refuse-to-guess at the boundary:
a `relation` operand throws naming expandRelationOperands (materialize
first), an `opaque` operand throws with a post-filtering directive.
- test/OrmAdapters.test.js makes the README's "Cerbos ORM adapters
accept the filter" claim CI-executable against the REAL packages
(@cerbos/orm-prisma 4.x, @cerbos/orm-drizzle, new devDeps with
@prisma/client + drizzle-orm peers): conditional and membership plans
pin exact Prisma where objects and Drizzle SQL (+params), kinds pass
through, expanded relation plans render as id IN (...), opaque plans
are refused.
- Recipes in README/query-plans guide ("Using the official Cerbos ORM
adapters"), exports tables, CerbosSdkQueryPlan type, CHANGELOG,
CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reads The /loader subpath (and the CLI on top of it) used only synchronous fs calls; a large policy repository therefore paid one blocking read at a time on cold start. The subpath now ships BOTH drivers over one shared decision core: - Top-level functions stay exactly as they were (sync, unchanged API). - New `promises` namespace (Node's fs.promises idiom — same function names returning promises): promises.loadPolicyFile / loadPolicyDirectory / writePolicyBundle / loadPolicyBundle. promises.loadPolicyDirectory walks directories and reads policy + _schemas files CONCURRENTLY, bounded by the new `concurrency` option (default 64, via the shared createLimiter from src/async.js) — fast cold starts over many files without blocking the event loop. createPolicyBundle is pure CPU and stays top-level only. - Shared core, not duplication: routing/JSON+YAML ingestion (ingestPolicyText), _schemas parsing (addSchemaDefinition), directory assembly, bundle stamping/serialization/verification (buildBundle/ensureBundle/resolveBundle), skip/sort rules and option normalization are single functions consumed by both drivers — results are byte-identical (deterministic sorted ingestion order regardless of read-completion order), which the tests pin with deep equality between drivers for directories, files and bundle artifacts. - Browser stub exports the same `promises` surface as rejecting stubs. - The kerberos CLI now loads policies through the async driver and reads test-suite files concurrently too. - loader.d.ts `promises` typings + tsd; export-parity covers the new surface; docs (policy-loader guide "Async loading", README, CHANGELOG, CLAUDE.md). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…navailable
`test/OrmAdapters.test.js` failed the whole Node 18 CI leg with
ERR_REQUIRE_ESM. The cause is entirely third-party: `@cerbos/orm-prisma`
and `@cerbos/orm-drizzle` are CommonJS but `require("@cerbos/core")`,
which is ESM-only, so merely LOADING them needs Node's require(esm)
support — 20.19+ / 22.12+, and present-but-flagged on 22.10-22.11. CI's
Node 20 leg resolves to 20.20.2 and its Node 22 leg to a 22.12+ patch,
which is why only the 18 matrix entry broke.
Feature-detect via `process.features.require_module` rather than parsing
versions (a flagged-off runtime then reads correctly too) and gate only
the two adapter suites with `{ skip }`. The `toCerbosQueryPlan` suite
requires nothing third-party, so it now runs on Node 18 as well —
previously the module-load crash took it down with everything else, and
`src/planning/sdk.js` coverage on that leg rises from 46% to 87%.
Documented the constraint in README + docs/guide/query-plans.md: the
adapters are gated to Node 20.19+/22.12+, while `toCerbosQueryPlan` and
the rest of Kerberos keep the package's `engines: >=18` promise.
Verified: Node 18.19.1 `test:coverage` green (973 pass, thresholds met);
Node 22.10.0 skips as intended; Node 24 runs all 10 adapter tests.
Alex-Dolid
added a commit
that referenced
this pull request
Aug 30, 2026
…th Cerbos integration (#9) * fix: wave-0 critical fixes + contained hardening from code review Security / correctness: - Conditions: inherited-key strategy lookups (constructor/toString/valueOf) no longer turn a broken conditional rule into an unconditional match — own-property guard, fail-closed (+regression tests) - Scope depth capped at 16 dot-segments (KerberosValidationError, propagates even under onError:'deny') and scope strings at 512 chars across all three validation backends — closes a cache-read amplification / quadratic-CPU request vector ESM interop: - src/index.js is now built only from ...require('./file.js') re-export spreads (new src/publicExports.js carries the codec names + PlanKind): cjs-module-lexer bailed on the local-variable spread and silently dropped 19 names (codec fns, PlanKind, Effect, schemas, validation helpers) from the ESM named surface. Full 64/64 CJS<->ESM parity restored; EXPR_META / evalExprAst stay internal (+ESM smoke tests) Performance: - Engine log helpers early-return when the logger is disabled (mirrors RelationResolver#logDebug) — no timestamp/entry allocation per call: simple isAllowed 317k -> 513k ops/sec (+63%), cache-backed 141k -> 249k (+77%) on the bench harness Resilience / observability: - RelationResolver session memos evict rejected singleflight promises, so a transient backend failure never poisons a shared memo (+test, README and docs lifetime note) - winston/consola-shaped loggers (which expose .log alongside info/debug) now route to the structured writer instead of silently losing the audit trail (+regression test) - Metadata response schemas accept the engine's real includeMeta output: optional matchedPolicy, deny-reason enum, resolution trace union in all three backends (+round-trip test against live checkResources meta) Docs: - CLAUDE.md: replace the stale pre-v3 logging<->error coupling claim with the actual onError contract, correct the runtime-split requirer list, the pnpm version reference and the lint scope - LICENSE year range 2024-2026 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: wave-1 resilience & scalability options from code review Cache reader (createCacheReader): - Full-jitter exponential backoff between retry attempts (delayMs base 25ms, doubling per attempt, jitter on by default; delayMs: 0 restores immediate retries) — back-to-back retries defeated the mechanism and amplified load on struggling backends - Deterministic adapter errors (TypeError/SyntaxError) are no longer retried - Optional timeoutMs bounds each read attempt via a shared withTimeout helper (new src/async.js, platform-neutral) — a hung backend fails as KerberosCacheError instead of hanging authorization; the no-timeout hot path stays a direct awaited call Engine options: - cacheRetry.onExhausted: 'miss' — opt-in degraded mode: exhausted retries count as a cache miss so evaluation falls through to static sources; a cache outage no longer disables statically-resolvable decisions (visible via the cache 'error' metric + guarded log entry) - cacheKeyPrefix — namespaces ALL cache keys (policies + derived roles) for multi-tenant/shared-store deployments; closes the silent cross-tenant derivedRoles:<name> collision - relationsTimeoutMs — bounds relations.check/list calls; a hung resolver fails as KerberosRelationsError per onError Batch resolution memo: - checkResources shares one singleflight lookups memo across the batch (and planResources across its role closure): each distinct policy/derived-roles document resolves once per batch instead of once per resource — a 20-resource batch went from 40 duplicate cache reads to 2 — with trace entries replayed into every resource's meta.resolution. Gated on cache.enabled; single-shot isAllowed and static-only configs keep an allocation-free fast path (bench: simple 525k ops/s, static batch 41.2k, cache-backed 260k — all at or above the pre-change numbers) checkResources error contract: - Error-shaped batch denials are now marked { reason: 'evaluation-error', errorName } under includeMeta, so an outage is distinguishable from a policy DENY; documented that onError applies at request level only (README + docs wording fixed accordingly) Reverse lookups (RelationResolver): - onTruncated: 'throw' option — a maxResults truncation of lookupSubjects / lookupResources raises a typed KerberosRelationsError instead of silently narrowing the result; truncation always recorded on the span as kerberos.result.truncated - expandRelationOperands accepts an { ids, truncated } envelope and degrades a truncated relation branch to the sound `opaque` post-filter operator instead of baking an incomplete id list into the plan Types & docs: KerberosCacheRetry type, new options in index.d.ts / relations.d.ts, 'evaluation-error' reason (+ errorName) in the d.ts and all three Metadata schema backends, README/docs option tables and truncation contract; 14 new regression tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: wave-2 observability & audit completeness from code review Audit stream completeness: - Fail-closed denials now reach the audit log and the kerberos.decisions counter: a resource whose evaluation rejects inside a checkResources batch logs its DENY decisions marked { reason: 'evaluation-error', errorName }, and the onError:'deny' fallback of isAllowed emits an equivalent decision entry — previously the most security-relevant denials (cache outages) were exactly the ones missing from the decision stream - Audit entries record principalRoles (the role set the decision was based on); roles change over time, so past entries stay explainable - New engine option audit: { includeMeta: true } — decision tracing runs for every request when a logger is attached, so audit entries carry meta.resolution and the policy-miss reason regardless of the caller's per-request includeMeta response flag (the response stays gated) Decision trace: - Derived-roles imports are now traced: { source: 'derivedRoles', name, matched, origin? } entries in meta.resolution — previously the one cache-backed resolution step invisible to the trace (an evicted/corrupt document silently stopped rules from matching with no hint); Metadata schemas (all three backends) and index.d.ts extended accordingly Log levels: - PlanResources.result audit entries go out at INFO level (new writer.info channel; structured -> sink.info, legacy -> logger.info/log): an ALWAYS_ALLOWED (fail-open) plan now survives the docs' own pino({ level: 'info' }) production recipe instead of vanishing with the lifecycle debug events Swallowed-failure visibility: - New kerberos.observability.failures counter (sink: logger|telemetry): logger/telemetry sink failures stay swallowed (the never-affect- authorization contract) but are no longer invisible; the engine also console.warns once per instance on the first swallowed logger failure Relations seam: - The request span now carries kerberos.relations.count and kerberos.relations.duration_ms measured at the engine seam, so relation latency is attributable even with a custom resolver that has no instrumentation of its own (built-in resolver metrics stay inside the resolver to avoid double counting) - Fixed the stale kerberos.request.duration description (it also records PlanResources and Relations* calls) Docs: telemetry tables (six instruments, planResources span attributes), decision-metadata resolution entry shapes, configuration/README option lists and audit-entry field docs; 10 new regression tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: wave-3 hot-path optimizations from code review Synchronous evaluation driver (the big one): - Fully-synchronous configurations (no cache, no relations — the zero-dependency baseline) now evaluate through a sync driver that skips interior promise allocation and microtask hops entirely (~10 awaited frames per request before). Layering/merge semantics are NOT duplicated: both drivers share #applyParentRoleResult / #mergeRoleResultInto / #mergeSourceResults, and the new test/SyncAsyncParity.test.js pins end-to-end driver equivalence (responses byte-identical incl. meta) - Bench: simple isAllowed 514k -> ~800k ops/s (+55%), derived-roles 405k -> ~650k, static 10-resource batch 39k -> ~63k Validate once, not three times: - isAllowed/checkResources/planResources validate ARGUMENTS once with the constructor-precompiled validator and assemble the internal request without the old buildRequest re-parse — under Zod the same principal/resource was deep-parsed up to 3x per call (1+2N per batch) and P/principal ended up as different clones (identity now restored). The public static Kerberos.parseRequest keeps full validation for external callers; new Zod-backend bench scenario records the cost (~470k ops/s) Cache-path work reduction: - Cross-request instance memo in #resolveFromCache keyed by the IDENTITY of the raw cached value (WeakMap; bounded per-key map for string values): an unchanged document skips deserialize+validate+construct entirely while TTL/invalidation stays backend-owned (new reference = rebuild). Bench: cache-backed isAllowed 243k -> ~330k ops/s (+36%); tests cover reuse, replacement-invalidation and string-equality memoization - Per-role policy lookups resolve as one settled wave on the cache path (no short-circuit exists to lose), with per-role trace buffers keeping meta.resolution deterministic; the planner's parentRoles closure BFS is level-batched (RelationResolver #subjectClosure pattern) — O(depth) round-trip waves instead of O(roles) sequential ones Bounded fan-out: - New maxConcurrency option on the engine (checkResources batch chains) and the RelationResolver (lookupResources candidate verification) — a zero-dependency FIFO limiter in src/async.js; settleAll now lives there too, shared by engine and resolver (one home for the documented parallelism policy) Minor: - Policy/derived-roles check() skips the two per-check request spreads when the policy declares neither constants nor variables (the common case) - Bench harness covers the previously-blind paths: role+parentRoles chain, scoped requests, Zod validation, includeMeta, cache-backed 50-resource batch; README benchmark table refreshed with the new numbers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: wave-4 guards, hardening & cleanup from code review Drift guards (the checks the hand-maintained d.ts relied on humans for): - Six correctness lint rules opted back in per-rule (no-undef above all — the only static guard against typo'd identifiers in a codebase of guarded catch blocks); the category itself stays off as documented - test/tests.test-d.ts pulls the /tests subpath declarations into tsd — which immediately surfaced two latent errors that had shipped unchecked: tests.d.ts imported from the package name (never resolvable) and shadowed three classes with parameters named after them (self-referential types) - test/ExportParity.test.js diffs runtime Object.keys of all three entry points against each d.ts's declared value exports, both directions - The six public static parse* helpers (and the Tests DSL statics) are now declared in index.d.ts / tests.d.ts — closing the drift the parity guard would have caught Hardening: - Policy shapes are deep-frozen after construction and the constructors now CLONE the plain spine of their input first (cloneShapeTree in the new src/freeze.js, shared with the codec's AST freezing): live engine state can no longer be rewritten via policy.shape, the caller's literal is neither mutated (pre-existing wart) nor frozen, and the same literal can construct many instances; Effect is frozen like PlanKind - codec: evalNew gets the own-property constructor guard (defense in depth, mirrors evalCall); new maxBuiltStringLength limit (default 1M chars) caps strings BUILT by repeat/padStart/padEnd — a tiny expression could otherwise allocate ~0.5GB per evaluation, exactly the compromised-store memory-exhaustion the codec's threat model promises to prevent - RelationResolver #validKinds is seeded from the schema's type names and no longer caches caller-supplied kinds — a fuzzing caller could grow the set without bound Behavior improvements: - checkResources onError:'deny' fallback returns one all-DENY result per requested resource (positional parity like Cerbos) instead of results: [] which crashed positional consumers during the incident 'deny' exists to survive (+end-to-end test via the one real request-level failure path) - Resolver spans accept opts.callId (kerberos.call_id attribute); the engine passes its kerberosCallId through the relations seam automatically - Scope-chain memo evicts LRU at capacity instead of freezing on the first 1000 scopes; codec AST cache gets an LRU touch and drops the has/get pair - Reverse static tuple index builds lazily on first reverse-API use — check/list-only deployments no longer pay double index memory - Subject-set algebra extracted to src/Relations/subjectSet.js with a table-driven unit matrix (concrete × wildcard × exclusion across union/intersection/subtraction); settleAll shared via src/async.js - Constructor validator wiring table-driven: one backend dispatch + one builder-name list instead of three hand-synced 7-assignment blocks Docs & meta: - CHANGELOG: 3.x link refs, mis-filed 3.0.0 additions moved to Added, duplicate Changed merged, [Unreleased] section - README/docs: static-over-cached scope-shadowing warning (hybrid deployments), deny-fallback shape, codec limits, callId option; CLAUDE.md documents pnpm size Deliberately skipped: #runtime imports-field alias (would break webpack 4 — the legacy browser map cannot remap a '#'-specifier, defeating the finding's own premise) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: refresh bundle-size numbers after wave 0-4 hardening pnpm size moved from 25.1/15.1 KB to 28.6/16.3 KB (min+gzip) for the main entry and /relations subpath as the engine grew across the review waves (memoization, resilience options, frozen shapes, etc.). Synced every README/docs/CLAUDE.md mention, including CLAUDE.md's long-stale "~8 KB" figure left over from before ReBAC/planning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: Enhance TypeScript support, add playground, and improve conformance (#7) * feat(types): typed authoring via an optional application schema (P0.1) Closes the DX gap against CASL, which was the strongest argument against adopting Kerberos.js in a TS-first codebase: `attr` was `Record<string, unknown>`, kinds and actions were bare `string`, and condition callbacks were untyped. An application now declares its authorization domain once: type AppSchema = { principal: { roles: 'admin' | 'user'; attr: { department: string } }; resources: { document: { actions: 'view' | 'edit'; attr: { ownerId: string } }; invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; }; }; const kerberos = new Kerberos<AppSchema>(policies, derivedRoles); and the resource kind then drives everything else — `action`, `attr`, `roles`, and the `{ P, R, V, C }` envelope handed to conditions, variables and outputs. Policy documents are discriminated unions over `resource:`, so a rule naming another kind's action, an undeclared role, or a condition reading an attribute the kind does not have is a compile error instead of a silent EFFECT_DENY in production. Zero runtime change: this is entirely in the hand-maintained `.d.ts`, and every type parameter defaults to the new `AnySchema`, which reproduces the previous untyped surface verbatim — the pre-existing `types.test-d.ts` passes unmodified apart from the PlanKind line noted below. Also in this commit, all type-level: - `Effect` and `PlanKind` are declared as frozen const objects rather than TypeScript `enum`s. The runtime has always been a frozen plain object (verified), so the `enum` declaration mis-described it and, worse, made `effect: 'EFFECT_ALLOW'` in a plain JSON policy literal a type error — exactly the form stored/serialized policies carry. - `checkResources` gains overloads: the response's effects are typed `Effect`, or `boolean` when `effectAsBoolean` is passed, instead of the `Effect | boolean` union in both cases. - `{ $expr }` descriptors are accepted in `condition.match` and in `output`, which stored policies always used but the types rejected. - `errorName` added to the `checkResources` result meta (present at runtime since the wave-1 work, missing from the response type). - `BaseRule` gains the optional `name` it has always accepted. New: `test/typed-schema.test-d.ts` — 30+ assertions pinning the narrowing behaviour and the backward-compatible defaults, including `expectError` cases for wrong-action/wrong-kind/wrong-role/wrong-attr. Verified the harness is real by temporarily asserting an error on valid code and confirming tsd failed. It also pulls `relations.d.ts` into the same compilation and asserts a `RelationResolver` stays assignable to a schema-typed engine. Docs: new "TypeScript" section in README and /guide/typescript, exports table extended with the type-only surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(docs): in-browser playground (P0.3) Adoption, not engine capability, is the binding constraint — and Kerberos.js is uniquely cheap to demo because the engine *is* a browser library. The playground is a static VitePress page with no backend: policies and requests are edited in the page, and `isAllowed` / `checkResources` / `planResources` run in the visitor's own tab. That is simultaneously the demo and the proof of the "runs in your browser" claim. Three seeded examples: RBAC+ABAC (derived roles, variables, conditions, outputs), a query plan (showing the Cerbos-compatible conditional filter with `P.id` partially evaluated into a literal), and scope-chain walking. Conditions in the examples are `{ "$expr": "..." }` strings rather than functions, so every example is also a valid *stored* policy and the page exercises the eval-free codec path a cache-backed deployment uses. Implementation note: the package is CommonJS and lives outside node_modules (no workspace self-link), so Vite pre-bundles it neither in dev nor through the default rollup commonjs `include` — a plain alias produced "module is not defined" at runtime. Instead of guessing at interop flags, a small Vite plugin serves the engine as a virtual module built by esbuild with exactly the options `scripts/size.js` already uses. The page therefore ships the same artifact `pnpm size` reports, and a broken browser/node runtime swap fails the docs build loudly rather than silently shipping `node:crypto`. Verified in a real browser against the dev server: the RBAC example activates the OWNER derived role, evaluates its `$expr` condition, emits the rule output and the full resolution trace in ~5 ms; the query-plan example returns KIND_CONDITIONAL with the expected or/eq tree. Production build checked too — the engine is a lazily-loaded chunk (30 KB gzipped, only fetched when the playground is opened), with zero Node builtins and the browser `randomUUID` shim in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(conformance): Cerbos conformance suite (P0.2) "Cerbos-compatible" was a self-assertion. This makes it checkable. One corpus — written in Cerbos's OWN formats, so a real PDP serves it verbatim — is executed by both engines: policy documents in Cerbos policy YAML, decision expectations in the published TestSuite schema, and query-plan expectations shaped after Cerbos's internal QueryPlannerTestSuite golden files. It runs against Kerberos anywhere with no Docker, and additionally against a live Cerbos PDP when CERBOS_URL is set, which the new CI job does. That second leg is the point: without it a wrong expectation could make the two engines look compatible when neither matches Cerbos. 21 cases green: RBAC wildcards, derived roles, ABAC conditions, deny- overrides, policy-miss denial, and four query plans including partial evaluation (a principal-only conjunct folding away at plan time) and multi-action conjunction. Deliberate scope decision: there is NO CEL parser here. A CEL→$expr importer is a much larger piece of work that belongs in the package, and half-building it inside a test harness would have produced a bad importer and an untrustworthy suite. Instead the corpus is restricted to expressions that are simultaneously valid CEL and valid Kerberos `$expr` (verified against the codec), so one source string feeds both engines unchanged. `lib/load.js` is therefore a structural mapper whose governing invariant is that it refuses to guess — anything outside the supported subset throws ConformanceUnsupportedError rather than being dropped, because a silently skipped rule turns a real conformance failure into a false pass. Plan filters are compared after canonicalization (sorting the children of and/or and the operands of eq/ne) because neither engine promises an operand order; plans containing Kerberos-only operators (opaque, relation) fail the scope check instead of being compared against something Cerbos cannot express. Also records what the suite cannot yet settle. Kerberos was verified directly to be deny-overrides unconditionally — an ALLOW for one role and a DENY for another resolve to DENY for a principal holding both, regardless of rule order. Cerbos is documented as deny-overrides for resource-policy rules but is also described as having anti-lockout behaviour where an ALLOW from one role wins; both cannot be true. suites/ticket_test.yaml encodes that exact case as an executable probe, and DIVERGENCES.md states plainly that a failure there on the first live run is a finding to record, not an expectation to edit green. Every single-role test passes either way, which is precisely why it needs a dedicated probe. DIVERGENCES.md also catalogues the deliberate gaps (attribute schemas, scopePermissions, exported variable sets, auxData/JWT, globals) and tags each as enforced by the corpus, enforced by the loader, or documented only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the P0 strategic work in the changelog Notes the typed-authoring surface, the conformance suite and the playground, plus the two type-level breaking changes that land with them (Effect/PlanKind as const objects, checkResources overloads) so the v4 release notes have them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(conformance): verify cross-role conflict resolution against a live PDP Ran the suite against a real Cerbos 0.55.0 serving the same corpus. It behaved exactly as designed: 20/21 passed and the one failure was the probe built for this question. The divergence is real and now reproduced in-repo. Kerberos: deny-overrides, unconditionally. Cerbos >=0.41: deny-overrides WITHIN a role, allow-overrides ACROSS roles. So an ALLOW on role A plus a DENY scoped only to role B resolves to ALLOW in Cerbos and DENY here, for a principal holding both. Cerbos's evaluation loop runs per principal role and returns the first role that independently allows, overwriting a DENY recorded by an earlier role — deliberate anti-lockout behaviour. Bounded the blast radius with two more corpus cases, both confirmed against the live PDP: a DENY still wins in both engines when it covers the allowing role, whether as `roles: ['*']` or by enumerating it. Only the scoped-to-another-role shape diverges. Rather than delete the failing case or bend it green, the suite now records divergences explicitly: an expectation may carry `cerbosActions` with the other engine's verified answer, and the runner asserts BOTH that Cerbos still returns it AND that the two engines still disagree. A divergence entry that goes stale — because either engine changed — now fails loudly instead of quietly misdescribing reality. Two operational traps found and recorded, both of which would have hidden this: the behaviour changed in Cerbos 0.41.0 (0.40.0 returns DENY, matching Kerberos), and `ghcr.io/cerbos/cerbos:latest` is stale and still serves 0.40.0 — so a parity check against `latest` validates the old semantics and sees nothing. CI pins an explicit version for exactly this reason. Cerbos's own docs lagged the code here until 0.52.0 and did not flag it as breaking. Surfaced in the README next to the positioning table, since "if you know Cerbos, you already know Kerberos.js" now has a documented exception. Noting the direction of risk: porting Cerbos policies here fails closed, never open. Whether Kerberos should adopt Cerbos's semantics is a product decision and is deliberately NOT made here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix!: resolve resource-policy conflicts per principal role, as Cerbos does BREAKING CHANGE: an EFFECT_DENY scoped to one role no longer vetoes an EFFECT_ALLOW carried by a different role the principal also holds. This is MORE PERMISSIVE than before — audit any policy relying on a role-scoped deny to revoke access. Kerberos was deny-overrides unconditionally. Cerbos >=0.41 is deny-overrides WITHIN a principal role and allow-overrides ACROSS roles: its loop runs once per role and returns the first role that independently allows, overwriting a DENY recorded by an earlier one. That is deliberate anti-lockout behaviour — picking up an extra, less privileged role must not take away what another role grants. The conformance suite reproduced the difference against a live PDP; this makes the two agree. Denies that already cover the allowing role are unaffected: `roles: ['*']` covers every role by definition, and an enumerated role covers itself. Single-role principals and same-role conflicts are unchanged, which is precisely why this stayed invisible for so long. Implementation notes: - ResourcePolicy.check collects the rules that actually FIRED and then resolves per role bucket. Only fired rules are collected, so the common shapes (no denies, or no allows) short-circuit exactly as before — `pnpm bench` shows no regression (simple role match 783K ops/sec). - Derived roles are not a dimension of their own; they collapse into the principal roles listed in their `parentRoles`. Verified empirically against Cerbos: widening a derived role's parentRoles to include the allowing role flips the outcome to DENY. DerivedRoles gained `getActivated()` returning name -> parentRoles, and the engine now threads that Map (a drop-in for the old Set: `.has`/`.keys` read the same) down to the policy. - `ruleCoversRole` matches parentRoles literally, with no `*` wildcard, deliberately mirroring `DerivedRoles.#parentRolesMatch` and the planner's `intersects` — a wildcard here would have made the runtime and the query planner disagree about which role a derived rule belongs to. - The planner mirrors the same per-role composition, since runtime/planner parity is a project invariant. The parity suite did NOT catch the planner drift: every principal in its grid held exactly one role, so the cross-role path was never exercised. It now carries multi-role principals, a role-scoped deny and a deny reached through a derived role. Confirmed the fixture is load-bearing by restoring the old planner against it — 4 failures, 0 with the new one. Verified: 594 unit tests (new test/ConflictResolution.test.js covers all four combinations, both evaluation drivers, derived-role attribution, the no-roles edge case and query plans), and 24/24 conformance cases green against a real Cerbos 0.55.0 with no remaining divergences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix!: role policies narrow the resource layer instead of replacing it BREAKING CHANGE: a RolePolicy no longer grants access on its own, multiple role policies union instead of intersecting, and a role with no applicable role policy imposes no restriction. Deployments relying on a RolePolicy to grant must add the matching ResourcePolicy rules. Verified against a real Cerbos 0.55.0 PDP on identical policies. Four distinct divergences, all now closed: resourcePolicy doc: ALLOW [view, edit, delete] roles ['*'] rolePolicy R1: allowActions [view] rolePolicy R2: allowActions [edit] roles was (Kerberos) Cerbos / now [R1] view view [R2] edit edit [R1,R2] (nothing) view + edit <- union, not intersection [R1,PLAIN] view everything <- unpolicied role is unconstrained plus: a role policy allowlisting an action the resource policy withholds used to grant it (now denies), and a role policy with no resource policy at all used to grant (now denies). A PrincipalPolicy override is never narrowed by the role layer — also confirmed against the live PDP. Kerberos treated rolePolicy as a ranked layer that replaced the resource layer and whose denies were sticky across roles. Cerbos treats it as a pure narrowing filter unioned across roles — the same anti-lockout philosophy as the resource-policy conflict fix: holding an extra role must never take access away. Implementation: - `#evaluateRolePolicies` unions (Allow wins) and abstains entirely unless EVERY unique principal role has a role policy that targets this resource kind. `RolePolicy.check` already yields no effects for a foreign kind, so applicability falls out of the effect count. - `#evaluatePolicySources` always runs the resource layer for the actions the principal policy left open; `#mergeSourceResults` downgrades a resource ALLOW to DENY when the filter applies and no role permits it. Both the sync and async drivers, kept in step. - The planner mirrors it: `AND(resourceLayer, roleFilter)` with the filter as a union, gated on the same all-roles-covered condition. - parentRoles are untouched: intersection ALONG the inheritance chain, union ACROSS the principal's roles. Test churn is the semantics landing, not collateral. Several existing tests encoded the old model in their names ("deny precedence", "role policies override resource allows"); those are rewritten. Two plan tests built engines from role policies alone and would now be trivially DENIED — one of them had been asserting the intersection and was passing for the wrong reason. They now pair each role policy with a permissive resource policy so the filter is actually observable. PlanParity again did not catch the planner drift: its only role-policy principal held a single role. It now carries a policied+unpolicied pair and a two-policied-role pair; confirmed load-bearing by restoring the old role layer against it (7 failures, 0 with the new one). The corpus also gained a guard the offline run was missing: Cerbos refuses a policy file with more than one YAML document, which our loader happily accepted — it now refuses too, so offline cannot pass on a corpus a real PDP would not load. 609 unit tests and 30/30 conformance cases green, offline and against the live PDP. Bench unchanged (simple role match 806K ops/sec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: a role policy constrains its role for every kind, not just the ones it names Regression from the previous commit, caught by a differential sweep against a live Cerbos PDP. I had defined "applicable role policy" as one whose rules target the requested resource kind, so a principal holding a role whose policy only mentions OTHER kinds was treated as unconstrained there. Cerbos scopes the restriction to the ROLE, not to the kind: holding a role that has a role policy restricts it everywhere, and for a kind the policy never mentions it allowlists nothing, so the answer is DENY. Verified: resourcePolicy pdoc: ALLOW [...] roles ['*'] rolePolicy GLOBBER: allowActions [...] on `adoc` only roles ['GLOBBER'] on pdoc -> was ALLOW, Cerbos DENY <- fail-open roles ['GLOBBER','PLAIN'] on pdoc -> ALLOW (PLAIN unconstrained), unchanged The gate is now the count of resolved role policies rather than the count that matched the kind; a policy that yields no effects simply contributes nothing to the union, which is exactly "permits nothing here". The planner follows, and `applicableRolePolicies` disappears since `roleAllowNode` already returns FALSE for a non-matching policy. The conformance suite missed this because no corpus role policy targeted a kind other than the one under test — it now has one, pinned against the live PDP alongside the unconstrained-partner case that lifts the filter. 609 unit tests and 32/32 conformance green, offline and live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix!: adopt Cerbos's full rule-table evaluation model (globs, scope walk, deny rows) BREAKING CHANGE: three semantic families change, all verified against a live Cerbos 0.55.0 PDP and cross-checked against Cerbos's documentation and v0.55.0 source. A 2000+-decision differential sweep that previously showed 106 mismatches now shows ZERO. 1. Wildcards (was partially FAIL-OPEN). Name matching now globs exactly as Cerbos's internal/util/globs_common.go does: bare `*` matches anything, any other `*` stays within a `:`-delimited segment (view:* matches view:public, not view or view:a:b), `**` crosses segments. Applies to resource-policy actions AND roles (team_*), principal-policy resource and action, role-policy resource and allowActions, and derived-role parentRoles (['*'] and adm* now work). Previously only a bare `*` matched, so `DENY view:*` silently failed to deny. 2. Scoped policies evaluate per (action, role) — OVERRIDE_PARENT. The first scope whose policy decides an (action, role) seals it; a failed condition decides nothing and falls through to the parent scope; another role can still win an allow at the base scope. Previously the first policy found in the chain decided ALL actions for its source. Applies to principal (principal scope chain), resource, and role policies. Confirmed against the docs' own wording: "The first policy to produce a decision for a given action is the winner" + "If a rule is matched but its condition is not met ... evaluation continues up the hierarchy." 3. Role policies are synthetic DENY ROWS in the same per-role walk, at their own scope — replacing the request-level filter I introduced two commits ago, whose "all roles constrained" gate had a cross-bucket bug the sweep caught: an allow reaching only role RA cannot be revived by role RB's allowlist (Cerbos denies; the filter model allowed). Also: role policies ride the RESOURCE scope chain and resource policyVersion — Cerbos's own docs say principal scope, but its rule table and a live PDP say resource; we follow the engine and record the doc discrepancy. Also fixed by the chain resolver: cache-backed scope resolution is now per scope (memory first, then cache, at EACH scope), which retires the documented hybrid-deployment caveat where a static base-scope policy permanently shadowed a more specific cached policy. Architecture: the resource layer, the role filter and per-source scope lookup collapsed into ONE shared decision walk (src/decision.js), used by ResourcePolicy.check (a single-scope instance of it), both engine drivers, and — symbolically, via the same fold — the query planner. Glob matchers (src/matching.js) are precompiled per rule as non-enumerable props, mirroring the old actionsSet optimization. The docs-verification pass (evaluation/derived_roles/resource/principal/role/scoped_policies pages + v0.55.0 source) confirmed all seven previously-implemented semantics with citations; its findings and the two places where Cerbos's docs contradict its own engine are recorded in conformance/DIVERGENCES.md. Verification: 659 unit tests (+50: matching matrix, scoped PlanParity sweep with a scoped resource policy, scoped role policy and a glob rule); conformance grew 32 → 57 cases — wildcards_test.yaml and scope_walk_test.yaml pin every probed behaviour including both scope-source directions and the cross-bucket case — green offline AND against the live PDP. Coverage 98%. Bench: simple isAllowed ~650K ops/sec (was 806K before chain semantics, 318K at baseline) — the cost of walking chains instead of stopping at the first policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(conformance): pin the condition-error divergence; sharpen the doc-conflict notes Both places where Cerbos's documentation contradicts its own engine are now backed by numbers I measured rather than by a reading of the source. Condition runtime errors: with a DENY rule whose condition raises (`R.attr.missing.deep`), a live 0.55.0 PDP on its default config returns EFFECT_ALLOW — the erroring rule is silently skipped, exactly as Cerbos's engine page warns ("an EFFECT_DENY rule could be silently skipped"); strictEvaluation=true flips it to EFFECT_DENY. Cerbos's conditions page claims the opposite for v0.55; the engine page and the source are right. Kerberos has no per-rule skip: the error follows `onError`, and inside a checkResources batch it isolates to a fail-closed DENY with reason: 'evaluation-error'. Recorded as a divergence via `cerbosActions` so the suite tells us if Cerbos ever makes good on its docs. Role-policy scope: replaced the prose claim with the measured table (RT@acme allowlists 'other', RT@base allowlists 'ping' → principal-scoped request ALLOWs, resource-scoped request DENYs), which is what settles that the field follows the resource scope chain despite the docs calling it a principal scope. 58 conformance cases, green offline and against the live PDP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: Implement Cerbos policy importer, attribute schema enforcement, and async loader (#8) * feat: Cerbos policy importer — YAML + CEL→$expr on the /cerbos subpath New @alexify/kerberos/cerbos subpath (strategic track P1): turns an existing Cerbos policy repository into Kerberos policies in-process, with zero dependencies. - src/cerbos/yaml.js — parser for the YAML subset Cerbos policies are written in (block/flow/quoted/block-scalar forms, comments, --- streams); anchors, aliases, tags, directives, multi-line plain scalars and tab indentation throw. Verified differentially against the reference `yaml` package over the whole conformance corpus plus edge-case tables (test/CerbosYaml.test.js). - src/cerbos/cel.js — CEL lexer + recursive-descent parser for the full expression grammar (raw/triple-quoted strings, hex/uint/double literals, // comments); bytes literals, message construction and leading-dot names rejected at parse with offsets. - src/cerbos/translate.js — celToExpr: CEL AST → JS for the safe $expr interpreter (documented jsep setup). Timestamps are epoch-ms numbers (Date.parse/Date.now) so comparison/equality/arithmetic stay numeric; Go-style duration literals constant-fold to ms; has() → typeof … !== "undefined" (explicit null is present, as in CEL); in → .includes (fail-loud on maps); replace → split/join (CEL replaces every occurrence); UTC date accessors incl. the zero-based getDayOfMonth; int-literal division truncates. Macros, matches(), Cerbos extension functions, globals/runtime/auxData throw named errors. Every translation is tested semantically through the codec. - src/cerbos/importer.js — importCerbosPolicies: structural document mapper for all four policy kinds; skips disabled: true, accepts SCOPE_PERMISSIONS_OVERRIDE_PARENT, validates effects, refuses unknown keys at every level; the only opt-out is drop: ['schemas']. - conformance/importer.test.js — the whole PDP-pinned corpus re-run through the public importer: all 52 decision and 4 plan expectations hold for importer-loaded policies (real YAML parsing + CEL translation instead of the shared-subset passthrough). - Wiring: package.json exports/files, cerbos.d.ts (+ tsd + export parity), pnpm size entry (10.7 KB min+gzip, browser-clean), README section, docs guide page + nav + exports/installation tables, CHANGELOG, CLAUDE.md, conformance README/DIVERGENCES cross-refs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: attribute schema enforcement — Cerbos `schemas` parity Resource policies now accept a Cerbos-shaped `schemas:` block (principalSchema/resourceSchema refs + ignoreWhen.actions globs, all three validation backends extended), enforced through the new `schemas` engine option (src/attributeSchemas.js): - definitions map refs to validators: JSON Schema (compiled with the `ajv` option), Zod-like schemas, or plain validator functions — all normalized at construction; - enforcement 'reject' (default when the option is set) denies every action of an invalid request — a principal policy cannot rescue it — with reason 'invalid-attributes' and Cerbos-shaped validationErrors ({ path, message, source }) on the checkResources result, never gated on includeMeta; 'warn' reports without changing decisions; 'none' or an absent option leaves policy schema refs inert (Cerbos's own unconfigured default); - ignoreWhen skips validation only when EVERY requested action matches; with scoped policies the most specific chain entry declaring schemas wins; a ref missing from definitions throws KerberosValidationError regardless of onError; - wired into BOTH evaluation drivers before principal evaluation, with the resolved resource chain threaded into the decision walk so enforcement never adds a second chain lookup; validationErrors also reach audit entries; - the Cerbos importer now translates schemas: blocks verbatim (drop: ['schemas'] still discards them); - types (ResourcePolicyAttributeSchemas, AttributeValidationError, KerberosAttributeSchemasOptions, the 'invalid-attributes' reason), README/guides/DIVERGENCES/CHANGELOG updated; 21 new unit tests incl. sync/async driver parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: /loader subpath — file/directory policy loader + hash-stamped bundles New Node-only @alexify/kerberos/loader subpath (strategic track P1): - loadPolicyDirectory / loadPolicyFile read policy-as-code repositories: Kerberos serialized JSON and Cerbos YAML/JSON mix freely (`.yaml` and apiVersion-carrying JSON route through the /cerbos importer; the `cerbos` option forces or disables that routing), deterministic sorted order, `_`-prefixed and hidden entries skipped, `_schemas/**.json` surfaced keyed both bare and `cerbos:///…` for the engine's schemas.definitions option, and an optional codec deserializes documents straight into constructor inputs. - createPolicyBundle / writePolicyBundle / loadPolicyBundle implement GitOps bundle artifacts: `version` is the SHA-256 of the canonical sorted-key JSON of { policies, derivedRoles } (byte-reproducible with createdAt: null), recomputed and verified on load — tampered, truncated or foreign-stamped bundles throw; live (deserialized) policies are rejected at bundling since functions would silently stringify away. - The one documented exception to the src/ platform-neutral rule: src/loader/index.js uses node:fs/path/crypto directly, and browser bundlers substitute src/loader/browser.js (identical surface, throwing stubs) via the package `browser` map + exports condition. - Typed KerberosLoaderError (carries `file`); loader.d.ts + tsd + export-parity; fixtures-based tests incl. an end-to-end engine build with _schemas wiring and bundle tamper detection; README/guide/docs nav/CHANGELOG/CLAUDE.md updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: `kerberos` CLI — policy testing + bundle baking from the command line New published binary (package.json bin, strategic track P1): - `kerberos test <policiesDir> <testsDir>` runs Cerbos-TestSuite-format suites (*_test.yaml/json: named principal/resource fixtures + expected effects) against a /loader-loaded policy directory, so a pure policy repository tests itself in CI with zero engineering glue. jsep and its documented plugins resolve from the CALLER's project (createRequire on cwd) with an actionable install hint when { $expr } policies need them; --schemas reject|warn wires _schemas/ into attribute-schema enforcement (ajv resolved the same way); --json emits a structured report; the runner refuses expectation features it does not check (e.g. outputs) instead of silently passing. Exit codes: 0/1/2. - `kerberos bundle <dir> --out <file> [--reproducible]` bakes the hash-stamped bundle artifact from the /loader subpath. - Tested by spawning the real binary: passing/failing suites, --json, load-bearing --schemas flag (same suite passes only with enforcement), refused expectation keys, bundle verification, byte-stable --reproducible output. lint/format now cover bin/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: seeded fuzz suite + published cross-library benchmarks Strategic track P1 "published comparative benchmarks + fuzzing": - test/Fuzz.test.js — deterministic (mulberry32-seeded) mutation fuzzing of the security-sensitive surfaces, running as part of `pnpm test` (FUZZ_ITERATIONS cranks it): the $expr codec must throw typed errors only, never leak functions, never pollute Object.prototype; every expression celToExpr emits must compile under the documented jsep setup; the YAML parser and RelationResolver throw typed errors only. The suite already paid for itself: a 20k-iteration run caught @jsep-plugin/new emitting a malformed callee-less NewExpression node for `new R.attr.x`, which escaped the codec as a raw TypeError — the validator/evaluators now guard the missing callee and reject it as KerberosExprError (regression-pinned). - bench/compare.js (`pnpm bench:compare`) — Kerberos vs @casl/ability vs casbin on one shared RBAC+ownership scenario, with allow/deny sanity cross-checks so the three implementations provably encode the same rules; scripts/size-compare.js (`pnpm size:compare`) — browser min+gzip comparison (casbin does not bundle for the browser at all). Honest numbers and their caveats published in docs/guide/benchmarks.md and the README (CASL's prebuilt check is faster because it does dramatically less; @cerbos/embedded and OPA-WASM excluded because their bundles cannot be built from open tooling). Docs benchmark table synced with the README's current numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: verified Cerbos ORM-adapter compatibility (toCerbosQueryPlan) Strategic track P1 "verified ORM-adapter compat": - New main-entry export `toCerbosQueryPlan` (src/planning/sdk.js): converts planResources output from the HTTP-API operand encoding Kerberos emits ({ variable } / { expression: { operator, operands } }) into the flattened @cerbos/core SDK encoding ({ name } / { operator, operands }) the official Cerbos ORM adapters consume — plan kinds are byte-identical, so this is the one hop needed. Kerberos-only operators follow refuse-to-guess at the boundary: a `relation` operand throws naming expandRelationOperands (materialize first), an `opaque` operand throws with a post-filtering directive. - test/OrmAdapters.test.js makes the README's "Cerbos ORM adapters accept the filter" claim CI-executable against the REAL packages (@cerbos/orm-prisma 4.x, @cerbos/orm-drizzle, new devDeps with @prisma/client + drizzle-orm peers): conditional and membership plans pin exact Prisma where objects and Drizzle SQL (+params), kinds pass through, expanded relation plans render as id IN (...), opaque plans are refused. - Recipes in README/query-plans guide ("Using the official Cerbos ORM adapters"), exports tables, CerbosSdkQueryPlan type, CHANGELOG, CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: refresh bundle-size numbers after the P1 wave Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(loader): async driver — the `promises` namespace with concurrent reads The /loader subpath (and the CLI on top of it) used only synchronous fs calls; a large policy repository therefore paid one blocking read at a time on cold start. The subpath now ships BOTH drivers over one shared decision core: - Top-level functions stay exactly as they were (sync, unchanged API). - New `promises` namespace (Node's fs.promises idiom — same function names returning promises): promises.loadPolicyFile / loadPolicyDirectory / writePolicyBundle / loadPolicyBundle. promises.loadPolicyDirectory walks directories and reads policy + _schemas files CONCURRENTLY, bounded by the new `concurrency` option (default 64, via the shared createLimiter from src/async.js) — fast cold starts over many files without blocking the event loop. createPolicyBundle is pure CPU and stays top-level only. - Shared core, not duplication: routing/JSON+YAML ingestion (ingestPolicyText), _schemas parsing (addSchemaDefinition), directory assembly, bundle stamping/serialization/verification (buildBundle/ensureBundle/resolveBundle), skip/sort rules and option normalization are single functions consumed by both drivers — results are byte-identical (deterministic sorted ingestion order regardless of read-completion order), which the tests pin with deep equality between drivers for directories, files and bundle artifacts. - Browser stub exports the same `promises` surface as rejecting stubs. - The kerberos CLI now loads policies through the async driver and reads test-suite files concurrently too. - loader.d.ts `promises` typings + tsd; export-parity covers the new surface; docs (policy-loader guide "Async loading", README, CHANGELOG, CLAUDE.md). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): skip the Cerbos ORM-adapter suites where require(esm) is unavailable `test/OrmAdapters.test.js` failed the whole Node 18 CI leg with ERR_REQUIRE_ESM. The cause is entirely third-party: `@cerbos/orm-prisma` and `@cerbos/orm-drizzle` are CommonJS but `require("@cerbos/core")`, which is ESM-only, so merely LOADING them needs Node's require(esm) support — 20.19+ / 22.12+, and present-but-flagged on 22.10-22.11. CI's Node 20 leg resolves to 20.20.2 and its Node 22 leg to a 22.12+ patch, which is why only the 18 matrix entry broke. Feature-detect via `process.features.require_module` rather than parsing versions (a flagged-off runtime then reads correctly too) and gate only the two adapter suites with `{ skip }`. The `toCerbosQueryPlan` suite requires nothing third-party, so it now runs on Node 18 as well — previously the module-load crash took it down with everything else, and `src/planning/sdk.js` coverage on that leg rises from 46% to 87%. Documented the constraint in README + docs/guide/query-plans.md: the adapters are gated to Node 20.19+/22.12+, while `toCerbosQueryPlan` and the rest of Kerberos keep the package's `engines: >=18` promise. Verified: Node 18.19.1 `test:coverage` green (973 pass, thresholds met); Node 22.10.0 skips as intended; Node 24 runs all 10 adapter tests. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * docs: add Vercel Web Analytics and Speed Insights to the VitePress site The docs site is already deployed to Vercel (vercel.json), so both products are one theme hook away. Wired in `enhanceApp` rather than as Vue components: the vanilla `inject()` / `injectSpeedInsights()` entry points need no layout slot and keep `theme/index.ts` a pure extension of DefaultTheme. Two details the naive wiring gets wrong: - The site is prerendered, and both packages are browser-only, so the SSR pass returns early. Verified: zero `_vercel` references in the built HTML, both scripts present in the client chunk. - Analytics tracks VitePress' pushState navigations by itself, but Speed Insights does not — an SPA has to announce the route or every Core Web Vital is attributed to whichever page loaded first. Hooked through `router.onAfterRouteChange` (the non-deprecated name in VitePress 1.6). Both are devDependencies and `files` in package.json omits `docs/`, so the published package and its zero-dependency runtime are untouched. Verified in the browser against `docs:dev`: both scripts load, pageviews fire for `/` -> `/guide/why` -> `/guide/scopes` across SPA navigations, a vitals payload is emitted, and the injected script's `data-route` tracks the route (`/guide/scopes` -> `/guide/rebac`). Data starts flowing once Analytics and Speed Insights are enabled on the Vercel project. * chore: bump version --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request introduces several major features and improvements to Kerberos.js, focusing on enhanced Cerbos compatibility, new tooling for policy testing and loading, attribute schema enforcement, and expanded documentation and benchmarking. The most significant changes are grouped below:
Cerbos Compatibility and Integration:
toCerbosQueryPlanexport, enabling conversion ofplanResourcesoutput into the@cerbos/coreSDK encoding. This is verified in CI using the real@cerbos/orm-prismaand@cerbos/orm-drizzlepackages, ensuring that Kerberos plans are accepted by official Cerbos adapters. Special handling is provided for Kerberos-only operators, with explicit error reporting for unsupported plan types.@alexify/kerberos/cerbossubpath) that translates Cerbos YAML/JSON policies—including CEL expressions—into Kerberos policies, with strict erroring on unsupported constructs and full conformance test coverage.Tooling and Loader Enhancements:
@alexify/kerberos/loadersubpath) supporting mixed Kerberos and Cerbos policy repositories, versioned hash-stamped bundles, and concurrent loading for fast cold starts. The loader is used internally by the new policy-testing CLI. [1] [2]kerberosCLI for running Cerbos-TestSuite-format policy tests and bundling policies, with schema enforcement options and machine-readable output.Attribute Schema Enforcement:
schemasparity. New engine options allow for reject/warn/none enforcement modes, with validation errors surfaced in results and audit logs. [1] [2]Testing, Fuzzing, and Benchmarking:
pnpm bench:compare) and bundle-size comparisons (pnpm size:compare) against CASL and Casbin, with results documented. [1] [2]Documentation and Project Metadata:
CLAUDE.mdto reflect new features: Cerbos importer, loader, attribute schema enforcement, new CLI commands, and platform-specific loader handling. Also updated the project size estimate (~32 KB min+gzip). [1] [2] [3] [4] [5]These changes significantly expand Kerberos.js’s interoperability, usability, and correctness, especially for teams integrating with Cerbos or requiring strong policy validation and testing.