Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
# Smoke: keeps the bundle-size script working and its numbers visible in CI logs.
- run: pnpm size

test:
runs-on: ubuntu-latest
Expand Down
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,80 @@ All notable changes to **`@alexify/kerberos`** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.1.0] - 2026-07-21

### Added

- **Resources Query Plan API — `kerberos.planResources(args)`**, Cerbos-compatible
([`/api/plan/resources`](https://docs.cerbos.dev/cerbos/latest/api/#resources-query-plan)):
partially evaluates the policies against everything known at plan time (full
principal, `resource.kind`, known `attr`) and returns a filter over the
unknown resource fields — `KIND_ALWAYS_ALLOWED` / `KIND_ALWAYS_DENIED` /
`KIND_CONDITIONAL` with a Cerbos-shaped `{ operator, operands }` condition
tree (`request.resource.id` / `request.resource.attr.*` variables; operators
`and/or/not/eq/ne/lt/le/gt/ge/in/add/sub/mult/div/mod/index/list`), ready for
translation into database queries (compatible in shape with Cerbos ORM
query-plan adapters).
- **Full layer parity with `isAllowed`**: principal override (Deny wins,
conditional principal rules compose residually), role-policy allowlist with
implicit deny + `parentRoles` intersection (Deny-wins across roles, cycle
detection), resource layer Deny-over-Allow with default deny, scope
first-match-wins + `policyVersion` selection, cache-backed policies —
guarded by a property-style parity suite (`test/PlanParity.test.js`) that
grid-samples unknown attributes against real `isAllowed` results.
- **Partial evaluator** for codec-compiled `{ $expr }` conditions
(`src/planning/`): constant folding through the codec's own interpreter
(including `&&`/`||`/`?:` laziness), `variables` inlined at `V.*` use
sites, `C.*`/`P.*` folded to literals; plain JS-function conditions and
non-translatable constructs degrade soundly to the Kerberos **`opaque`**
operator (translator post-filters).
- **ReBAC bridge**: relation-backed derived roles plan as the Kerberos
**`relation`** operator; the new exported **`expandRelationOperands(plan,
lookup)`** helper materializes them into
`in(request.resource.id, [ids])` via any resolver (e.g.
`RelationResolver.lookupResources`).
- `action` (single) **or** `actions` (multi — the plan is the AND of the
per-action plans, Cerbos semantics); `includeMeta` adds `filterDebug`
(s-expression rendering), `matchedScopes` and the `resolution` trace;
`onError: 'deny'` fail-closes to `KIND_ALWAYS_DENIED`; wildcard `'*'`
actions are rejected at validation.
- Policy sources resolve as one concurrent `Promise.allSettled` wave
(principal / roles+parent-closure / resource+derived-roles) — with a
cache-backed store, one round-trip wave instead of three sequential ones;
the `meta.resolution` trace order stays deterministic.
- `buildPlanResourcesArgs` schema builders across all three validation
backends (Zod / JSON Schema / TypeBox), `Kerberos.parsePlanResourcesArgs`,
the exported **`PlanKind` enum** (`AlwaysAllowed` / `AlwaysDenied` /
`Conditional`, mirroring `Effect`), hand-maintained types (`PlanFilter`,
`PlanExpressionOperand`, `PlanResourcesArgs`, `PlanResourcesResponse`), a
`planResources` bench scenario and a README section with the planning
flow diagram.
- **Plan observability**: each `planResources` call records the outcome —
span attributes (`kerberos.plan.kind`, `kerberos.plan.opaque_count`,
`kerberos.plan.relation_count`, `kerberos.plan.actions_count`), a new
**`kerberos.plans`** counter (by filter kind and resource kind) and a
structured `PlanResources.result` audit entry — so an `ALWAYS_ALLOWED`
filter (a fail-open query) is distinguishable from an `ALWAYS_DENIED` one
in traces, metrics and logs.
- Bundle-size tooling: `pnpm size` (esbuild browser bundle, minified +
gzipped, per entry) with the measured numbers in the README; CI smoke-runs
it, and lint/format now also cover `scripts/` and `bench/`.

### Fixed

- **Wire safety of query-plan filters**: folded constants that JSON transport
would silently corrupt (`undefined` vanishes, `NaN`/`Infinity` become
`null`, `Date` instances become ISO strings, `BigInt` throws) are no longer
emitted into filter operands — such conditions degrade to the sound `opaque`
operator instead, and the parity suite now verifies filters **after** a JSON
round-trip.
- Plan construction no longer throws on principals whose attributes contain
circular structures (node deduplication survives unserializable values).
- Compiled `{ $expr }` ASTs are now **deeply frozen** at parse time: the
cached AST shared by every closure of the same expression (and exposed to
the query planner) can no longer be mutated to alter other consumers'
evaluation.

## [3.0.0] - 2026-07-20

### Added
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project

Kerberos.js (`@alexify/kerberos`) is a zero-dependency (~6 KB), in-process authorization engine for JavaScript — a lightweight, embeddable alternative to Cerbos. It evaluates `resourcePolicy` / `principalPolicy` / `rolePolicy` documents against `(principal, resource, action)` requests and returns `EFFECT_ALLOW` / `EFFECT_DENY`, with optional derived roles, conditions, variables, constants, outputs, scopes, schema validation, audit logging, cache-backed dynamic policies, and ReBAC (relation-backed derived roles + a built-in SpiceDB-inspired Zanzibar-lite resolver on the `/relations` subpath). Runs in Node.js and the browser.
Kerberos.js (`@alexify/kerberos`) is a zero-dependency (~8 KB), in-process authorization engine for JavaScript — a lightweight, embeddable alternative to Cerbos. It evaluates `resourcePolicy` / `principalPolicy` / `rolePolicy` documents against `(principal, resource, action)` requests and returns `EFFECT_ALLOW` / `EFFECT_DENY`, with optional derived roles, conditions, variables, constants, outputs, scopes, schema validation, audit logging, cache-backed dynamic policies, ReBAC (relation-backed derived roles + a built-in SpiceDB-inspired Zanzibar-lite resolver on the `/relations` subpath), and Cerbos-compatible resources query plans (`planResources`). Runs in Node.js and the browser.

Package manager is **pnpm** (`packageManager: pnpm@11.5.0`). CommonJS throughout (`require`/`module.exports`), no build/transpile step — `src/` ships as-is.

Expand Down Expand Up @@ -55,7 +55,7 @@ Per-action resolution order (`#evaluatePolicySources`), computed independently f
3. Otherwise, fall back to `ResourcePolicy` matched by `resource.kind` (rules matched by `roles` / `derivedRoles`, evaluated via `Conditions`/`Variables`/`Constants`/`Outputs`).
4. No match → `EFFECT_DENY`.

Public API is just `isAllowed(args)` (single action → boolean) and `checkResources(args, effectAsBoolean?)` (batch, multiple resources/actions → structured response with `kerberosCallId`, `outputs`, optional `meta`). Both share the `#runRequest` lifecycle wrapper (telemetry span + audit events + `onError` semantics) and the `#evaluatePolicySources` core; the three per-source lookups go through the single `#resolvePolicy` resolver (scope-chain memoized per instance). Internals always use canonical `EFFECT_*` strings — `effectAsBoolean` converts once at the response boundary. `checkResources` evaluates resources concurrently (`Promise.allSettled`); a rejected resource fail-closes to DENY for its actions without failing the batch. Error semantics: all logger/telemetry calls are internally guarded (can never affect decisions); evaluation errors follow the `onError: 'throw' | 'deny'` option; malformed arguments always throw `KerberosValidationError`; transient `cache.get` failures retry per `cacheRetry` then surface as `KerberosCacheError`; corrupt cache entries log as `KerberosCodecError` and count as a miss. Duplicate policy keys (and derived-roles names) throw at construction. With `includeMeta`, denied actions carry a `reason` and `meta.resolution` records every policy lookup. Shared DSL parsers live in `src/policyParsers.js`; wildcard/default tokens (`ALL_ACTIONS`, `ALL_ROLES`, `ALL_RESOURCES`, `DEFAULT_VERSION`, `BASE_SCOPE`) in `src/schemas/index.js` — use the semantically-matching constant.
Public API is `isAllowed(args)` (single action → boolean), `checkResources(args, effectAsBoolean?)` (batch, multiple resources/actions → structured response with `kerberosCallId`, `outputs`, optional `meta`) and `planResources(args)` (query planning — see the Query planning section). Both share the `#runRequest` lifecycle wrapper (telemetry span + audit events + `onError` semantics) and the `#evaluatePolicySources` core; the three per-source lookups go through the single `#resolvePolicy` resolver (scope-chain memoized per instance). Internals always use canonical `EFFECT_*` strings — `effectAsBoolean` converts once at the response boundary. `checkResources` evaluates resources concurrently (`Promise.allSettled`); a rejected resource fail-closes to DENY for its actions without failing the batch. Error semantics: all logger/telemetry calls are internally guarded (can never affect decisions); evaluation errors follow the `onError: 'throw' | 'deny'` option; malformed arguments always throw `KerberosValidationError`; transient `cache.get` failures retry per `cacheRetry` then surface as `KerberosCacheError`; corrupt cache entries log as `KerberosCodecError` and count as a miss. Duplicate policy keys (and derived-roles names) throw at construction. With `includeMeta`, denied actions carry a `reason` and `meta.resolution` records every policy lookup. Shared DSL parsers live in `src/policyParsers.js`; wildcard/default tokens (`ALL_ACTIONS`, `ALL_ROLES`, `ALL_RESOURCES`, `DEFAULT_VERSION`, `BASE_SCOPE`) in `src/schemas/index.js` — use the semantically-matching constant.

### Validation backends (`src/validation/`)

Expand All @@ -67,6 +67,10 @@ Kerberos is cache-**agnostic**: `cache.js` (`createCacheReader`) wraps anything

Because remote-stored policies must be JSON (no live functions), `codec.js` (`createSafeExprCodec`) implements an **eval-free AST-allowlist interpreter** on top of `jsep`: conditions/variables/outputs are authored as `{ "$expr": "..." }` strings, parsed once per (jsep instance, expression) and cached, then walked against `{P, R, V, C}` plus a curated safe-builtins allowlist (`Math`, `Date`, `parseInt`/`parseFloat`/etc.). `__proto__`/`prototype`/`constructor` member access is blocked at the interpreter level regardless of how it's spelled, and there is deliberately no `eval`/`new Function`/`fn.toString()` anywhere in this path — see the "Serialization mechanism" section of `README.md` for the rationale before changing this file. The recommended production stack layered on top (documented in README, not part of this package) is `keyv` → `cacheable` (`CacheSync`) → `qified` (pub/sub invalidation across hosts).

### Query planning (`src/planning/`)

`kerberos.planResources(args)` returns a Cerbos-compatible resources query plan: `filter.kind` `KIND_ALWAYS_ALLOWED`/`KIND_ALWAYS_DENIED`/`KIND_CONDITIONAL` plus a `{ operator, operands }` condition tree over `request.resource.id` / `request.resource.attr.*` (Cerbos operator vocabulary + two Kerberos extensions: `opaque` = statically unplannable → translator post-filters; `relation` = ReBAC dependency → materialized by the exported `expandRelationOperands(plan, lookup)` helper). Infra folder in the `src/caching/` style (flat modules, NOT the four-file DSL pattern): `nodes.js` (plan-node model — const/expr/and/or/not/opaque/relation, normalizing constructors with constant folding/flattening/dedup, `toFilter`/`fromOperand`/`toDebugString`), `partialEval.js` (`createExprPlanner` — bottom-up partial evaluation of codec-compiled `$expr` ASTs with explicit `&&`/`||`/`?:` laziness; folds via the codec's own interpreter, residualizes unknown `R` members, inlines variables, degrades soundly to `opaque`; per-policy instances carry that policy's constants/variables context), `planner.js` (`buildResourcePlan` — pure sync layer composition mirroring `#evaluatePolicySources`: per action `OR(AND(PA,¬PD), AND(¬PA,¬PD,layer))` where layer = role allowlist (AND across applicable roles, parentRoles intersection, cycle throw) or resource layer (`AND(OR allows, NOT(OR denies))`); multi-action = AND), `expand.js` (the ReBAC bridge). The engine does all async work (`#planPolicySources`: policy/derived-roles/role-closure resolution incl. cache fallback) before calling the pure planner. Codec seam: `compileExpr` attaches frozen `{ expr, ast, roots }` meta under the exported `EXPR_META` Symbol; `evalExprAst` re-exposes the interpreter — both are internal (explicitly destructured OUT of the public surface in `src/index.js`; only `expandRelationOperands` and the `PlanKind` enum are public). The engine's `#planPolicySources` resolves the three source chains (principal / roles+parent-closure / resource+derived-roles) as one concurrent `Promise.allSettled` wave with per-chain trace buffers (deterministic `meta.resolution` order). Invariants: the planner never mutates shared cached ASTs (builds new nodes only); soundness rule — when a construct can't be translated, emit `opaque`, never guess; plannable conditions are codec-compiled `$expr` closures (static in-process policies must go through `deserializePolicy` first — the constructor deliberately does NOT auto-deserialize); parity with `isAllowed` is enforced by the grid-sampling suite in `test/PlanParity.test.js` — any change to evaluation semantics in `Kerberos.js`/policy classes must keep that suite green (and vice versa: planner changes must not drift from the runtime).

### Logging (`src/logging.js`)

`logger` option accepts `true` (legacy `console.group`/`table`/`debug` output), a custom console-like object, or a structured logger (e.g. Pino, detected via `info`/`debug` methods) which receives one structured audit entry per evaluated action. When logging is enabled, runtime/validation errors are caught and converted to fallback results (`isAllowed` → `false`, `checkResources` → empty results) instead of being thrown; when disabled, errors propagate to the caller.
Expand Down
Loading
Loading