From ce5b9d0dd4dbe09245ab07b122ba7c40f61c1773 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 20 Jul 2026 19:33:02 +0300 Subject: [PATCH 1/5] feature: added Query Plans (planResources) --- CHANGELOG.md | 43 ++ CLAUDE.md | 8 +- README.md | 177 +++++++- bench/bench.js | 40 ++ index.d.ts | 78 ++++ package.json | 2 +- src/Kerberos.js | 212 ++++++++- src/caching/codec.js | 32 +- src/index.js | 7 +- src/planning/expand.js | 82 ++++ src/planning/nodes.js | 226 ++++++++++ src/planning/partialEval.js | 458 ++++++++++++++++++++ src/planning/planner.js | 244 +++++++++++ src/schemas/index.js | 34 ++ src/schemas/kerberos.js | 38 ++ test/PlanParity.test.js | 263 +++++++++++ test/PlanResources.test.js | 837 ++++++++++++++++++++++++++++++++++++ test/Planning.test.js | 338 +++++++++++++++ test/types.test-d.ts | 36 ++ 19 files changed, 3141 insertions(+), 14 deletions(-) create mode 100644 src/planning/expand.js create mode 100644 src/planning/nodes.js create mode 100644 src/planning/partialEval.js create mode 100644 src/planning/planner.js create mode 100644 test/PlanParity.test.js create mode 100644 test/PlanResources.test.js create mode 100644 test/Planning.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7690da4..1ce061b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ 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-20 + +### 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. + - `buildPlanResourcesArgs` schema builders across all three validation + backends (Zod / JSON Schema / TypeBox), `Kerberos.parsePlanResourcesArgs`, + hand-maintained types (`PlanKind`, `PlanFilter`, `PlanExpressionOperand`, + `PlanResourcesArgs`, `PlanResourcesResponse`), a `planResources` bench + scenario and a README section with the planning flow diagram. + ## [3.0.0] - 2026-07-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b2efa01..b244384 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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/`) @@ -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` is public). 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. diff --git a/README.md b/README.md index 9d8ba74..bdfae22 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Kerberos.js is a JavaScript library for authorization solutions. It is a simple - Cerbos is a powerful authorization engine, but it is written in Go and requires a separate server to run. - We all know that gRPC is faster than REST API because it uses protobuf. But it can be even faster—by avoiding network requests altogether. Often, maintaining a separate service just for your permissions can be unnecessary, don’t you think? -- Kerberos.js is a lightweight alternative that can be used in the browser or server-side JavaScript applications (only up to 6 KB). +- Kerberos.js is a lightweight alternative that can be used in the browser or server-side JavaScript applications (only up to 8 KB, query planner included). - Some features that are only available in the paid version of Cerbos(Cerbos Hub) are available here for free. - Embedded Cerbos features: - In-browser/serverless authorization; @@ -27,6 +27,7 @@ Kerberos.js is a JavaScript library for authorization solutions. It is a simple - [x] APIs: - [x] isAllowed API; - [x] CheckResourceSet API; + - [x] PlanResources API (Cerbos-compatible [query plans](#query-plans-planresources)); - [x] Audit logs; - [x] Logger (legacy console + structured / Pino); - [x] In-browser/serverless authorization; @@ -62,6 +63,7 @@ Kerberos.js is a JavaScript library for authorization solutions. It is a simple - [Metadata](#metadata) - [Caching / Storing Policies](#caching--storing-policies) - [ReBAC (Relations)](#rebac-relations) +- [Query Plans (planResources)](#query-plans-planresources) - [Testing](#testing) - [Benchmarks](#benchmarks) @@ -244,6 +246,27 @@ const response = await kerberos.checkResources({ // } ``` +### `kerberos.planResources(args) => Promise` + +Builds a **resources query plan**: instead of a yes/no decision for one resource, it returns a *filter* describing **which** resources of a kind the principal may act on — ready to translate into a database query. See [Query Plans](#query-plans-planresources). + +- `args.principal` — the principal (`id`, `roles`, optional `policyVersion`, `scope`, `attr`). +- `args.resource` — the resource **kind** (`kind`, optional `policyVersion`, `scope`, `attr`). No `id`: `attr` carries only the *known* attributes; everything else stays unknown and surfaces in the filter. +- `args.action` **or** `args.actions` — exactly one of them; multiple actions plan the conjunction (Cerbos AND semantics). The wildcard `'*'` cannot be planned. +- `args.reqId` / `args.includeMeta` — as in `checkResources`; `includeMeta` adds `filterDebug`, `matchedScopes` and the `resolution` trace. + +```javascript +const plan = await kerberos.planResources({ + principal: { id: 'user1', roles: ['USER'] }, + resource: { kind: 'expense' }, + action: 'view', +}); +// { +// kerberosCallId: '…', action: 'view', resourceKind: 'expense', policyVersion: 'default', +// filter: { kind: 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL', condition? }, +// } +``` + ### Exports | Export | Purpose | @@ -253,6 +276,7 @@ const response = await kerberos.checkResources({ | `ResourcePolicy`, `PrincipalPolicy`, `RolePolicy`, `DerivedRoles` | Policy classes (rarely constructed directly). | | `Conditions`, `Variables`, `Constants`, `Outputs` | DSL building blocks. | | `createSafeExprCodec`, `serializePolicy`, `deserializePolicy` | Safe AST codec for [dynamic/stored policies](#caching--storing-policies). | +| `expandRelationOperands` | Materializes ReBAC `relation` operands of a [query plan](#query-plans-planresources) into id filters. | | `registerAjvKeywords`, `createAjvAdapter` | [Validation](#schema-validation) helpers. | | `JsonSchemas`, `TypeBoxSchemas`, `ZodSchemas`, `KerberosJsonSchemas`, `ResourcePolicyJsonSchemas`, `PrincipalPolicyJsonSchemas`, `RolePolicyJsonSchemas`, … | Schema builders for the three backends. | @@ -1240,7 +1264,7 @@ What it borrows from SpiceDB (see [`src/Relations/`](./src/Relations)): - the **recursive check** with short-circuiting (union stops at the first ALLOW, intersection at the first DENY, exclusion is base-first and order-sensitive); - **per-request memoization** of subproblems (`(resource#relation@subject)`), shared across a whole `checkResources` batch; concurrent identical document reads coalesce (the in-process analog of SpiceDB's singleflight); - **depth limiting instead of cycle tracking** (`maxDepth`, default 50) — visited-sets are semantically unsound under exclusions, so cyclic relationship data throws a typed `KerberosRelationsError`; -- **caveats** (ABAC-on-ReBAC): named conditions bound to tuples with write-time context; at check time the written context takes precedence over the check-time `context` argument, and the condition sees `{ P, ctx }`. Caveats are ordinary Kerberos `Conditions` — for JSON/cache-stored schemas author them as `{ match: { $expr: '...' } }` and pass a codec built with `createSafeExprCodec({ jsep, roots: ['P', 'ctx'] })` (same eval-free guarantees as dynamic policies). A throwing or false caveat fails closed. There is deliberately no CEL and no partial evaluation (`CONDITIONAL` results) — in-process, the full context is available at check time; +- **caveats** (ABAC-on-ReBAC): named conditions bound to tuples with write-time context; at check time the written context takes precedence over the check-time `context` argument, and the condition sees `{ P, ctx }`. Caveats are ordinary Kerberos `Conditions` — for JSON/cache-stored schemas author them as `{ match: { $expr: '...' } }` and pass a codec built with `createSafeExprCodec({ jsep, roots: ['P', 'ctx'] })` (same eval-free guarantees as dynamic policies). A throwing or false caveat fails closed. There is deliberately no CEL and no partial evaluation of caveats (`CONDITIONAL` results) — in-process, the full context is available at check time (engine-level query planning is a separate, explicit API: [`planResources`](#query-plans-planresources)); - **reverse lookups**: `lookupSubjects` walks the permission tree forward and expands groups (wildcards come back as `'user:*'`, or `{ subject: 'user:*', exclusions: [...] }` under exclusions; caveated tuples are treated as present — an upper bound); `lookupResources` uses compile-time reachability entrypoints plus candidate verification for intersection/exclusion/caveat paths (the LookupResources2 pattern). ### Resolver telemetry @@ -1275,6 +1299,154 @@ This is deliberately **not** full Zanzibar. The hard part of Zanzibar is distrib - the staleness window for dynamic tuples equals your cache-invalidation window (e.g. qified pub/sub propagation). Until an invalidation propagates, a just-revoked subject may still pass on another host — if that window matters for your threat model, put revocation-sensitive checks behind static tuples, shorten TTLs, or use a centralized authorization service (SpiceDB) instead; - there are no per-request consistency levels and no revision tokens. +## Query Plans (planResources) + +`isAllowed` answers *"may this principal act on **this** resource?"*. `planResources` answers the inverse — *"**which** resources may this principal act on?"* — by **partially evaluating** the policies against everything known at plan time (the full principal, `resource.kind`, any known `attr`) and returning a *filter* over the unknown resource fields. Translate that filter into a `WHERE` clause and the database returns exactly the permitted rows — no fetch-all-then-filter. + +The response is shaped like the [Cerbos PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/#resources-query-plan) (`filter.kind` + `condition` operand tree, same operator vocabulary), so Cerbos-ecosystem query-plan adapters ([queryPlanToPrisma](https://github.com/cerbos/query-plan-adapters), etc.) understand the shape. Kerberos adds two operators of its own: [`opaque`](#opaque-conditions-post-filtering) and [`relation`](#relation-operands-rebac). + +```javascript +const { Kerberos, createSafeExprCodec, deserializePolicy } = require('@alexify/kerberos'); + +const codec = createSafeExprCodec({ jsep }); +const policy = deserializePolicy({ + resourcePolicy: { + resource: 'expense', + version: 'default', + rules: [ + { actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['USER'], + condition: { match: { $expr: "R.attr.ownerId === P.id || R.attr.status === 'APPROVED'" } } }, + ], + }, +}, codec); + +const kerberos = new Kerberos([policy], []); +const plan = await kerberos.planResources({ + principal: { id: 'u1', roles: ['USER'] }, + resource: { kind: 'expense' }, + action: 'view', +}); +// plan.filter: +// { +// kind: 'KIND_CONDITIONAL', +// condition: { expression: { operator: 'or', operands: [ +// { expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.ownerId' }, { value: 'u1' }] } }, +// { expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.status' }, { value: 'APPROVED' }] } }, +// ] } }, +// } +``` + +Unconditional outcomes short-circuit: `filter.kind` is `KIND_ALWAYS_ALLOWED` / `KIND_ALWAYS_DENIED` with no `condition` (skip the query, or return everything/nothing). + +### How a plan is composed + +The planner mirrors [Mixed Policy Evaluation](#mixed-policy-evaluation) symbolically, layer by layer. Which layer decides is already known at plan time (it depends only on the principal and `resource.kind`); what stays *unknown* is only whether rule conditions over unknown `R.attr` / `R.id` hold — those become the residual filter: + +```mermaid +flowchart TD + A([planResources: principal · resource.kind + known attr · action]) --> P{{"PrincipalPolicy
(by principal.id)"}} + + P -->|"conditions fold to a constant:
unconditional ALLOW / DENY"| SC([Short-circuit: KIND_ALWAYS_ALLOWED / KIND_ALWAYS_DENIED]) + P -->|"conditions read unknown R.attr →
residual branches AND(PA,¬PD) ∨ AND(¬PA,¬PD,next layer ↓)"| RL + P -->|no principal policy| RL{{"RolePolicy layer
(applicability is a constant: P.roles × R.kind)"}} + + RL -->|"applicable: AND across roles
(allowlist, implicit deny, parentRoles intersection)"| NORM + RL -->|not applicable| DRI + + subgraph DRI ["Derived-roles inlining (importDerivedRoles)"] + direction TB + CB["Condition-backed: constant parentRoles gate (P known)
+ the definition's condition inlined (residual)"] --> EDR([derived-role plan nodes]) + RB["Relation-backed: sync gates + relation operand
(materialized later via expandRelationOperands)"] --> EDR + end + + EDR --> RES{{"ResourcePolicy
(AND(OR allow rules, NOT(OR deny rules)))"}} + RES --> NORM["Normalization: constant folding · flattening · dedup"] + + NORM -->|TRUE| AA([KIND_ALWAYS_ALLOWED]) + NORM -->|FALSE| AD([KIND_ALWAYS_DENIED]) + NORM -->|residual tree| COND(["KIND_CONDITIONAL + condition
(operators and/or/not/eq/…/in + opaque/relation)"]) +``` + +Every layer keeps its runtime semantics: principal rules override (Deny wins), the role layer is an allowlist with implicit deny and `parentRoles` intersection, the resource layer is Deny-over-Allow with default deny — the parity is enforced by a property-style test suite ([`test/PlanParity.test.js`](./test/PlanParity.test.js)) that grid-samples unknown attributes and compares the filter against real `isAllowed` results. + +### Operators + +`condition` is a tree of `{ expression: { operator, operands } }` / `{ variable }` / `{ value }` operands. Variables are Cerbos-named: `request.resource.id` and `request.resource.attr.`. + +| Operators | Meaning | +| --------- | ------- | +| `and`, `or`, `not` | Boolean composition. | +| `eq`, `ne`, `lt`, `le`, `gt`, `ge` | Comparisons (`===`, `!==`, `<`, `<=`, `>`, `>=`). | +| `in` | List membership (`list.includes(x)`). | +| `add`, `sub`, `mult`, `div`, `mod` | Arithmetic (`+`, `-`, `*`, `/`, `%`). | +| `index`, `list` | Computed member access, list literals. | +| `opaque` **(Kerberos)** | Statically unplannable condition — [post-filter](#opaque-conditions-post-filtering). | +| `relation` **(Kerberos)** | ReBAC dependency — [expand or post-check](#relation-operands-rebac). | + +### Writing plannable policies + +The planner works on the codec's `{ $expr }` ASTs, so **plannable conditions are the ones the [safe expression codec](#serialization-mechanism-security--performance) compiled** — cache-loaded policies, or static policies passed through `deserializePolicy(json, codec)` first. Rules of thumb: + +- **Author conditions as `{ $expr: '…' }`**, not JS functions — a plain function is a black box and plans as `opaque`. +- **Prefer `===` over `==`** — both map to `eq`, but SQL `=` has no JS coercion semantics. +- **Compare booleans explicitly** (`R.attr.isPublic === true`): a bare `R.attr.isPublic` leaf is planned as `eq(attr, true)`, which diverges for truthy non-boolean values. +- **`.includes` means list membership** — use it on array attrs (a residual receiver is assumed to be a list; a constant *string* receiver would mean substring semantics and plans as `opaque`). +- Not plannable (always sound, degrade to `opaque`): `??`, `**`, bitwise ops, `typeof`, ternaries whose test reads unknown attrs, method calls other than `.includes`, `Math`/`Date` over unknown values, object/`new` expressions over unknown values. +- An attr **missing** from `resource.attr` means *unknown*, not `undefined` — it becomes a filter variable, never a folded value. +- `Date.now()` (and friends) evaluate **at plan time** — same trade-off as Cerbos; re-plan when time matters. + +`variables` are partially evaluated and inlined at their `V.*` use sites; `C.*` constants and everything derivable from `P` fold into literal values. Plain JS-function *variables* still fold when they only touch known fields (they are executed against a guard that marks any unknown-field access as `opaque`). + +### Opaque conditions (post-filtering) + +`{ operator: 'opaque', operands: [{ value: { src, reason } }] }` marks a spot the planner could not translate (`reason: 'js-function' | 'unsupported-expression'`, `src` identifies the condition). A translator must treat it as *unknown*: fetch the candidate rows matching the rest of the filter, then post-filter each row with a real `isAllowed` call. Everything AND-ed around an opaque node still narrows the fetch. + +### Relation operands (ReBAC) + +[Relation-backed derived roles](#relation-backed-derived-roles) plan as `{ operator: 'relation', operands: [{ value: { name, relation } }] }` — the ABAC part of the filter is complete, the ReBAC part depends on relationship data. Materialize it with `expandRelationOperands`: + +```javascript +const { expandRelationOperands } = require('@alexify/kerberos'); +const { RelationResolver } = require('@alexify/kerberos/relations'); + +const resolver = new RelationResolver({ schema, tuples }); +const expanded = await expandRelationOperands(plan, ({ relation }) => + resolver.lookupResources({ subject: `user:${principal.id}`, permission: relation, resourceType: 'document' })); +// every relation operand becomes: in(request.resource.id, ['doc1', 'doc7', …]) +// (an empty id list folds the branch to FALSE — possibly the whole plan to KIND_ALWAYS_DENIED) +``` + +The lookup is any `({ name, relation }) => ids` function — resolver-agnostic, like the engine's `relations` seam. Without expansion, treat `relation` like `opaque`: post-check the rows. + +### Translating a plan + +Translators are deliberately **not** part of the package (same delegation philosophy as caching/validation). A hand-rolled SQL mapping is a ~40-line recursive walk: + +```javascript +const OPS = { and: 'AND', or: 'OR', eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }; + +function toSql(operand, params) { + if ('value' in operand) return params.push(operand.value), `$${params.length}`; + if ('variable' in operand) { + if (operand.variable === 'request.resource.id') return 'id'; + return operand.variable.replace('request.resource.attr.', ''); // map to your column names + } + const { operator, operands } = operand.expression; + if (operator === 'not') return `NOT (${toSql(operands[0], params)})`; + if (operator === 'in') return `${toSql(operands[0], params)} = ANY(${toSql(operands[1], params)})`; + if (OPS[operator]) return `(${operands.map((op) => toSql(op, params)).join(` ${OPS[operator]} `)})`; + throw new Error(`post-filter required: ${operator}`); // opaque / relation / index / list… +} + +const params = []; +const where = + plan.filter.kind === 'KIND_ALWAYS_ALLOWED' ? 'TRUE' + : plan.filter.kind === 'KIND_ALWAYS_DENIED' ? 'FALSE' + : toSql(plan.filter.condition, params); +``` + +Since the shape matches Cerbos, the [Cerbos ORM adapters](https://docs.cerbos.dev/cerbos/latest/recipes/orm/) (Prisma, Drizzle, Mongoose, SQLAlchemy…) accept the `filter` for the shared operator vocabulary — route `opaque`/`relation` operands to a post-filter (or pre-expand `relation` as shown above). + ## Testing ```javascript @@ -1417,6 +1589,7 @@ Apple Silicon (M-series), Node v24: | `isAllowed` — derived roles + variables + condition | ~300,000 | | `checkResources` — 10 resources × 3 actions | ~41,000 | | `isAllowed` — cache-backed dynamic policy (`$expr`, in-memory Map) | ~150,000 | +| `planResources` — `$expr` policy (variables + deny rule) | ~54,000 | | `relations.check` — direct tuple (flat) | ~850,000 | | `relations.check` — deep walk (3 arrows + nested groups) | ~120,000 | | `isAllowed` — relation-backed derived role (deep walk) | ~80,000 | diff --git a/bench/bench.js b/bench/bench.js index 2e2d82a..3fec5cc 100644 --- a/bench/bench.js +++ b/bench/bench.js @@ -139,6 +139,46 @@ async function main() { cached.isAllowed({ principal, action: 'view', resource: docResource }), ), ); + + // Query planning: partial evaluation of a rich $expr policy (variables + + // constants + allow/deny rules) into a Cerbos-shaped filter. + const { createSafeExprCodec, deserializePolicy } = require('../src/index.js'); + const codec = createSafeExprCodec({ jsep }); + const plannable = new Kerberos( + [ + deserializePolicy( + { + resourcePolicy: { + version: 'default', + resource: 'document', + constants: { minQty: 10 }, + variables: { isOwner: { $expr: 'R.attr.ownerId === P.id' } }, + rules: [ + { + actions: ['view'], + effect: 'EFFECT_ALLOW', + roles: ['USER'], + condition: { match: { all: [{ $expr: 'V.isOwner' }, { $expr: 'R.attr.qty > C.minQty' }] } }, + }, + { + actions: ['*'], + effect: 'EFFECT_DENY', + roles: ['*'], + condition: { match: { $expr: "R.attr.status === 'ARCHIVED'" } }, + }, + ], + }, + }, + codec, + ), + ], + [], + ); + results.push( + await bench('planResources — $expr policy (variables + deny rule)', () => + plannable.planResources({ principal, resource: { kind: 'document' }, action: 'view' }), + ), + ); } // ReBAC scenarios: the built-in Zanzibar-lite resolver over static tuples. diff --git a/index.d.ts b/index.d.ts index 0907d61..d5cf38e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -697,6 +697,80 @@ export function createCacheReader( retry?: { attempts?: number } | null, ): { enabled: boolean; get(key: string): Promise }; +/** planResources filter outcome (Cerbos-compatible). */ +export type PlanKind = 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL'; + +/** + * One operand of a planResources condition tree: a literal, a reference to an + * unknown resource field (`request.resource.id` / `request.resource.attr.*`) + * or a nested expression. Operators follow the Cerbos vocabulary + * (`and/or/not/eq/ne/lt/le/gt/ge/in/add/sub/mult/div/mod/index/list`) plus the + * Kerberos extensions `opaque` (statically unplannable condition — post-filter + * required) and `relation` (ReBAC dependency — see expandRelationOperands). + */ +export type PlanExpressionOperand = + | { value: unknown } + | { variable: string } + | { expression: { operator: string; operands: PlanExpressionOperand[] } }; + +export type PlanFilter = { + kind: PlanKind; + /** Present only for KIND_CONDITIONAL. */ + condition?: PlanExpressionOperand; +}; + +/** planResources plans over a resource KIND: no `id`, `attr` = KNOWN fields. */ +export type RequestPlanResource = { + kind: string; + policyVersion?: string; + scope?: string; + attr?: Record; +}; + +export type PlanResourcesArgs = { + reqId?: string; + principal: RequestPrincipal; + resource: RequestPlanResource; + /** Exactly one of `action` / `actions` must be provided. */ + action?: string; + /** Multiple actions plan the conjunction (Cerbos AND semantics). */ + actions?: string[]; + includeMeta?: boolean; +}; + +export type PlanResourcesResponse = { + reqId?: string; + kerberosCallId: string; + /** Echo of the request form: `action` for single-action requests… */ + action?: string; + /** …or `actions` for multi-action requests. */ + actions?: string[]; + resourceKind: string; + policyVersion: string; + filter: PlanFilter; + meta?: { + /** Human-readable s-expression rendering of the condition. */ + filterDebug: string; + matchedScopes: { + principal: string | null; + resource: string | null; + roles: Record; + }; + resolution: KerberosResolutionTraceEntry[]; + }; +}; + +/** + * Replaces every `relation` operand of a plan with + * `in(request.resource.id, [ids])` via the supplied lookup (typically backed + * by `RelationResolver.lookupResources` from `@alexify/kerberos/relations`), + * then re-normalizes the filter. Returns a new response object. + */ +export function expandRelationOperands( + planResponse: PlanResourcesResponse, + lookup: (args: { name: string; relation: string }) => Promise> | Iterable, +): Promise; + export class Kerberos { constructor(policies: KerberosPolicy[], derivedRoles: KerberosDerivedRoles[], options?: KerberosOptions); static generateCallId(): string; @@ -736,6 +810,7 @@ export class Kerberos { }; }[]; }>; + planResources(args: PlanResourcesArgs): Promise; } export class KerberosZodSchemas { static buildResourcePolicyInstance(z: unknown): unknown; @@ -744,6 +819,7 @@ export class KerberosZodSchemas { static buildDerivedRolesInstance(z: unknown): unknown; static buildIsAllowedArgs(z: unknown): unknown; static buildCheckResourcesArgs(z: unknown): unknown; + static buildPlanResourcesArgs(z: unknown): unknown; } export class KerberosJsonSchemas { static buildResourcePolicyInstance(): Record; @@ -752,6 +828,7 @@ export class KerberosJsonSchemas { static buildDerivedRolesInstance(): Record; static buildIsAllowedArgs(): Record; static buildCheckResourcesArgs(): Record; + static buildPlanResourcesArgs(): Record; } export class KerberosTypeBoxSchemas { static buildResourcePolicyInstance(typebox: TypeBoxLike): unknown; @@ -760,6 +837,7 @@ export class KerberosTypeBoxSchemas { static buildDerivedRolesInstance(typebox: TypeBoxLike): unknown; static buildIsAllowedArgs(typebox: TypeBoxLike): unknown; static buildCheckResourcesArgs(typebox: TypeBoxLike): unknown; + static buildPlanResourcesArgs(typebox: TypeBoxLike): unknown; } export function registerAjvKeywords(ajv: AjvLike): AjvLike; diff --git a/package.json b/package.json index 2be52df..aa2434a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alexify/kerberos", - "version": "3.0.0", + "version": "3.1.0", "description": "Fast, zero-dependency in-process authorization engine for Node.js and browsers — ABAC, RBAC and ReBAC (Zanzibar-style relations) with Cerbos-like policies, caveats, OpenTelemetry and pluggable validation/caching", "main": "index.js", "browser": { diff --git a/src/Kerberos.js b/src/Kerberos.js index b2d5415..ae49f20 100644 --- a/src/Kerberos.js +++ b/src/Kerberos.js @@ -9,6 +9,8 @@ const { createTelemetryWriter } = require('./telemetry.js'); const { createCacheReader } = require('./caching/cache.js'); const { KerberosCodecError, KerberosValidationError } = require('./errors.js'); const { createSafeExprCodec } = require('./caching/codec.js'); +const { PLAN_KINDS, toDebugString, toFilter } = require('./planning/nodes.js'); +const { buildResourcePlan } = require('./planning/planner.js'); const { createAjvAdapter, parseWithValidation, registerAjvKeywords } = require('./validation'); // Platform runtime: bundlers swap this for `./runtime/browser.js` via the // package.json `browser` field map when targeting the browser. @@ -161,6 +163,22 @@ class Kerberos { }); } + /** + * Parses `planResources` arguments using the configured validation backend. + * + * @param {unknown} args + * @param {object} [options] + * @returns {unknown} + */ + static parsePlanResourcesArgs(args, options = {}) { + return parseWithValidation(args, { + ...options, + buildJson: () => KerberosJsonSchemas.buildPlanResourcesArgs(), + buildTypeBox: (t) => KerberosTypeBoxSchemas.buildPlanResourcesArgs(t), + buildZod: (z) => KerberosZodSchemas.buildPlanResourcesArgs(z), + }); + } + #resourcePolicies = new Map(); #principalPolicies = new Map(); @@ -198,6 +216,8 @@ class Kerberos { #checkResourcesArgsValidator = null; + #planResourcesArgsValidator = null; + #getCallId = null; #onError = 'throw'; @@ -276,6 +296,7 @@ class Kerberos { this.#requestValidator = ZodSchemas.buildRequest(z); this.#isAllowedArgsValidator = KerberosZodSchemas.buildIsAllowedArgs(z); this.#checkResourcesArgsValidator = KerberosZodSchemas.buildCheckResourcesArgs(z); + this.#planResourcesArgsValidator = KerberosZodSchemas.buildPlanResourcesArgs(z); } else if (this.#ajv && this.#typebox) { this.#resourcePolicyValidator = createAjvAdapter( this.#ajv, @@ -302,6 +323,10 @@ class Kerberos { this.#ajv, KerberosTypeBoxSchemas.buildCheckResourcesArgs(this.#typebox), ); + this.#planResourcesArgsValidator = createAjvAdapter( + this.#ajv, + KerberosTypeBoxSchemas.buildPlanResourcesArgs(this.#typebox), + ); } else if (this.#ajv) { this.#resourcePolicyValidator = createAjvAdapter(this.#ajv, KerberosJsonSchemas.buildResourcePolicyInstance()); this.#principalPolicyValidator = createAjvAdapter(this.#ajv, KerberosJsonSchemas.buildPrincipalPolicyInstance()); @@ -310,6 +335,7 @@ class Kerberos { this.#requestValidator = createAjvAdapter(this.#ajv, JsonSchemas.buildRequest()); this.#isAllowedArgsValidator = createAjvAdapter(this.#ajv, KerberosJsonSchemas.buildIsAllowedArgs()); this.#checkResourcesArgsValidator = createAjvAdapter(this.#ajv, KerberosJsonSchemas.buildCheckResourcesArgs()); + this.#planResourcesArgsValidator = createAjvAdapter(this.#ajv, KerberosJsonSchemas.buildPlanResourcesArgs()); } const { resourcePolicies, principalPolicies, rolePolicies } = this.#getPoliciesMaps(policies); @@ -458,17 +484,21 @@ class Kerberos { return derivedRolesMap; } + /** + * Resolves one derived-roles definition set by name: in-memory first, then + * the cache fallback. Shared by runtime evaluation and query planning. + */ + async #resolveDerivedRolesSetByName(name) { + const role = this.#derivedRoles.get(name); + if (role) return role; + return this.#resolveFromCache(`derivedRoles:${name}`, (shape) => new DerivedRoles(shape, this.#policyOptions())); + } + async #getImportedDerivedRoles(policy, req, relationsMemo, trace) { const importedRoles = new Set(); const relationCandidates = []; for (const name of policy.importDerivedRoles) { - let role = this.#derivedRoles.get(name); - if (!role) { - role = await this.#resolveFromCache( - `derivedRoles:${name}`, - (shape) => new DerivedRoles(shape, this.#policyOptions()), - ); - } + const role = await this.#resolveDerivedRolesSetByName(name); if (!role) continue; const derivedRoles = role.get(req); if (derivedRoles) for (const derivedRole of derivedRoles) importedRoles.add(derivedRole); @@ -795,6 +825,54 @@ class Kerberos { }; } + /** + * Resolves the transitive parentRoles closure for the query planner: every + * role reachable from the principal's role policies, mapped to its resolved + * policy (or null). Parent lookups are untraced — runtime parity with + * `#evaluateRolePolicy`, which resolves parents without a trace. + */ + async #resolveRolePolicyClosure(rolePolicies, req) { + const closure = new Map(); + const queue = []; + for (const policy of rolePolicies) { + closure.set(policy.role, policy); + for (const parentRole of policy.parentRoles) queue.push(parentRole); + } + // Cursor-based BFS (no shift); the closure map doubles as the visited set, + // so parentRoles cycles terminate here and are reported by the planner. + for (let i = 0; i < queue.length; i++) { + const role = queue[i]; + if (closure.has(role)) continue; + const policy = await this.#getRolePolicyByName(role, req); + closure.set(role, policy ?? null); + if (policy) for (const parentRole of policy.parentRoles) queue.push(parentRole); + } + return closure; + } + + async #resolveDerivedRolesSets(policy) { + const sets = []; + for (const name of policy.importDerivedRoles) { + const set = await this.#resolveDerivedRolesSetByName(name); + if (set) sets.push(set); + } + return sets; + } + + /** + * Resolves every policy source the planner needs (async, cache-aware); the + * planner itself (`buildResourcePlan`) is pure and synchronous. + */ + async #planPolicySources(principal, resource, actions, trace) { + const req = { principal, resource, P: principal, R: resource, actions }; + const principalPolicy = await this.#getPrincipalPolicy(req, trace); + const rolePolicies = await this.#getRolePolicies(req, trace); + const rolePolicyClosure = await this.#resolveRolePolicyClosure(rolePolicies, req); + const resourcePolicy = await this.#getResourcePolicy(req, trace); + const derivedRolesSets = resourcePolicy ? await this.#resolveDerivedRolesSets(resourcePolicy) : []; + return { principalPolicy, rolePolicies, rolePolicyClosure, resourcePolicy, derivedRolesSets }; + } + /** * Evaluates all policy sources for a request. Effects are always canonical * `EFFECT_ALLOW`/`EFFECT_DENY` strings — the `effectAsBoolean` response @@ -1092,6 +1170,126 @@ class Kerberos { (callId) => ({ results: [], kerberosCallId: callId, reqId: args?.reqId }), ); } + + // Response scaffold shared by the success path and the onError:'deny' + // fallback: echoes the request form (`action` vs `actions`) like Cerbos. + static #buildPlanResponse(callId, reqId, resource, action, actions, filter) { + const response = { kerberosCallId: callId }; + if (reqId) response.reqId = reqId; + if (action !== undefined) response.action = action; + else if (actions !== undefined) response.actions = actions; + response.resourceKind = resource?.kind; + response.policyVersion = resource?.policyVersion ?? DEFAULT_VERSION; + response.filter = filter; + return response; + } + + /** + * Builds a Cerbos-compatible resources query plan: which resources of a + * kind the principal could act on, as a filter to translate into a data + * query. `resource.attr` carries the KNOWN attributes; everything else is + * treated as unknown and surfaces in the residual condition as + * `request.resource.attr.*` / `request.resource.id` operands. Kerberos + * extensions: the `opaque` operator (statically unplannable condition — + * translators must post-filter) and `relation` (ReBAC dependency — see + * `expandRelationOperands`). + * + * @param {Record} args + * @returns {Promise>} + */ + async planResources(args) { + const reqKind = 'PlanResources'; + + return this.#runRequest( + reqKind, + args?.reqId, + async (callId) => { + const parsedArgs = this.#parseValidated('Invalid planResources arguments', () => + Kerberos.parsePlanResourcesArgs(args, { + schema: this.#planResourcesArgsValidator, + z: this.#z, + ajv: this.#ajv, + typebox: this.#typebox, + }), + ); + + // The schemas keep `action`/`actions` independently optional; the + // exactly-one-of invariant (and the wildcard rejection) are enforced + // here so the rule also holds without a validation backend. + const hasAction = parsedArgs.action !== undefined; + const hasActions = parsedArgs.actions !== undefined; + if (hasAction === hasActions) { + throw new KerberosValidationError( + 'Invalid planResources arguments: provide exactly one of "action" or "actions"', + ); + } + const actions = hasAction ? [parsedArgs.action] : [...parsedArgs.actions]; + // Guarded here too (not only in the schemas): with no validation + // backend an empty list would otherwise plan an empty conjunction — + // KIND_ALWAYS_ALLOWED, a fail-open. + if (!actions.length) { + throw new KerberosValidationError('Invalid planResources arguments: "actions" must not be empty'); + } + for (const action of actions) { + if (typeof action !== 'string' || !action.length) { + throw new KerberosValidationError('Invalid planResources arguments: actions must be non-empty strings'); + } + if (action === ALL_ACTIONS) { + throw new KerberosValidationError( + `Invalid planResources arguments: the wildcard action "${ALL_ACTIONS}" cannot be planned`, + ); + } + } + + const trace = parsedArgs.includeMeta ? [] : null; + const sources = await this.#planPolicySources(parsedArgs.principal, parsedArgs.resource, actions, trace); + const { node } = buildResourcePlan({ + principal: parsedArgs.principal, + resource: parsedArgs.resource, + actions, + ...sources, + hasRelations: Boolean(this.#relations), + trace, + }); + + const response = Kerberos.#buildPlanResponse( + callId, + parsedArgs.reqId, + parsedArgs.resource, + hasAction ? parsedArgs.action : undefined, + actions, + toFilter(node), + ); + + if (parsedArgs.includeMeta) { + const matchedScopes = { + principal: sources.principalPolicy ? (sources.principalPolicy.scope ?? '') : null, + resource: sources.resourcePolicy ? (sources.resourcePolicy.scope ?? '') : null, + roles: {}, + }; + const seenRoles = new Set(); + for (const role of parsedArgs.principal.roles) { + if (seenRoles.has(role)) continue; + seenRoles.add(role); + const policy = sources.rolePolicyClosure.get(role); + matchedScopes.roles[role] = policy ? (policy.scope ?? '') : null; + } + response.meta = { filterDebug: toDebugString(node), matchedScopes, resolution: trace }; + } + + return response; + }, + (callId) => + Kerberos.#buildPlanResponse( + callId, + args?.reqId, + args?.resource, + typeof args?.action === 'string' ? args.action : undefined, + Array.isArray(args?.actions) ? [...args.actions] : undefined, + { kind: PLAN_KINDS.ALWAYS_DENIED }, + ), + ); + } } module.exports = { diff --git a/src/caching/codec.js b/src/caching/codec.js index 1b8ba13..81683ce 100644 --- a/src/caching/codec.js +++ b/src/caching/codec.js @@ -159,6 +159,12 @@ const ALLOWED_GLOBALS = { Math, Date }; const DEFAULT_ROOTS = ['P', 'R', 'V', 'C']; +// Compiled `{ $expr }` closures carry their source/AST under this symbol so the +// query planner (src/planning/) can partially evaluate them. Non-enumerable and +// frozen: invisible to serialization, and consumers must never mutate the AST — +// it is the same object the closure evaluates (and lives in the shared cache). +const EXPR_META = Symbol('kerberos.exprMeta'); + // Safe-by-default resource limits for expressions loaded from a remote store. // All three are overridable via createSafeExprCodec options (set a limit to // Infinity to disable it) — a compromised or misbehaving store must not be able @@ -544,7 +550,11 @@ function createSafeExprCodec({ jsep, roots, maxCachedExprs, maxExprLength, maxDe function compileExpr(expr) { const ast = parseExpr(expr, jsep, limits); - return (ctx) => evalNode(ast, ctx, config); + const fn = (ctx) => evalNode(ast, ctx, config); + Object.defineProperty(fn, EXPR_META, { + value: Object.freeze({ expr, ast, roots: config.roots }), + }); + return fn; } const deserializeHandlers = { @@ -578,6 +588,22 @@ function createSafeExprCodec({ jsep, roots, maxCachedExprs, maxExprLength, maxDe }; } +/** + * Evaluates an already-validated jsep AST (e.g. one taken from a compiled + * closure's EXPR_META) against a context, using the same strict allowlist + * interpreter the codec compiles to. Exists for the query planner to fold + * fully-known subtrees; not part of the public package surface. + * + * @param {Record} node + * @param {Record} ctx + * @param {{ roots?: Iterable | Set }} [options] + * @returns {unknown} + */ +function evalExprAst(node, ctx, { roots } = {}) { + const config = { roots: roots instanceof Set ? roots : new Set(roots || DEFAULT_ROOTS) }; + return evalNode(node, ctx, config); +} + /** * Serializes a policy/derived-roles shape into a JSON-safe document. * @@ -624,8 +650,12 @@ function deserializePolicy(json, codec) { } module.exports = { + // EXPR_META and evalExprAst are internal seams for src/planning/ — they are + // intentionally NOT re-exported from src/index.js. + EXPR_META, KerberosExprError, createSafeExprCodec, + evalExprAst, serializePolicy, deserializePolicy, }; diff --git a/src/index.js b/src/index.js index 8d5f7c7..6f6dac3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,7 @@ +// EXPR_META / evalExprAst are internal seams between the codec and the query +// planner (src/planning/) — deliberately kept out of the public surface. +const { EXPR_META, evalExprAst, ...codecExports } = require('./caching/codec.js'); + module.exports = { ...require('./Constants/index.js'), ...require('./Conditions/index.js'), @@ -11,7 +15,8 @@ module.exports = { ...require('./Kerberos.js'), ...require('./errors.js'), ...require('./caching/cache.js'), - ...require('./caching/codec.js'), + ...codecExports, + ...require('./planning/expand.js'), ...require('./schemas'), ...require('./validation'), }; diff --git a/src/planning/expand.js b/src/planning/expand.js new file mode 100644 index 0000000..c5968c6 --- /dev/null +++ b/src/planning/expand.js @@ -0,0 +1,82 @@ +/** + * ReBAC bridge for query plans: materializes `relation` operands. + * + * A plan built over relation-backed derived roles carries + * `{ operator: 'relation', operands: [{ value: { name, relation } }] }` + * placeholders — the ABAC filter is complete, but the ReBAC part depends on + * relationship data the planner cannot see. This helper resolves each + * placeholder through a caller-supplied lookup (typically wrapping + * `RelationResolver.lookupResources` from `@alexify/kerberos/relations`, but + * any resolver works) into `in(request.resource.id, [ids])`, then + * re-normalizes the tree and recomputes `filter.kind` — an empty id list + * folds the branch to FALSE, which can collapse the whole plan. + */ + +const { andNode, constNode, exprNode, fromOperand, notNode, orNode, toDebugString, toFilter } = require('./nodes.js'); + +async function resolveRelationIds(lookup, detail) { + const ids = await lookup({ name: detail.name, relation: detail.relation }); + const list = ids instanceof Set ? [...ids] : Array.isArray(ids) ? ids : []; + return list; +} + +/** + * Walks a plan node and replaces every `relation` node with the looked-up + * id-membership expression. Lookups run sequentially: plans hold few distinct + * relations, and `expandOne` memoizes by `name|relation`. + */ +async function expandNode(node, expandOne) { + switch (node.t) { + case 'and': + case 'or': { + const children = []; + for (const child of node.children) children.push(await expandNode(child, expandOne)); + return node.t === 'and' ? andNode(children) : orNode(children); + } + case 'not': + return notNode(await expandNode(node.child, expandOne)); + case 'relation': + return expandOne(node); + default: + return node; + } +} + +/** + * Expands the `relation` operands of a planResources response. + * + * @param {{ filter: { kind: string, condition?: object }, meta?: { filterDebug?: string } }} planResponse + * @param {(args: { name: string, relation: string }) => Promise> | Iterable} lookup + * Resolves one relation-backed derived role to the ids of the resources the + * principal holds the relation on (array or Set; empty → no access). + * @returns {Promise} a new response object with a materialized filter + */ +async function expandRelationOperands(planResponse, lookup) { + if (typeof lookup !== 'function') { + throw new TypeError('expandRelationOperands requires a lookup({ name, relation }) function'); + } + const { filter } = planResponse; + if (!filter?.condition) return planResponse; + + const memo = new Map(); + const expandOne = async (node) => { + const key = `${node.name}|${node.relation}`; + if (!memo.has(key)) { + const ids = await resolveRelationIds(lookup, node); + const expanded = ids.length + ? exprNode({ + expression: { operator: 'in', operands: [{ variable: 'request.resource.id' }, { value: ids }] }, + }) + : constNode(false); + memo.set(key, expanded); + } + return memo.get(key); + }; + + const node = await expandNode(fromOperand(filter.condition), expandOne); + const response = { ...planResponse, filter: toFilter(node) }; + if (planResponse.meta) response.meta = { ...planResponse.meta, filterDebug: toDebugString(node) }; + return response; +} + +module.exports = { expandRelationOperands }; diff --git a/src/planning/nodes.js b/src/planning/nodes.js new file mode 100644 index 0000000..1cc8d75 --- /dev/null +++ b/src/planning/nodes.js @@ -0,0 +1,226 @@ +/** + * Plan-node model for `kerberos.planResources()`. + * + * Nodes form a boolean tree over residual conditions. The constructors + * normalize on the way up (constant folding, flattening, dedup), so any tree + * built through them is already in normal form: `const` nodes only ever + * survive at the root, `and`/`or` never nest a child of the same kind and + * always hold at least two children, `not` never wraps a constant. + * + * Serialization (`toFilter`) produces the Cerbos PlanResources filter shape — + * `KIND_ALWAYS_ALLOWED` / `KIND_ALWAYS_DENIED` / `KIND_CONDITIONAL` with an + * `{ operator, operands }` expression tree — extended with two Kerberos + * operators: `opaque` (statically unplannable condition, translators must + * post-filter) and `relation` (ReBAC dependency, see expandRelationOperands). + * + * @typedef {Record} PlanOperand + * Cerbos operand: `{ value }` | `{ variable }` | `{ expression: { operator, operands } }`. + * @typedef {( + * { t: 'const', v: boolean } | + * { t: 'expr', e: PlanOperand } | + * { t: 'and' | 'or', children: PlanNode[] } | + * { t: 'not', child: PlanNode } | + * { t: 'opaque', src: string, reason: string } | + * { t: 'relation', name: string, relation: string } + * )} PlanNode + */ + +const PLAN_KINDS = Object.freeze({ + ALWAYS_ALLOWED: 'KIND_ALWAYS_ALLOWED', + ALWAYS_DENIED: 'KIND_ALWAYS_DENIED', + CONDITIONAL: 'KIND_CONDITIONAL', +}); + +const TRUE = Object.freeze({ t: 'const', v: true }); +const FALSE = Object.freeze({ t: 'const', v: false }); + +function constNode(value) { + return value ? TRUE : FALSE; +} + +function exprNode(operand) { + return { t: 'expr', e: operand }; +} + +function opaqueNode(src, reason) { + return { t: 'opaque', src, reason }; +} + +function relationNode(name, relation) { + return { t: 'relation', name, relation }; +} + +// Two opaque nodes are never provably the same condition (two distinct JS +// functions can render identical sources), so they are exempt from dedup. +function dedupKey(node) { + if (node.t === 'opaque') return null; + return JSON.stringify(node); +} + +/** + * Shared normalization for `and`/`or`. `absorbing` is the constant that + * decides the whole node (`false` for and, `true` for or); the opposite + * constant is the identity and is dropped. + * + * @param {'and' | 'or'} kind + * @param {PlanNode[]} children + * @param {boolean} absorbing + * @returns {PlanNode} + */ +function logicalNode(kind, children, absorbing) { + const flat = []; + const seen = new Set(); + for (const child of children) { + if (child.t === 'const') { + if (child.v === absorbing) return constNode(absorbing); + continue; // identity element + } + const nested = child.t === kind ? child.children : [child]; + for (const node of nested) { + const key = dedupKey(node); + if (key !== null) { + if (seen.has(key)) continue; + seen.add(key); + } + flat.push(node); + } + } + if (!flat.length) return constNode(!absorbing); + if (flat.length === 1) return flat[0]; + return { t: kind, children: flat }; +} + +function andNode(children) { + return logicalNode('and', children, false); +} + +function orNode(children) { + return logicalNode('or', children, true); +} + +function notNode(child) { + if (child.t === 'const') return constNode(!child.v); + if (child.t === 'not') return child.child; + return { t: 'not', child }; +} + +/** + * Serializes a plan node into a Cerbos condition operand. + * + * @param {PlanNode} node + * @returns {PlanOperand} + */ +function toOperand(node) { + switch (node.t) { + case 'const': + return { value: node.v }; + case 'expr': + return node.e; + case 'and': + case 'or': + return { expression: { operator: node.t, operands: node.children.map(toOperand) } }; + case 'not': + return { expression: { operator: 'not', operands: [toOperand(node.child)] } }; + case 'opaque': + return { expression: { operator: 'opaque', operands: [{ value: { src: node.src, reason: node.reason } }] } }; + case 'relation': + return { + expression: { operator: 'relation', operands: [{ value: { name: node.name, relation: node.relation } }] }, + }; + default: + throw new TypeError(`Unknown plan node: ${node.t}`); + } +} + +/** + * Converts a normalized plan node into the response `filter`. + * + * @param {PlanNode} node + * @returns {{ kind: string, condition?: PlanOperand }} + */ +function toFilter(node) { + if (node.t === 'const') { + return { kind: node.v ? PLAN_KINDS.ALWAYS_ALLOWED : PLAN_KINDS.ALWAYS_DENIED }; + } + return { kind: PLAN_KINDS.CONDITIONAL, condition: toOperand(node) }; +} + +/** + * Rebuilds a plan node from a Cerbos condition operand. Boolean positions + * (children of and/or/not and the root) recurse; every other expression is an + * opaque-to-us leaf kept verbatim. Reconstruction runs through the normalizing + * constructors, so replacing a subtree and re-running `fromOperand` restores + * normal form — this is what `expandRelationOperands` relies on. + * + * @param {PlanOperand} operand + * @returns {PlanNode} + */ +function fromOperand(operand) { + const expression = operand && typeof operand === 'object' ? operand.expression : undefined; + if (!expression || typeof expression !== 'object') { + if (operand && typeof operand === 'object' && typeof operand.value === 'boolean') { + return constNode(operand.value); + } + return exprNode(operand); + } + const { operator, operands } = expression; + switch (operator) { + case 'and': + return andNode(operands.map(fromOperand)); + case 'or': + return orNode(operands.map(fromOperand)); + case 'not': + return notNode(fromOperand(operands[0])); + case 'opaque': { + const detail = operands[0]?.value ?? {}; + return opaqueNode(detail.src, detail.reason); + } + case 'relation': { + const detail = operands[0]?.value ?? {}; + return relationNode(detail.name, detail.relation); + } + default: + return exprNode(operand); + } +} + +function renderOperand(operand) { + if (operand && typeof operand === 'object') { + if ('variable' in operand) return String(operand.variable); + if ('value' in operand) return JSON.stringify(operand.value); + if (operand.expression && typeof operand.expression === 'object') { + const { operator, operands } = operand.expression; + return `(${operator} ${operands.map(renderOperand).join(' ')})`; + } + } + return JSON.stringify(operand); +} + +/** + * Human-readable s-expression rendering of a plan node (the `meta.filterDebug` + * payload, mirroring Cerbos). + * + * @param {PlanNode} node + * @returns {string} + */ +function toDebugString(node) { + if (node.t === 'const') return String(node.v); + return renderOperand(toOperand(node)); +} + +module.exports = { + PLAN_KINDS, + TRUE, + FALSE, + andNode, + constNode, + exprNode, + fromOperand, + notNode, + opaqueNode, + orNode, + relationNode, + toDebugString, + toFilter, + toOperand, +}; diff --git a/src/planning/partialEval.js b/src/planning/partialEval.js new file mode 100644 index 0000000..826ea7e --- /dev/null +++ b/src/planning/partialEval.js @@ -0,0 +1,458 @@ +/** + * Partial evaluator for codec-compiled `{ $expr }` conditions. + * + * Bottom-up over the jsep AST: every subtree yields a PlanValue — + * `{ k: 'const', v }` (fully known), `{ k: 'residual', operand }` (a Cerbos + * operand over unknown resource fields) or `{ k: 'opaque' }` (not statically + * plannable). Known at plan time: `P`, `C`, resource `kind`/`scope`/ + * `policyVersion` and the attr keys the caller provided; unknown: `R.id` and + * every other attr key. Constant folding reuses the codec's own interpreter + * (`evalExprAst`), so folded semantics are exactly the runtime's, including + * `&&`/`||`/`?:` laziness (short-circuits are planned explicitly and a branch + * is only folded once it is known to be reachable). + * + * Soundness rule: when in doubt, produce `opaque` — never guess a value. The + * one deliberate exception is a bare residual value in boolean position + * (`R.attr.isPublic`), which becomes `eq(variable, true)`: Cerbos-compatible, + * documented as "author boolean attrs explicitly in plannable policies". + */ + +const { EXPR_META, evalExprAst } = require('../caching/codec.js'); +const { andNode, constNode, exprNode, notNode, opaqueNode, orNode, toOperand } = require('./nodes.js'); + +const BLOCKED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +// jsep binary operators that translate 1:1 into Cerbos filter operators. +const JS_TO_CERBOS_BINARY = Object.assign(Object.create(null), { + '===': 'eq', + '==': 'eq', + '!==': 'ne', + '!=': 'ne', + '<': 'lt', + '<=': 'le', + '>': 'gt', + '>=': 'ge', + '+': 'add', + '-': 'sub', + '*': 'mult', + '/': 'div', + '%': 'mod', +}); + +// Operators whose result is boolean-valued: their residuals may stand directly +// in boolean position. `index`/`variable` results have unknown type and get +// the eq(x, true) wrap; arithmetic residuals in boolean position are opaque. +const BOOLEAN_RESULT_OPS = new Set(['and', 'or', 'not', 'eq', 'ne', 'lt', 'le', 'gt', 'ge', 'in']); + +const OPAQUE = Object.freeze({ k: 'opaque' }); + +function constPV(value) { + return { k: 'const', v: value }; +} + +function residualPV(operand) { + return { k: 'residual', operand }; +} + +function toOp(planValue) { + return planValue.k === 'const' ? { value: planValue.v } : planValue.operand; +} + +function describeFn(fn) { + return fn.name ? `[function ${fn.name}]` : '[function]'; +} + +// Sentinel thrown by the throwing-R proxy when a plain JS-function variable +// touches an unknown resource field; any throw downgrades the variable to +// opaque, so the sentinel never escapes the variable-evaluation try block. +class UnknownFieldAccess extends Error {} + +/** + * Proxy over the partially-known resource: known fields answer normally, + * `id` and unlisted attr keys throw the sentinel. Used only when evaluating + * plain JS-function variables (the runtime evaluates every variable eagerly + * per check, so plan-time execution adds no new side-effect surface). + */ +function createThrowingResource(knownR) { + const attr = new Proxy(knownR.attr, { + get(target, key) { + if (typeof key === 'symbol' || Object.prototype.hasOwnProperty.call(target, key)) return target[key]; + throw new UnknownFieldAccess(); + }, + }); + const base = { ...knownR, attr }; + return new Proxy(base, { + get(target, key) { + if (typeof key === 'symbol' || Object.prototype.hasOwnProperty.call(target, key)) return target[key]; + throw new UnknownFieldAccess(); + }, + }); +} + +/** + * Creates a per-policy expression planner. + * + * @param {object} options + * @param {Record} options.principal - fully-known P + * @param {{ kind: string, scope?: string, policyVersion?: string, attr?: Record }} options.resource + * @param {string[]} options.actions - requested actions (visible to JS-fn variables, like at runtime) + * @param {{ get: () => Record } | undefined} [options.constants] - Constants instance + * @param {{ shape: Record } | undefined} [options.variables] - Variables instance + * @returns {{ planCondition: (conditions: { shape: { match: unknown } } | undefined) => import('./nodes.js').PlanNode }} + */ +function createExprPlanner({ principal, resource, actions, constants, variables }) { + const C = { ...constants?.get() }; + const knownAttr = resource.attr && typeof resource.attr === 'object' ? resource.attr : {}; + const knownR = { kind: resource.kind, attr: knownAttr }; + if (resource.scope !== undefined) knownR.scope = resource.scope; + if (resource.policyVersion !== undefined) knownR.policyVersion = resource.policyVersion; + + // ctx.V accumulates const-folded variables as they are planned, so a later + // fold of an AST that references V. resolves through the interpreter. + const ctx = { P: principal, R: knownR, V: {}, C }; + + const variableShapes = variables?.shape ?? {}; + const variablePlans = new Map(); + // Runtime variables are evaluated against a request WITHOUT V (they never + // see each other) — while planning a variable body, V access is opaque. + let planningVariable = false; + + // Source of the $expr currently being planned; stamped onto opaque nodes. + let currentSrc = ''; + + function evalConst(node) { + return evalExprAst(node, ctx); + } + + function variablePlan(name) { + if (variablePlans.has(name)) return variablePlans.get(name); + const fn = variableShapes[name]; + let plan; + if (typeof fn !== 'function') { + plan = constPV(undefined); // undeclared variable: V. is undefined at runtime too + } else if (fn[EXPR_META]) { + const outerSrc = currentSrc; + const wasPlanning = planningVariable; + planningVariable = true; + currentSrc = fn[EXPR_META].expr; + try { + plan = planValue(fn[EXPR_META].ast); + } finally { + planningVariable = wasPlanning; + currentSrc = outerSrc; + } + } else { + // Plain JS function: run it against the known context; touching an + // unknown field (or any other throw) downgrades to opaque. + const R = createThrowingResource(knownR); + const req = { P: principal, R, principal, resource: R, actions, constants: C, C }; + try { + plan = constPV(fn(req)); + } catch { + plan = OPAQUE; + } + } + if (plan.k === 'const') ctx.V[name] = plan.v; + variablePlans.set(name, plan); + return plan; + } + + /** Peels a member chain into its base node and ordered segment list. */ + function peelChain(node) { + const segments = []; + let current = node; + while (current.type === 'MemberExpression') { + segments.unshift(current.computed ? { node: current.property } : { key: current.property.name }); + current = current.object; + } + return { base: current, segments }; + } + + /** Resolves computed segments to const keys where possible. */ + function resolveSegments(segments) { + const resolved = []; + for (const segment of segments) { + if (segment.key !== undefined) { + if (BLOCKED_KEYS.has(segment.key)) return null; + resolved.push({ key: segment.key }); + continue; + } + const keyPlan = planValue(segment.node); + if (keyPlan.k === 'const') { + const key = keyPlan.v; + if ((typeof key !== 'string' && typeof key !== 'number') || BLOCKED_KEYS.has(String(key))) return null; + resolved.push({ key: String(key) }); + } else if (keyPlan.k === 'opaque') { + return null; + } else { + resolved.push({ residual: keyPlan.operand }); + } + } + return resolved; + } + + /** Appends the remaining segments to an operand as `index` operations. */ + function indexChain(operand, segments) { + let current = operand; + for (const segment of segments) { + const keyOperand = segment.key !== undefined ? { value: segment.key } : segment.residual; + current = { expression: { operator: 'index', operands: [current, keyOperand] } }; + } + return residualPV(current); + } + + function planResourceMember(node, segments) { + if (!segments.length || segments[0].key === undefined) return OPAQUE; + const [head, ...rest] = segments; + if (head.key === 'kind' || head.key === 'scope' || head.key === 'policyVersion') { + return rest.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); + } + if (head.key === 'id') { + return rest.length ? OPAQUE : residualPV({ variable: 'request.resource.id' }); + } + if (head.key !== 'attr' || !rest.length) return OPAQUE; + + // Leading run of const keys after `attr` decides known vs residual. + let splitIndex = 0; + while (splitIndex < rest.length && rest[splitIndex].key !== undefined) splitIndex++; + if (!splitIndex) return OPAQUE; // R.attr[] + if (Object.prototype.hasOwnProperty.call(knownAttr, rest[0].key)) { + // Known attr: fully-const paths fold through the interpreter (throws + // propagate — runtime parity); residual keys into a known value are a + // rarity not worth planning. + return splitIndex === rest.length ? constPV(evalConst(node)) : OPAQUE; + } + const path = rest.slice(0, splitIndex).map((segment) => segment.key); + const variable = { variable: `request.resource.attr.${path.join('.')}` }; + return indexChain(variable, rest.slice(splitIndex)); + } + + function planVariableMember(node, segments) { + if (!segments.length || segments[0].key === undefined) return OPAQUE; + if (planningVariable) return OPAQUE; // runtime variables never see V + const plan = variablePlan(segments[0].key); + const rest = segments.slice(1); + if (plan.k === 'opaque') return OPAQUE; + if (plan.k === 'const') { + return rest.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); + } + return indexChain(plan.operand, rest); + } + + function planMember(node) { + const { base, segments } = peelChain(node); + if (base.type !== 'Identifier') return OPAQUE; + const resolved = resolveSegments(segments); + if (!resolved) return OPAQUE; + switch (base.name) { + case 'R': + return planResourceMember(node, resolved); + case 'V': + return planVariableMember(node, resolved); + case 'P': + case 'C': + case 'Math': + case 'Date': + return resolved.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); + default: + return OPAQUE; + } + } + + function planShortCircuit(node) { + const left = planValue(node.left); + if (left.k !== 'const') return OPAQUE; // value-position semantics are not boolean — cannot residualize + const { operator } = node; + if (operator === '&&') return left.v ? planValue(node.right) : left; + if (operator === '||') return left.v ? left : planValue(node.right); + return left.v === null || left.v === undefined ? planValue(node.right) : left; // ?? + } + + function planBinary(node) { + if (node.operator === '&&' || node.operator === '||' || node.operator === '??') { + return planShortCircuit(node); + } + const left = planValue(node.left); + const right = planValue(node.right); + if (left.k === 'const' && right.k === 'const') return constPV(evalConst(node)); + const operator = JS_TO_CERBOS_BINARY[node.operator]; + if (!operator || left.k === 'opaque' || right.k === 'opaque') return OPAQUE; + return residualPV({ expression: { operator, operands: [toOp(left), toOp(right)] } }); + } + + function planCall(node) { + const { callee } = node; + const argPlans = node.arguments.map((argument) => planValue(argument)); + const argsConst = argPlans.every((plan) => plan.k === 'const'); + + if (callee.type === 'Identifier') { + return argsConst ? constPV(evalConst(node)) : OPAQUE; + } + if (callee.type !== 'MemberExpression') return OPAQUE; + + const receiver = planValue(callee.object); + if (receiver.k === 'const' && argsConst && (callee.computed ? planValue(callee.property).k === 'const' : true)) { + return constPV(evalConst(node)); + } + const isIncludes = !callee.computed && callee.property.name === 'includes' && node.arguments.length === 1; + if (!isIncludes || receiver.k === 'opaque' || argPlans[0].k === 'opaque') return OPAQUE; + // `in` is list membership. A const string receiver would mean substring + // semantics — not expressible; a residual receiver is assumed to be a + // list (documented plannability constraint). + if (receiver.k === 'const' && !Array.isArray(receiver.v)) return OPAQUE; + return residualPV({ expression: { operator: 'in', operands: [toOp(argPlans[0]), toOp(receiver)] } }); + } + + function planArray(node) { + const plans = node.elements.map((element) => planValue(element)); + if (plans.every((plan) => plan.k === 'const')) return constPV(evalConst(node)); + if (plans.some((plan) => plan.k === 'opaque')) return OPAQUE; + return residualPV({ expression: { operator: 'list', operands: plans.map(toOp) } }); + } + + // ObjectExpression / NewExpression: fold when fully known, otherwise opaque. + function planObjectOrNew(node) { + const parts = node.type === 'ObjectExpression' ? node.properties : node.arguments; + for (const part of parts) { + const valueNode = node.type === 'ObjectExpression' ? (part.shorthand ? part.key : part.value) : part; + if (planValue(valueNode).k !== 'const') return OPAQUE; + if (node.type === 'ObjectExpression' && part.computed && planValue(part.key).k !== 'const') return OPAQUE; + } + return constPV(evalConst(node)); + } + + function planUnary(node) { + if (node.operator === '!') { + const inner = planBool(node.argument); + const negated = notNode(inner); + if (negated.t === 'const') return constPV(negated.v); + if (negated.t === 'opaque') return OPAQUE; + return residualPV(toOperand(negated)); + } + return planValue(node.argument).k === 'const' ? constPV(evalConst(node)) : OPAQUE; + } + + function planConditionalValue(node) { + const test = planValue(node.test); + if (test.k !== 'const') return OPAQUE; + return planValue(test.v ? node.consequent : node.alternate); + } + + /** + * Value-level partial evaluation: PlanValue for any expression node. + * + * @param {Record} node + * @returns {{ k: 'const', v: unknown } | { k: 'residual', operand: object } | { k: 'opaque' }} + */ + function planValue(node) { + switch (node.type) { + case 'Literal': + return constPV(node.value); + case 'Identifier': + if (node.name === 'P' || node.name === 'C') return constPV(ctx[node.name]); + if (node.name === 'Math' || node.name === 'Date') return constPV(node.name === 'Math' ? Math : Date); + return OPAQUE; // bare R / V / custom roots + case 'MemberExpression': + return planMember(node); + case 'BinaryExpression': + return planBinary(node); + case 'UnaryExpression': + return planUnary(node); + case 'CallExpression': + return planCall(node); + case 'ArrayExpression': + return planArray(node); + case 'ConditionalExpression': + return planConditionalValue(node); + case 'ObjectExpression': + case 'NewExpression': + return planObjectOrNew(node); + default: + return OPAQUE; + } + } + + function valueToBool(planned) { + if (planned.k === 'const') return constNode(Boolean(planned.v)); + if (planned.k === 'opaque') return opaqueNode(currentSrc, 'unsupported-expression'); + const { operand } = planned; + if (operand.expression && BOOLEAN_RESULT_OPS.has(operand.expression.operator)) return exprNode(operand); + // Bare value in boolean position: eq(x, true) — documented constraint. + if (operand.variable || operand.expression?.operator === 'index') { + return exprNode({ expression: { operator: 'eq', operands: [operand, { value: true }] } }); + } + return opaqueNode(currentSrc, 'unsupported-expression'); + } + + /** + * Boolean-level partial evaluation: PlanNode for a condition expression. + * + * @param {Record} node + * @returns {import('./nodes.js').PlanNode} + */ + function planBool(node) { + if (node.type === 'BinaryExpression' && (node.operator === '&&' || node.operator === '||')) { + const left = planBool(node.left); + if (node.operator === '&&') { + if (left.t === 'const' && !left.v) return left; + return andNode([left, planBool(node.right)]); + } + if (left.t === 'const' && left.v) return left; + return orNode([left, planBool(node.right)]); + } + if (node.type === 'UnaryExpression' && node.operator === '!') { + return notNode(planBool(node.argument)); + } + if (node.type === 'ConditionalExpression') { + const test = planValue(node.test); + if (test.k !== 'const') return opaqueNode(currentSrc, 'unsupported-expression'); + return planBool(test.v ? node.consequent : node.alternate); + } + return valueToBool(planValue(node)); + } + + function planLeaf(fn) { + const meta = fn[EXPR_META]; + if (!meta) return opaqueNode(describeFn(fn), 'js-function'); + const outerSrc = currentSrc; + currentSrc = meta.expr; + try { + return planBool(meta.ast); + } finally { + currentSrc = outerSrc; + } + } + + /** + * Plans a Conditions match tree. Exact parity with Conditions.isFulfilled: + * empty/invalid strategy payloads fail closed to FALSE, unknown keys are + * ignored, multiple strategies on one object must all pass (AND). + * + * @param {unknown} match + * @returns {import('./nodes.js').PlanNode} + */ + function planMatch(match) { + if (typeof match === 'function') return planLeaf(match); + if (typeof match !== 'object' || match === null) return constNode(false); + const parts = []; + for (const key of Object.keys(match)) { + const conds = match[key]; + if (key !== 'any' && key !== 'all' && key !== 'none') continue; // forward-compat: ignore unknown keys + if (!Array.isArray(conds) || !conds.length) return constNode(false); + if (key === 'any') parts.push(orNode(conds.map(planMatch))); + else if (key === 'all') parts.push(andNode(conds.map(planMatch))); + else parts.push(andNode(conds.map((cond) => notNode(planMatch(cond))))); + } + if (!parts.length) return constNode(false); + return andNode(parts); + } + + function planCondition(conditions) { + if (!conditions) return constNode(true); // unconditional rule + return planMatch(conditions.shape.match); + } + + return { planCondition }; +} + +module.exports = { createExprPlanner }; diff --git a/src/planning/planner.js b/src/planning/planner.js new file mode 100644 index 0000000..22dbbb3 --- /dev/null +++ b/src/planning/planner.js @@ -0,0 +1,244 @@ +/** + * Layer-composition planner: mirrors `#evaluatePolicySources` symbolically. + * + * Per action the runtime resolves Principal → Role → Resource, each layer + * seeing only actions the previous one left unset. At plan time the layer + * SELECTORS are constants (which policies resolve, whether any role policy + * targets `R.kind` — all derived from the fully-known principal and + * `resource.kind`); only the principal layer can defer the choice, because + * its rule conditions may read unknown resource fields. Hence per action: + * + * plan = OR( AND(PA, NOT(PD)), — principal decides Allow + * AND(NOT(PA), NOT(PD), layer) ) — principal silent → next layer + * + * where PA/PD are the ORs of fulfilled allow/deny principal rules, and + * `layer` is the role layer when applicable (allowlist with implicit deny, + * Deny-wins across roles, parentRoles intersection — semantics of + * `#evaluateRolePolicies`) or the resource layer otherwise + * (`AND(OR(allow rules), NOT(OR(deny rules)))`, default deny). + * + * All inputs are pre-resolved policy class instances — this module is pure + * and synchronous; the engine does the (async, cache-aware) lookups. + */ + +const { ALL_ACTIONS, ALL_RESOURCES, ALL_ROLES, Effect } = require('../schemas'); +const { FALSE, andNode, constNode, notNode, orNode, relationNode } = require('./nodes.js'); +const { createExprPlanner } = require('./partialEval.js'); + +function intersects(roles, principalRoleSet) { + for (const role of roles) if (principalRoleSet.has(role)) return true; + return false; +} + +/** + * Builds the query plan for one request. + * + * @param {object} input + * @param {Record} input.principal + * @param {{ kind: string, scope?: string, policyVersion?: string, attr?: Record }} input.resource + * @param {string[]} input.actions + * @param {import('../PrincipalPolicy').PrincipalPolicy | null} input.principalPolicy + * @param {import('../RolePolicy').RolePolicy[]} input.rolePolicies - resolved for deduped principal roles, order kept + * @param {Map} input.rolePolicyClosure - incl. transitive parents + * @param {import('../ResourcePolicy').ResourcePolicy | null} input.resourcePolicy + * @param {import('../DerivedRoles').DerivedRoles[]} input.derivedRolesSets - resolved importDerivedRoles, order kept + * @param {boolean} input.hasRelations + * @param {Array | null} [input.trace] - decision trace (includeMeta); receives no-resolver entries + * @returns {{ node: import('./nodes.js').PlanNode, perAction: Map }} + */ +function buildResourcePlan({ + principal, + resource, + actions, + principalPolicy, + rolePolicies, + rolePolicyClosure, + resourcePolicy, + derivedRolesSets, + hasRelations, + trace = null, +}) { + const principalRoleSet = new Set(principal.roles); + + // One expression planner per policy: each policy evaluates conditions + // against its own constants/variables context, exactly like check(). + const planners = new Map(); + function plannerFor(key, holder) { + let planner = planners.get(key); + if (!planner) { + planner = createExprPlanner({ + principal, + resource, + actions, + constants: holder?.constants, + variables: holder?.variables, + }); + planners.set(key, planner); + } + return planner; + } + + // ---- principal layer ----------------------------------------------------- + + function principalNodes(action) { + if (!principalPolicy) return { allow: FALSE, deny: FALSE }; + const planner = plannerFor(principalPolicy, principalPolicy.shape.principalPolicy); + const allowParts = []; + const denyParts = []; + for (const rule of principalPolicy.rules ?? []) { + if (rule.resource !== ALL_RESOURCES && rule.resource !== resource.kind) continue; + for (const actionRule of rule.actions) { + if (actionRule.action !== ALL_ACTIONS && actionRule.action !== action) continue; + const node = planner.planCondition(actionRule.condition); + (actionRule.effect === Effect.Deny ? denyParts : allowParts).push(node); + } + } + return { allow: orNode(allowParts), deny: orNode(denyParts) }; + } + + // ---- role layer ---------------------------------------------------------- + + function roleMatchesResource(policy) { + for (const rule of policy.rules ?? []) { + if (rule.resource === ALL_RESOURCES || rule.resource === resource.kind) return true; + } + return false; + } + + const applicableRolePolicies = rolePolicies.filter(roleMatchesResource); + + /** + * Effective role allow: the child's fulfilled allowlist OR, intersected + * with every resolvable parent (a parent that never targets the resource + * contributes FALSE — runtime parity: the child's Allow is downgraded). + * Memoized per `${policyKey}|${action}`; a cycle throws the same error the + * runtime does. + */ + function roleAllowNode(policy, action, memo, stack) { + const policyKey = `${policy.role}.${policy.version}.${policy.scope ?? ''}|${action}`; + if (memo.has(policyKey)) return memo.get(policyKey); + if (stack.has(policyKey)) { + throw new Error(`Circular role policy inheritance detected for role "${policy.role}"`); + } + stack.add(policyKey); + + let node; + if (!roleMatchesResource(policy)) { + node = FALSE; + } else { + const planner = plannerFor(policy, policy.shape.rolePolicy); + const allowParts = []; + for (const rule of policy.rules ?? []) { + if (rule.resource !== ALL_RESOURCES && rule.resource !== resource.kind) continue; + if (!rule.allowActionsSet.has(ALL_ACTIONS) && !rule.allowActionsSet.has(action)) continue; + allowParts.push(planner.planCondition(rule.condition)); + } + node = orNode(allowParts); + for (const parentRole of policy.parentRoles) { + const parentPolicy = rolePolicyClosure.get(parentRole); + if (!parentPolicy) continue; + node = andNode([node, roleAllowNode(parentPolicy, action, memo, stack)]); + } + } + + stack.delete(policyKey); + memo.set(policyKey, node); + return node; + } + + function roleLayerNode(action) { + // Deny-wins across roles: every applicable role must effectively allow. + const memo = new Map(); + const parts = applicableRolePolicies.map((policy) => roleAllowNode(policy, action, memo, new Set())); + return andNode(parts); + } + + // ---- derived roles (resource layer) -------------------------------------- + + const derivedRoleNodes = new Map(); + + function derivedRoleNode(name) { + if (derivedRoleNodes.has(name)) return derivedRoleNodes.get(name); + const parts = []; + for (const set of derivedRolesSets) { + const def = set.roles.get(name); + if (!def) continue; + const planner = plannerFor(set, set.shape); + if (def.relation) { + const gateParts = []; + if (Array.isArray(def.parentRoles) && def.parentRoles.length) { + gateParts.push(constNode(intersects(def.parentRoles, principalRoleSet))); + } + if (def.condition) gateParts.push(planner.planCondition(def.condition)); + const gate = andNode(gateParts); + if (!hasRelations) { + // Runtime parity: without a resolver the candidate can never + // activate — trace it instead of denying silently. + if (trace && gate !== FALSE) { + trace.push({ + source: 'relations', + name: def.name, + relation: def.relation, + matched: false, + reason: 'no-relations-resolver', + }); + } + parts.push(FALSE); + } else { + parts.push(andNode([gate, relationNode(def.name, def.relation)])); + } + } else { + const gate = constNode(intersects(def.parentRoles, principalRoleSet)); + parts.push(andNode([gate, planner.planCondition(def.condition)])); + } + } + const node = orNode(parts); + derivedRoleNodes.set(name, node); + return node; + } + + // ---- resource layer ------------------------------------------------------ + + function resourceLayerNode(action) { + if (!resourcePolicy) return FALSE; // 'policy-miss' default deny + const planner = plannerFor(resourcePolicy, resourcePolicy.shape.resourcePolicy); + const allowParts = []; + const denyParts = []; + for (const rule of resourcePolicy.rules ?? []) { + if (!rule.actionsSet.has(ALL_ACTIONS) && !rule.actionsSet.has(action)) continue; + + let rolesGate = FALSE; + if (Array.isArray(rule.roles)) { + rolesGate = constNode(rule.roles.some((role) => role === ALL_ROLES || principalRoleSet.has(role))); + } + let derivedGate = FALSE; + if (Array.isArray(rule.derivedRoles)) { + derivedGate = orNode(rule.derivedRoles.map(derivedRoleNode)); + } + const gate = orNode([rolesGate, derivedGate]); + const node = andNode([gate, planner.planCondition(rule.condition)]); + (rule.effect === Effect.Deny ? denyParts : allowParts).push(node); + } + // Deny-over-Allow with default deny: allowed ⇔ some allow ∧ no deny. + return andNode([orNode(allowParts), notNode(orNode(denyParts))]); + } + + // ---- composition --------------------------------------------------------- + + const roleLayerApplicable = applicableRolePolicies.length > 0; + + function planAction(action) { + const { allow, deny } = principalNodes(action); + const layer = roleLayerApplicable ? roleLayerNode(action) : resourceLayerNode(action); + return orNode([andNode([allow, notNode(deny)]), andNode([notNode(allow), notNode(deny), layer])]); + } + + const perAction = new Map(); + for (const action of actions) perAction.set(action, planAction(action)); + // Multi-action requests plan the conjunction (Cerbos semantics: the rows + // where ALL requested actions are allowed). + const node = andNode([...perAction.values()]); + return { node, perAction }; +} + +module.exports = { buildResourcePlan }; diff --git a/src/schemas/index.js b/src/schemas/index.js index 75e849a..abb36c5 100644 --- a/src/schemas/index.js +++ b/src/schemas/index.js @@ -46,6 +46,17 @@ class ZodSchemas { }); } + // planResources plans over a resource KIND, not an instance: no `id`, and + // `attr` holds only the KNOWN attributes (everything else stays unknown). + static buildRequestPlanResource(z) { + return z.object({ + kind: z.string(), + policyVersion: z.string().optional(), + scope: ZodSchemas.buildScopeString(z).optional(), + attr: z.record(z.string(), z.unknown()).optional(), + }); + } + static buildRequest(z) { return z.object({ principal: ZodSchemas.buildRequestPrincipal(z), @@ -170,6 +181,19 @@ class JsonSchemas { ); } + // See ZodSchemas.buildRequestPlanResource — kind-level resource, no `id`. + static buildRequestPlanResource() { + return JsonSchemas.buildObjectShape( + { + kind: { type: 'string' }, + policyVersion: { type: 'string' }, + scope: JsonSchemas.buildScopeString(), + attr: JsonSchemas.buildUnknownRecordShape(), + }, + ['kind'], + ); + } + static buildRequest() { return JsonSchemas.buildObjectShape( { @@ -235,6 +259,16 @@ class TypeBoxSchemas { }); } + // See ZodSchemas.buildRequestPlanResource — kind-level resource, no `id`. + static buildRequestPlanResource(t) { + return t.Object({ + kind: t.String(), + policyVersion: t.Optional(t.String()), + scope: t.Optional(TypeBoxSchemas.buildScopeString(t)), + attr: t.Optional(TypeBoxSchemas.buildUnknownRecordShape(t)), + }); + } + static buildRequest(t) { return t.Object({ principal: TypeBoxSchemas.buildRequestPrincipal(t), diff --git a/src/schemas/kerberos.js b/src/schemas/kerberos.js index bc0578e..31fb614 100644 --- a/src/schemas/kerberos.js +++ b/src/schemas/kerberos.js @@ -46,6 +46,19 @@ class KerberosZodSchemas extends ZodSchemas { includeMeta: z.boolean().optional(), }); } + + // Exactly-one-of action/actions is enforced in planResources itself (a + // manual check keeps the three backends' schemas simple and identical). + static buildPlanResourcesArgs(z) { + return z.object({ + reqId: z.string().optional(), + principal: ZodSchemas.buildRequestPrincipal(z), + resource: ZodSchemas.buildRequestPlanResource(z), + action: z.string().optional(), + actions: z.array(z.string()).nonempty().optional(), + includeMeta: z.boolean().optional(), + }); + } } class KerberosJsonSchemas extends JsonSchemas { @@ -97,6 +110,20 @@ class KerberosJsonSchemas extends JsonSchemas { ['principal', 'resources'], ); } + + static buildPlanResourcesArgs() { + return JsonSchemas.buildObjectShape( + { + reqId: { type: 'string' }, + principal: JsonSchemas.buildRequestPrincipal(), + resource: JsonSchemas.buildRequestPlanResource(), + action: { type: 'string' }, + actions: JsonSchemas.buildNonEmptyArrayShape({ type: 'string' }), + includeMeta: { type: 'boolean' }, + }, + ['principal', 'resource'], + ); + } } class KerberosTypeBoxSchemas extends TypeBoxSchemas { @@ -140,6 +167,17 @@ class KerberosTypeBoxSchemas extends TypeBoxSchemas { includeMeta: t.Optional(t.Boolean()), }); } + + static buildPlanResourcesArgs(t) { + return t.Object({ + reqId: t.Optional(t.String()), + principal: TypeBoxSchemas.buildRequestPrincipal(t), + resource: TypeBoxSchemas.buildRequestPlanResource(t), + action: t.Optional(t.String()), + actions: t.Optional(TypeBoxSchemas.buildNonEmptyArrayShape(t, t.String())), + includeMeta: t.Optional(t.Boolean()), + }); + } } module.exports = { diff --git a/test/PlanParity.test.js b/test/PlanParity.test.js new file mode 100644 index 0000000..3223e72 --- /dev/null +++ b/test/PlanParity.test.js @@ -0,0 +1,263 @@ +const { describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); + +const { Effect, Kerberos, createSafeExprCodec, deserializePolicy } = require('../src/index.js'); + +const jsepModule = require('jsep'); +const jsep = jsepModule.default || jsepModule; +jsep.plugins.register(require('@jsep-plugin/object'), require('@jsep-plugin/ternary'), require('@jsep-plugin/new')); +jsep.addUnaryOp('typeof'); + +const codec = createSafeExprCodec({ jsep }); + +// --------------------------------------------------------------------------- +// Test-side interpreter for the Cerbos operand tree. Any operator outside the +// documented vocabulary (including `opaque` / `relation`) throws — drift in +// the planner output fails the suite loudly instead of passing vacuously. +// --------------------------------------------------------------------------- +const OPERATORS = { + and: (operands) => operands.every(Boolean), + or: (operands) => operands.some(Boolean), + not: ([value]) => !value, + eq: ([left, right]) => left === right, + ne: ([left, right]) => left !== right, + lt: ([left, right]) => left < right, + le: ([left, right]) => left <= right, + gt: ([left, right]) => left > right, + ge: ([left, right]) => left >= right, + add: ([left, right]) => left + right, + sub: ([left, right]) => left - right, + mult: ([left, right]) => left * right, + div: ([left, right]) => left / right, + mod: ([left, right]) => left % right, + in: ([item, list]) => (Array.isArray(list) ? list.includes(item) : false), + index: ([base, key]) => base?.[key], + list: (operands) => operands, +}; + +function evalOperand(operand, row) { + if ('value' in operand) return operand.value; + if ('variable' in operand) { + const path = operand.variable.split('.'); + assert.strictEqual(path[0], 'request'); + assert.strictEqual(path[1], 'resource'); + let current = row; + for (let i = 2; i < path.length; i++) current = current?.[path[i]]; + return current; + } + const { operator, operands } = operand.expression; + const handler = OPERATORS[operator]; + if (!handler) throw new Error(`Unexpected operator in plan: ${operator}`); + return handler(operands.map((child) => evalOperand(child, row))); +} + +function evalFilter(filter, row) { + if (filter.kind === 'KIND_ALWAYS_ALLOWED') return true; + if (filter.kind === 'KIND_ALWAYS_DENIED') return false; + return Boolean(evalOperand(filter.condition, row)); +} + +// --------------------------------------------------------------------------- +// Fixture: an all-$expr policy suite exercising every layer. +// --------------------------------------------------------------------------- +const policies = [ + deserializePolicy( + { + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + constants: { minQty: 10 }, + variables: { + isOpen: { $expr: "R.attr.status === 'OPEN'" }, + }, + rules: [ + { actions: ['view'], effect: Effect.Allow, roles: ['ADMIN'] }, + { + actions: ['view', 'edit'], + effect: Effect.Allow, + derivedRoles: ['OWNER'], + condition: { match: { all: [{ $expr: 'V.isOpen' }] } }, + }, + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: 'R.attr.public === true' } }, + }, + { + actions: ['count'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: 'R.attr.qty > C.minQty && R.attr.qty % 2 === 0' } }, + }, + { + actions: ['*'], + effect: Effect.Deny, + roles: ['*'], + condition: { match: { $expr: 'R.attr.banned === true' } }, + }, + ], + }, + }, + codec, + ), + deserializePolicy( + { + principalPolicy: { + principal: 'boss', + version: 'default', + rules: [ + { + resource: 'document', + actions: [ + { action: 'view', effect: Effect.Allow, condition: { match: { $expr: 'R.attr.classified !== true' } } }, + { action: 'edit', effect: Effect.Deny }, + ], + }, + ], + }, + }, + codec, + ), + deserializePolicy( + { + rolePolicy: { + role: 'CONTRACTOR', + version: 'default', + parentRoles: ['STAFF'], + rules: [ + { + resource: 'document', + allowActions: ['view'], + condition: { match: { $expr: 'R.attr.public === true' } }, + }, + ], + }, + }, + codec, + ), + deserializePolicy( + { + rolePolicy: { + role: 'STAFF', + version: 'default', + rules: [{ resource: 'document', allowActions: ['view', 'edit'] }], + }, + }, + codec, + ), +]; + +const derivedRoles = [ + deserializePolicy( + { + name: 'doc_roles', + definitions: [ + { name: 'OWNER', parentRoles: ['USER'], condition: { match: { $expr: 'R.attr.ownerId === P.id' } } }, + ], + }, + codec, + ), +]; + +const principals = [ + { id: 'root', roles: ['ADMIN'] }, + { id: 'u1', roles: ['USER'] }, + { id: 'u2', roles: ['USER'] }, + { id: 'boss', roles: ['USER'] }, + { id: 'c1', roles: ['CONTRACTOR'] }, +]; + +const actions = ['view', 'edit', 'count']; + +// Sampled grid over the unknown attributes (64 rows). +const attrGrid = []; +for (const ownerId of ['u1', 'u2']) { + for (const status of ['OPEN', 'CLOSED']) { + for (const isPublic of [true, false]) { + for (const banned of [true, false]) { + for (const classified of [true, false]) { + for (const qty of [5, 12]) { + attrGrid.push({ ownerId, status, public: isPublic, banned, classified, qty }); + } + } + } + } + } +} + +const kerberos = new Kerberos(policies, derivedRoles); + +async function assertParity(principal, action, planArgs, rows) { + const plan = await kerberos.planResources(planArgs); + for (const attr of rows) { + const row = { id: 'r1', attr }; + const planned = evalFilter(plan.filter, row); + const actual = await kerberos.isAllowed({ + principal, + resource: { id: 'r1', kind: 'document', attr }, + action, + }); + assert.strictEqual( + planned, + actual, + `drift for ${principal.id}/${action} on ${JSON.stringify(attr)}: plan=${planned} isAllowed=${actual}`, + ); + } +} + +describe('planResources ↔ isAllowed parity', () => { + for (const principal of principals) { + for (const action of actions) { + it(`matches isAllowed for ${principal.id}/${action} across the attr grid`, async () => { + await assertParity(principal, action, { principal, resource: { kind: 'document' }, action }, attrGrid); + }); + } + } + + it('keeps parity when some attributes are known at plan time', async () => { + const known = { status: 'OPEN', banned: false }; + const rows = attrGrid.filter((attr) => attr.status === 'OPEN' && attr.banned === false); + for (const principal of principals) { + const plan = await kerberos.planResources({ + principal, + resource: { kind: 'document', attr: known }, + action: 'view', + }); + for (const attr of rows) { + const planned = evalFilter(plan.filter, { id: 'r1', attr }); + const actual = await kerberos.isAllowed({ + principal, + resource: { id: 'r1', kind: 'document', attr }, + action: 'view', + }); + assert.strictEqual(planned, actual, `partial-known drift for ${principal.id} on ${JSON.stringify(attr)}`); + } + } + }); + + it('keeps parity for multi-action plans (AND of the per-action results)', async () => { + for (const principal of principals) { + const plan = await kerberos.planResources({ + principal, + resource: { kind: 'document' }, + actions: ['view', 'edit'], + }); + for (const attr of attrGrid) { + const planned = evalFilter(plan.filter, { id: 'r1', attr }); + const view = await kerberos.isAllowed({ + principal, + resource: { id: 'r1', kind: 'document', attr }, + action: 'view', + }); + const edit = await kerberos.isAllowed({ + principal, + resource: { id: 'r1', kind: 'document', attr }, + action: 'edit', + }); + assert.strictEqual(planned, view && edit, `multi-action drift for ${principal.id} on ${JSON.stringify(attr)}`); + } + } + }); +}); diff --git a/test/PlanResources.test.js b/test/PlanResources.test.js new file mode 100644 index 0000000..f364cec --- /dev/null +++ b/test/PlanResources.test.js @@ -0,0 +1,837 @@ +const { describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); +const { Keyv } = require('keyv'); +const { z } = require('zod'); +const AjvModule = require('ajv'); +const Ajv = AjvModule.default ?? AjvModule; +const { Type } = require('@sinclair/typebox'); + +const { + Effect, + Kerberos, + KerberosValidationError, + createSafeExprCodec, + deserializePolicy, + expandRelationOperands, + serializePolicy, +} = require('../src/index.js'); + +const jsepModule = require('jsep'); +const jsep = jsepModule.default || jsepModule; +jsep.plugins.register(require('@jsep-plugin/object'), require('@jsep-plugin/ternary'), require('@jsep-plugin/new')); +jsep.addUnaryOp('typeof'); + +const codec = createSafeExprCodec({ jsep }); + +const user = { id: 'u1', roles: ['USER'] }; +const docKind = { kind: 'document' }; + +const ALLOWED = 'KIND_ALWAYS_ALLOWED'; +const DENIED = 'KIND_ALWAYS_DENIED'; +const CONDITIONAL = 'KIND_CONDITIONAL'; + +function dynamicPolicy(shape) { + return deserializePolicy(shape, codec); +} + +async function planOf(kerberos, overrides = {}) { + const args = { principal: user, resource: docKind, action: 'view', ...overrides }; + if (overrides.actions) delete args.action; + return kerberos.planResources(args); +} + +describe('planResources', () => { + describe('resource layer', () => { + it('returns ALWAYS_DENIED when no policy exists at all', async () => { + const kerberos = new Kerberos([], []); + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + assert.strictEqual(plan.resourceKind, 'document'); + assert.strictEqual(plan.policyVersion, 'default'); + assert.strictEqual(plan.action, 'view'); + assert.ok(plan.kerberosCallId); + }); + + it('returns ALWAYS_ALLOWED for an unconditional allow rule', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [{ actions: ['view'], effect: Effect.Allow, roles: ['USER'] }], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: ALLOWED }); + }); + + it('emits a residual condition for $expr rules and folds known attrs', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: "R.attr.status === 'OPEN'" } }, + }, + ], + }, + }), + ], + [], + ); + + const plan = await planOf(kerberos); + assert.strictEqual(plan.filter.kind, CONDITIONAL); + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.status' }, { value: 'OPEN' }] }, + }); + + const known = await planOf(kerberos, { resource: { kind: 'document', attr: { status: 'OPEN' } } }); + assert.deepStrictEqual(known.filter, { kind: ALLOWED }); + const knownDenied = await planOf(kerberos, { resource: { kind: 'document', attr: { status: 'CLOSED' } } }); + assert.deepStrictEqual(knownDenied.filter, { kind: DENIED }); + }); + + it('inverts deny rules and honors Deny-over-Allow', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { actions: ['*'], effect: Effect.Allow, roles: ['USER'] }, + { + actions: ['view'], + effect: Effect.Deny, + roles: ['*'], + condition: { match: { $expr: "R.attr.status === 'ARCHIVED'" } }, + }, + ], + }, + }), + ], + [], + ); + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter.condition, { + expression: { + operator: 'not', + operands: [ + { + expression: { + operator: 'eq', + operands: [{ variable: 'request.resource.attr.status' }, { value: 'ARCHIVED' }], + }, + }, + ], + }, + }); + }); + + it('treats non-matching roles as ALWAYS_DENIED and wildcard roles as matching', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { actions: ['view'], effect: Effect.Allow, roles: ['ADMIN'] }, + { actions: ['list'], effect: Effect.Allow, roles: ['*'] }, + ], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: DENIED }); + assert.deepStrictEqual((await planOf(kerberos, { action: 'list' })).filter, { kind: ALLOWED }); + }); + + it('marks JS-function conditions as opaque operands', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: (req) => req.R.attr.ownerId === req.P.id }, + }, + ], + }, + }, + ], + [], + ); + const plan = await planOf(kerberos); + assert.strictEqual(plan.filter.kind, CONDITIONAL); + assert.strictEqual(plan.filter.condition.expression.operator, 'opaque'); + assert.strictEqual(plan.filter.condition.expression.operands[0].value.reason, 'js-function'); + }); + }); + + describe('principal layer', () => { + const resourcePolicy = { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [{ actions: ['*'], effect: Effect.Allow, roles: ['USER'] }], + }, + }; + + it('unconditional principal ALLOW short-circuits to ALWAYS_ALLOWED', async () => { + const kerberos = new Kerberos( + [ + { + principalPolicy: { + principal: 'u1', + version: 'default', + rules: [{ resource: 'document', actions: [{ action: 'view', effect: Effect.Allow }] }], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: ALLOWED }); + }); + + it('unconditional principal DENY beats an unconditional resource ALLOW', async () => { + const kerberos = new Kerberos( + [ + resourcePolicy, + { + principalPolicy: { + principal: 'u1', + version: 'default', + rules: [{ resource: '*', actions: [{ action: 'view', effect: Effect.Deny }] }], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: DENIED }); + }); + + it('a residual principal condition composes with the fallthrough layer', async () => { + const kerberos = new Kerberos( + [ + resourcePolicy, + dynamicPolicy({ + principalPolicy: { + principal: 'u1', + version: 'default', + rules: [ + { + resource: 'document', + actions: [ + { + action: 'view', + effect: Effect.Deny, + condition: { match: { $expr: 'R.attr.classified === true' } }, + }, + ], + }, + ], + }, + }), + ], + [], + ); + // deny wins where classified; resource allows otherwise: + // OR(AND(FALSE, ...), AND(NOT(FALSE), NOT(classified), TRUE)) → not(classified) + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter.condition, { + expression: { + operator: 'not', + operands: [ + { + expression: { + operator: 'eq', + operands: [{ variable: 'request.resource.attr.classified' }, { value: true }], + }, + }, + ], + }, + }); + }); + + it('does not consult the principal policy of another principal', async () => { + const kerberos = new Kerberos( + [ + resourcePolicy, + { + principalPolicy: { + principal: 'u2', + version: 'default', + rules: [{ resource: '*', actions: [{ action: '*', effect: Effect.Deny }] }], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: ALLOWED }); + }); + }); + + describe('role layer', () => { + it('applies allowlist semantics with implicit deny', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [{ actions: ['*'], effect: Effect.Allow, roles: ['*'] }], + }, + }, + { + rolePolicy: { role: 'USER', version: 'default', rules: [{ resource: 'document', allowActions: ['view'] }] }, + }, + ], + [], + ); + // Allowlisted action → allowed; unlisted action → implicit deny even + // though the resource policy would allow it. + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: ALLOWED }); + assert.deepStrictEqual((await planOf(kerberos, { action: 'delete' })).filter, { kind: DENIED }); + }); + + it('keeps role rule conditions residual', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + rolePolicy: { + role: 'USER', + version: 'default', + rules: [ + { + resource: 'document', + allowActions: ['view'], + condition: { match: { $expr: 'R.attr.public === true' } }, + }, + ], + }, + }), + ], + [], + ); + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.public' }, { value: true }] }, + }); + }); + + it('lets deny win across multiple role policies', async () => { + const kerberos = new Kerberos( + [ + { + rolePolicy: { role: 'USER', version: 'default', rules: [{ resource: 'document', allowActions: ['view'] }] }, + }, + { + rolePolicy: { + role: 'AUDITOR', + version: 'default', + rules: [{ resource: 'document', allowActions: ['audit'] }], + }, + }, + ], + [], + ); + // AUDITOR matches the resource but does not allowlist 'view' → Deny + // wins over USER's Allow. + const plan = await planOf(kerberos, { principal: { id: 'u1', roles: ['USER', 'AUDITOR'] } }); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + }); + + it('intersects child allows with parent role policies', async () => { + const child = { + rolePolicy: { + role: 'EDITOR', + version: 'default', + parentRoles: ['READER'], + rules: [{ resource: 'document', allowActions: ['view', 'edit'] }], + }, + }; + const parent = { + rolePolicy: { role: 'READER', version: 'default', rules: [{ resource: 'document', allowActions: ['view'] }] }, + }; + const kerberos = new Kerberos([child, parent], []); + const editor = { id: 'u1', roles: ['EDITOR'] }; + + assert.deepStrictEqual((await planOf(kerberos, { principal: editor })).filter, { kind: ALLOWED }); + // 'edit' is allowlisted by the child but not by the parent → denied. + assert.deepStrictEqual((await planOf(kerberos, { principal: editor, action: 'edit' })).filter, { kind: DENIED }); + }); + + it('denies when a parent role policy never targets the resource', async () => { + const kerberos = new Kerberos( + [ + { + rolePolicy: { + role: 'EDITOR', + version: 'default', + parentRoles: ['READER'], + rules: [{ resource: 'document', allowActions: ['view'] }], + }, + }, + { + rolePolicy: { + role: 'READER', + version: 'default', + rules: [{ resource: 'invoice', allowActions: ['view'] }], + }, + }, + ], + [], + ); + const plan = await planOf(kerberos, { principal: { id: 'u1', roles: ['EDITOR'] } }); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + }); + + it('throws on circular role policy inheritance (runtime parity)', async () => { + const kerberos = new Kerberos( + [ + { + rolePolicy: { + role: 'A', + version: 'default', + parentRoles: ['B'], + rules: [{ resource: 'document', allowActions: ['view'] }], + }, + }, + { + rolePolicy: { + role: 'B', + version: 'default', + parentRoles: ['A'], + rules: [{ resource: 'document', allowActions: ['view'] }], + }, + }, + ], + [], + ); + await assert.rejects( + planOf(kerberos, { principal: { id: 'u1', roles: ['A'] } }), + /Circular role policy inheritance/, + ); + }); + }); + + describe('derived roles', () => { + it('inlines condition-backed definitions gated by parentRoles', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + rules: [{ actions: ['view'], effect: Effect.Allow, derivedRoles: ['OWNER'] }], + }, + }), + ], + [ + dynamicPolicy({ + name: 'doc_roles', + definitions: [ + { name: 'OWNER', parentRoles: ['USER'], condition: { match: { $expr: 'R.attr.ownerId === P.id' } } }, + ], + }), + ], + ); + + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.ownerId' }, { value: 'u1' }] }, + }); + // parentRoles gate is a plan-time constant: a guest can never be OWNER. + const guest = await planOf(kerberos, { principal: { id: 'u1', roles: ['GUEST'] } }); + assert.deepStrictEqual(guest.filter, { kind: DENIED }); + }); + + it('emits relation operands for relation-backed definitions', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + rules: [{ actions: ['view'], effect: Effect.Allow, derivedRoles: ['VIEWER'] }], + }, + }, + ], + [{ name: 'doc_roles', definitions: [{ name: 'VIEWER', relation: 'viewer' }] }], + { relations: { check: async () => true } }, + ); + const plan = await planOf(kerberos); + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'relation', operands: [{ value: { name: 'VIEWER', relation: 'viewer' } }] }, + }); + }); + + it('folds relation-backed definitions to DENIED without a resolver and traces why', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + rules: [{ actions: ['view'], effect: Effect.Allow, derivedRoles: ['VIEWER'] }], + }, + }, + ], + [{ name: 'doc_roles', definitions: [{ name: 'VIEWER', relation: 'viewer' }] }], + ); + const plan = await planOf(kerberos, { includeMeta: true }); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + const entry = plan.meta.resolution.find((item) => item.source === 'relations'); + assert.deepStrictEqual(entry, { + source: 'relations', + name: 'VIEWER', + relation: 'viewer', + matched: false, + reason: 'no-relations-resolver', + }); + }); + + it('expandRelationOperands materializes relation nodes via the lookup', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + rules: [{ actions: ['view'], effect: Effect.Allow, derivedRoles: ['VIEWER'] }], + }, + }, + ], + [{ name: 'doc_roles', definitions: [{ name: 'VIEWER', relation: 'viewer' }] }], + { relations: { check: async () => true } }, + ); + const plan = await kerberos.planResources({ + principal: user, + resource: docKind, + action: 'view', + includeMeta: true, + }); + + const calls = []; + const expanded = await expandRelationOperands(plan, async (args) => { + calls.push(args); + return new Set(['d1', 'd2']); + }); + assert.deepStrictEqual(calls, [{ name: 'VIEWER', relation: 'viewer' }]); + assert.deepStrictEqual(expanded.filter.condition, { + expression: { operator: 'in', operands: [{ variable: 'request.resource.id' }, { value: ['d1', 'd2'] }] }, + }); + assert.strictEqual(expanded.meta.filterDebug, '(in request.resource.id ["d1","d2"])'); + // Original response is untouched. + assert.strictEqual(plan.filter.condition.expression.operator, 'relation'); + + const empty = await expandRelationOperands(plan, async () => []); + assert.deepStrictEqual(empty.filter, { kind: DENIED }); + + // Plans without a condition pass through unchanged. + const denied = await planOf(new Kerberos([], []), {}); + assert.strictEqual(await expandRelationOperands(denied, async () => []), denied); + await assert.rejects(expandRelationOperands(plan, null), TypeError); + }); + }); + + describe('scopes, versions and cache-backed policies', () => { + it('resolves the most specific scoped policy (first-match-wins)', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [{ actions: ['view'], effect: Effect.Allow, roles: ['USER'] }], + }, + }, + { + resourcePolicy: { + resource: 'document', + version: 'default', + scope: 'acme', + rules: [{ actions: ['view'], effect: Effect.Deny, roles: ['*'] }], + }, + }, + ], + [], + ); + const base = await planOf(kerberos, { includeMeta: true }); + assert.deepStrictEqual(base.filter, { kind: ALLOWED }); + assert.strictEqual(base.meta.matchedScopes.resource, ''); + + const scoped = await planOf(kerberos, { resource: { kind: 'document', scope: 'acme.team' }, includeMeta: true }); + assert.deepStrictEqual(scoped.filter, { kind: DENIED }); + assert.strictEqual(scoped.meta.matchedScopes.resource, 'acme'); + const lookup = scoped.meta.resolution.find((entry) => entry.source === 'resource'); + assert.deepStrictEqual(lookup.scopesSearched, ['acme.team', 'acme', '']); + assert.strictEqual(lookup.matchedScope, 'acme'); + }); + + it('honors policyVersion and echoes it in the response', async () => { + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + resource: 'document', + version: 'v2', + rules: [{ actions: ['view'], effect: Effect.Allow, roles: ['USER'] }], + }, + }, + ], + [], + ); + assert.deepStrictEqual((await planOf(kerberos)).filter, { kind: DENIED }); + const versioned = await planOf(kerberos, { resource: { kind: 'document', policyVersion: 'v2' } }); + assert.deepStrictEqual(versioned.filter, { kind: ALLOWED }); + assert.strictEqual(versioned.policyVersion, 'v2'); + }); + + it('plans cache-backed $expr policies (origin: cache in the trace)', async () => { + const keyv = new Keyv(); + await keyv.set( + 'resource:document:default:', + serializePolicy( + { + resourcePolicy: { + resource: 'document', + version: 'default', + importDerivedRoles: ['doc_roles'], + rules: [{ actions: ['view'], effect: Effect.Allow, derivedRoles: ['OWNER'] }], + }, + }, + { jsep }, + ), + ); + await keyv.set( + 'derivedRoles:doc_roles', + serializePolicy( + { + name: 'doc_roles', + definitions: [ + { name: 'OWNER', parentRoles: ['USER'], condition: { match: { $expr: 'R.attr.ownerId === P.id' } } }, + ], + }, + { jsep }, + ), + ); + + const kerberos = new Kerberos([], [], { cache: keyv, codec: { jsep } }); + const plan = await planOf(kerberos, { includeMeta: true }); + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.ownerId' }, { value: 'u1' }] }, + }); + const lookup = plan.meta.resolution.find((entry) => entry.source === 'resource'); + assert.strictEqual(lookup.origin, 'cache'); + }); + }); + + describe('multiple actions (AND semantics)', () => { + it('plans the conjunction of the per-action plans', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { actions: ['view'], effect: Effect.Allow, roles: ['USER'] }, + { + actions: ['edit'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: 'R.attr.ownerId === P.id' } }, + }, + ], + }, + }), + ], + [], + ); + const plan = await planOf(kerberos, { actions: ['view', 'edit'] }); + assert.deepStrictEqual(plan.actions, ['view', 'edit']); + assert.strictEqual(plan.action, undefined); + // view is unconditionally allowed → the conjunction reduces to edit's condition. + assert.deepStrictEqual(plan.filter.condition, { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.ownerId' }, { value: 'u1' }] }, + }); + + const denied = await planOf(kerberos, { actions: ['view', 'delete'] }); + assert.deepStrictEqual(denied.filter, { kind: DENIED }); + }); + }); + + describe('argument validation', () => { + const kerberos = new Kerberos([], []); + + it('requires exactly one of action / actions', async () => { + await assert.rejects(kerberos.planResources({ principal: user, resource: docKind }), KerberosValidationError); + await assert.rejects( + kerberos.planResources({ principal: user, resource: docKind, action: 'view', actions: ['view'] }), + KerberosValidationError, + ); + await assert.rejects( + kerberos.planResources({ principal: user, resource: docKind, actions: [] }), + KerberosValidationError, + ); + await assert.rejects( + kerberos.planResources({ principal: user, resource: docKind, actions: [42] }), + KerberosValidationError, + ); + }); + + it('rejects the wildcard action', async () => { + await assert.rejects( + kerberos.planResources({ principal: user, resource: docKind, action: '*' }), + /wildcard action/, + ); + }); + + it('validates argument shapes across the three backends', async () => { + const ajv = () => new Ajv({ allowUnionTypes: true }); + const backends = [ + new Kerberos([], [], { z }), + new Kerberos([], [], { ajv: ajv(), typebox: Type }), + new Kerberos([], [], { ajv: ajv() }), + ]; + for (const instance of backends) { + const plan = await instance.planResources({ principal: user, resource: docKind, action: 'view' }); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + // Plan resources take no `id`; a malformed principal must throw. + await assert.rejects( + instance.planResources({ principal: { id: 'u1', roles: [] }, resource: docKind, action: 'view' }), + KerberosValidationError, + ); + await assert.rejects( + instance.planResources({ principal: user, resource: {}, action: 'view' }), + KerberosValidationError, + ); + } + }); + }); + + describe('error semantics and observability', () => { + const badPolicy = () => + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: 'R.attr.obj.x === 1' } }, + }, + ], + }, + }); + const badResource = { kind: 'document', attr: { obj: null } }; + + it('propagates evaluation errors by default (onError: throw)', async () => { + const kerberos = new Kerberos([badPolicy()], []); + await assert.rejects(planOf(kerberos, { resource: badResource }), TypeError); + }); + + it('fails closed with onError: deny', async () => { + const kerberos = new Kerberos([badPolicy()], [], { onError: 'deny' }); + const plan = await planOf(kerberos, { resource: badResource, reqId: 'req-9' }); + assert.deepStrictEqual(plan.filter, { kind: DENIED }); + assert.strictEqual(plan.reqId, 'req-9'); + assert.strictEqual(plan.action, 'view'); + assert.strictEqual(plan.resourceKind, 'document'); + assert.ok(plan.kerberosCallId); + }); + + it('always rethrows validation errors even with onError: deny', async () => { + const kerberos = new Kerberos([], [], { onError: 'deny' }); + await assert.rejects(kerberos.planResources({ principal: user, resource: docKind }), KerberosValidationError); + }); + + it('emits PlanResources start/finish audit events to a structured logger', async () => { + const entries = []; + const logger = { + info: (entry) => entries.push(entry), + debug: (entry) => entries.push(entry), + error: (entry) => entries.push(entry), + }; + const kerberos = new Kerberos([], [], { logger }); + await planOf(kerberos, { reqId: 'req-1' }); + const events = entries.map((entry) => entry.event); + assert.ok(events.includes('PlanResources.start')); + assert.ok(events.includes('PlanResources.finish')); + const start = entries.find((entry) => entry.event === 'PlanResources.start'); + assert.strictEqual(start.reqId, 'req-1'); + assert.ok(start.callId); + }); + }); + + describe('includeMeta', () => { + it('returns filterDebug, matchedScopes and the resolution trace', async () => { + const kerberos = new Kerberos( + [ + dynamicPolicy({ + resourcePolicy: { + resource: 'document', + version: 'default', + rules: [ + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: { $expr: 'R.attr.qty > 5' } }, + }, + ], + }, + }), + { + rolePolicy: { + role: 'AUDITOR', + version: 'default', + rules: [{ resource: 'invoice', allowActions: ['audit'] }], + }, + }, + ], + [], + ); + const plan = await planOf(kerberos, { principal: { id: 'u1', roles: ['USER', 'AUDITOR'] }, includeMeta: true }); + assert.strictEqual(plan.meta.filterDebug, '(gt request.resource.attr.qty 5)'); + assert.deepStrictEqual(plan.meta.matchedScopes, { + principal: null, + resource: '', + roles: { USER: null, AUDITOR: '' }, + }); + const sources = plan.meta.resolution.map((entry) => entry.source); + assert.ok(sources.includes('principal')); + assert.ok(sources.includes('role')); + assert.ok(sources.includes('resource')); + }); + + it('omits meta without includeMeta', async () => { + const kerberos = new Kerberos([], []); + assert.strictEqual((await planOf(kerberos)).meta, undefined); + }); + }); +}); diff --git a/test/Planning.test.js b/test/Planning.test.js new file mode 100644 index 0000000..02724e1 --- /dev/null +++ b/test/Planning.test.js @@ -0,0 +1,338 @@ +const { describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); + +const { EXPR_META, evalExprAst } = require('../src/caching/codec.js'); +const { + FALSE, + TRUE, + andNode, + exprNode, + fromOperand, + notNode, + opaqueNode, + orNode, + relationNode, + toDebugString, + toFilter, + toOperand, +} = require('../src/planning/nodes.js'); +const { createExprPlanner } = require('../src/planning/partialEval.js'); +const { createSafeExprCodec } = require('../src/index.js'); + +const jsepModule = require('jsep'); +const jsep = jsepModule.default || jsepModule; +jsep.plugins.register(require('@jsep-plugin/object'), require('@jsep-plugin/ternary'), require('@jsep-plugin/new')); +jsep.addUnaryOp('typeof'); + +const codec = createSafeExprCodec({ jsep }); + +const eqExpr = (variable, value) => exprNode({ expression: { operator: 'eq', operands: [{ variable }, { value }] } }); + +describe('Planning', () => { + describe('codec seams (EXPR_META / evalExprAst)', () => { + it('attaches frozen, non-enumerable meta to compiled closures', () => { + const fn = codec.compileExpr('R.attr.a > 1'); + const meta = fn[EXPR_META]; + assert.ok(meta); + assert.strictEqual(meta.expr, 'R.attr.a > 1'); + assert.strictEqual(typeof meta.ast, 'object'); + assert.ok(meta.roots.has('R')); + assert.ok(Object.isFrozen(meta)); + // Non-enumerable: invisible to serialization and key walks. + assert.deepStrictEqual(Object.keys(fn), []); + assert.strictEqual(JSON.stringify({ fn }), '{}'); + }); + + it('evalExprAst evaluates an AST with the default roots', () => { + const { ast } = codec.compileExpr('P.id === "u1" && C.limit > 2')[EXPR_META]; + assert.strictEqual(evalExprAst(ast, { P: { id: 'u1' }, C: { limit: 3 } }), true); + assert.strictEqual(evalExprAst(ast, { P: { id: 'u2' }, C: { limit: 3 } }), false); + }); + + it('evalExprAst honors custom roots', () => { + const caveatCodec = createSafeExprCodec({ jsep, roots: ['P', 'ctx'] }); + const { ast } = caveatCodec.compileExpr('ctx.ip === "10.0.0.1"')[EXPR_META]; + assert.strictEqual(evalExprAst(ast, { ctx: { ip: '10.0.0.1' } }, { roots: ['P', 'ctx'] }), true); + }); + }); + + describe('nodes: normalization', () => { + const a = eqExpr('request.resource.attr.a', 1); + const b = eqExpr('request.resource.attr.b', 2); + + it('and: absorbing false, identity true, flatten, unwrap', () => { + assert.strictEqual(andNode([a, FALSE, b]), FALSE); + assert.deepStrictEqual(andNode([TRUE, a]), a); + assert.deepStrictEqual(andNode([andNode([a, b]), TRUE]), { t: 'and', children: [a, b] }); + assert.strictEqual(andNode([]), TRUE); + }); + + it('or: absorbing true, identity false, flatten, unwrap', () => { + assert.strictEqual(orNode([a, TRUE, b]), TRUE); + assert.deepStrictEqual(orNode([FALSE, a]), a); + assert.deepStrictEqual(orNode([orNode([a, b]), FALSE]), { t: 'or', children: [a, b] }); + assert.strictEqual(orNode([]), FALSE); + }); + + it('deduplicates identical children but never opaque nodes', () => { + assert.deepStrictEqual(andNode([a, a, b]), { t: 'and', children: [a, b] }); + const opaque = opaqueNode('[function]', 'js-function'); + const doubled = andNode([opaque, opaqueNode('[function]', 'js-function')]); + assert.strictEqual(doubled.children.length, 2); + }); + + it('not: folds constants and double negation', () => { + assert.strictEqual(notNode(TRUE), FALSE); + assert.strictEqual(notNode(FALSE), TRUE); + assert.deepStrictEqual(notNode(notNode(a)), a); + assert.deepStrictEqual(notNode(a), { t: 'not', child: a }); + }); + + it('toFilter maps constants to ALWAYS_* and everything else to CONDITIONAL', () => { + assert.deepStrictEqual(toFilter(TRUE), { kind: 'KIND_ALWAYS_ALLOWED' }); + assert.deepStrictEqual(toFilter(FALSE), { kind: 'KIND_ALWAYS_DENIED' }); + const filter = toFilter(a); + assert.strictEqual(filter.kind, 'KIND_CONDITIONAL'); + assert.deepStrictEqual(filter.condition, toOperand(a)); + }); + + it('serializes opaque and relation nodes as Kerberos operators', () => { + assert.deepStrictEqual(toOperand(opaqueNode('src', 'js-function')), { + expression: { operator: 'opaque', operands: [{ value: { src: 'src', reason: 'js-function' } }] }, + }); + assert.deepStrictEqual(toOperand(relationNode('doc_viewer', 'viewer')), { + expression: { operator: 'relation', operands: [{ value: { name: 'doc_viewer', relation: 'viewer' } }] }, + }); + }); + + it('fromOperand round-trips and re-normalizes boolean positions', () => { + const tree = andNode([a, notNode(orNode([b, relationNode('r', 'rel')]))]); + assert.deepStrictEqual(fromOperand(toOperand(tree)), tree); + // A subtree replaced by a constant re-folds through the constructors. + const withConst = { expression: { operator: 'and', operands: [{ value: false }, toOperand(a)] } }; + assert.strictEqual(fromOperand(withConst), FALSE); + }); + + it('renders a readable s-expression debug string', () => { + assert.strictEqual( + toDebugString(andNode([a, notNode(b)])), + '(and (eq request.resource.attr.a 1) (not (eq request.resource.attr.b 2)))', + ); + assert.strictEqual(toDebugString(TRUE), 'true'); + }); + }); + + describe('partialEval: expression planning', () => { + const principal = { id: 'u1', roles: ['USER'], attr: { dept: 'sales' } }; + const baseResource = { kind: 'document' }; + + function plan(expr, { resource = baseResource, constants, variables } = {}) { + const planner = createExprPlanner({ principal, resource, actions: ['view'], constants, variables }); + return planner.planCondition({ shape: { match: codec.compileExpr(expr) } }); + } + + it('folds fully-known expressions to constants', () => { + assert.strictEqual(plan('P.id === "u1"'), TRUE); + assert.strictEqual(plan('P.attr.dept === "hr"'), FALSE); + assert.strictEqual(plan('Math.max(1, 2) === 2'), TRUE); + assert.strictEqual(plan('Date.now() > 0'), TRUE); + }); + + it('folds known resource fields and residualizes unknown ones', () => { + assert.strictEqual(plan('R.kind === "document"'), TRUE); + assert.deepStrictEqual( + plan('R.attr.status === "open"', { resource: { kind: 'document', attr: { status: 'open' } } }), + TRUE, + ); + assert.deepStrictEqual(plan('R.attr.status === "open"'), eqExpr('request.resource.attr.status', 'open')); + assert.deepStrictEqual(plan('R.id === "d1"'), eqExpr('request.resource.id', 'd1')); + assert.deepStrictEqual(plan('R.attr.meta.level === 3'), eqExpr('request.resource.attr.meta.level', 3)); + }); + + it('maps comparison and arithmetic operators to Cerbos names', () => { + const node = plan('(R.attr.qty + 1) > 10'); + assert.deepStrictEqual(node, { + t: 'expr', + e: { + expression: { + operator: 'gt', + operands: [ + { + expression: { + operator: 'add', + operands: [{ variable: 'request.resource.attr.qty' }, { value: 1 }], + }, + }, + { value: 10 }, + ], + }, + }, + }); + assert.strictEqual(plan('R.attr.a !== 1').e.expression.operator, 'ne'); + assert.strictEqual(plan('R.attr.a < 1').e.expression.operator, 'lt'); + assert.strictEqual(plan('R.attr.a <= 1').e.expression.operator, 'le'); + assert.strictEqual(plan('R.attr.a >= 1').e.expression.operator, 'ge'); + assert.strictEqual(plan('R.attr.a % 2 === 0').e.expression.operands[0].expression.operator, 'mod'); + }); + + it('maps includes() to in with membership semantics', () => { + assert.deepStrictEqual(plan('["a", "b"].includes(R.attr.tag)'), { + t: 'expr', + e: { + expression: { + operator: 'in', + operands: [{ variable: 'request.resource.attr.tag' }, { value: ['a', 'b'] }], + }, + }, + }); + // Residual receiver: assumed to be a list. + const node = plan('R.attr.tags.includes("x")'); + assert.deepStrictEqual(node.e.expression.operands, [{ value: 'x' }, { variable: 'request.resource.attr.tags' }]); + // Const STRING receiver would mean substring semantics — opaque. + assert.strictEqual(plan('"abc".includes(R.attr.s)').t, 'opaque'); + // Residual list literal. + const listNode = plan('[R.attr.a, 1].includes(R.attr.b)'); + assert.strictEqual(listNode.e.expression.operands[1].expression.operator, 'list'); + }); + + it('wraps bare residual values in boolean position as eq(x, true)', () => { + assert.deepStrictEqual(plan('R.attr.isPublic'), eqExpr('request.resource.attr.isPublic', true)); + assert.deepStrictEqual(plan('!R.attr.isPublic'), notNode(eqExpr('request.resource.attr.isPublic', true))); + }); + + it('preserves && / || laziness against the known side', () => { + assert.deepStrictEqual(plan('P.id === "u1" && R.attr.qty > 1').e.expression.operator, 'gt'); + assert.strictEqual(plan('P.id === "zzz" && R.attr.qty > 1'), FALSE); + assert.strictEqual(plan('P.id === "u1" || R.attr.qty > 1'), TRUE); + assert.deepStrictEqual(plan('P.id === "zzz" || R.attr.qty > 1').e.expression.operator, 'gt'); + }); + + it('takes the reachable ternary branch on a known test, opaque otherwise', () => { + assert.deepStrictEqual( + plan('P.id === "u1" ? R.attr.a === 1 : R.attr.b === 2'), + eqExpr('request.resource.attr.a', 1), + ); + assert.strictEqual(plan('R.attr.flag ? R.attr.a === 1 : R.attr.b === 2').t, 'opaque'); + }); + + it('degrades unplannable constructs to opaque (always sound)', () => { + for (const expr of [ + 'R.attr.a ?? 1', + 'R.attr.a ** 2 === 4', + 'typeof R.attr.a === "string"', + 'Math.floor(R.attr.a) > 1', + 'R.attr.a.toLowerCase() === "x"', + 'new Date(R.attr.a).getTime() > 0', + '({ x: R.attr.a }).x === 1', + 'R.attr["__proto__"] === 1', + 'R === 1', + 'R.attr === 1', + ]) { + const node = plan(expr); + assert.strictEqual(node.t, 'opaque', `expected opaque for: ${expr}`); + assert.strictEqual(node.reason, 'unsupported-expression'); + assert.strictEqual(node.src, expr); + } + }); + + it('plans JS-function leaves as opaque', () => { + const planner = createExprPlanner({ principal, resource: baseResource, actions: ['view'] }); + const named = function myCondition() { + return true; + }; + assert.deepStrictEqual(planner.planCondition({ shape: { match: named } }), { + t: 'opaque', + src: '[function myCondition]', + reason: 'js-function', + }); + }); + + it('mirrors Conditions.isFulfilled strategy semantics exactly', () => { + const planner = createExprPlanner({ principal, resource: baseResource, actions: ['view'] }); + const leaf = codec.compileExpr('R.attr.a === 1'); + const residual = eqExpr('request.resource.attr.a', 1); + + assert.deepStrictEqual(planner.planCondition({ shape: { match: { all: [leaf] } } }), residual); + assert.deepStrictEqual(planner.planCondition({ shape: { match: { any: [leaf] } } }), residual); + assert.deepStrictEqual(planner.planCondition({ shape: { match: { none: [leaf] } } }), notNode(residual)); + // Empty / invalid strategy payloads fail closed. + assert.strictEqual(planner.planCondition({ shape: { match: { all: [] } } }), FALSE); + assert.strictEqual(planner.planCondition({ shape: { match: {} } }), FALSE); + assert.strictEqual(planner.planCondition({ shape: { match: { description: 'noop' } } }), FALSE); + assert.strictEqual(planner.planCondition({ shape: { match: null } }), FALSE); + // Multiple strategies on one object AND together. + assert.deepStrictEqual( + planner.planCondition({ shape: { match: { all: [leaf], none: [codec.compileExpr('R.attr.b === 2')] } } }), + andNode([residual, notNode(eqExpr('request.resource.attr.b', 2))]), + ); + // No condition at all → unconditional rule. + assert.strictEqual(planner.planCondition(undefined), TRUE); + }); + + it('inlines $expr variables (const, residual) and folds through V', () => { + const variables = { + shape: { + isOwner: codec.compileExpr('R.attr.ownerId === P.id'), + me: codec.compileExpr('P.id'), + cfg: codec.compileExpr('({ min: 5 })'), + }, + }; + assert.deepStrictEqual(plan('V.isOwner', { variables }), eqExpr('request.resource.attr.ownerId', 'u1')); + assert.deepStrictEqual(plan('R.attr.author === V.me', { variables }), { + t: 'expr', + e: { + expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.author' }, { value: 'u1' }] }, + }, + }); + assert.deepStrictEqual(plan('R.attr.qty > V.cfg.min', { variables }), { + t: 'expr', + e: { expression: { operator: 'gt', operands: [{ variable: 'request.resource.attr.qty' }, { value: 5 }] } }, + }); + }); + + it('treats V-in-variable as opaque (runtime variables never see V)', () => { + const variables = { + shape: { + a: codec.compileExpr('V.b'), + b: codec.compileExpr('P.id'), + }, + }; + assert.strictEqual(plan('V.a === "u1"', { variables }).t, 'opaque'); + }); + + it('evaluates plain JS-function variables against known fields only', () => { + const variables = { + shape: { + dept: (req) => req.P.attr.dept, + kind: (req) => req.R.kind, + known: (req) => req.R.attr.status, + unknown: (req) => req.R.attr.secret, + throwing: () => { + throw new Error('boom'); + }, + }, + }; + const resource = { kind: 'document', attr: { status: 'open' } }; + assert.strictEqual(plan('V.dept === "sales"', { resource, variables }), TRUE); + assert.strictEqual(plan('V.kind === "document"', { resource, variables }), TRUE); + assert.strictEqual(plan('V.known === "open"', { resource, variables }), TRUE); + assert.strictEqual(plan('V.unknown === "x"', { resource, variables }).t, 'opaque'); + assert.strictEqual(plan('V.throwing === 1', { resource, variables }).t, 'opaque'); + }); + + it('treats undeclared variables as undefined (runtime parity)', () => { + assert.strictEqual(plan('V.nope === undefined2', {}).t, 'opaque'); // unknown identifier stays opaque + assert.strictEqual(plan('V.nope === null', {}), FALSE); // undefined === null → false, folded + }); + + it('folds constants through C', () => { + const constants = { get: () => ({ minQty: 10 }) }; + assert.deepStrictEqual(plan('R.attr.qty > C.minQty', { constants }).e.expression.operands[1], { value: 10 }); + }); + + it('propagates evaluation errors on fully-known subtrees (runtime parity)', () => { + const resource = { kind: 'document', attr: { obj: null } }; + assert.throws(() => plan('R.attr.obj.x === 1', { resource }), TypeError); + }); + }); +}); diff --git a/test/types.test-d.ts b/test/types.test-d.ts index d796cd3..0b50d65 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -1,10 +1,16 @@ +import { expectType } from 'tsd'; import { Effect, Kerberos, + expandRelationOperands, type KerberosDerivedRoles, type KerberosPolicy, type KerberosTelemetryApi, type KerberosTelemetryOptions, + type PlanExpressionOperand, + type PlanFilter, + type PlanKind, + type PlanResourcesResponse, } from '../index.js'; const policy = { @@ -90,3 +96,33 @@ kerberos.isAllowed({ action: 'read', resource: { id: 'doc1', kind: 'document' }, }); + +// planResources: kind-level resource (no id), single action or actions[] +expectType>( + kerberos.planResources({ + principal: { id: 'user1', roles: ['USER'] }, + resource: { kind: 'document', attr: { status: 'OPEN' } }, + action: 'read', + includeMeta: true, + }), +); +kerberos.planResources({ + principal: { id: 'user1', roles: ['USER'] }, + resource: { kind: 'document' }, + actions: ['read', 'edit'], +}); + +declare const planResponse: PlanResourcesResponse; +expectType(planResponse.filter); +expectType(planResponse.filter.kind); +// The operand union accepts nested expressions, variables and literals. +const conditionalOperand: PlanExpressionOperand = { + expression: { + operator: 'eq', + operands: [{ variable: 'request.resource.attr.status' }, { value: 'OPEN' }], + }, +}; +void conditionalOperand; + +expectType>(expandRelationOperands(planResponse, async () => ['id1'])); +expectType>(expandRelationOperands(planResponse, () => new Set(['id1']))); From adb06b43b29d129fbc8c057c412cf7ff9378408b Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 20 Jul 2026 20:14:16 +0300 Subject: [PATCH 2/5] refactor: Query Plans (planResources) --- CHANGELOG.md | 12 +- CLAUDE.md | 2 +- README.md | 1 + index.d.ts | 6 +- src/Kerberos.js | 58 +++++++- src/index.js | 3 + src/planning/expand.js | 54 ++++--- src/planning/nodes.js | 124 ++++++++++------ src/planning/partialEval.js | 282 ++++++++++++++++++++++-------------- src/planning/planner.js | 44 ++++-- test/PlanResources.test.js | 8 +- test/types.test-d.ts | 6 +- 12 files changed, 398 insertions(+), 202 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce061b..16b0f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,11 +42,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (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`, - hand-maintained types (`PlanKind`, `PlanFilter`, `PlanExpressionOperand`, - `PlanResourcesArgs`, `PlanResourcesResponse`), a `planResources` bench - scenario and a README section with the planning flow diagram. + 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. ## [3.0.0] - 2026-07-20 diff --git a/CLAUDE.md b/CLAUDE.md index b244384..4f26eb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Because remote-stored policies must be JSON (no live functions), `codec.js` (`cr ### 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` is public). 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). +`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`) diff --git a/README.md b/README.md index bdfae22..cf21e92 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ const plan = await kerberos.planResources({ | `ResourcePolicy`, `PrincipalPolicy`, `RolePolicy`, `DerivedRoles` | Policy classes (rarely constructed directly). | | `Conditions`, `Variables`, `Constants`, `Outputs` | DSL building blocks. | | `createSafeExprCodec`, `serializePolicy`, `deserializePolicy` | Safe AST codec for [dynamic/stored policies](#caching--storing-policies). | +| `PlanKind` | `{ AlwaysAllowed, AlwaysDenied, Conditional }` — [query plan](#query-plans-planresources) filter kinds. | | `expandRelationOperands` | Materializes ReBAC `relation` operands of a [query plan](#query-plans-planresources) into id filters. | | `registerAjvKeywords`, `createAjvAdapter` | [Validation](#schema-validation) helpers. | | `JsonSchemas`, `TypeBoxSchemas`, `ZodSchemas`, `KerberosJsonSchemas`, `ResourcePolicyJsonSchemas`, `PrincipalPolicyJsonSchemas`, `RolePolicyJsonSchemas`, … | Schema builders for the three backends. | diff --git a/index.d.ts b/index.d.ts index d5cf38e..e0a89f1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -698,7 +698,11 @@ export function createCacheReader( ): { enabled: boolean; get(key: string): Promise }; /** planResources filter outcome (Cerbos-compatible). */ -export type PlanKind = 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL'; +export enum PlanKind { + AlwaysAllowed = 'KIND_ALWAYS_ALLOWED', + AlwaysDenied = 'KIND_ALWAYS_DENIED', + Conditional = 'KIND_CONDITIONAL', +} /** * One operand of a planResources condition tree: a literal, a reference to an diff --git a/src/Kerberos.js b/src/Kerberos.js index ae49f20..904ae38 100644 --- a/src/Kerberos.js +++ b/src/Kerberos.js @@ -9,7 +9,7 @@ const { createTelemetryWriter } = require('./telemetry.js'); const { createCacheReader } = require('./caching/cache.js'); const { KerberosCodecError, KerberosValidationError } = require('./errors.js'); const { createSafeExprCodec } = require('./caching/codec.js'); -const { PLAN_KINDS, toDebugString, toFilter } = require('./planning/nodes.js'); +const { PlanKind, toDebugString, toFilter } = require('./planning/nodes.js'); const { buildResourcePlan } = require('./planning/planner.js'); const { createAjvAdapter, parseWithValidation, registerAjvKeywords } = require('./validation'); // Platform runtime: bundlers swap this for `./runtime/browser.js` via the @@ -859,17 +859,61 @@ class Kerberos { return sets; } + // The two dependent planning chains (roles → parent closure; resource → + // derived-roles sets) — split out so #planPolicySources can run all three + // sources as one concurrent allSettled wave. + async #planRoleSources(req, trace) { + const rolePolicies = await this.#getRolePolicies(req, trace); + const rolePolicyClosure = await this.#resolveRolePolicyClosure(rolePolicies, req); + return { rolePolicies, rolePolicyClosure }; + } + + async #planResourceSources(req, trace) { + const resourcePolicy = await this.#getResourcePolicy(req, trace); + const derivedRolesSets = resourcePolicy ? await this.#resolveDerivedRolesSets(resourcePolicy) : []; + return { resourcePolicy, derivedRolesSets }; + } + /** * Resolves every policy source the planner needs (async, cache-aware); the * planner itself (`buildResourcePlan`) is pure and synchronous. + * + * The three independent chains (principal / role+closure / resource+derived + * roles) resolve concurrently: in-memory lookups stay synchronous-fast, but + * with a cache-backed store this turns up to three sequential round-trip + * waves into one. `Promise.allSettled` follows the engine's parallelism + * policy — every sibling settles, then the first rejection rethrows. Each + * chain records into its own trace buffer, concatenated in the canonical + * principal → roles → resource order, so `meta.resolution` stays + * deterministic regardless of cache-read completion order. */ async #planPolicySources(principal, resource, actions, trace) { const req = { principal, resource, P: principal, R: resource, actions }; - const principalPolicy = await this.#getPrincipalPolicy(req, trace); - const rolePolicies = await this.#getRolePolicies(req, trace); - const rolePolicyClosure = await this.#resolveRolePolicyClosure(rolePolicies, req); - const resourcePolicy = await this.#getResourcePolicy(req, trace); - const derivedRolesSets = resourcePolicy ? await this.#resolveDerivedRolesSets(resourcePolicy) : []; + const principalTrace = trace ? [] : null; + const roleTrace = trace ? [] : null; + const resourceTrace = trace ? [] : null; + + const settled = await Promise.allSettled([ + this.#getPrincipalPolicy(req, principalTrace), + this.#planRoleSources(req, roleTrace), + this.#planResourceSources(req, resourceTrace), + ]); + + if (trace) { + for (const entry of principalTrace) trace.push(entry); + for (const entry of roleTrace) trace.push(entry); + for (const entry of resourceTrace) trace.push(entry); + } + + for (const outcome of settled) { + if (outcome.status === 'rejected') { + throw outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason)); + } + } + + const principalPolicy = settled[0].value; + const { rolePolicies, rolePolicyClosure } = settled[1].value; + const { resourcePolicy, derivedRolesSets } = settled[2].value; return { principalPolicy, rolePolicies, rolePolicyClosure, resourcePolicy, derivedRolesSets }; } @@ -1286,7 +1330,7 @@ class Kerberos { args?.resource, typeof args?.action === 'string' ? args.action : undefined, Array.isArray(args?.actions) ? [...args.actions] : undefined, - { kind: PLAN_KINDS.ALWAYS_DENIED }, + { kind: PlanKind.AlwaysDenied }, ), ); } diff --git a/src/index.js b/src/index.js index 6f6dac3..08ee10a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,8 @@ // EXPR_META / evalExprAst are internal seams between the codec and the query // planner (src/planning/) — deliberately kept out of the public surface. const { EXPR_META, evalExprAst, ...codecExports } = require('./caching/codec.js'); +// Only the filter-kind enum is public from the plan-node model. +const { PlanKind } = require('./planning/nodes.js'); module.exports = { ...require('./Constants/index.js'), @@ -17,6 +19,7 @@ module.exports = { ...require('./caching/cache.js'), ...codecExports, ...require('./planning/expand.js'), + PlanKind, ...require('./schemas'), ...require('./validation'), }; diff --git a/src/planning/expand.js b/src/planning/expand.js index c5968c6..73a676f 100644 --- a/src/planning/expand.js +++ b/src/planning/expand.js @@ -12,34 +12,52 @@ * folds the branch to FALSE, which can collapse the whole plan. */ -const { andNode, constNode, exprNode, fromOperand, notNode, orNode, toDebugString, toFilter } = require('./nodes.js'); +const { + andNode, + constNode, + createDispatch, + exprNode, + fromOperand, + notNode, + orNode, + toDebugString, + toFilter, +} = require('./nodes.js'); -async function resolveRelationIds(lookup, detail) { - const ids = await lookup({ name: detail.name, relation: detail.relation }); - const list = ids instanceof Set ? [...ids] : Array.isArray(ids) ? ids : []; +async function resolveRelationIds(lookup, node) { + const ids = await lookup({ name: node.name, relation: node.relation }); + const list = []; + // Any iterable of ids works (array, Set, generator); strings are scalars, + // not id lists. + if (ids !== null && ids !== undefined && typeof ids !== 'string' && typeof ids[Symbol.iterator] === 'function') { + for (const id of ids) list.push(id); + } return list; } +async function expandLogical(node, expandOne) { + const children = new Array(node.children.length); + for (let i = 0; i < node.children.length; i++) children[i] = await expandNode(node.children[i], expandOne); + return node.t === 'and' ? andNode(children) : orNode(children); +} + +// O(1) node-kind dispatch; kinds outside the table (expr/opaque/const) hold no +// relation nodes and pass through untouched. +const NODE_EXPANDERS = createDispatch({ + and: expandLogical, + or: expandLogical, + not: async (node, expandOne) => notNode(await expandNode(node.child, expandOne)), + relation: (node, expandOne) => expandOne(node), +}); + /** * Walks a plan node and replaces every `relation` node with the looked-up * id-membership expression. Lookups run sequentially: plans hold few distinct * relations, and `expandOne` memoizes by `name|relation`. */ async function expandNode(node, expandOne) { - switch (node.t) { - case 'and': - case 'or': { - const children = []; - for (const child of node.children) children.push(await expandNode(child, expandOne)); - return node.t === 'and' ? andNode(children) : orNode(children); - } - case 'not': - return notNode(await expandNode(node.child, expandOne)); - case 'relation': - return expandOne(node); - default: - return node; - } + const expander = NODE_EXPANDERS[node.t]; + return expander ? expander(node, expandOne) : node; } /** diff --git a/src/planning/nodes.js b/src/planning/nodes.js index 1cc8d75..2d45d04 100644 --- a/src/planning/nodes.js +++ b/src/planning/nodes.js @@ -25,10 +25,24 @@ * )} PlanNode */ -const PLAN_KINDS = Object.freeze({ - ALWAYS_ALLOWED: 'KIND_ALWAYS_ALLOWED', - ALWAYS_DENIED: 'KIND_ALWAYS_DENIED', - CONDITIONAL: 'KIND_CONDITIONAL', +/** + * Builds a prototype-less dispatch table (same rationale as the codec's + * interpreter tables: lookups can never resolve to inherited members and the + * hot path stays O(1) instead of a switch scan). Shared by the planning + * modules. + * + * @param {Record} entries + * @returns {Record} + */ +function createDispatch(entries) { + return Object.assign(Object.create(null), entries); +} + +// Runtime enum (typed as `enum PlanKind` in index.d.ts, like `Effect`). +const PlanKind = Object.freeze({ + AlwaysAllowed: 'KIND_ALWAYS_ALLOWED', + AlwaysDenied: 'KIND_ALWAYS_DENIED', + Conditional: 'KIND_CONDITIONAL', }); const TRUE = Object.freeze({ t: 'const', v: true }); @@ -60,7 +74,8 @@ function dedupKey(node) { /** * Shared normalization for `and`/`or`. `absorbing` is the constant that * decides the whole node (`false` for and, `true` for or); the opposite - * constant is the identity and is dropped. + * constant is the identity and is dropped. Flattening, dedup and the + * constant checks all happen in the same single pass over the children. * * @param {'and' | 'or'} kind * @param {PlanNode[]} children @@ -104,6 +119,27 @@ function notNode(child) { return { t: 'not', child }; } +function buildLogicalOperand(node) { + const operands = new Array(node.children.length); + for (let i = 0; i < node.children.length; i++) operands[i] = toOperand(node.children[i]); + return { expression: { operator: node.t, operands } }; +} + +// O(1) node-kind dispatch for serialization into Cerbos operands. +const OPERAND_BUILDERS = createDispatch({ + const: (node) => ({ value: node.v }), + expr: (node) => node.e, + and: buildLogicalOperand, + or: buildLogicalOperand, + not: (node) => ({ expression: { operator: 'not', operands: [toOperand(node.child)] } }), + opaque: (node) => ({ + expression: { operator: 'opaque', operands: [{ value: { src: node.src, reason: node.reason } }] }, + }), + relation: (node) => ({ + expression: { operator: 'relation', operands: [{ value: { name: node.name, relation: node.relation } }] }, + }), +}); + /** * Serializes a plan node into a Cerbos condition operand. * @@ -111,25 +147,9 @@ function notNode(child) { * @returns {PlanOperand} */ function toOperand(node) { - switch (node.t) { - case 'const': - return { value: node.v }; - case 'expr': - return node.e; - case 'and': - case 'or': - return { expression: { operator: node.t, operands: node.children.map(toOperand) } }; - case 'not': - return { expression: { operator: 'not', operands: [toOperand(node.child)] } }; - case 'opaque': - return { expression: { operator: 'opaque', operands: [{ value: { src: node.src, reason: node.reason } }] } }; - case 'relation': - return { - expression: { operator: 'relation', operands: [{ value: { name: node.name, relation: node.relation } }] }, - }; - default: - throw new TypeError(`Unknown plan node: ${node.t}`); - } + const build = OPERAND_BUILDERS[node.t]; + if (!build) throw new TypeError(`Unknown plan node: ${node.t}`); + return build(node); } /** @@ -140,11 +160,33 @@ function toOperand(node) { */ function toFilter(node) { if (node.t === 'const') { - return { kind: node.v ? PLAN_KINDS.ALWAYS_ALLOWED : PLAN_KINDS.ALWAYS_DENIED }; + return { kind: node.v ? PlanKind.AlwaysAllowed : PlanKind.AlwaysDenied }; } - return { kind: PLAN_KINDS.CONDITIONAL, condition: toOperand(node) }; + return { kind: PlanKind.Conditional, condition: toOperand(node) }; } +function fromOperandList(operands) { + const nodes = new Array(operands.length); + for (let i = 0; i < operands.length; i++) nodes[i] = fromOperand(operands[i]); + return nodes; +} + +// O(1) operator dispatch for reconstruction; operators outside this table are +// opaque-to-us expression leaves kept verbatim. +const FROM_EXPRESSION_BUILDERS = createDispatch({ + and: (operands) => andNode(fromOperandList(operands)), + or: (operands) => orNode(fromOperandList(operands)), + not: (operands) => notNode(fromOperand(operands[0])), + opaque: (operands) => { + const detail = operands[0]?.value ?? {}; + return opaqueNode(detail.src, detail.reason); + }, + relation: (operands) => { + const detail = operands[0]?.value ?? {}; + return relationNode(detail.name, detail.relation); + }, +}); + /** * Rebuilds a plan node from a Cerbos condition operand. Boolean positions * (children of and/or/not and the root) recurse; every other expression is an @@ -163,25 +205,8 @@ function fromOperand(operand) { } return exprNode(operand); } - const { operator, operands } = expression; - switch (operator) { - case 'and': - return andNode(operands.map(fromOperand)); - case 'or': - return orNode(operands.map(fromOperand)); - case 'not': - return notNode(fromOperand(operands[0])); - case 'opaque': { - const detail = operands[0]?.value ?? {}; - return opaqueNode(detail.src, detail.reason); - } - case 'relation': { - const detail = operands[0]?.value ?? {}; - return relationNode(detail.name, detail.relation); - } - default: - return exprNode(operand); - } + const build = FROM_EXPRESSION_BUILDERS[expression.operator]; + return build ? build(expression.operands) : exprNode(operand); } function renderOperand(operand) { @@ -190,7 +215,9 @@ function renderOperand(operand) { if ('value' in operand) return JSON.stringify(operand.value); if (operand.expression && typeof operand.expression === 'object') { const { operator, operands } = operand.expression; - return `(${operator} ${operands.map(renderOperand).join(' ')})`; + let out = `(${operator}`; + for (const child of operands) out += ` ${renderOperand(child)}`; + return `${out})`; } } return JSON.stringify(operand); @@ -209,11 +236,12 @@ function toDebugString(node) { } module.exports = { - PLAN_KINDS, - TRUE, FALSE, + PlanKind, + TRUE, andNode, constNode, + createDispatch, exprNode, fromOperand, notNode, diff --git a/src/planning/partialEval.js b/src/planning/partialEval.js index 826ea7e..02ca9a2 100644 --- a/src/planning/partialEval.js +++ b/src/planning/partialEval.js @@ -11,6 +11,9 @@ * `&&`/`||`/`?:` laziness (short-circuits are planned explicitly and a branch * is only folded once it is known to be reachable). * + * Node/operator dispatch uses prototype-less strategy tables (mirroring the + * codec's NODE_EVALUATORS) and all collection walks are single-pass loops. + * * Soundness rule: when in doubt, produce `opaque` — never guess a value. The * one deliberate exception is a bare residual value in boolean position * (`R.attr.isPublic`), which becomes `eq(variable, true)`: Cerbos-compatible, @@ -18,12 +21,12 @@ */ const { EXPR_META, evalExprAst } = require('../caching/codec.js'); -const { andNode, constNode, exprNode, notNode, opaqueNode, orNode, toOperand } = require('./nodes.js'); +const { andNode, constNode, createDispatch, exprNode, notNode, opaqueNode, orNode, toOperand } = require('./nodes.js'); const BLOCKED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); // jsep binary operators that translate 1:1 into Cerbos filter operators. -const JS_TO_CERBOS_BINARY = Object.assign(Object.create(null), { +const JS_TO_CERBOS_BINARY = createDispatch({ '===': 'eq', '==': 'eq', '!==': 'ne', @@ -168,109 +171,129 @@ function createExprPlanner({ principal, resource, actions, constants, variables return { base: current, segments }; } - /** Resolves computed segments to const keys where possible. */ - function resolveSegments(segments) { - const resolved = []; - for (const segment of segments) { + /** + * Resolves computed segments to const keys where possible. One pass also + * answers "is any segment residual" so callers never re-scan the list. + * Returns null when a segment makes the whole member unplannable (blocked + * key, opaque or non-scalar computed key). + * + * @returns {{ segments: Array<{ key?: string, residual?: object }>, hasResidual: boolean } | null} + */ + function resolveSegments(rawSegments) { + const segments = []; + let hasResidual = false; + for (const segment of rawSegments) { if (segment.key !== undefined) { if (BLOCKED_KEYS.has(segment.key)) return null; - resolved.push({ key: segment.key }); + segments.push({ key: segment.key }); continue; } const keyPlan = planValue(segment.node); if (keyPlan.k === 'const') { const key = keyPlan.v; if ((typeof key !== 'string' && typeof key !== 'number') || BLOCKED_KEYS.has(String(key))) return null; - resolved.push({ key: String(key) }); + segments.push({ key: String(key) }); } else if (keyPlan.k === 'opaque') { return null; } else { - resolved.push({ residual: keyPlan.operand }); + hasResidual = true; + segments.push({ residual: keyPlan.operand }); } } - return resolved; + return { segments, hasResidual }; } /** Appends the remaining segments to an operand as `index` operations. */ - function indexChain(operand, segments) { + function indexChain(operand, segments, from) { let current = operand; - for (const segment of segments) { + for (let i = from; i < segments.length; i++) { + const segment = segments[i]; const keyOperand = segment.key !== undefined ? { value: segment.key } : segment.residual; current = { expression: { operator: 'index', operands: [current, keyOperand] } }; } return residualPV(current); } - function planResourceMember(node, segments) { + function planResourceMember(node, resolved) { + const { segments, hasResidual } = resolved; if (!segments.length || segments[0].key === undefined) return OPAQUE; - const [head, ...rest] = segments; - if (head.key === 'kind' || head.key === 'scope' || head.key === 'policyVersion') { - return rest.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); + const head = segments[0].key; + if (head === 'kind' || head === 'scope' || head === 'policyVersion') { + // The first segment is a const key, so any residual lives in the rest. + return hasResidual ? OPAQUE : constPV(evalConst(node)); } - if (head.key === 'id') { - return rest.length ? OPAQUE : residualPV({ variable: 'request.resource.id' }); + if (head === 'id') { + return segments.length === 1 ? residualPV({ variable: 'request.resource.id' }) : OPAQUE; } - if (head.key !== 'attr' || !rest.length) return OPAQUE; + if (head !== 'attr' || segments.length === 1) return OPAQUE; // Leading run of const keys after `attr` decides known vs residual. - let splitIndex = 0; - while (splitIndex < rest.length && rest[splitIndex].key !== undefined) splitIndex++; - if (!splitIndex) return OPAQUE; // R.attr[] - if (Object.prototype.hasOwnProperty.call(knownAttr, rest[0].key)) { + let splitIndex = 1; + while (splitIndex < segments.length && segments[splitIndex].key !== undefined) splitIndex++; + if (splitIndex === 1) return OPAQUE; // R.attr[] + if (Object.prototype.hasOwnProperty.call(knownAttr, segments[1].key)) { // Known attr: fully-const paths fold through the interpreter (throws // propagate — runtime parity); residual keys into a known value are a // rarity not worth planning. - return splitIndex === rest.length ? constPV(evalConst(node)) : OPAQUE; + return splitIndex === segments.length ? constPV(evalConst(node)) : OPAQUE; } - const path = rest.slice(0, splitIndex).map((segment) => segment.key); - const variable = { variable: `request.resource.attr.${path.join('.')}` }; - return indexChain(variable, rest.slice(splitIndex)); + let path = `request.resource.attr.${segments[1].key}`; + for (let i = 2; i < splitIndex; i++) path += `.${segments[i].key}`; + return indexChain({ variable: path }, segments, splitIndex); } - function planVariableMember(node, segments) { + function planVariableMember(node, resolved) { + const { segments, hasResidual } = resolved; if (!segments.length || segments[0].key === undefined) return OPAQUE; if (planningVariable) return OPAQUE; // runtime variables never see V const plan = variablePlan(segments[0].key); - const rest = segments.slice(1); if (plan.k === 'opaque') return OPAQUE; if (plan.k === 'const') { - return rest.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); + // The first segment is a const key, so any residual lives in the rest. + return hasResidual ? OPAQUE : constPV(evalConst(node)); } - return indexChain(plan.operand, rest); + return indexChain(plan.operand, segments, 1); + } + + function planKnownRootMember(node, resolved) { + return resolved.hasResidual ? OPAQUE : constPV(evalConst(node)); } + // O(1) member-base dispatch; unknown roots (bare custom roots, whole-R + // usage) fall through to opaque in planMember. + const memberPlanners = createDispatch({ + R: planResourceMember, + V: planVariableMember, + P: planKnownRootMember, + C: planKnownRootMember, + Math: planKnownRootMember, + Date: planKnownRootMember, + }); + function planMember(node) { const { base, segments } = peelChain(node); if (base.type !== 'Identifier') return OPAQUE; + const planner = memberPlanners[base.name]; + if (!planner) return OPAQUE; const resolved = resolveSegments(segments); if (!resolved) return OPAQUE; - switch (base.name) { - case 'R': - return planResourceMember(node, resolved); - case 'V': - return planVariableMember(node, resolved); - case 'P': - case 'C': - case 'Math': - case 'Date': - return resolved.some((segment) => segment.key === undefined) ? OPAQUE : constPV(evalConst(node)); - default: - return OPAQUE; - } + return planner(node, resolved); } - function planShortCircuit(node) { - const left = planValue(node.left); - if (left.k !== 'const') return OPAQUE; // value-position semantics are not boolean — cannot residualize - const { operator } = node; - if (operator === '&&') return left.v ? planValue(node.right) : left; - if (operator === '||') return left.v ? left : planValue(node.right); - return left.v === null || left.v === undefined ? planValue(node.right) : left; // ?? - } + // Value-position short-circuits keep the interpreter's laziness: the right + // branch is only planned once the left side is a known constant. + const shortCircuitPlanners = createDispatch({ + '&&': (node, left) => (left.v ? planValue(node.right) : left), + '||': (node, left) => (left.v ? left : planValue(node.right)), + '??': (node, left) => (left.v === null || left.v === undefined ? planValue(node.right) : left), + }); function planBinary(node) { - if (node.operator === '&&' || node.operator === '||' || node.operator === '??') { - return planShortCircuit(node); + const shortCircuit = shortCircuitPlanners[node.operator]; + if (shortCircuit) { + const left = planValue(node.left); + // Non-const left: value-position semantics are not boolean — opaque. + return left.k === 'const' ? shortCircuit(node, left) : OPAQUE; } const left = planValue(node.left); const right = planValue(node.right); @@ -282,8 +305,16 @@ function createExprPlanner({ principal, resource, actions, constants, variables function planCall(node) { const { callee } = node; - const argPlans = node.arguments.map((argument) => planValue(argument)); - const argsConst = argPlans.every((plan) => plan.k === 'const'); + // One pass over the arguments: plan + const/opaque flags together. + const argPlans = new Array(node.arguments.length); + let argsConst = true; + let argsOpaque = false; + for (let i = 0; i < node.arguments.length; i++) { + const plan = planValue(node.arguments[i]); + argPlans[i] = plan; + if (plan.k !== 'const') argsConst = false; + if (plan.k === 'opaque') argsOpaque = true; + } if (callee.type === 'Identifier') { return argsConst ? constPV(evalConst(node)) : OPAQUE; @@ -295,7 +326,7 @@ function createExprPlanner({ principal, resource, actions, constants, variables return constPV(evalConst(node)); } const isIncludes = !callee.computed && callee.property.name === 'includes' && node.arguments.length === 1; - if (!isIncludes || receiver.k === 'opaque' || argPlans[0].k === 'opaque') return OPAQUE; + if (!isIncludes || receiver.k === 'opaque' || argsOpaque) return OPAQUE; // `in` is list membership. A const string receiver would mean substring // semantics — not expressible; a residual receiver is assumed to be a // list (documented plannability constraint). @@ -304,10 +335,17 @@ function createExprPlanner({ principal, resource, actions, constants, variables } function planArray(node) { - const plans = node.elements.map((element) => planValue(element)); - if (plans.every((plan) => plan.k === 'const')) return constPV(evalConst(node)); - if (plans.some((plan) => plan.k === 'opaque')) return OPAQUE; - return residualPV({ expression: { operator: 'list', operands: plans.map(toOp) } }); + // One pass: element operands + const/opaque flags together. + const operands = new Array(node.elements.length); + let allConst = true; + for (let i = 0; i < node.elements.length; i++) { + const plan = planValue(node.elements[i]); + if (plan.k === 'opaque') return OPAQUE; + if (plan.k !== 'const') allConst = false; + operands[i] = toOp(plan); + } + if (allConst) return constPV(evalConst(node)); + return residualPV({ expression: { operator: 'list', operands } }); } // ObjectExpression / NewExpression: fold when fully known, otherwise opaque. @@ -338,6 +376,31 @@ function createExprPlanner({ principal, resource, actions, constants, variables return planValue(test.v ? node.consequent : node.alternate); } + const identifierPlanners = createDispatch({ + P: () => constPV(principal), + C: () => constPV(C), + Math: () => constPV(Math), + Date: () => constPV(Date), + }); + + // O(1) node-type dispatch — the planning analog of the codec's + // NODE_EVALUATORS. Unknown node types degrade to opaque (always sound). + const valuePlanners = createDispatch({ + Literal: (node) => constPV(node.value), + Identifier: (node) => { + const planner = identifierPlanners[node.name]; + return planner ? planner() : OPAQUE; // bare R / V / custom roots + }, + MemberExpression: planMember, + BinaryExpression: planBinary, + UnaryExpression: planUnary, + CallExpression: planCall, + ArrayExpression: planArray, + ConditionalExpression: planConditionalValue, + ObjectExpression: planObjectOrNew, + NewExpression: planObjectOrNew, + }); + /** * Value-level partial evaluation: PlanValue for any expression node. * @@ -345,31 +408,8 @@ function createExprPlanner({ principal, resource, actions, constants, variables * @returns {{ k: 'const', v: unknown } | { k: 'residual', operand: object } | { k: 'opaque' }} */ function planValue(node) { - switch (node.type) { - case 'Literal': - return constPV(node.value); - case 'Identifier': - if (node.name === 'P' || node.name === 'C') return constPV(ctx[node.name]); - if (node.name === 'Math' || node.name === 'Date') return constPV(node.name === 'Math' ? Math : Date); - return OPAQUE; // bare R / V / custom roots - case 'MemberExpression': - return planMember(node); - case 'BinaryExpression': - return planBinary(node); - case 'UnaryExpression': - return planUnary(node); - case 'CallExpression': - return planCall(node); - case 'ArrayExpression': - return planArray(node); - case 'ConditionalExpression': - return planConditionalValue(node); - case 'ObjectExpression': - case 'NewExpression': - return planObjectOrNew(node); - default: - return OPAQUE; - } + const planner = valuePlanners[node.type]; + return planner ? planner(node) : OPAQUE; } function valueToBool(planned) { @@ -384,31 +424,40 @@ function createExprPlanner({ principal, resource, actions, constants, variables return opaqueNode(currentSrc, 'unsupported-expression'); } - /** - * Boolean-level partial evaluation: PlanNode for a condition expression. - * - * @param {Record} node - * @returns {import('./nodes.js').PlanNode} - */ - function planBool(node) { - if (node.type === 'BinaryExpression' && (node.operator === '&&' || node.operator === '||')) { - const left = planBool(node.left); + // Boolean-position dispatch: logical operators become plan nodes directly + // (preserving laziness), everything else goes through the value layer. + const boolPlanners = createDispatch({ + BinaryExpression: (node) => { if (node.operator === '&&') { + const left = planBool(node.left); if (left.t === 'const' && !left.v) return left; return andNode([left, planBool(node.right)]); } - if (left.t === 'const' && left.v) return left; - return orNode([left, planBool(node.right)]); - } - if (node.type === 'UnaryExpression' && node.operator === '!') { - return notNode(planBool(node.argument)); - } - if (node.type === 'ConditionalExpression') { + if (node.operator === '||') { + const left = planBool(node.left); + if (left.t === 'const' && left.v) return left; + return orNode([left, planBool(node.right)]); + } + return valueToBool(planValue(node)); + }, + UnaryExpression: (node) => + node.operator === '!' ? notNode(planBool(node.argument)) : valueToBool(planValue(node)), + ConditionalExpression: (node) => { const test = planValue(node.test); if (test.k !== 'const') return opaqueNode(currentSrc, 'unsupported-expression'); return planBool(test.v ? node.consequent : node.alternate); - } - return valueToBool(planValue(node)); + }, + }); + + /** + * Boolean-level partial evaluation: PlanNode for a condition expression. + * + * @param {Record} node + * @returns {import('./nodes.js').PlanNode} + */ + function planBool(node) { + const planner = boolPlanners[node.type]; + return planner ? planner(node) : valueToBool(planValue(node)); } function planLeaf(fn) { @@ -423,6 +472,24 @@ function createExprPlanner({ principal, resource, actions, constants, variables } } + function planMatchList(conds) { + const nodes = new Array(conds.length); + for (let i = 0; i < conds.length; i++) nodes[i] = planMatch(conds[i]); + return nodes; + } + + // Strategy table mirroring Conditions' #strategies; the empty/non-array + // fail-closed guard runs before dispatch (shared by all three). + const matchStrategies = createDispatch({ + any: (conds) => orNode(planMatchList(conds)), + all: (conds) => andNode(planMatchList(conds)), + none: (conds) => { + const nodes = new Array(conds.length); + for (let i = 0; i < conds.length; i++) nodes[i] = notNode(planMatch(conds[i])); + return andNode(nodes); + }, + }); + /** * Plans a Conditions match tree. Exact parity with Conditions.isFulfilled: * empty/invalid strategy payloads fail closed to FALSE, unknown keys are @@ -436,12 +503,11 @@ function createExprPlanner({ principal, resource, actions, constants, variables if (typeof match !== 'object' || match === null) return constNode(false); const parts = []; for (const key of Object.keys(match)) { + const strategy = matchStrategies[key]; + if (!strategy) continue; // forward-compat: ignore unknown keys const conds = match[key]; - if (key !== 'any' && key !== 'all' && key !== 'none') continue; // forward-compat: ignore unknown keys if (!Array.isArray(conds) || !conds.length) return constNode(false); - if (key === 'any') parts.push(orNode(conds.map(planMatch))); - else if (key === 'all') parts.push(andNode(conds.map(planMatch))); - else parts.push(andNode(conds.map((cond) => notNode(planMatch(cond))))); + parts.push(strategy(conds)); } if (!parts.length) return constNode(false); return andNode(parts); diff --git a/src/planning/planner.js b/src/planning/planner.js index 22dbbb3..d46fd72 100644 --- a/src/planning/planner.js +++ b/src/planning/planner.js @@ -60,6 +60,13 @@ function buildResourcePlan({ }) { const principalRoleSet = new Set(principal.roles); + function matchesPrincipalRoles(roles) { + for (const role of roles) { + if (role === ALL_ROLES || principalRoleSet.has(role)) return true; + } + return false; + } + // One expression planner per policy: each policy evaluates conditions // against its own constants/variables context, exactly like check(). const planners = new Map(); @@ -90,7 +97,8 @@ function buildResourcePlan({ for (const actionRule of rule.actions) { if (actionRule.action !== ALL_ACTIONS && actionRule.action !== action) continue; const node = planner.planCondition(actionRule.condition); - (actionRule.effect === Effect.Deny ? denyParts : allowParts).push(node); + if (actionRule.effect === Effect.Deny) denyParts.push(node); + else allowParts.push(node); } } return { allow: orNode(allowParts), deny: orNode(denyParts) }; @@ -105,7 +113,10 @@ function buildResourcePlan({ return false; } - const applicableRolePolicies = rolePolicies.filter(roleMatchesResource); + const applicableRolePolicies = []; + for (const policy of rolePolicies) { + if (roleMatchesResource(policy)) applicableRolePolicies.push(policy); + } /** * Effective role allow: the child's fulfilled allowlist OR, intersected @@ -149,7 +160,10 @@ function buildResourcePlan({ function roleLayerNode(action) { // Deny-wins across roles: every applicable role must effectively allow. const memo = new Map(); - const parts = applicableRolePolicies.map((policy) => roleAllowNode(policy, action, memo, new Set())); + const parts = new Array(applicableRolePolicies.length); + for (let i = 0; i < applicableRolePolicies.length; i++) { + parts[i] = roleAllowNode(applicableRolePolicies[i], action, memo, new Set()); + } return andNode(parts); } @@ -209,15 +223,18 @@ function buildResourcePlan({ let rolesGate = FALSE; if (Array.isArray(rule.roles)) { - rolesGate = constNode(rule.roles.some((role) => role === ALL_ROLES || principalRoleSet.has(role))); + rolesGate = constNode(matchesPrincipalRoles(rule.roles)); } let derivedGate = FALSE; if (Array.isArray(rule.derivedRoles)) { - derivedGate = orNode(rule.derivedRoles.map(derivedRoleNode)); + const derivedParts = new Array(rule.derivedRoles.length); + for (let i = 0; i < rule.derivedRoles.length; i++) derivedParts[i] = derivedRoleNode(rule.derivedRoles[i]); + derivedGate = orNode(derivedParts); } const gate = orNode([rolesGate, derivedGate]); const node = andNode([gate, planner.planCondition(rule.condition)]); - (rule.effect === Effect.Deny ? denyParts : allowParts).push(node); + if (rule.effect === Effect.Deny) denyParts.push(node); + else allowParts.push(node); } // Deny-over-Allow with default deny: allowed ⇔ some allow ∧ no deny. return andNode([orNode(allowParts), notNode(orNode(denyParts))]); @@ -233,12 +250,17 @@ function buildResourcePlan({ return orNode([andNode([allow, notNode(deny)]), andNode([notNode(allow), notNode(deny), layer])]); } - const perAction = new Map(); - for (const action of actions) perAction.set(action, planAction(action)); // Multi-action requests plan the conjunction (Cerbos semantics: the rows - // where ALL requested actions are allowed). - const node = andNode([...perAction.values()]); - return { node, perAction }; + // where ALL requested actions are allowed). One pass fills both the + // per-action map and the conjunction input. + const perAction = new Map(); + const actionNodes = new Array(actions.length); + for (let i = 0; i < actions.length; i++) { + const node = planAction(actions[i]); + perAction.set(actions[i], node); + actionNodes[i] = node; + } + return { node: andNode(actionNodes), perAction }; } module.exports = { buildResourcePlan }; diff --git a/test/PlanResources.test.js b/test/PlanResources.test.js index f364cec..b73c58a 100644 --- a/test/PlanResources.test.js +++ b/test/PlanResources.test.js @@ -10,6 +10,7 @@ const { Effect, Kerberos, KerberosValidationError, + PlanKind, createSafeExprCodec, deserializePolicy, expandRelationOperands, @@ -26,9 +27,10 @@ const codec = createSafeExprCodec({ jsep }); const user = { id: 'u1', roles: ['USER'] }; const docKind = { kind: 'document' }; -const ALLOWED = 'KIND_ALWAYS_ALLOWED'; -const DENIED = 'KIND_ALWAYS_DENIED'; -const CONDITIONAL = 'KIND_CONDITIONAL'; +// The runtime PlanKind enum members are the canonical Cerbos strings. +const ALLOWED = PlanKind.AlwaysAllowed; +const DENIED = PlanKind.AlwaysDenied; +const CONDITIONAL = PlanKind.Conditional; function dynamicPolicy(shape) { return deserializePolicy(shape, codec); diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 0b50d65..710d94d 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -1,7 +1,8 @@ -import { expectType } from 'tsd'; +import { expectAssignable, expectType } from 'tsd'; import { Effect, Kerberos, + PlanKind, expandRelationOperands, type KerberosDerivedRoles, type KerberosPolicy, @@ -9,7 +10,6 @@ import { type KerberosTelemetryOptions, type PlanExpressionOperand, type PlanFilter, - type PlanKind, type PlanResourcesResponse, } from '../index.js'; @@ -115,6 +115,8 @@ kerberos.planResources({ declare const planResponse: PlanResourcesResponse; expectType(planResponse.filter); expectType(planResponse.filter.kind); +expectAssignable(PlanKind.AlwaysAllowed); +expectType(PlanKind.Conditional); // The operand union accepts nested expressions, variables and literals. const conditionalOperand: PlanExpressionOperand = { expression: { From 7d74bd1c47f4fe8126168df3aef0181a4b655593 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 20 Jul 2026 20:19:25 +0300 Subject: [PATCH 3/5] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf21e92..4da3051 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Kerberos.js -Kerberos.js is a JavaScript library for authorization solutions. It is a simple and lightweight Cerbos (Cerbos mini). +Kerberos.js is a JavaScript library for authorization solutions. It is a simple and lightweight Cerbos (Cerbos mini) + lightweight SpiceDB (Zanzibar-lite) implementation. ### Motivation: -- Cerbos is a powerful authorization engine, but it is written in Go and requires a separate server to run. +- Cerbos and SpiceDB are powerful authorization engines, but they are written in Go and require a separate server to run. - We all know that gRPC is faster than REST API because it uses protobuf. But it can be even faster—by avoiding network requests altogether. Often, maintaining a separate service just for your permissions can be unnecessary, don’t you think? - Kerberos.js is a lightweight alternative that can be used in the browser or server-side JavaScript applications (only up to 8 KB, query planner included). - Some features that are only available in the paid version of Cerbos(Cerbos Hub) are available here for free. @@ -13,6 +13,7 @@ Kerberos.js is a JavaScript library for authorization solutions. It is a simple - isAllowed API; - lack of some functionality in your Cerbos policies. With Kerberos.js you can use all the power of JavaScript to create your policies. - if you are using Cerbos Hub and you want to test your policies locally, it can be a bit tricky. With Kerberos.js you can test your policies locally without any hassle. +- if you are using SpiceDB and want a lightweight, in-process alternative, Kerberos.js provides a Zanzibar-lite implementation. ### Features: From 4ab09dce4fa930c26d559ba164f57b9288ce82b8 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 20 Jul 2026 22:09:30 +0300 Subject: [PATCH 4/5] chore: update docs --- README.md | 903 +++++++++++++++++++++++++----------------------- package.json | 4 +- pnpm-lock.yaml | 271 +++++++++++++++ scripts/size.js | 71 ++++ 4 files changed, 814 insertions(+), 435 deletions(-) create mode 100644 scripts/size.js diff --git a/README.md b/README.md index 4da3051..f2bc68a 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,96 @@ # Kerberos.js -Kerberos.js is a JavaScript library for authorization solutions. It is a simple and lightweight Cerbos (Cerbos mini) + lightweight SpiceDB (Zanzibar-lite) implementation. - -### Motivation: - -- Cerbos and SpiceDB are powerful authorization engines, but they are written in Go and require a separate server to run. -- We all know that gRPC is faster than REST API because it uses protobuf. But it can be even faster—by avoiding network requests altogether. Often, maintaining a separate service just for your permissions can be unnecessary, don’t you think? -- Kerberos.js is a lightweight alternative that can be used in the browser or server-side JavaScript applications (only up to 8 KB, query planner included). -- Some features that are only available in the paid version of Cerbos(Cerbos Hub) are available here for free. - - Embedded Cerbos features: - - In-browser/serverless authorization; - - isAllowed API; -- lack of some functionality in your Cerbos policies. With Kerberos.js you can use all the power of JavaScript to create your policies. -- if you are using Cerbos Hub and you want to test your policies locally, it can be a bit tricky. With Kerberos.js you can test your policies locally without any hassle. -- if you are using SpiceDB and want a lightweight, in-process alternative, Kerberos.js provides a Zanzibar-lite implementation. - -### Features: - -- [x] Derived roles; -- [x] Resource policies; -- [x] Principal policies; -- [x] Role policies (with `parentRoles` inheritance); -- [x] Conditions; -- [x] Variables and constants; -- [x] Outputs; -- [x] Testing (`@alexify/kerberos/tests`); -- [x] APIs: - - [x] isAllowed API; - - [x] CheckResourceSet API; - - [x] PlanResources API (Cerbos-compatible [query plans](#query-plans-planresources)); -- [x] Audit logs; -- [x] Logger (legacy console + structured / Pino); -- [x] In-browser/serverless authorization; -- [x] Scopes; -- [x] Metadata; -- [x] Pluggable schema validation (Zod, JSON Schema + Ajv, TypeBox + Ajv); -- [x] Caching / storing dynamic policies (cache-agnostic, with a safe AST-based serialization codec); -- [x] OpenTelemetry (traces + metrics, zero-dependency delegation); -- [x] ReBAC — relation-backed derived roles + a built-in in-process "Zanzibar-lite" resolver inspired by SpiceDB (`@alexify/kerberos/relations`); - ---- -**_P.S. We are tying to keep the API as close as possible to Cerbos. If you are familiar with Cerbos, you will feel at home with Kerberos.js._** - -> **Version 2.0** — see the [CHANGELOG](./CHANGELOG.md) for everything that changed since `1.0.0` (principal & role policies, outputs, scopes, metadata, pluggable validation & logging, and cache-agnostic dynamic policies). +[![npm](https://img.shields.io/npm/v/%40alexify%2Fkerberos)](https://www.npmjs.com/package/@alexify/kerberos) +[![CI](https://github.com/Alexis-Technologies/kerberos/actions/workflows/ci.yml/badge.svg)](https://github.com/Alexis-Technologies/kerberos/actions/workflows/ci.yml) +[![node](https://img.shields.io/node/v/%40alexify%2Fkerberos)](#installation) +[![dependencies](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)](#bundle-size) +[![license](https://img.shields.io/npm/l/%40alexify%2Fkerberos)](./LICENSE) + +An **embedded, zero-dependency authorization engine** for Node.js and the browser: Cerbos-style policies (RBAC + ABAC), a SpiceDB-inspired "Zanzibar-lite" resolver (ReBAC) and Cerbos-compatible query plans — all in-process, no server to deploy, [~24 KB min+gzip](#bundle-size). The API deliberately stays as close to Cerbos as possible: if you know Cerbos, you already know Kerberos.js. + +```javascript +import { Kerberos, Effect } from '@alexify/kerberos'; + +const kerberos = new Kerberos([{ + resourcePolicy: { + resource: 'expense', + version: 'default', + rules: [{ actions: ['view'], effect: Effect.Allow, roles: ['USER'], + condition: { match: ({ P, R }) => R.attr.ownerId === P.id } }], + }, +}], []); + +await kerberos.isAllowed({ + principal: { id: 'sally', roles: ['USER'] }, + action: 'view', + resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally' } }, +}); // → true +``` + +### Why in-process? + +**Authorization as a library, not a service.** Cerbos and SpiceDB are excellent engines, but each runs as a separate Go server: another deployment, another network hop on every check, another thing that can be down. In a JavaScript stack, Kerberos.js gives you the same policy models with zero infrastructure — decisions are a function call, policies ship (and roll back) atomically with your code, and there is no PDP to keep in sync. A centralized service remains the right choice for polyglot stacks — see [When NOT to use it](#when-not-to-use-kerberosjs). + +**Policies are data plus the full power of JavaScript.** In-process policies use plain JS functions for conditions, variables and outputs — no expression-language ceiling. Policies stored in a cache/database use the same shapes with safe, eval-free [`$expr` expressions](#caching--storing-policies). Local testing needs no emulator: the [`/tests` subpath](#testing) runs Cerbos-style declarative test suites against the real engine. + +**One engine everywhere.** The [browser build](#browser-usage) contains zero Node builtins, so the same policies that guard your API also gate your UI (hide buttons, filter menus) — without maintaining a second source of truth. Serverless and edge runtimes get the same benefit: no cold-start dependency on an external PDP. + +### Positioning + +| | **Kerberos.js** | **Cerbos** | **SpiceDB** | +| --- |---------------------------------------------------------------------------------------|------------------------------------| --- | +| Deployment | in-process library (JS) | PDP service (sidecar/central) | central service | +| Policy model | Cerbos-style RBAC+ABAC + ReBAC + query plans | RBAC+ABAC (policies style) | ReBAC (Zanzibar) | +| Conditions | JS functions / safe `$expr` | CEL | caveats (CEL) | +| Query plans | `planResources` (Cerbos-compatible shape) | `PlanResources` | `LookupResources` | +| Consistency | in-process state + your cache ([honest limitations](#consistency-honest-limitations)) | per-PDP policy sync | Zanzibar consistency (zookies) | +| Best when | JS/TS stack, zero-infra, browser/edge | polyglot stack, central governance | relationship graphs at scale, strict consistency | + +### When NOT to use Kerberos.js + +- **Polyglot backends** — if Go/Python/Java services need the same decisions, a central PDP (Cerbos) beats reimplementing policies per language. +- **Zanzibar-grade consistency** — the built-in ReBAC resolver reads current in-memory/cache state and deliberately has no revision tokens; if the [New Enemy Problem](https://authzed.com/docs/spicedb/concepts/consistency) matters for your threat model, use SpiceDB. +- **Non-engineering policy ownership** — policies here live in code/storage you control; if compliance teams need a managed policy workflow and UI, that is Cerbos Hub's territory. + +### Features + +| Area | What you get | +| ---- | ------------ | +| **Policy engine** | [Resource / principal / role policies](#policy-types) (with `parentRoles` inheritance), [derived roles](#quick-start), conditions, variables & constants, [outputs](#outputs), [scopes & policy versions](#scopes-and-policy-versions) | +| **APIs** | [`isAllowed`](#kerberosisallowedargs--promiseboolean), [`checkResources`](#kerberoscheckresourcesargs-effectasboolean--false--promisecheckresourcesresponse), [`planResources`](#query-plans-planresources) (Cerbos-compatible query plans) | +| **Dynamic policies** | [Cache-agnostic storage](#caching--storing-policies) with a safe, eval-free `$expr` codec (jsep AST allowlist) | +| **ReBAC** | [Relation-backed derived roles](#rebac-relations) + a built-in Zanzibar-lite resolver (`@alexify/kerberos/relations`) | +| **Observability** | [Audit logs](#options) (console / structured / Pino), [OpenTelemetry](#opentelemetry) traces + metrics, [decision metadata](#decision-metadata-includemeta) | +| **DX** | [Pluggable validation](#schema-validation) (Zod / JSON Schema + Ajv / TypeBox), [testing DSL](#testing) (`/tests`), hand-maintained TypeScript types, [browser build](#browser-usage) | + +> **Version 3.x** — see the [CHANGELOG](./CHANGELOG.md) for everything that changed since `2.0.0`: ReBAC with the built-in Zanzibar-lite resolver (`3.0.0`), OpenTelemetry, the Node/browser runtime split, and Cerbos-compatible query plans via `planResources` (`3.1.0`). ## Table of Contents - [Installation](#installation) -- [Quick Start](#usage) -- [API Reference](#api-reference) + - [Bundle size](#bundle-size) · [Browser usage](#browser-usage) +- [Quick Start](#quick-start) - [Policy Types](#policy-types) - - [ResourcePolicy](#resourcepolicy) - - [PrincipalPolicy](#principalpolicy) - - [RolePolicy](#rolepolicy) - - [Mixed Policy Evaluation](#mixed-policy-evaluation) + - [ResourcePolicy](#resourcepolicy) · [PrincipalPolicy](#principalpolicy) · [RolePolicy](#rolepolicy) · [Mixed Policy Evaluation](#mixed-policy-evaluation) +- [Scopes and Policy Versions](#scopes-and-policy-versions) +- [API Reference](#api-reference) + - [`new Kerberos(...)`](#new-kerberospolicies-derivedroles-options) · [`isAllowed`](#kerberosisallowedargs--promiseboolean) · [`checkResources`](#kerberoscheckresourcesargs-effectasboolean--false--promisecheckresourcesresponse) · [`planResources`](#kerberosplanresourcesargs--promiseplanresourcesresponse) · [Errors](#errors) · [Exports](#exports) - [Configuration Options](#configuration-options) - - [Using Pino for Production Logging](#using-pino-for-production-logging) -- [OpenTelemetry](#opentelemetry) -- [Schema Validation](#schema-validation) - - [Zod](#using-zod) · [JSON Schema + Ajv](#using-json-schema--ajv) · [TypeBox + Ajv](#using-typebox--ajv) · [Explicit Builders](#using-explicit-builders) + - [Options](#options) · [Pino logging](#using-pino-for-production-logging) · [Call ID generation](#call-id-generation) - [Outputs](#outputs) -- [Scopes and Policy Versions](#scopes-and-policy-versions) -- [Metadata](#metadata) +- [Decision metadata (includeMeta)](#decision-metadata-includemeta) - [Caching / Storing Policies](#caching--storing-policies) + - [How it works](#how-it-works-fallback-layer) · [`codec` modes](#codec-option--three-modes) · [Dynamic policy format](#dynamic-policy-format) · [Safe builtins](#allowed-safe-builtins) · [Serialization mechanism](#serialization-mechanism-security--performance) - [ReBAC (Relations)](#rebac-relations) + - [Relation-backed derived roles](#relation-backed-derived-roles) · [Zanzibar-lite resolver](#the-built-in-zanzibar-lite-resolver) · [Dynamic tuples](#dynamic-tuples-cache-backed) · [Consistency](#consistency-honest-limitations) - [Query Plans (planResources)](#query-plans-planresources) + - [How a plan is composed](#how-a-plan-is-composed) · [Operators](#operators) · [Writing plannable policies](#writing-plannable-policies) · [Translating a plan](#translating-a-plan) - [Testing](#testing) +- [Schema Validation](#schema-validation) + - [Zod](#using-zod) · [JSON Schema + Ajv](#using-json-schema--ajv) · [TypeBox + Ajv](#using-typebox--ajv) · [Explicit Builders](#using-explicit-builders) +- [OpenTelemetry](#opentelemetry) - [Benchmarks](#benchmarks) +- [Changelog](#changelog) · [License](#license) · [Used by](#used-by) ## Installation @@ -74,6 +98,19 @@ Kerberos.js is a JavaScript library for authorization solutions. It is a simple npm install @alexify/kerberos ``` +Requires **Node.js ≥ 18** (or any modern browser through a bundler). The package is CommonJS; both `require('@alexify/kerberos')` and `import { Kerberos } from '@alexify/kerberos'` (via Node/bundler ESM interop) work — the examples below use `import`. + +### Bundle size + +Zero runtime dependencies. Measured with `pnpm size` (esbuild browser bundle, fully minified with identifier mangling, then gzipped): + +| Entry | min | min+gzip | +| ----- | ---:| --------:| +| `@alexify/kerberos` (main entry, query planner included) | 91.4 KB | **24.4 KB** | +| `@alexify/kerberos/relations` (opt-in ReBAC resolver) | 56.5 KB | 15.0 KB | + +The `/relations` and `/tests` subpaths are only bundled if you import them. Optional tooling (`jsep`, `zod`, `ajv`, `@sinclair/typebox`, `@opentelemetry/api`) is never included — you install what you use. + ### Browser usage The package ships two entrypoints: a Node.js entry (`index.js`, uses `node:crypto` / `node:perf_hooks` directly) and a browser entry (`browser.js`) declared via the package.json `browser` field and the `browser` condition in `exports`. Browser bundlers pick the browser build automatically — **no configuration needed** for webpack 5, Vite, esbuild (`platform: 'browser'`), Parcel or Bun. Rollup users need [`@rollup/plugin-node-resolve`](https://github.com/rollup/plugins/tree/master/packages/node-resolve) with `browser: true`. @@ -86,209 +123,65 @@ Notes: - The package is CommonJS, so browser usage requires a bundler (no bare `