diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5cd2f6..c3e7cc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,3 +52,53 @@ jobs: - run: pnpm install --frozen-lockfile # A broken docs build (including dead links) should fail the PR, not the deploy. - run: pnpm run docs:build + + # Runs the shared corpus against BOTH engines. The suite is meaningful without + # Docker (it asserts Kerberos against the recorded expectations), but this job + # also stands up a real Cerbos PDP over the same policy directory so that a + # wrong expectation cannot make the two engines look compatible. + conformance: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + # The Cerbos version is pinned deliberately: the `latest` tag is stale and + # still serves 0.40.0, which predates the 0.41.0 change to cross-role + # conflict resolution — testing against it would hide a known divergence. + # See conformance/DIVERGENCES.md. + # + # Fails fast with a readable compile error if the corpus is malformed, + # rather than surfacing as an opaque 400 from the running PDP. + - name: Compile the corpus with Cerbos + run: | + docker run --rm -v "${{ github.workspace }}/conformance/policies:/policies:ro" \ + ghcr.io/cerbos/cerbos:0.55.0 compile --skip-tests /policies + + - name: Start the Cerbos PDP + run: | + docker run --rm -d --name cerbos \ + -v "${{ github.workspace }}/conformance/policies:/policies:ro" \ + -p 3592:3592 \ + ghcr.io/cerbos/cerbos:0.55.0 server \ + --set=storage.disk.directory=/policies \ + --set=storage.disk.watchForChanges=false \ + --set=engine.lenientScopeSearch=true + for i in $(seq 1 60); do + if curl -sf http://localhost:3592/_cerbos/health | grep -q SERVING; then exit 0; fi + sleep 1 + done + echo "Cerbos never became ready"; docker logs cerbos; exit 1 + + - run: pnpm test:conformance + env: + CERBOS_URL: http://localhost:3592 + + - if: always() + run: docker logs cerbos || true diff --git a/.oxlintrc.json b/.oxlintrc.json index e58fcd7..1200964 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -247,6 +247,12 @@ ], "node/no-exports-assign": "error", "node/no-new-require": "error", - "node/no-path-concat": "error" + "node/no-path-concat": "error", + "no-undef": "error", + "no-dupe-else-if": "error", + "getter-return": "error", + "no-setter-return": "error", + "no-unused-private-class-members": "error", + "no-constant-binary-expression": "error" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index de22f66..a4bb234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,201 @@ 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). +## [4.0.0] - 2026-08-30 + +Code-review hardening waves (0–4): the Conditions inherited-key fail-open fix, +restored Node-ESM named exports, scope-depth caps, cache-reader +backoff/timeout/degraded-mode options (`cacheRetry`, `cacheKeyPrefix`, +`relationsTimeoutMs`, `maxConcurrency`), per-batch policy-resolution memo and +cross-request instance memo, audit-stream completeness (fail-closed denials, +`principalRoles`, the `audit` option, info-level plan results, +`kerberos.observability.failures`), the synchronous evaluation driver +(~2.5× simple `isAllowed`), reverse-lookup truncation signaling +(`onTruncated`), frozen policy shapes/tokens, and d.ts/export-parity guards. + +### Added + +- **Verified Cerbos ORM-adapter compatibility.** New `toCerbosQueryPlan` + export converts `planResources` output (HTTP-API operand encoding) into + the flattened `@cerbos/core` SDK encoding, and the claim that Cerbos's + official adapters "accept the filter" is now CI-executable: + `test/OrmAdapters.test.js` runs the real `@cerbos/orm-prisma` and + `@cerbos/orm-drizzle` packages against Kerberos plans and pins the + produced Prisma `where` objects / Drizzle SQL. The Kerberos-only + operators are handled by contract — `relation` plans convert only after + `expandRelationOperands` (the converter throws otherwise, naming it), + `opaque` plans throw with a post-filtering directive. Recipes in the + query-plans guide. +- **Fuzzing + published comparative benchmarks.** A deterministic seeded + fuzz suite (`test/Fuzz.test.js`, part of `pnpm test`, crankable via + `FUZZ_ITERATIONS`) covers the `$expr` codec, the CEL translator's output + contract, the YAML parser and `RelationResolver` — it already caught and + fixed a real contract bug: `@jsep-plugin/new` emits a malformed + callee-less node for `new R.attr.x`, which the codec now rejects as + `KerberosExprError` instead of crashing with a raw `TypeError`. New + `pnpm bench:compare` (Kerberos vs `@casl/ability` vs `casbin` on a + shared RBAC+ABAC scenario) and `pnpm size:compare` (browser min+gzip) + publish honest cross-library numbers, with the caveats, in the + Benchmarks docs. +- **Policy-testing CLI.** The package now ships a `kerberos` binary: + `kerberos test ` runs Cerbos-`TestSuite`-format + suites (`*_test.yaml`/`*_test.json`, named fixtures + expected effects) + against a policy directory — policies load through the `/loader` subpath + (Kerberos JSON + Cerbos YAML/JSON), `{ $expr }` conditions resolve jsep + from the caller's project, `--schemas reject|warn` wires `_schemas/` into + attribute-schema enforcement, `--json` emits a machine-readable report, + and unsupported expectation features fail the run instead of silently + passing. `kerberos bundle --out [--reproducible]` bakes a + hash-stamped policy bundle. +- **File/directory policy loader + versioned bundles** — the new Node-only + **`@alexify/kerberos/loader`** subpath: `loadPolicyDirectory` / + `loadPolicyFile` read policy-as-code repositories (Kerberos serialized + JSON and Cerbos YAML/JSON mix freely — `apiVersion` documents route + through the `/cerbos` importer; `_schemas/**.json` come back keyed for + `schemas.definitions`; deterministic sorted order; `_`-prefixed and + hidden entries skipped), and `createPolicyBundle` / `writePolicyBundle` / + `loadPolicyBundle` implement hash-stamped GitOps artifacts: `version` is + the SHA-256 of the canonical sorted-key JSON, recomputed and verified on + load so tampered or truncated bundles throw. Both a synchronous driver + (top-level functions) and an asynchronous one (the `promises` namespace, + Node's `fs.promises` idiom) share one decision core, so results are + byte-identical; `promises.loadPolicyDirectory` reads files concurrently + (bounded by the `concurrency` option, default 64) to keep cold starts + fast over large policy repositories — the `kerberos` CLI uses it + internally. Browser bundlers substitute throwing/rejecting stubs via the + package `browser` map. Typed `KerberosLoaderError`. +- **Attribute schema enforcement** (Cerbos `schemas` parity). Resource + policies now accept a `schemas:` block (`principalSchema` / + `resourceSchema` refs with `ignoreWhen.actions` globs), enforced through + the new `schemas` engine option: `definitions` maps refs to validators + (JSON Schema via `ajv`, Zod, or plain functions), `enforcement` picks + `reject` (deny + Cerbos-shaped `validationErrors` on the result) / `warn` + (report only) / `none`. Unset ⇒ schema refs stay inert, matching Cerbos's + default. Failures always reach `checkResources` results and the audit log; + denied actions carry `reason: 'invalid-attributes'` under `includeMeta`. + The Cerbos importer now translates `schemas:` blocks verbatim instead of + requiring `drop: ['schemas']`. +- **Cerbos policy importer** — the new **`@alexify/kerberos/cerbos`** subpath + turns an existing Cerbos policy repository into Kerberos policies, with zero + dependencies: `importCerbosPolicies` (YAML/JSON documents → serialized + `{ $expr }` documents for `deserializePolicy`), `celToExpr` (a real CEL + parser + translator to the safe-interpreter subset), `parseYamlDocuments` + (a YAML-subset parser verified differentially against the reference `yaml` + package), and `KerberosImportError`. The importer refuses to guess: + unsupported Cerbos constructs (macros, `matches()`, extension functions, + `exportVariables`, `REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS`, unknown keys, …) + throw named errors instead of being dropped — the only opt-out is + `drop: ['schemas']`. Verified end-to-end by running the whole conformance + corpus through the importer (`conformance/importer.test.js`): every + PDP-pinned decision and query-plan expectation holds for importer-loaded + policies. See the new "Importing Cerbos Policies" guide. +- **Typed authoring.** `Kerberos` and every policy/request/response type are + now generic over an optional application schema naming resource kinds, their + actions and attribute bags, and the principal's roles and attributes. The + resource kind narrows the action, the attribute shapes and the `{ P, R, V, C }` + envelope handed to conditions; policy documents become discriminated unions + over `resource:`, so a rule naming another kind's action or an undeclared role + is a compile error. Purely type-level — every parameter defaults to the new + `AnySchema`, which reproduces the previous untyped surface exactly. New + helper types: `KerberosSchema`, `AnySchema`, `ResourceKindOf`, `ActionOf`, + `ResourceAttrOf`, `PrincipalRoleOf`, `PrincipalAttrOf`, `PolicyEvalRequest`, + `CheckResourcesArgs`/`Entry`/`Result`/`Response`. See the new "TypeScript" + guide. +- **Cerbos conformance suite** (`conformance/`, not published to npm). One + corpus in Cerbos's own policy and `TestSuite` formats runs against Kerberos + always, and against a real Cerbos PDP in CI. Known semantic gaps are recorded + in `conformance/DIVERGENCES.md`. New `pnpm test:conformance`. +- **In-browser playground** on the docs site — the real engine running + client-side, with no backend. + +### Changed + +- **BREAKING (semantics): resource-policy conflicts now resolve per principal + role, matching Cerbos.** `EFFECT_DENY` overrides `EFFECT_ALLOW` *within* a + role, but an `EFFECT_ALLOW` from *any* role wins *across* roles. Kerberos was + previously deny-overrides unconditionally, which returned `EFFECT_DENY` where + Cerbos ≥ 0.41 returns `EFFECT_ALLOW` — verified against a live Cerbos PDP and + now covered by the conformance suite. + + **This is more permissive than before.** A `DENY` scoped to one role no longer + vetoes an `ALLOW` carried by a different role the principal also holds. Audit + any policy that relies on a role-scoped deny to revoke access: to keep the old + outcome the deny must cover the allowing role, either with `roles: ['*']` or + by naming it explicitly. Denies that already do are unaffected, as are + single-role principals and same-role conflicts. + + Rules reached through `derivedRoles` count for the principal roles listed in + that definition's `parentRoles` — derived roles collapse into the role + dimension rather than forming one of their own. `planResources` follows the + same rule; `test/PlanParity.test.js` gained multi-role principals, which is + the shape that made the old behaviour invisible. +- **BREAKING (semantics): the full Cerbos rule-table evaluation model.** A + differential sweep (2000+ decisions) against a live Cerbos 0.55.0 PDP, + cross-checked against Cerbos's documentation and v0.55.0 source, surfaced + and closed the remaining semantic gaps. All are pinned by the conformance + suites (57 cases, offline and against the live PDP — zero divergences): + + - **Wildcards**: name matching now globs exactly like Cerbos — bare `*` + matches anything, any other `*` stays within a `:`-delimited segment + (`view:*` matches `view:public`, not `view` or `view:a:b`), `**` crosses + segments. Applies to resource-policy `actions` and `roles`, + principal-policy `resource` and `action`, role-policy `resource` and + `allowActions`, and derived-role `parentRoles` (`parentRoles: ['*']` + works now). Previously only a bare `*` in `actions`/`roles` matched — a + `DENY` on `view:*` silently failed to deny (fail-open, fixed). + - **Scoped policies evaluate per action, per role** (Cerbos + `SCOPE_PERMISSIONS_OVERRIDE_PARENT`): the first scope that decides an + (action, role) seals it, a failed condition falls through to the parent + scope, and an action undecided at the specific scope is decided by a less + specific policy. Previously the first policy found decided ALL actions. + Applies to principal, resource and role policies alike. + - **Role policies are synthetic deny rows in the per-role walk**, at their + own scope: an allow must come from a resource rule reaching the SAME + principal role — another role's allowlist cannot revive it (the + cross-bucket case). Role policies also follow the RESOURCE scope chain + and resource `policyVersion` (Cerbos's docs say principal scope; its + engine and a live PDP say resource — recorded in DIVERGENCES.md). + - **Cache-backed scope resolution is per scope** (memory first, then cache, + at each scope): a static base-scope policy no longer shadows a more + specific cached policy — the documented hybrid-deployment caveat is + retired. + + Internals: the resource/role layers collapsed into one shared decision walk + (`src/decision.js`) used by `ResourcePolicy.check`, both engine drivers and + (symbolically) the query planner; glob matchers (`src/matching.js`) are + precompiled per rule. +- **BREAKING (semantics): role policies are now a narrowing filter over the + resource policy, not a ranked layer that can grant.** Matching Cerbos, and + verified against a live PDP: + + - a role policy **cannot allow what the resource policy withholds** — with no + matching `ResourcePolicy`, a `RolePolicy` alone now grants nothing; + - multiple role policies **union** instead of intersecting: a principal may do + what *any* of its roles allowlists, so holding an extra role can widen + access but never narrow it; + - a role with **no role policy at all is unrestricted** (that role's bucket + passes the resource-layer result through unfiltered) — but holding a role + that *has* a role policy constrains it everywhere, including resource + kinds its rules never mention (where it permits nothing); + - a `PrincipalPolicy` override is never narrowed by the role layer. + + `parentRoles` are unchanged — the child still keeps only what each locally + defined parent role policy allows (intersection *along the chain*, union + *across* roles). Deployments that relied on a `RolePolicy` to grant access on + its own must add the corresponding `ResourcePolicy` rules. + +- **BREAKING (types): `Effect` and `PlanKind` are const objects, not `enum`s.** + The runtime has always been a frozen plain object, so the `enum` declaration + mis-described it and made `effect: 'EFFECT_ALLOW'` in a plain JSON policy + literal a type error — exactly the form stored policies carry. `Effect.Allow` + and `PlanKind.Conditional` are unchanged; only `enum`-specific type usage + (e.g. `PlanKind.Conditional` as a *type*) needs updating. +- **`checkResources` is now overloaded on `effectAsBoolean`**: the response's + effects are typed `Effect`, or `boolean` when the flag is passed, instead of + the `Effect | boolean` union in both cases. +- `{ $expr }` descriptors are accepted by the types in `condition.match` and + `output` — stored policies always used them, but the types rejected them. + ## [3.1.0] - 2026-07-21 ### Added @@ -174,6 +369,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New exported constants: `ALL_ROLES`, `ALL_RESOURCES`, `DEFAULT_VERSION`, `BASE_SCOPE` (plus `ALL_ACTIONS` and `createCacheReader` are now typed). +- **Native OpenTelemetry support (traces + metrics)** via the new `telemetry` + constructor option, following the same zero-dependency delegation philosophy + as `logger`/`cache`: pass `{ api }` (the `@opentelemetry/api` module — Kerberos + derives its own tracer/meter with the `@alexify/kerberos` instrumentation + scope) or pre-created `{ tracer, meter }` instances. One span per + `isAllowed`/`checkResources` call (started **active**, so auto-instrumented + cache spans nest under it), per-decision `kerberos.decision` events, `ERROR` + span status + exception events on failures, plus two metrics: + `kerberos.decisions` counter and `kerberos.request.duration` histogram. + Identity attributes (`kerberos.principal.id`, `kerberos.resource.id`) are on + by default and can be stripped with `telemetry.includeIdentity: false`. + Telemetry failures never affect authorization results, and the + logger-controlled error contract (fallback vs rethrow) is unchanged. New + structural types (`KerberosTelemetryOptions`, `KerberosTracer`, + `KerberosMeter`, …) are exported from `index.d.ts`. + +- **Browser/server entrypoint split** (pino-style). New root `browser.js` entry + plus a package.json `browser` field (object map) and a `browser` condition in + `exports`: browser bundlers (webpack, Vite, esbuild `platform: browser`, + Rollup node-resolve with `browser: true`, Parcel, Bun) now automatically pick + a build with **zero Node.js builtins**. +- New `src/runtime/node.js` / `src/runtime/browser.js` platform modules holding + the only platform-specific code (`generateCallId`, `getNow`). The Node + runtime uses `node:crypto` / `node:perf_hooks` directly; the browser runtime + uses `globalThis.crypto.randomUUID` (with a pseudo-UUID fallback for insecure + contexts) and `globalThis.performance` (falling back to `Date.now`). +- `engines.node >= 18` — documents the already-implicit runtime floor. + ### Performance - **`checkResources` evaluates resources concurrently** via @@ -209,37 +432,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now throw at construction** instead of crashing later during evaluation — every definition must be either condition-backed or relation-backed. - `pnpm-lock.yaml` is no longer published in the npm tarball. - -- **Native OpenTelemetry support (traces + metrics)** via the new `telemetry` - constructor option, following the same zero-dependency delegation philosophy - as `logger`/`cache`: pass `{ api }` (the `@opentelemetry/api` module — Kerberos - derives its own tracer/meter with the `@alexify/kerberos` instrumentation - scope) or pre-created `{ tracer, meter }` instances. One span per - `isAllowed`/`checkResources` call (started **active**, so auto-instrumented - cache spans nest under it), per-decision `kerberos.decision` events, `ERROR` - span status + exception events on failures, plus two metrics: - `kerberos.decisions` counter and `kerberos.request.duration` histogram. - Identity attributes (`kerberos.principal.id`, `kerberos.resource.id`) are on - by default and can be stripped with `telemetry.includeIdentity: false`. - Telemetry failures never affect authorization results, and the - logger-controlled error contract (fallback vs rethrow) is unchanged. New - structural types (`KerberosTelemetryOptions`, `KerberosTracer`, - `KerberosMeter`, …) are exported from `index.d.ts`. - -- **Browser/server entrypoint split** (pino-style). New root `browser.js` entry - plus a package.json `browser` field (object map) and a `browser` condition in - `exports`: browser bundlers (webpack, Vite, esbuild `platform: browser`, - Rollup node-resolve with `browser: true`, Parcel, Bun) now automatically pick - a build with **zero Node.js builtins**. -- New `src/runtime/node.js` / `src/runtime/browser.js` platform modules holding - the only platform-specific code (`generateCallId`, `getNow`). The Node - runtime uses `node:crypto` / `node:perf_hooks` directly; the browser runtime - uses `globalThis.crypto.randomUUID` (with a pseudo-UUID fallback for insecure - contexts) and `globalThis.performance` (falling back to `Date.now`). -- `engines.node >= 18` — documents the already-implicit runtime floor. - -### Changed - - Removed the try/catch `require('crypto')` / `require('node:perf_hooks')` feature detection from `src/Kerberos.js` — each platform entry now targets its runtime directly. Node behavior is unchanged; browser bundles get @@ -387,6 +579,9 @@ Initial release. - In-browser / serverless authorization. - Built-in test harness (`Tests`). +[4.0.0]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v4.0.0 +[3.1.0]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v3.1.0 +[3.0.0]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v3.0.0 [2.0.1]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v2.0.1 [2.0.0]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v2.0.0 [1.0.0]: https://github.com/Alexis-Technologies/kerberos/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 024dc54..58f8ec1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -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. +Kerberos.js (`@alexify/kerberos`) is a zero-dependency (~32 KB min+gzip), 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, attribute-schema enforcement (Cerbos `schemas` parity via the `schemas` engine option), audit logging, cache-backed dynamic policies, ReBAC (relation-backed derived roles + a built-in SpiceDB-inspired Zanzibar-lite resolver on the `/relations` subpath), Cerbos-compatible resources query plans (`planResources`), a Cerbos policy importer (YAML + CEL→`$expr`, on the `/cerbos` subpath), and a Node-only file/directory policy loader with hash-stamped bundles (`/loader` subpath). 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. +Package manager is **pnpm** (the exact pinned version is the `packageManager` field in `package.json`). CommonJS throughout (`require`/`module.exports`), no build/transpile step — `src/` ships as-is. ## Commands @@ -16,13 +16,17 @@ node --test test/Kerberos.test.js # run a single test file node --test --test-name-pattern="scope" # filter tests by name pnpm test:types # type-check test/types.test-d.ts against index.d.ts via tsd pnpm test:coverage # c8 coverage over src/ -pnpm lint # oxlint src test -pnpm format # oxfmt src test (format:check for CI) +pnpm test:conformance # Cerbos conformance corpus (node --test conformance/*.test.js) +pnpm lint # oxlint src test scripts bench conformance +pnpm format # oxfmt src test scripts bench conformance (format:check for CI) pnpm bench # ops/sec benchmark harness (bench/bench.js) +pnpm bench:compare # cross-library comparison (CASL/casbin; bench/compare.js) +pnpm size:compare # cross-library bundle-size comparison (scripts/size-compare.js) +pnpm size # bundle-size report (scripts/size.js; CI-enforced smoke) pnpm docs:dev # VitePress dev server for docs/ (docs:build / docs:preview too) ``` -Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off) — their native bindings require Node ≥20.19, so CI (`.github/workflows/ci.yml`) runs `lint`/`format:check` in a single job pinned to Node 22, separate from the `test` job, which runs `test:coverage` + `test:types` across the Node 18/20/22 matrix, and a `docs` job (Node 22) that runs `docs:build`. Lint/format deliberately target `src test scripts bench` only, so `docs/` is not covered by them. +Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off) — their native bindings require Node ≥20.19, so CI (`.github/workflows/ci.yml`) runs `lint`/`format:check` in a single job pinned to Node 22, separate from the `test` job, which runs `test:coverage` + `test:types` across the Node 18/20/22 matrix, a `docs` job (Node 22) that runs `docs:build`, and a `conformance` job (Node 22) that stands up a real Cerbos PDP in Docker and runs `test:conformance` against it. Lint/format deliberately target `src test scripts bench conformance` only, so `docs/` is not covered by them. Style: 2-space indent, single quotes, semicolons, 120-char lines (see `.editorconfig`, `.oxfmtrc.json`). @@ -41,22 +45,23 @@ Every DSL concept (`Conditions`, `Constants`, `DerivedRoles`, `Outputs`, `Princi ### Platform runtime split (`src/runtime/`) -`src/runtime/node.js` and `src/runtime/browser.js` are the **only** platform-specific files in the package — both export the identical `{ generateCallId, getNow }` interface. The Node variant uses `node:crypto` / `node:perf_hooks` directly (no try/catch feature detection); the browser variant uses `globalThis.crypto?.randomUUID` (pseudo-UUID fallback for insecure contexts) and `globalThis.performance` (falling back to `Date.now`), reading globals at call time so fallback branches stay testable. `src/Kerberos.js` requires `./runtime/node.js`; browser bundlers swap it via the package.json `browser` field object map (`"./index.js" → "./browser.js"`, `"./src/runtime/node.js" → "./src/runtime/browser.js"`) plus the `browser` condition in `exports`. Invariants: everything else in `src/` must stay platform-neutral (no Node builtins); any new Node builtin usage goes into `src/runtime/node.js` with a matching browser counterpart; renaming runtime files requires updating the `browser` map keys in `package.json` (a mismatch fails loudly at bundle time thanks to the `node:` prefix). Root `browser.js` intentionally mirrors `index.js` — do not deduplicate them. +`src/runtime/node.js` and `src/runtime/browser.js` are the **only** platform-specific files in the package — both export the identical `{ generateCallId, getNow }` interface. The Node variant uses `node:crypto` / `node:perf_hooks` directly (no try/catch feature detection); the browser variant uses `globalThis.crypto?.randomUUID` (pseudo-UUID fallback for insecure contexts) and `globalThis.performance` (falling back to `Date.now`), reading globals at call time so fallback branches stay testable. `src/Kerberos.js` and `src/Relations/RelationResolver.js` require `./runtime/node.js`; browser bundlers swap it via the package.json `browser` field object map (`"./index.js" → "./browser.js"`, `"./src/runtime/node.js" → "./src/runtime/browser.js"`) plus the `browser` condition in `exports`. Invariants: everything else in `src/` must stay platform-neutral (no Node builtins) — the ONE deliberate exception is `src/loader/` (the Node-only `/loader` subpath), whose browser counterpart `src/loader/browser.js` (throwing stubs, mapped via the `browser` field) must export the identical surface; any other new Node builtin usage goes into `src/runtime/node.js` with a matching browser counterpart; renaming runtime files requires updating the `browser` map keys in `package.json` (a mismatch fails loudly at bundle time thanks to the `node:` prefix). Root `browser.js` intentionally mirrors `index.js` — do not deduplicate them. ### Request evaluation flow (`src/Kerberos.js`) `Kerberos` is the sole runtime engine. Policies passed to the constructor are parsed (via `Kerberos.parsePolicy`) and stored in four private `Map`s keyed by scope-aware cache keys: `#resourcePolicies`, `#principalPolicies`, `#rolePolicies`, `#derivedRoles`. -Policy/version/scope lookup (`#getResourcePolicy`, `#getPrincipalPolicy`, `#getRolePolicyByName`) walks the **scope search chain** (`Kerberos.getScopeSearchChain`, most-specific → base `''`) crossed with `policyVersion` (default `'default'`). In-memory `Map`s are checked first; on a miss, if a `cache` option was supplied, it falls back to `await cache.get(key)` (see Caching below). +Policy/version/scope lookup (`#resolvePolicyChain` + wrappers, sync twin `#resolvePolicyChainFromMemory`) collects the **whole chain** along the scope search chain (`Kerberos.getScopeSearchChain`, most-specific → base `''`) crossed with `policyVersion` (default `'default'`) — at each scope the in-memory `Map` is checked first, then (if a `cache` option was supplied) `await cache.get(key)` (see Caching below). -Per-action resolution order (`#evaluatePolicySources`), computed independently for every action in a request: +Per-action resolution (`#evaluatePolicySources` / sync twin), matching Cerbos's rule-table semantics — verified against a live PDP by `conformance/`: -1. `PrincipalPolicy` matching `principal.id` — explicit `Allow`/`Deny` wins immediately. -2. Otherwise, all `RolePolicy` entries matching `principal.roles[]` are evaluated (`#evaluateRolePolicies`); `Deny` wins over `Allow` across roles. `parentRoles` intersect the child's allowed actions with each locally-defined parent role policy. -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`. +1. **Principal chain** (principal scope chain, most specific first): the first `PrincipalPolicy` whose rule fires for an action decides it (deny beats allow within a policy; a failed condition decides nothing — falls through to the parent scope). An explicit decision is final and is never narrowed by the role layer. +2. **Unified decision walk** (`src/decision.js`, `evaluateDecisionLayer`) for the remaining actions — per action, per principal role ("bucket"), walking the RESOURCE scope chain: at each scope a bucket sees the resource policy's fired rules that reach it (via `roles`/derived roles whose `parentRoles` cover the bucket) plus **synthetic deny rows** from role policies at that scope (a role policy denies every action it does not allowlist there, for ANY kind — so having a role policy constrains that role everywhere). Deny beats allow within a scope; the first deciding scope seals the bucket; across buckets an allow from any role wins (anti-lockout). Role policies never grant — the allow must come from a resource policy reaching the SAME bucket. `parentRoles` attach the parents' deny rows to the child's bucket (intersection along the chain, union across roles). Role policies ride the resource scope chain and resource policyVersion (Cerbos's docs say principal scope; its engine and a live PDP say resource — see `conformance/DIVERGENCES.md`). +3. No decision anywhere → `EFFECT_DENY` (`policy-miss` under tracing). -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. +Name matching is Cerbos-glob everywhere (`src/matching.js`, precompiled per rule as non-enumerable `*Matcher` props): bare `*` matches anything, other `*` stay within a `:` segment, `**` crosses segments. Globs apply to resource-policy `actions`/`roles`, principal-policy `resource`/`action`, role-policy `resource`/`allowActions`, and derived-role `parentRoles`; `rules[].derivedRoles` refs are exact. `ResourcePolicy.check` is a single-scope instance of the same decision walk (class-level and engine-level semantics cannot drift); policy lookups collect the whole chain with per-scope memory-then-cache precedence (a static base-scope policy no longer shadows a more specific cached one). + +Attribute-schema enforcement (`src/attributeSchemas.js`, the `schemas` engine option) runs before principal evaluation in BOTH drivers: under `reject`, invalid principal/resource attributes deny every action (`reason: 'invalid-attributes'`, Cerbos-shaped `validationErrors` on the result — never gated on `includeMeta`); `warn` only reports; validators (JSON Schema/Zod/function) compile at construction, and the resolved resource chain is threaded into the decision walk so enforcement adds no second chain lookup. 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/`) @@ -70,11 +75,11 @@ 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` 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). +`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. `sdk.js` (`toCerbosQueryPlan` — HTTP-operand → `@cerbos/core`-SDK-operand conversion for the official Cerbos ORM adapters; throws on `relation` (directs to `expandRelationOperands`) and `opaque` (directs to post-filtering); verified against the real `@cerbos/orm-prisma`/`@cerbos/orm-drizzle` packages in `test/OrmAdapters.test.js`). Codec seam: `compileExpr` attaches frozen `{ expr, ast, roots }` meta under the exported `EXPR_META` Symbol; `evalExprAst` re-exposes the interpreter — both are internal (explicitly destructured OUT of the public surface in `src/index.js`; only `expandRelationOperands` and the `PlanKind` enum are public). The engine's `#planPolicySources` resolves the three source chains (principal / roles+parent-closure / resource+derived-roles) as one concurrent `Promise.allSettled` wave with per-chain trace buffers (deterministic `meta.resolution` order). Invariants: the planner never mutates shared cached ASTs (builds new nodes only); soundness rule — when a construct can't be translated, emit `opaque`, never guess; plannable conditions are codec-compiled `$expr` closures (static in-process policies must go through `deserializePolicy` first — the constructor deliberately does NOT auto-deserialize); parity with `isAllowed` is enforced by the grid-sampling suite in `test/PlanParity.test.js` — any change to evaluation semantics in `Kerberos.js`/policy classes must keep that suite green (and vice versa: planner changes must not drift from the runtime). ### Logging (`src/logging.js`) -`logger` option accepts `true` (legacy `console.group`/`table`/`debug` output), a custom console-like object, or a structured logger (e.g. Pino, detected via `info`/`debug` methods) which receives one structured audit entry per evaluated action. When logging is enabled, runtime/validation errors are caught and converted to fallback results (`isAllowed` → `false`, `checkResources` → empty results) instead of being thrown; when disabled, errors propagate to the caller. +`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. Logging is pure observability: every logger call is internally guarded (a throwing logger can never affect decisions), and it never changes error behavior — whether evaluation errors are rethrown or converted into fail-closed results is decided solely by the `onError` option (`'throw'` | `'deny'`), while `KerberosValidationError` (malformed arguments) always propagates regardless. ### OpenTelemetry (`src/telemetry.js`) @@ -87,13 +92,29 @@ Two layers, both SpiceDB-inspired (see the "borrow vs skip" notes in `README.md` 1. **Engine seam** (`relations` constructor option, main entry): a delegation contract `{ check({principal, resource, relation}, {memo}), list?({...relations[]}, {memo}) }` — any resolver works. Derived-role definitions gain an optional `relation:` field (then `parentRoles`/`condition` become optional sync gates — `DerivedRoles.getRelationCandidates`); the engine resolves candidates on its only async phase (`#getImportedDerivedRoles` → `#resolveRelationCandidates`): list-first, `Promise.allSettled` check fallback (a rejection still surfaces per `onError` after all settle), one `relationsMemo` Map per public call shared across the whole `checkResources` batch, `{ source: 'relations', ... }` decision-trace entries under `includeMeta`. 2. **Built-in resolver** (the `RelationResolver` class, exported ONLY from the `@alexify/kerberos/relations` subpath — CJS doesn't tree-shake, keep it out of the main entry): `RelationSchema.js` compiles the JSON schema DSL into SpiceDB's userset-rewrite algebra (`{kind: 'ref'|'arrow'|'union'|'intersection'|'exclusion'}` nodes) with fail-fast reference checks and a precomputed O(1) admission-key Set per relation (`buildAdmissionKey`/`getRelationAdmission`); `RelationResolver.js` is a class in the Kerberos style (private fields, constructor-precompiled arg validators, prototype-less O(1) strategy tables over `node.kind` instead of switch — `#rewriteEvaluators`/`#subjectCollectors`/`#reachabilityCollectors`) holding the tuple indexes (static forward+reverse Maps; cache fallback docs `rel:::`, opt-in reverse docs `rel:rev:` behind `reverseIndex: true`), the recursive check (per-request memo of completed values + `maxDepth` guard, deliberately NO visited-set — SpiceDB semantics, unsound under exclusion; document reads memoize the *promise* as an in-process singleflight), caveat evaluation (`{ P, ctx }` only — no `R`, which is what keeps memoized subproblems batch-shareable; written context beats check-time context and is always passed as a copy; a caveat that THROWS raises `KerberosRelationsError` — errors are never read as answers, since "not matched" would widen access in exclusion subtract positions), and the reverse APIs (`lookupSubjects` collect-walk over a per-type Map/Set subject-set algebra with wildcard-exclusion sets; `lookupResources` via reachability analysis + candidate verification). Parallelism policy: check paths stay sequential (short-circuit saves cache reads); collect/lookup paths that need every branch run as `Promise.allSettled` waves via `settleAll` (all siblings settle, then the first rejection rethrows); BFS queues are cursor/level-based, never `shift()`. Optional `telemetry` option (spans per public call + `kerberos.relations.checks`; cache reads tagged `kerberos.cache.kind: relation`), guarded like the engine's. Static tuples are validated against the schema at construction (throw); cached doc entries the schema doesn't admit are skipped; corrupt docs THROW `KerberosCodecError` (misses stay empty). Memo keys are namespaced AND scoped: all entries carry the resolver-instance token, `check|`/`lr|` decision entries additionally carry principal/context identity tokens (WeakMap-assigned per object reference) — sharing a memo across principals/contexts/instances is safe by construction. Subject-relation refs must reference RELATIONS (permissions rejected at compile — the closure BFS cannot expand them); `|` is a reserved name char (admission-key delimiter); compiled refs/rewrite nodes are frozen and introspection getters return copies. Caveat `{ $expr }` conditions require a codec built with roots `['P', 'ctx']`. Typed error: `KerberosRelationsError`. +### Cerbos importer (`src/cerbos/`) + +`@alexify/kerberos/cerbos` (root `cerbos.js`/`cerbos.d.ts`; kept out of the main entry like `/relations`) turns Cerbos policy documents into Kerberos serialized documents. Infra folder in the `src/caching/` style (flat modules, NOT the four-file DSL pattern), all platform-neutral and zero-dependency: `yaml.js` (a parser for the YAML subset Cerbos policies are written in — block/flow/quoted/block-scalar forms; anchors, aliases, tags, directives, multi-line plain scalars and tab indentation THROW; verified differentially against the reference `yaml` package over the whole conformance corpus in `test/CerbosYaml.test.js`), `cel.js` (CEL lexer + recursive-descent parser for the full expression grammar; bytes literals, message construction and leading-dot names rejected at parse), `translate.js` (`celToExpr` — CEL AST → JS for the `$expr` interpreter, targeting the documented jsep setup incl. `addUnaryOp('typeof')`; timestamps are epoch-ms numbers via `Date.parse`/`Date.now`, durations constant-folded ms, `has()` → `typeof … !== "undefined"`, `in` → `.includes`, `replace` → split/join; macros, `matches()`, Cerbos extension functions, `globals`/`runtime`/`auxData` throw), `importer.js` (`importCerbosPolicies` — structural document mapper; skips `disabled: true`, accepts `SCOPE_PERMISSIONS_OVERRIDE_PARENT`, throws on `schemas` unless `drop: ['schemas']`, `exportVariables`/`exportConstants`, unknown keys at every level), `errors.js` (`KerberosImportError`, carries `line` for YAML errors). The governing invariant is the same refuse-to-guess rule as the conformance loader: translate faithfully or throw — never drop or approximate. Output is SERIALIZED documents (`{ $expr }`) for `deserializePolicy`; the importer never compiles expressions itself and has no jsep dependency. `conformance/importer.test.js` re-runs every decision and plan suite against an engine built via the public importer — a change to yaml/cel/translate semantics must keep that green. + +### CLI (`bin/kerberos.js`) + +The published `kerberos` binary (package.json `bin`; lint/format cover `bin/`). `kerberos test ` runs Cerbos-`TestSuite`-format suites (own expansion logic mirroring `conformance/lib/suite.js` — conformance/ is not published; refuses expectation keys it does not check, e.g. `outputs`) against policies loaded via the loader's async `promises` driver (suite files also read concurrently); jsep + plugins resolve from the CALLER's project via `createRequire(cwd/package.json)` with a fallback to the package's own resolution; `--schemas reject|warn` wires `_schemas/` through the engine's `schemas` option (ajv resolved the same way). `kerberos bundle --out [--reproducible]` writes a hash-stamped bundle. Exit codes: 0 ok, 1 failing cases, 2 usage/config. Tested by spawning the real binary (`test/Cli.test.js`). + +### Policy loader (`src/loader/`) + +`@alexify/kerberos/loader` (root `loader.js`/`loader.d.ts`) — Node-only boot-time file/directory loader + hash-stamped bundles. Two drivers over ONE shared decision core (routing/parsing/classification/bundle stamping+verification are single functions fed file lists and text): the top-level functions are sync, the `promises` namespace (Node `fs.promises` idiom, same names) is async and reads files concurrently (bounded via `createLimiter` from `src/async.js`, `concurrency` option, default 64) while keeping results byte-identical (deterministic sorted ingestion order regardless of read completion; tests assert deep equality between drivers). The CLI uses the async driver. `index.js` uses `node:fs`(+`/promises`)/`node:path`/`node:crypto` directly (the documented exception to the platform-neutral rule); `browser.js` exports the identical surface as throwing stubs (`promises.*` reject), wired via BOTH the package.json `browser` map and the `browser` condition of the `./loader` export. Directory loads are deterministic (sorted), skip `_`/`.`-prefixed entries, route `.yaml` and `apiVersion`-carrying JSON through the `/cerbos` importer (`cerbos: 'auto' | true | false`), and surface `_schemas/**.json` keyed both bare and `cerbos:///…` for the engine's `schemas.definitions`. Bundles: `version` = SHA-256 of canonical sorted-key JSON of `{ policies, derivedRoles }`; `loadPolicyBundle` re-hashes and throws on mismatch; live (deserialized) policies are rejected at bundling (functions would silently stringify away). Typed error `KerberosLoaderError` (carries `file`). + ### Testing DSL (`src/Tests/`) `KerberosTest`/`KerberosTests` (exported only from the `@alexify/kerberos/tests` subpath, not the main entry) implement a Cerbos-style declarative test runner: a JSON-ish fixture of `principals`/`resources`/`tests` is run against a live `Kerberos` instance and asserted with `node:test`. `Tests/Mocks/` provides named principal/resource fixture helpers. Use this pattern (see `README.md` "Testing" section) rather than hand-rolling policy assertions when adding policy-behavior tests. ### Public exports -The full package surface is assembled in `src/index.js` (main entry), `tests.js` (dev-only `/tests` subpath) and `relations.js` (`/relations` subpath) — check all three when adding a new export, and update `index.d.ts` / `tests.d.ts` / `relations.d.ts` in the repo root accordingly, since types are hand-maintained (not generated). Also update `docs/api/exports.md`, which mirrors those three tables for the docs site. +The full package surface is assembled in `src/index.js` (main entry), `tests.js` (dev-only `/tests` subpath), `relations.js` (`/relations` subpath), `cerbos.js` (`/cerbos` subpath) and `loader.js` (Node-only `/loader` subpath) — check all five when adding a new export, and update `index.d.ts` / `tests.d.ts` / `relations.d.ts` / `cerbos.d.ts` / `loader.d.ts` in the repo root accordingly, since types are hand-maintained (not generated; `test/ExportParity.test.js` guards runtime ↔ d.ts drift). Also update `docs/api/exports.md`, which mirrors those tables for the docs site. + +### Cerbos conformance suite (`conformance/`) + +Not part of the published package (`files` in `package.json` is an explicit allowlist). One corpus written in **Cerbos's own formats** — policy documents under `policies/`, `TestSuite`-schema decision expectations and `QueryPlannerTestSuite`-shaped plan expectations under `suites/` — is executed against Kerberos always, and additionally against a real Cerbos PDP when `CERBOS_URL` is set (the CI `conformance` job does this via Docker). `lib/load.js` is a **structural** mapper, not a Cerbos importer: there is no CEL parser, and the corpus is restricted to expressions that are simultaneously valid CEL and valid Kerberos `$expr` so one string feeds both engines (the real importer lives on the `/cerbos` subpath, and `importer.test.js` re-runs every suite through it). Its governing invariant is that it **refuses to guess** — any construct outside the supported subset throws `ConformanceUnsupportedError` rather than being dropped, because a silently-skipped rule turns a real conformance failure into a false pass. Plan filters are compared after canonicalization (`lib/canonical.js`) since neither engine promises an operand order; plans containing Kerberos-only operators (`opaque`, `relation`) fail the scope check instead of being compared. Known semantic gaps live in `conformance/DIVERGENCES.md` — when the live leg disagrees, record it there rather than editing the expectation green. ### Documentation site (`docs/`) @@ -106,9 +127,10 @@ Conventions and gotchas: - `ignoreDeadLinks` is intentionally off: `pnpm docs:build` failing on a dead link is the check that cross-page anchors are still valid. - Mermaid comes from `vitepress-plugin-mermaid`. Its transitive deps (`@braintree/sanitize-url`, `dayjs`, `debug`, `cytoscape`, `cytoscape-cose-bilkent`) are direct devDependencies **because** the plugin hardcodes them into `optimizeDeps.include` and pnpm's strict linking otherwise leaves them unresolvable in `docs:dev`. Diagram labels also need the `line-height` override at the bottom of `custom.css` — mermaid sizes nodes without knowing VitePress' global line-height, so multi-line labels get clipped without it. - The version in the nav dropdown (`v3.1.0`) and the `hostname` constant are hand-synced — bump the former with `package.json` at release time. +- The theme's `enhanceApp` wires Vercel **Web Analytics** (`@vercel/analytics`) and **Speed Insights** (`@vercel/speed-insights`) — devDependencies, so nothing reaches npm. Both are browser-only and the site is prerendered, hence the `import.meta.env.SSR` early return. Analytics picks up VitePress' pushState navigations on its own; Speed Insights does not, so `router.onAfterRouteChange` calls `setRoute` (it writes `data-route` on the injected script) — without it every Core Web Vital would be attributed to whichever page loaded first. Data only flows once both features are enabled in the Vercel project; off Vercel (`docs:dev`) the packages load their debug scripts and log instead of sending. ### Why the package doesn't ship a separate ESM build -CJS doesn't give property-level tree-shaking inside a single module, but the real size control for this package is the subpath exports (`/relations`, `/tests`), which drop entire files rather than individual exports. The main entry's DSL classes (Conditions, ResourcePolicy, RolePolicy, DerivedRoles, ...) are interdependent — Kerberos.js needs all of them at once — so there's no dead code to shake there regardless of module format. +CJS doesn't give property-level tree-shaking inside a single module, but the real size control for this package is the subpath exports (`/relations`, `/tests`, `/cerbos`, `/loader`), which drop entire files rather than individual exports. The main entry's DSL classes (Conditions, ResourcePolicy, RolePolicy, DerivedRoles, ...) are interdependent — Kerberos.js needs all of them at once — so there's no dead code to shake there regardless of module format. A full ESM+CJS dual build is a deliberate non-goal: (1) it would contradict the "src/ ships as-is, no build step" philosophy; (2) heavy use of `instanceof` for self-classification (parsePolicy, schema builders, RelationResolver) creates a real dual package hazard risk if a CJS and an ESM copy ever load simultaneously in the same dependency tree. diff --git a/LICENSE b/LICENSE index bdd6dcd..eee0d78 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Alexis Technologies +Copyright (c) 2024-2026 Alexis Technologies Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 23ae34f..30a8a57 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![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, [~25 KB min+gzip](#bundle-size). The API deliberately stays as close to Cerbos as possible: if you know Cerbos, you already know Kerberos.js. +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, [~29 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'; @@ -46,6 +46,9 @@ await kerberos.isAllowed({ | 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 | +> [!NOTE] +> Compatibility with Cerbos is checked in CI by a [conformance suite](./conformance/) that runs one corpus against both engines — every decision and every query plan is compared to a real Cerbos PDP. Features Kerberos deliberately does not implement (CEL, attribute schemas, `scopePermissions`, `auxData`) are catalogued in [DIVERGENCES.md](./conformance/DIVERGENCES.md). + ### 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. @@ -61,7 +64,8 @@ await kerberos.isAllowed({ | **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) | +| **DX** | [Pluggable validation](#schema-validation) (Zod / JSON Schema + Ajv / TypeBox), [testing DSL](#testing) (`/tests`), [typed authoring](#typescript) via an optional app schema, [browser build](#browser-usage), [live playground](https://kerberosjs.vercel.app/playground) | +| **Compatibility** | A [conformance suite](./conformance/) runs one corpus — written in Cerbos's own policy and test formats — against both Kerberos and a real Cerbos PDP in CI; known gaps are listed in [DIVERGENCES.md](./conformance/DIVERGENCES.md) | > **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`). @@ -75,19 +79,25 @@ await kerberos.isAllowed({ - [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) +- [TypeScript](#typescript) + - [Declaring a schema](#declaring-a-schema) · [What it buys you](#what-it-buys-you) · [Schema helper types](#schema-helper-types) - [Configuration Options](#configuration-options) - [Options](#options) · [Pino logging](#using-pino-for-production-logging) · [Call ID generation](#call-id-generation) - [Outputs](#outputs) - [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) +- [Importing Cerbos Policies](#importing-cerbos-policies) + - [What is translated](#what-is-translated) · [CEL → `$expr`](#the-cel--expr-translation) · [How this is verified](#how-the-importer-is-verified) +- [Loading Policies from Files](#loading-policies-from-files) - [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) + - [How a plan is composed](#how-a-plan-is-composed) · [Operators](#operators) · [Writing plannable policies](#writing-plannable-policies) · [ORM adapters](#using-the-official-cerbos-orm-adapters) · [Translating a plan](#translating-a-plan) - [Testing](#testing) + - [CLI](#policy-testing-from-the-command-line) - [Schema Validation](#schema-validation) - - [Zod](#using-zod) · [JSON Schema + Ajv](#using-json-schema--ajv) · [TypeBox + Ajv](#using-typebox--ajv) · [Explicit Builders](#using-explicit-builders) + - [Zod](#using-zod) · [JSON Schema + Ajv](#using-json-schema--ajv) · [TypeBox + Ajv](#using-typebox--ajv) · [Explicit Builders](#using-explicit-builders) · [Attribute schemas](#attribute-schemas-cerbos-schemas) - [OpenTelemetry](#opentelemetry) - [Benchmarks](#benchmarks) - [Changelog](#changelog) · [License](#license) · [Used by](#used-by) @@ -106,10 +116,11 @@ Zero runtime dependencies. Measured with `pnpm size` (esbuild browser bundle, fu | Entry | min | min+gzip | | ----- | ---:| --------:| -| `@alexify/kerberos` (main entry, query planner included) | 93.6 KB | **25.1 KB** | -| `@alexify/kerberos/relations` (opt-in ReBAC resolver) | 57.2 KB | 15.1 KB | +| `@alexify/kerberos` (main entry, query planner included) | 115.6 KB | **31.9 KB** | +| `@alexify/kerberos/relations` (opt-in ReBAC resolver) | 60.5 KB | 16.3 KB | +| `@alexify/kerberos/cerbos` (opt-in [Cerbos importer](#importing-cerbos-policies)) | 34.0 KB | 10.9 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. +The `/relations`, `/tests` and `/cerbos` 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 @@ -208,6 +219,29 @@ const kerberos = new Kerberos( `ResourcePolicy` is the workhorse policy type, selected by `resource.kind`. Rules are matched by action, then by `roles` or `derivedRoles`, and may also use `conditions`, `variables`, `constants`, `outputs`, versions, and scopes — see the [Quick Start](#quick-start) for a complete example. +#### Conflict resolution + +Conflicts are resolved **per principal role**, matching Cerbos: `EFFECT_DENY` overrides `EFFECT_ALLOW` **within** a role, and an `EFFECT_ALLOW` from **any** role wins across roles. Rule order never decides the outcome. + +This is deliberate anti-lockout behaviour — picking up an extra, less privileged role can never take away access another role grants: + +```javascript +rules: [ + { actions: ['close'], effect: Effect.Allow, roles: ['SUPPORT'] }, + { actions: ['close'], effect: Effect.Deny, roles: ['AUDITOR'] }, +]; +// principal roles ['SUPPORT', 'AUDITOR'] -> EFFECT_ALLOW +``` + +A deny that is meant to hold regardless has to cover the role carrying the allow — either with the `'*'` wildcard or by naming it: + +```javascript +{ actions: ['close'], effect: Effect.Deny, roles: ['*'] } // always denies +{ actions: ['close'], effect: Effect.Deny, roles: ['SUPPORT', 'AUDITOR'] } // denies both roles +``` + +Derived roles do not form a dimension of their own: a rule reached through `derivedRoles` counts for the principal roles listed in that definition's `parentRoles`. + ### PrincipalPolicy `PrincipalPolicy` follows the Cerbos-style model for principal-specific overrides. It is bound to a single principal and targets `resource + action` directly instead of `roles` / `derivedRoles`. @@ -250,7 +284,21 @@ const sallyPrincipalPolicy = { ### RolePolicy -`RolePolicy` follows the Cerbos-style role-centric model. It is bound to a single role, targets `resource + allowActions`, and behaves as an allowlist for matching resources. If a matching role policy exists for the current resource and the action is not listed in `allowActions`, Kerberos returns `EFFECT_DENY` for that role layer. +`RolePolicy` follows the Cerbos-style role-centric model. It is bound to a single role, targets `resource + allowActions`, and acts as a **narrowing filter over the [`ResourcePolicy`](#resourcepolicy)** — it never grants on its own. Three consequences worth internalising: + +- **A role policy cannot allow what the resource policy withholds.** The resource policy is always what grants; a role policy only takes away. With no matching `ResourcePolicy` at all, nothing is allowed. +- **Multiple role policies union.** A principal may do what **any** of its roles permits. Holding an extra role can widen access, never narrow it. +- **A role with no applicable role policy is unrestricted.** If any of the principal's roles has no role policy targeting this resource kind, the filter does not apply at all. + +```javascript +// resourcePolicy `report` allows view + edit + delete for roles: ['*'] +rolePolicy READER: allowActions: ['view'] +rolePolicy WRITER: allowActions: ['edit'] + +roles: ['READER'] -> view (filtered to the allowlist) +roles: ['READER', 'WRITER'] -> view, edit (union, not intersection) +roles: ['READER', 'PLAIN'] -> view, edit, delete (PLAIN is unconstrained) +``` ```javascript const userRolePolicy = { @@ -281,18 +329,17 @@ const userRolePolicy = { }; ``` -`RolePolicy` also supports `parentRoles`. When present, the child role can only keep actions that are also allowed by each locally defined parent role policy. Missing parent role policies are treated as external IdP roles and do not impose extra constraints inside Kerberos. +`RolePolicy` also supports `parentRoles`. Within a single role, the child keeps only actions that are **also** allowed by each locally defined parent role policy (intersection along the inheritance chain — distinct from the union *across* the principal's roles). Missing parent role policies are treated as external IdP roles and do not impose extra constraints inside Kerberos. ### Mixed Policy Evaluation When mixed policy types are present, Kerberos resolves each action in this order: 1. Find the matching `PrincipalPolicy` for the request principal. -2. If it returns an explicit `EFFECT_ALLOW` or `EFFECT_DENY`, use that result. -3. Otherwise, evaluate all matching `RolePolicy` entries for the principal roles. -4. If multiple role policies apply to the same action, `EFFECT_DENY` wins over `EFFECT_ALLOW`. -5. If the role layer is not applicable for that action, fall back to the matching `ResourcePolicy`. Before its rules are matched, the imported **derived roles are resolved**: condition-backed definitions evaluate synchronously, and relation-backed definitions (the `relation:` field) resolve through the configured [`relations` resolver](#rebac-relations) (ReBAC) — `list`-first with parallel `check` fallback, one shared memo per request. The resulting `effectiveDerivedRoles` then participate in rule matching alongside plain `roles`. -6. If nothing matches, return `EFFECT_DENY`. +2. If it returns an explicit `EFFECT_ALLOW` or `EFFECT_DENY`, use that result — role policies do not narrow a principal-policy override. +3. Otherwise, evaluate the matching `ResourcePolicy`. Before its rules are matched, the imported **derived roles are resolved**: condition-backed definitions evaluate synchronously, and relation-backed definitions (the `relation:` field) resolve through the configured [`relations` resolver](#rebac-relations) (ReBAC) — `list`-first with parallel `check` fallback, one shared memo per request. The resulting `effectiveDerivedRoles` then participate in rule matching alongside plain `roles`. Conflicts resolve **per principal role**: `EFFECT_DENY` overrides `EFFECT_ALLOW` within a role, an `EFFECT_ALLOW` from any role wins across roles. +4. Apply the `RolePolicy` layer as a **filter** on that result: if every principal role is constrained by an applicable role policy, an `EFFECT_ALLOW` survives only when at least one of those roles allowlists the action (union across roles, `parentRoles` intersection within a role). +5. If nothing matches, return `EFFECT_DENY`. The decision is computed **per action** — different actions in the same request may be resolved by different policy layers. Each lookup (principal / role / resource) walks the [scope search chain](#scopes-and-policy-versions) and `policyVersion`, and checks in-memory policies first, then the optional `cache`. @@ -300,11 +347,7 @@ The decision is computed **per action** — different actions in the same reques flowchart TD A([Request: principal · resource · action]) --> P{{"PrincipalPolicy
(by principal.id)"}} P -->|"EFFECT_ALLOW / EFFECT_DENY"| DONE([Action effect resolved]) - P -->|no matching rule| R{{"RolePolicy layer
(one per principal.roles[])"}} - - R -->|"EFFECT_DENY (wins over Allow)"| DONE - R -->|"EFFECT_ALLOW"| DONE - R -->|role layer not applicable| DR + P -->|no matching rule| DR subgraph DR ["Derived-roles resolution (importDerivedRoles)"] direction TB @@ -314,12 +357,16 @@ flowchart TD EDR --> RES{{"ResourcePolicy
(by resource.kind — rules match roles / derivedRoles)"}} - RES -->|"EFFECT_ALLOW / EFFECT_DENY"| DONE + RES -->|"EFFECT_ALLOW (per-role conflict resolution)"| RP{{"RolePolicy filter
(union across principal.roles[])"}} + RES -->|"EFFECT_DENY"| DONE RES -->|no rule matched| DEF([Default: EFFECT_DENY]) DEF --> DONE + + RP -->|"allowlisted by some role, or a role is unconstrained"| DONE + RP -->|"every role constrained and none allowlists it"| DEF ``` -> **Within the role layer:** every `RolePolicy` matching a `principal.roles[]` entry is evaluated; `EFFECT_DENY` wins over `EFFECT_ALLOW`. When a role declares `parentRoles`, the child keeps only the actions that are **also** allowed by each locally defined parent role policy (intersection). +> **Within the role layer:** the principal may do what **any** of its roles allowlists (union). A role with no applicable role policy is unrestricted, which disables the filter entirely. When a role declares `parentRoles`, the child keeps only the actions that are **also** allowed by each locally defined parent role policy (intersection along the chain). This keeps Kerberos.js aligned with the Cerbos-style principal override model described in the [Cerbos principal policies documentation](https://docs.cerbos.dev/cerbos/latest/policies/principal_policies) while extending the runtime with role-centric policy evaluation similar to [Cerbos role policies](https://docs.cerbos.dev/cerbos/latest/policies/role_policies). @@ -350,6 +397,22 @@ Scope behavior follows the Cerbos-style model: When both policy types are loaded, Kerberos first resolves principal overrides using the principal scope/version chain and then falls back to resource policy lookup when the principal policy is not applicable for a given action. +### How the scope chain is evaluated + +Matching Cerbos's `SCOPE_PERMISSIONS_OVERRIDE_PARENT` (its default), the chain is not a lookup for one policy — every policy found along it participates, and evaluation is **per action, per principal role**: + +- The first scope whose policy produces a decision (allow or deny) for an action and a role **seals** it; policies further up cannot change it. +- A rule whose condition fails decides nothing — the walk **falls through** to the parent scope for that action. +- The walk runs per principal role, so a deny sealing one role at a specific scope does not stop another role from winning an allow at the base scope (allow from any role wins across roles). +- A scope with no policy at all is simply skipped (Cerbos's `lenientScopeSearch`; Kerberos has no strict mode). + +Which scope drives which policy type: **resource policies and role policies** walk the *resource's* scope chain; **principal policies** walk the *principal's*. (Cerbos's docs describe role-policy scope as the principal's, but its engine — and a live PDP — match it against the resource's; see [DIVERGENCES.md](./conformance/DIVERGENCES.md).) + +### Wildcards + +Name fields glob, exactly as in Cerbos: a bare `*` matches anything; in any other pattern `*` matches within a single `:`-delimited segment (`view:*` matches `view:public` but neither the bare `view` nor `view:a:b`), and `**` crosses segments. Globs work in resource-policy `actions` and `roles`, principal-policy `resource` and `action`, role-policy `resource` and `allowActions`, and derived-role `parentRoles`. `rules[].derivedRoles` references are exact names — Cerbos's schema rejects globs there too. + + Example: ```javascript @@ -473,12 +536,13 @@ All error classes are exported from the main entry. Evaluation-phase errors foll | Export | Purpose | | ------ | ------- | | `Kerberos` | Main authorization engine. | -| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }`. | +| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }` — a frozen const object, [not an `enum`](#typescript). | | `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. | +| `toCerbosQueryPlan` | Converts a plan to the `@cerbos/core` SDK shape for the [official Cerbos ORM adapters](#using-the-official-cerbos-orm-adapters). | | `KerberosValidationError`, `KerberosCacheError`, `KerberosCodecError`, `KerberosExprError`, `KerberosRelationsError` | Typed [error classes](#errors). | | `registerAjvKeywords`, `createAjvAdapter` | [Validation](#schema-validation) helpers. | | `JsonSchemas`, `TypeBoxSchemas`, `ZodSchemas`, `KerberosJsonSchemas`, `ResourcePolicyJsonSchemas`, `PrincipalPolicyJsonSchemas`, `RolePolicyJsonSchemas`, … | Schema builders for the three backends. | @@ -500,6 +564,127 @@ Subpath **`@alexify/kerberos/tests`** (dev/test only — not loaded by the main | `PrincipalMock`, `PrincipalsMock`, `ResourceMock`, `ResourcesMock` | Named fixtures for test suites. | | `*ZodSchemas`, `*JsonSchemas`, `*TypeBoxSchemas` | Schema builders for the test harness. | +Subpath **`@alexify/kerberos/loader`** (Node-only [file/directory loader + versioned bundles](#loading-policies-from-files); browser bundlers substitute throwing stubs): + +| Export | Purpose | +| ------ | ------- | +| `loadPolicyDirectory`, `loadPolicyFile` | Read Kerberos JSON / Cerbos YAML+JSON policy files (+ `_schemas/`) into constructor inputs. | +| `createPolicyBundle`, `writePolicyBundle`, `loadPolicyBundle` | Hash-stamped (SHA-256, content-addressed) policy bundles with load-time integrity verification. | +| `KerberosLoaderError` | Typed error for I/O, format and bundle-integrity failures (carries `file`). | + +Subpath **`@alexify/kerberos/cerbos`** (the [Cerbos policy importer](#importing-cerbos-policies) — kept out of the main entry): + +| Export | Purpose | +| ------ | ------- | +| `importCerbosPolicies` | Cerbos YAML/JSON documents → `{ policies, derivedRoles }` serialized Kerberos documents. | +| `celToExpr` | Translates one CEL expression into a `$expr`-compatible JavaScript expression string. | +| `parseYamlDocuments` | The zero-dependency YAML-subset parser, standalone. | +| `KerberosImportError` | Typed error for unsupported constructs (carries `line` for YAML errors). | + +## TypeScript + +Kerberos.js ships hand-maintained types. By default every position is open — `kind` and `action` are `string`, `attr` is `Record` — which is what you want for policies loaded from a store at runtime. + +When your resource kinds are known at compile time, declare them once and the whole surface narrows to them. + +### Declaring a schema + +```typescript +import { Kerberos, Effect, type KerberosPolicy } from '@alexify/kerberos'; + +type AppSchema = { + principal: { + roles: 'admin' | 'user'; + attr: { department: string; clearance: number }; + }; + resources: { + document: { actions: 'view' | 'edit' | 'delete'; attr: { ownerId: string; status: 'draft' | 'published' } }; + invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; + }; +}; + +const kerberos = new Kerberos(policies, derivedRoles); +``` + +Both keys are optional — declare only `resources` if you do not want to enumerate roles. + +### What it buys you + +The resource kind drives everything else. `action`, `attr`, and the condition callbacks all narrow to the kind you named: + +```typescript +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng', clearance: 3 } }, + resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1', status: 'draft' } }, + action: 'edit', // ✅ autocompleted from `document`'s actions +}); + +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'document', id: 'd1' }, + action: 'approve', // ❌ 'approve' belongs to `invoice`, not `document` +}); +``` + +Policy documents are checked the same way — `resource:` discriminates the rules, so a typo in an action or a role is a compile error rather than a silent `EFFECT_DENY` at 3am: + +```typescript +const policy: KerberosPolicy = { + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [ + { actions: ['view', 'edit'], effect: Effect.Allow, roles: ['admin'] }, + { + actions: ['edit'], + effect: Effect.Allow, + roles: ['user'], + // R.attr is { ownerId: string; status: 'draft' | 'published' } + condition: { match: ({ R, P }) => R.attr?.ownerId === P.id && R.attr?.status === 'draft' }, + }, + ], + }, +}; +``` + +`checkResources` keeps each batch entry typed independently, so a mixed batch still catches a wrong action per kind: + +```typescript +const { results } = await kerberos.checkResources({ + principal: { id: 'u1', roles: ['user'] }, + resources: [ + { resource: { kind: 'document', id: 'd1' }, actions: ['view', 'edit'] }, + { resource: { kind: 'invoice', id: 'i1' }, actions: ['approve'] }, + ], +}); +``` + +The second argument now selects the effect representation through overloads: `checkResources(args)` resolves `results[].actions` to `Effect`, and `checkResources(args, true)` to `boolean` — previously both were typed as the `Effect | boolean` union. + +### Schema helper types + +Exported so you can build your own typed wrappers (an Express middleware, a React hook) over the same schema: + +| Type | Resolves to | +| ---- | ----------- | +| `ResourceKindOf` | Union of declared resource kinds. | +| `ActionOf` | Actions for kind `K`; every action across all kinds when `K` is omitted. | +| `ResourceAttrOf` | Attribute bag of kind `K`. | +| `PrincipalRoleOf` / `PrincipalAttrOf` | Declared principal roles / attributes. | +| `RequestPrincipal`, `RequestResource`, `BaseRequest` | Request shapes. | +| `PolicyEvalRequest` | The `{ P, R, V, C }` envelope a condition/variable/output callback receives. | +| `CheckResourcesArgs`, `CheckResourcesResponse`, `PlanResourcesArgs`, `PlanResourcesResponse` | Method arguments and responses. | +| `AnySchema` | The permissive default used when no schema is supplied. | + +> [!NOTE] +> Typing is **compile-time only** — there is no runtime cost and no runtime enforcement. A schema constrains the policies and requests you write in TypeScript; it does not validate policies loaded from a cache at runtime. For that, use [schema validation](#schema-validation). + +`Effect` and `PlanKind` are const objects rather than TypeScript `enum`s, so the raw wire strings that a stored policy or a serialized plan actually carries stay assignable: + +```typescript +const rule = { actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['user'] }; // ✅ no `Effect.Allow` needed +``` + ## Configuration Options The Kerberos constructor accepts an optional third parameter with configuration options: @@ -530,7 +715,7 @@ const kerberos = new Kerberos(policies, derivedRoles, { - Logging is pure observability: it never changes decisions or error behavior (that is [`onError`](#options)'s job), and a throwing logger is swallowed — it can never affect authorization. - **`onError`** (`'throw' | 'deny'`, default `'throw'`): What happens when policy **evaluation** fails at runtime (a throwing condition function, a failing cache backend, a ReBAC resolver error). - `'throw'` propagates the error to the caller; - - `'deny'` fails closed: `isAllowed` resolves to `false`, `checkResources` to `{ results: [], kerberosCallId, reqId? }`, `planResources` to a `KIND_ALWAYS_DENIED` filter. + - `'deny'` fails closed: `isAllowed` resolves to `false`, `checkResources` to one all-DENY result per requested resource (positional parity with the request, like the per-resource fail-closed path — entries that cannot be echoed back from malformed arguments are skipped), `planResources` to a `KIND_ALWAYS_DENIED` filter. - Malformed **arguments** are programming errors and always throw `KerberosValidationError`, regardless of this option. ```javascript @@ -540,8 +725,13 @@ const kerberos = new Kerberos(policies, derivedRoles, { - **`telemetry`** (KerberosTelemetryOptions): Enable OpenTelemetry traces and metrics. Pass `{ api }` (the `@opentelemetry/api` module) or `{ tracer, meter }` instances — see [OpenTelemetry](#opentelemetry). - **`cache`** (CacheLike): An optional cache used as a fallback source for dynamic/stored policies. Any object exposing a `get(key)` method is accepted (keyv, cacheable, cache-manager, ...). See [Caching / Storing policies](#caching--storing-policies). -- **`cacheRetry`** (`{ attempts?: number }`, default `{ attempts: 3 }`): Retry policy for transient `cache.get` failures. After the attempts are exhausted the failure surfaces as `KerberosCacheError` (and then follows `onError`). `attempts: 1` disables retrying. +- **`cacheRetry`** (`{ attempts?, delayMs?, jitter?, timeoutMs?, onExhausted? }`, default `{ attempts: 3, delayMs: 25, jitter: true }`): Retry policy for `cache.get` failures. Attempts are spaced by full-jitter exponential backoff (`delayMs` base, doubling per attempt; `delayMs: 0` restores immediate retries); deterministic adapter errors (`TypeError`/`SyntaxError`) are never retried. `timeoutMs` (off by default) bounds each read attempt so a *hung* backend fails instead of hanging authorization. After the attempts are exhausted the failure surfaces as `KerberosCacheError` (and then follows `onError`) — unless `onExhausted: 'miss'` opts into **degraded mode**: the read counts as a cache miss and evaluation falls through to the remaining static sources, so a cache outage no longer disables statically-resolvable decisions (the degradation stays visible via the `kerberos.cache.requests` `error` metric and a guarded error log entry). `attempts: 1` disables retrying. +- **`cacheKeyPrefix`** (`string`, default `''`): Prefix prepended to **every** cache key (policies *and* derived roles). Use it to namespace tenants or environments sharing one store — derived-roles documents are otherwise a single global `derivedRoles:` namespace, so two tenants publishing the same definition name on a shared store would silently overwrite each other. +- **`relationsTimeoutMs`** (`number`, off by default): Bounds each `relations.check` / `relations.list` call; a resolver that neither resolves nor rejects fails as `KerberosRelationsError` (following `onError`) instead of hanging the request. +- **`audit`** (`{ includeMeta?: boolean }`): Engine-level audit enrichment. With `{ includeMeta: true }` and a logger attached, decision tracing runs for **every** request, so audit entries always carry `meta.resolution` and the `policy-miss` reason — audit completeness stops depending on each call site remembering the per-request `includeMeta` flag. The response stays gated on the request flag. +- **`maxConcurrency`** (`number`, unbounded by default): Caps how many resources of a `checkResources` batch evaluate at once. Without it a 10k-resource batch launches 10k concurrent evaluation chains (each issuing its own cache reads) — memory spikes, event-loop saturation and a thundering herd on the cache backend. The built-in `RelationResolver` accepts the same option for its `lookupResources` candidate-verification fan-out. - **`codec`** (PolicyCodec): How cached policy documents are transformed before construction: `{ jsep }` enables the built-in safe `$expr` evaluator, `{ deserialize }` plugs in your own logic, and when omitted cached values are passed to policy constructors **as-is** — see [`codec` option — three modes](#codec-option--three-modes). +- **`schemas`** (`{ enforcement?, definitions? }`): **Attribute schema enforcement** — Cerbos [`schemas`](https://docs.cerbos.dev/cerbos/latest/policies/schemas) parity. Resource policies declare `schemas.principalSchema` / `resourceSchema` refs (with optional `ignoreWhen.actions` globs); this option maps the refs to validators and picks the level: `'reject'` (default when set) denies requests whose attributes fail validation, `'warn'` reports without changing decisions, `'none'` disables (the Cerbos default when unconfigured). Failures are returned as Cerbos-shaped `validationErrors` (`{ path, message, source }`) on `checkResources` results — regardless of `includeMeta` — and reach the audit log. A definition may be a JSON Schema object (compiled with the `ajv` option), a Zod schema, or a validator function. See [Attribute schemas](#attribute-schemas-cerbos-schemas). - **`relations`** (KerberosRelationsResolver): ReBAC resolver used by relation-backed derived roles — any object with a `check(args, opts)` method (and an optional batched `list`). See [ReBAC (Relations)](#rebac-relations). - **`z`**: Enables validation using the built-in Zod schema builders. - **`ajv`**: Enables validation using the built-in JSON Schema builders compiled with Ajv. @@ -565,7 +755,7 @@ const kerberos = new Kerberos(policies, derivedRoles, { }); ``` -With `Pino`, Kerberos emits structured audit entries that include `callId`, `reqId`, `reqKind`, `principalId`, `resourceId`, `action`, `effect`, `outputs`, and `meta`. This mode is better suited for production ingestion than the default console table output. +With `Pino`, Kerberos emits structured audit entries that include `callId`, `reqId`, `reqKind`, `principalId`, `principalRoles` (the role set the decision was based on — roles change over time, so past entries stay explainable), `resourceId`, `action`, `effect`, `outputs`, and `meta`. Fail-closed denials are part of the stream too: a resource whose evaluation failed inside a `checkResources` batch (and the `onError: 'deny'` fallback of `isAllowed`) logs its DENY decisions marked `reason: 'evaluation-error'`, and `planResources` results (`PlanResources.result`, with the filter kind) go out at **info** level like other decision entries — only lifecycle `*.start`/`*.finish` events sit at debug. This mode is better suited for production ingestion than the default console table output. It also emits lifecycle logs such as `IsAllowed.start`, `IsAllowed.error`, `IsAllowed.finish`, `CheckResources.start`, `CheckResources.finish` and `PlanResources.*`. Errors are always logged, but whether they are rethrown or converted into a fail-closed response is decided solely by the [`onError`](#options) option — never by the logger. @@ -797,12 +987,12 @@ Per action, `meta.actions[action]` includes: - **matchedPolicy**: The policy source that produced the decision — a resource source such as `resource.expense.vdefault/acme.corp`, a principal source such as `principal.sally.vdefault/acme.corp`, or a role source such as `role.USER.vdefault` - **matchedRule**: The exact rule that produced the decision - **matchedScope**: The scope of the matched policy (present for scoped policies) -- **reason** (denied actions only): why nothing allowed the action — `'rule-miss'` (no rule targeted the action / matched the principal's roles), `'condition-not-met'` (a rule targeted it but its condition failed) or `'policy-miss'` (no applicable policy existed at all) +- **reason** (denied actions only): why nothing allowed the action — `'rule-miss'` (no rule targeted the action / matched the principal's roles), `'condition-not-met'` (a rule targeted it but its condition failed), `'policy-miss'` (no applicable policy existed at all) or `'evaluation-error'` (the resource's evaluation rejected inside a `checkResources` batch and failed closed — paired with `errorName` so an outage is distinguishable from a policy DENY) At the result level: - **effectiveDerivedRoles**: derived roles that activated for this resource -- **resolution** (decision trace): every policy lookup that was attempted — `{ source, id, version, scopesSearched, matchedScope, origin? }` entries (with `origin: 'cache'` for cache-resolved policies) plus `{ source: 'relations', name, relation, matched, reason? }` entries for [relation-backed derived roles](#rebac-relations). The same trace appears in [`planResources` meta](#query-plans-planresources). +- **resolution** (decision trace): every policy lookup that was attempted — `{ source, id, version, scopesSearched, matchedScope, origin? }` entries (with `origin: 'cache'` for cache-resolved policies), `{ source: 'derivedRoles', name, matched, origin? }` entries for every imported derived-roles set (`matched: false` = the import resolved nowhere — e.g. an evicted or corrupt cache document silently stopping rules from matching), plus `{ source: 'relations', name, relation, matched, reason? }` entries for [relation-backed derived roles](#rebac-relations). The same trace appears in [`planResources` meta](#query-plans-planresources). ## Caching / Storing policies @@ -810,13 +1000,16 @@ Kerberos.js can resolve policies dynamically from a remote store (Redis, MongoDB ### How it works (fallback layer) -Static policies passed to the constructor stay in memory and are always checked first. The `cache` is only consulted on a **miss**: +Static policies passed to the constructor stay in memory; the `cache` is a fallback source. Resolution collects the **whole policy chain** along the scope search chain, with per-scope precedence: -1. Resolve the policy by `kind` / `id` / `role` + `policyVersion` + scope chain in memory. -2. On a miss, and only if a `cache` is configured, call `await cache.get(key)` for each scope in the chain. -3. On a hit, the JSON document is handled according to the `codec` option (see below). +1. For each scope in the chain (most specific → base), look the policy up in memory first, then — only on a miss at that scope, and only if a `cache` is configured — call `await cache.get(key)`. +2. On a hit, the JSON document is handled according to the `codec` option (see below). +3. Every policy found participates in [per-action scope evaluation](#scopes-and-policy-versions) — a more specific policy decides first, and actions it does not decide fall through to less specific ones. 4. If nothing matches, the action falls back to `EFFECT_DENY` (unchanged behavior). +> [!NOTE] +> Precedence is **per scope**: an in-memory policy wins at its own scope, but no longer shadows a *more specific* cached policy at a deeper scope. Hybrid deployments (static org-wide defaults in code + per-tenant overrides in the store) resolve the way scope specificity implies. + Cache keys follow this layout: | Policy type | Key format | @@ -1022,7 +1215,7 @@ Kerberos deliberately does **not** serialize raw JavaScript function bodies and - `fn.toString()` produces engine/bundler-specific output (V8 vs SpiderMonkey, Babel/esbuild/SWC, `[native code]`), which silently breaks serialization across environments. - Re-`eval`ing on every cache hit pays a JIT-compilation cost exactly when load is highest. -Instead, the built-in codec (`createSafeExprCodec({ jsep })`) uses an **AST allowlist interpreter** built on the tiny, eval-free [`jsep`](https://ericsmekens.github.io/jsep/) parser. Safe-by-default resource limits are configurable per codec: `createSafeExprCodec({ jsep, maxCachedExprs, maxExprLength, maxDepth })` — defaults `1000` cached ASTs (FIFO eviction), `4096` chars per expression, nesting depth `32` (unrelated to the ReBAC resolver's own `maxDepth: 50` walk limit). How it works: +Instead, the built-in codec (`createSafeExprCodec({ jsep })`) uses an **AST allowlist interpreter** built on the tiny, eval-free [`jsep`](https://ericsmekens.github.io/jsep/) parser. Safe-by-default resource limits are configurable per codec: `createSafeExprCodec({ jsep, maxCachedExprs, maxExprLength, maxDepth, maxBuiltStringLength })` — defaults `1000` cached ASTs (LRU-touched bounded cache), `4096` chars per expression, nesting depth `32` (unrelated to the ReBAC resolver's own `maxDepth: 50` walk limit), and `1_000_000` chars for strings **built** by expressions (`repeat`/`padStart`/`padEnd` — without the cap a tiny expression could allocate a ~0.5GB string per evaluation). How it works: 1. Each `{ $expr }` string is parsed **once** into an AST via your `jsep` instance, which is cached per (jsep instance, expression string) pair (`parse-once`). 2. Evaluation walks the AST per request with a strict allowlist — no `eval`, no `new Function`, no recompilation. @@ -1061,6 +1254,76 @@ To skip deserialization entirely (e.g. your cached documents are already plain J const kerberos = new Kerberos([], [], { cache }); // values passed as-is to policy constructors ``` +## Importing Cerbos Policies + +The **`@alexify/kerberos/cerbos`** subpath turns an existing **Cerbos policy repository** — YAML/JSON policy documents with CEL conditions — into Kerberos policies you can evaluate in-process, still with **zero dependencies**: the subpath ships its own parser for the YAML subset Cerbos policies are written in and its own CEL parser + translator. + +```javascript +import { importCerbosPolicies } from '@alexify/kerberos/cerbos'; +import { Kerberos, createSafeExprCodec, deserializePolicy } from '@alexify/kerberos'; + +// The importer emits SERIALIZED documents ({ $expr } conditions), so the +// standard dynamic-policy codec setup applies (see "Caching / Storing Policies"): +const codec = createSafeExprCodec({ jsep }); + +const { policies, derivedRoles } = importCerbosPolicies(yamlTexts); // strings, parsed objects, or arrays + +const kerberos = new Kerberos( + policies.map((doc) => deserializePolicy(doc, codec)), + derivedRoles.map((doc) => deserializePolicy(doc, codec)), +); +``` + +Because the output is plain JSON with `{ $expr }` descriptors, it is also exactly what the [cache layer](#caching--storing-policies) stores — import a Cerbos repo once and publish the results to Redis/keyv instead of constructing an engine directly. + +**The governing invariant: refuse to guess.** Every Cerbos construct is either translated with faithful semantics or rejected with a `KerberosImportError` naming the construct and its location — nothing is dropped or approximated silently, because a skipped rule or a mistranslated condition would change authorization decisions without a trace. The single opt-in exception: `importCerbosPolicies(input, { drop: ['schemas'] })` discards validation-only `schemas` blocks instead of throwing on them. + +### What is translated + +All four document kinds (`resourcePolicy`, `principalPolicy`, `rolePolicy` — Cerbos role policies have no version, so `default` is assumed — and `derivedRoles`), including scopes, `importDerivedRoles`, nested `all`/`any`/`none` condition combinators, `variables.local` / `constants.local`, and `output.expr` / `output.when`. Policies with `disabled: true` are skipped, matching Cerbos's own loader; `scopePermissions: SCOPE_PERMISSIONS_OVERRIDE_PARENT` (the Cerbos default, and exactly what Kerberos implements) is accepted. `schemas:` blocks translate verbatim (wire their definitions into the [`schemas` engine option](#attribute-schemas-cerbos-schemas) to enforce them; `drop: ['schemas']` discards them instead). Always rejected: `exportVariables`/`exportConstants` and `variables.import`, `REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS`, script conditions, and unknown keys at any level. + +### The CEL → `$expr` translation + +`celToExpr` (exported standalone) parses real CEL — full expression grammar with precedence, ternary, raw/triple-quoted strings, hex/uint literals, comments — and emits JavaScript for the [safe interpreter](#serialization-mechanism-security--performance). Highlights: + +| CEL | JavaScript (`$expr`) | +| --- | ------------------- | +| `request.principal` / `request.resource` (or `P` / `R` / `V` / `C` shorthand) | `P` / `R` / `V` / `C` | +| `==` / `!=` | `===` / `!==` | +| `x in list` | `list.includes(x)` (a *map* receiver errors at evaluation — fail-loud) | +| `has(R.attr.x)` | `typeof R.attr.x !== "undefined"` (an explicit `null` is *present*, as in CEL) | +| `size(x)` / `x.size()` | `x.length` | +| `timestamp(x)` / `now()` | `Date.parse(x)` / `Date.now()` — timestamps are epoch-ms numbers, so `<`, `==`, `-` work numerically | +| `duration("72h3m")` | constant-folded milliseconds | +| `t.getFullYear()` … | `new Date(t).getUTCFullYear()` … (CEL defaults to UTC; `getDayOfMonth()` gets the `- 1`) | +| `x.replace(a, b)` | `x.split(a).join(b)` (CEL replaces every occurrence) | +| `7 / 2` (int literals) | `Math.trunc(7 / 2)` (CEL integer division truncates) | + +Rejected by design, each with a named error: comprehension macros (`exists`/`all`/`filter`/`map`/`exists_one` — the interpreter has no lambdas), `matches()` (RE2), Cerbos extension functions (`hasIntersection`, `hierarchy`, `spiffeID`, …), `globals`, `runtime`, `request.auxData`, bytes literals, message construction, and any identifier the translator does not recognize. Documented deviations: `lowerAscii`/`upperAscii` map to full-Unicode case folding, and `/` with non-literal operands keeps JS numeric semantics (Cerbos attributes arrive as JSON numbers — CEL doubles — where the two agree). + +### How the importer is verified + +The whole [Cerbos conformance corpus](conformance/README.md) — real Cerbos policy YAML whose expected decisions are pinned against a live Cerbos PDP in CI — additionally runs **through the public importer** (`conformance/importer.test.js`): YAML parsed by this parser, CEL translated by this translator, and every decision and query-plan expectation must still hold. The YAML parser is separately verified differentially against the reference `yaml` package over the same corpus. + +## Loading Policies from Files + +The core package never touches the filesystem; the **Node-only** **`@alexify/kerberos/loader`** subpath is the boot-time bridge for **policy-as-code repositories** — and the bundle format is the GitOps artifact: + +```javascript +import { loadPolicyDirectory, writePolicyBundle, loadPolicyBundle } from '@alexify/kerberos/loader'; + +// Boot: load a directory (Kerberos JSON and Cerbos YAML/JSON can mix; `_schemas/` included). +const { policies, derivedRoles, schemas } = loadPolicyDirectory('./policies', { codec }); +const kerberos = new Kerberos(policies, derivedRoles, { ajv, schemas: { definitions: schemas } }); + +// CI: bake the repo into one hash-stamped artifact… +const bundle = writePolicyBundle('./dist/policies.bundle.json', loadPolicyDirectory('./policies')); +// …whose `version` is the SHA-256 of its canonical content. Loading VERIFIES it: +const verified = loadPolicyBundle('./dist/policies.bundle.json', { codec }); // tampered/truncated → throws +``` + +Directories load in deterministic sorted order, `_`-prefixed and hidden entries are skipped (the Cerbos repo convention), `.yaml` files and JSON documents carrying `apiVersion` route through the [Cerbos importer](#importing-cerbos-policies) automatically, and `_schemas/**.json` come back keyed for the [`schemas.definitions`](#attribute-schemas-cerbos-schemas) option. The top-level functions are synchronous; the **`promises` namespace** (Node's `fs.promises` idiom — same names, same shared core, byte-identical results) is the asynchronous driver, and `promises.loadPolicyDirectory` reads files **concurrently** (bounded by the `concurrency` option, default 64) so cold starts stay fast over large policy repositories without blocking the event loop: `const { promises: loader } = require('@alexify/kerberos/loader')`. Bundles hold serialized documents only, `createPolicyBundle(content, { createdAt: null })` is byte-reproducible, and in browsers every loader function throws a clear error (fetch a bundle over the network instead). Errors are typed `KerberosLoaderError`s naming the offending file. + ## ReBAC (Relations) Kerberos supports **relationship-based access control** (ReBAC) — "Google Drive-style" authorization where access flows through relationships (`viewer of the parent folder`, `member of the team that owns the document`) instead of attributes alone. The design is heavily inspired by [SpiceDB](https://github.com/authzed/spicedb) (the mature open-source implementation of Google's Zanzibar), adapted to the Kerberos philosophy: **in-process, zero-infra**, static data blazing fast, dynamic data through the same read-only `cache` fallback used for policies. @@ -1114,7 +1377,7 @@ const kerberos = new Kerberos([policy], [derivedRoles], { }); ``` -Resolver failures follow the [`onError`](#configuration-options) semantics, per-resource isolation in `checkResources` applies as usual, and with `includeMeta` every relation resolution is visible in `meta.resolution` as `{ source: 'relations', name, relation, matched }`. +Resolver failures follow the [`onError`](#configuration-options) semantics **at request level** (`isAllowed`, or a failure outside per-resource evaluation). *Inside* a `checkResources` batch, per-resource isolation always wins: a rejected resource fails closed to DENY for its actions without failing the batch — even with `onError: 'throw'` — and with `includeMeta` those error-shaped denials are marked `{ reason: 'evaluation-error', errorName }` so an outage is never mistaken for a policy DENY. Every relation resolution is likewise visible in `meta.resolution` as `{ source: 'relations', name, relation, matched }`. ### The built-in Zanzibar-lite resolver @@ -1181,7 +1444,7 @@ What it borrows from SpiceDB (see [`src/Relations/`](./src/Relations)): - **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 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). +- **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). Both APIs cap their result at `maxResults` (default 1000). **The cap truncates**: by default the first `maxResults` sorted entries come back with no error, so `1000` results is indistinguishable from `1000 of 80 000`. Set `onTruncated: 'throw'` to get a typed `KerberosRelationsError` instead — recommended whenever lookup results feed a query-plan filter (`expandRelationOperands`), where a silently narrowed id list would drop authorized rows from the translated query; alternatively pass an `{ ids, truncated: true }` envelope to `expandRelationOperands`, which then degrades that branch to the sound `opaque` post-filter operator. Truncation is always recorded on the call's telemetry span as `kerberos.result.truncated`. ### Resolver telemetry @@ -1205,7 +1468,7 @@ Exactly like dynamic policies, tuples can live in your cache/store — Kerberos Static tuples always win per `(resource, relation)` key — the cache is only consulted on a static miss, and sources for the same key are never merged. **A corrupt document throws a typed `KerberosCodecError`** (propagating per the engine's `onError` semantics) instead of resolving as empty — an "empty" read would silently *widen* access in exclusion positions (`read_only = viewer − editor`: a real editor whose editor document fails to parse would gain `read_only`). The same rule applies to a caveat whose condition **throws** (→ `KerberosRelationsError`): an evaluation error is never read as an answer; a caveat that cleanly evaluates to `false` simply does not match. Genuine absence (cache miss) still resolves as an empty set, and entries the schema does not admit are skipped with an operator log. Transient cache failures retry per `cacheRetry` and then surface as `KerberosCacheError`. -**Session memo contract** (`opts.memo` on `check`/`list`/`lookupSubjects`/`lookupResources`): pass one `Map` to share work across calls — document reads are shared whenever the same resolver instance is used, and decision entries are automatically scoped by resolver instance plus the *identity* of the `principal`/`context` objects, so reusing a memo across different principals, contexts or resolver instances is safe by construction (reuse the same object references to maximize sharing — that is exactly what the Kerberos engine does across a `checkResources` batch). +**Session memo contract** (`opts.memo` on `check`/`list`/`lookupSubjects`/`lookupResources`): pass one `Map` to share work across calls — document reads are shared whenever the same resolver instance is used, and decision entries are automatically scoped by resolver instance plus the *identity* of the `principal`/`context` objects, so reusing a memo across different principals, contexts or resolver instances is safe by construction (reuse the same object references to maximize sharing — that is exactly what the Kerberos engine does across a `checkResources` batch). A read that fails (rejects) is evicted from the memo automatically, so a transient backend failure never poisons a long-lived memo — the next call retries; successfully resolved reads stay memoized for the memo's lifetime, so treat the memo as request/batch-scoped when document freshness matters. ### Consistency (honest limitations) @@ -1283,7 +1546,7 @@ flowchart TD 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. +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 resolves conflicts per principal role (deny over allow within a role, allow over deny across roles) 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 @@ -1335,6 +1598,34 @@ const expanded = await expandRelationOperands(plan, ({ relation }) => 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. Mind the cardinality: a principal with access to a very large set of resources materializes a very large `in`-list — for those cases a post-check (or a resolver-side limit) can beat expansion. +### Using the official Cerbos ORM adapters + +Cerbos's own [query-plan adapters](https://github.com/cerbos/query-plan-adapters) — [`@cerbos/orm-prisma`](https://www.npmjs.com/package/@cerbos/orm-prisma) and [`@cerbos/orm-drizzle`](https://www.npmjs.com/package/@cerbos/orm-drizzle) — accept Kerberos plans through one exported hop: `toCerbosQueryPlan` converts the HTTP-API operand encoding Kerberos emits (`{ variable }` / `{ expression }`) into the flattened `@cerbos/core` SDK encoding the adapters consume (`{ name }` / `{ operator, operands }`; the plan kinds are byte-identical): + +```javascript +import { toCerbosQueryPlan, expandRelationOperands } from '@alexify/kerberos'; +import { queryPlanToPrisma } from '@cerbos/orm-prisma'; + +const plan = await kerberos.planResources({ principal, resource: { kind: 'document' }, action: 'view' }); +const result = queryPlanToPrisma({ + queryPlan: toCerbosQueryPlan(plan), + mapper: { + 'request.resource.attr.ownerId': { field: 'ownerId' }, + 'request.resource.id': { field: 'id' }, + }, +}); +// result.kind: ALWAYS_ALLOWED | ALWAYS_DENIED | CONDITIONAL (+ result.filters for Prisma's `where`) +``` + +The two Kerberos-only operators follow the refuse-to-guess rule at this boundary: + +- **`relation`** (ReBAC dependency) — materialize it first: `toCerbosQueryPlan(await expandRelationOperands(plan, lookup))`; the expanded plan renders as a plain `id IN (...)` filter. Handing an *unexpanded* plan to the converter throws, naming `expandRelationOperands`. +- **`opaque`** (statically unplannable condition) — the converter throws with a post-filtering directive; translate the rest of the query and filter the rows through `checkResources` afterwards. + +This path is CI-verified against the real adapter packages (`test/OrmAdapters.test.js`): conditional/membership plans render the expected Prisma `where` objects and Drizzle SQL, and both special operators take exactly the routes above. + +One caveat that is not ours: the adapter packages are CommonJS but depend on the ESM-only `@cerbos/core`, so **loading them** needs Node's `require(esm)` support — Node **20.19+ / 22.12+**. On Node 18 they cannot be required at all, and the verification suite skips accordingly. `toCerbosQueryPlan` itself, like the rest of Kerberos, runs on Node 18; only the third-party adapters are gated. + ### 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: @@ -1442,6 +1733,25 @@ describe('KerberosTests', () => { }); ``` +### Policy testing from the command line + +The package ships a `kerberos` CLI, so a **pure policy repository** — no engineering glue, no hand-written test harness — can test itself in CI: + +```bash +npx kerberos test ./policies ./tests +``` + +- Policies load exactly like [`loadPolicyDirectory`](#loading-policies-from-files): Kerberos JSON and Cerbos YAML/JSON mix freely, `{ $expr }` conditions resolve `jsep` (+ the documented plugins) from **your** project. +- Test suites are **Cerbos's own [`TestSuite`](https://api.cerbos.dev/latest/cerbos/policy/v1/TestSuite.schema.json) format** (`*_test.yaml` / `*_test.json`): named principal/resource fixtures plus expected effects per action — reviewable, engine-agnostic artifacts. +- `--schemas reject|warn` wires `_schemas/` into [attribute-schema enforcement](#attribute-schemas-cerbos-schemas); `--json` prints a machine-readable report; the exit code is `1` on any failing case (`2` for usage/config errors). +- The runner refuses to guess: an expectation feature it does not check (e.g. `outputs`) fails the run instead of silently passing. + +```bash +npx kerberos bundle ./policies --out dist/policies.bundle.json --reproducible +``` + +bakes the repo into a [hash-stamped bundle](#loading-policies-from-files) for GitOps pipelines. + ### Testing with Outputs You can also test policies with outputs functionality: @@ -1577,6 +1887,48 @@ Kerberos policies can contain JavaScript functions in: When using Ajv or TypeBox, Kerberos.js registers custom Ajv keywords so those function-bearing fields can still be validated at runtime. This keeps the DSL usable even though plain JSON Schema doesn't natively understand JavaScript functions. +### Attribute schemas (Cerbos `schemas`) + +Conditions read `P.attr` / `R.attr` — and garbage attributes silently flow into them (an undefined comparison quietly denies or allows). Cerbos guards this with per-kind attribute schemas; Kerberos implements the same model: + +```javascript +const kerberos = new Kerberos( + [{ + resourcePolicy: { + version: 'default', + resource: 'expense', + schemas: { + principalSchema: { ref: 'principal.json' }, + resourceSchema: { ref: 'expense.json', ignoreWhen: { actions: ['create'] } }, + }, + rules: [/* ... */], + }, + }], + [], + { + ajv: new Ajv({ allErrors: true }), + schemas: { + enforcement: 'reject', // 'reject' | 'warn' | 'none' + definitions: { + 'expense.json': { type: 'object', required: ['amount'], properties: { amount: { type: 'number' } } }, + 'principal.json': z.object({ department: z.string() }), // Zod works too + }, + }, + }, +); +``` + +Semantics (mirroring Cerbos): + +- **`reject`** — a request whose attributes fail validation is denied for **every** action (a principal policy cannot rescue it), with the failures reported as `validationErrors: [{ path, message, source: 'SOURCE_PRINCIPAL' | 'SOURCE_RESOURCE' }]` on the `checkResources` result and `reason: 'invalid-attributes'` under `includeMeta`. +- **`warn`** — `validationErrors` are reported (response + audit log) but decisions are unaffected. +- **`none`** / option absent — schema references in policies are inert, matching Cerbos's own default. +- **`ignoreWhen.actions`** (Cerbos globs) skips validation only when **every** requested action matches — one non-matching action in the batch entry re-enables it. +- With scoped policies, the **most specific** policy in the resource scope chain that declares `schemas` wins. +- A policy referencing a ref missing from `definitions` throws `KerberosValidationError` (always — a configuration error never reads as valid *or* invalid). + +Definitions may be plain JSON Schema objects (compiled with the engine's `ajv` option), Zod-like schemas (anything with `safeParse`), or validator functions returning error messages. The [Cerbos importer](#importing-cerbos-policies) translates `schemas:` blocks verbatim, so an imported policy repo enforces the same rules once you wire its schema files into `definitions`. + ## OpenTelemetry Kerberos.js ships native OpenTelemetry support (traces + metrics) following the same delegating philosophy as `logger` and `cache`: **the package never depends on `@opentelemetry/api`** (not even as a peer dependency). You pass either the api module or pre-created instances: @@ -1597,9 +1949,9 @@ const kerberos2 = new Kerberos(policies, derivedRoles, { Works out of the box with any registered SDK (e.g. `NodeSDK` from `@opentelemetry/sdk-node`); with no SDK registered, everything no-ops. -**Spans** — one per public call: `Kerberos.isAllowed` (decision attributes on the span) and `Kerberos.checkResources` (one `kerberos.decision` event per resource × action); the built-in ReBAC resolver adds `Kerberos.relations.check` / `.list` / `.lookupSubjects` / `.lookupResources` when given its own `telemetry` option (see [Resolver telemetry](#resolver-telemetry)). The span is started **active**, so spans created inside — e.g. an auto-instrumented Redis cache behind the `cache` option, or resolver spans under an engine span — nest correctly. Attributes include `kerberos.call_id`, `kerberos.req_id`, `kerberos.resource.kind`, `kerberos.action`, `kerberos.allowed` / `kerberos.effect`, `kerberos.matched_policy` / `kerberos.matched_rule` / `kerberos.matched_scope`, and identity attributes `kerberos.principal.id` / `kerberos.resource.id`. On errors the span gets `ERROR` status plus an exception event — error-handling behavior itself is controlled solely by the [`onError`](#configuration-options) option, never by telemetry or logging. +**Spans** — one per public call: `Kerberos.isAllowed` (decision attributes on the span), `Kerberos.checkResources` (one `kerberos.decision` event per resource × action) and `Kerberos.planResources` (plan attributes: `kerberos.plan.kind`, `kerberos.plan.actions_count`, `kerberos.plan.opaque_count`, `kerberos.plan.relation_count`); the built-in ReBAC resolver adds `Kerberos.relations.check` / `.list` / `.lookupSubjects` / `.lookupResources` when given its own `telemetry` option (see [Resolver telemetry](#resolver-telemetry)). When relation-backed derived roles resolve through the `relations` seam, the request span additionally carries `kerberos.relations.count` and `kerberos.relations.duration_ms` — so relation latency is attributable even with a **custom** resolver that has no instrumentation of its own. The span is started **active**, so spans created inside — e.g. an auto-instrumented Redis cache behind the `cache` option, or resolver spans under an engine span — nest correctly. Attributes include `kerberos.call_id`, `kerberos.req_id`, `kerberos.resource.kind`, `kerberos.action`, `kerberos.allowed` / `kerberos.effect`, `kerberos.matched_policy` / `kerberos.matched_rule` / `kerberos.matched_scope`, and identity attributes `kerberos.principal.id` / `kerberos.resource.id`. On errors the span gets `ERROR` status plus an exception event — error-handling behavior itself is controlled solely by the [`onError`](#configuration-options) option, never by telemetry or logging. -**Metrics** — four instruments: +**Metrics** — six instruments: | Instrument | Type | Unit | Attributes | | ---------- | ---- | ---- | ---------- | @@ -1608,6 +1960,7 @@ Works out of the box with any registered SDK (e.g. `NodeSDK` from `@opentelemetr | `kerberos.request.duration` | Histogram | `ms` | `kerberos.req_kind`, `error` | | `kerberos.cache.requests` | Counter | `{request}` | `kerberos.cache.result` (`hit`/`miss`/`error`), `kerberos.cache.kind` (only for ReBAC tuple reads: `relation`) | | `kerberos.relations.checks` | Counter | `{check}` | `kerberos.relations.result` (`allow`/`deny`) | +| `kerberos.observability.failures` | Counter | `{failure}` | `kerberos.observability.sink` (`logger`/`telemetry`) — swallowed sink failures. Authorization is never affected by a broken logger/exporter, but a non-zero rate here means audit or telemetry output is being **lost**; the engine also `console.warn`s once per instance on the first swallowed logger failure. | > Metric attributes deliberately exclude actions and principals to keep cardinality bounded — they assume a bounded set of resource kinds. @@ -1629,19 +1982,47 @@ Apple Silicon (M-series), Node v24: | Scenario | ops/sec | | -------- |---------:| -| `isAllowed` — simple role match | ~320,000 | -| `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) | ~60,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 | +| `isAllowed` — simple role match | ~800,000 | +| `isAllowed` — derived roles + variables + condition | ~650,000 | +| `checkResources` — 10 resources × 3 actions | ~63,000 | +| `checkResources` — 10 resources, includeMeta | ~61,000 | +| `isAllowed` — role policy + 2-level parentRoles chain | ~480,000 | +| `isAllowed` — 3-segment scoped request (chain walk) | ~640,000 | +| `isAllowed` — simple role match + Zod validation | ~470,000 | +| `isAllowed` — cache-backed dynamic policy (`$expr`, in-memory Map) | ~330,000 | +| `checkResources` — 50 resources, cache-backed | ~9,000 | +| `planResources` — `$expr` policy (variables + deny rule) | ~72,000 | +| `relations.check` — direct tuple (flat) | ~760,000 | +| `relations.check` — deep walk (3 arrows + nested groups) | ~106,000 | +| `isAllowed` — relation-backed derived role (deep walk) | ~77,000 | `checkResources` evaluates resources **concurrently** (`Promise.allSettled`): with a remote policy store, N resources cost one parallel wave of lookups instead of N sequential round-trips (measured ~8x faster with a 2ms-latency cache and 10 resources), and one failing resource never fails the batch — it fail-closes to `EFFECT_DENY` for its actions only. Numbers vary by hardware and Node version — treat them as relative guidance, not absolutes. The harness exists primarily to catch performance regressions between releases. +### Cross-library comparison + +The same scenario — role-gated actions plus one ownership condition — implemented in Kerberos, [CASL](https://casl.js.org) and [casbin](https://casbin.org) (`pnpm bench:compare`; Apple Silicon, Node v24): + +| Library · path | ops/sec | +| -------------- | -------:| +| `@alexify/kerberos` · `isAllowed` | ~640,000 | +| `@casl/ability` · check (prebuilt ability) | ~7,300,000 | +| `@casl/ability` · build + check (per request) | ~1,300,000 | +| `casbin` · `enforce` (in-memory model) | ~200,000 | + +Read it honestly — the libraries do different amounts of work per call. CASL's prebuilt check is a plain in-memory predicate and is faster because it does dramatically less: no policy documents, versions or scopes, no audit/telemetry path, no batch API, no query planner. Abilities are built **per user**, so the *build + check* row is the realistic per-request path. casbin interprets its model DSL on every call. The Kerberos number includes argument validation, the guarded audit/telemetry seams and the scope-chain walk. `@cerbos/embedded` and OPA-WASM are absent by necessity: their policy bundles cannot be built from open tooling alone (Cerbos Hub / the `opa` compiler), so honest numbers cannot be produced here. + +Bundle size for the browser, measured the same way as the table above (`pnpm size:compare`, esbuild, min+gzip): + +| Library | min+gzip | +| ------- | --------:| +| `@alexify/kerberos` (main entry) | 31.9 KB | +| `@casl/ability` | 6.6 KB | +| `casbin` | 33.9 KB — does not bundle for the browser (Node builtins); measured as a Node bundle | + +CASL is the size floor for a reason (it implements far less); casbin does not run in browsers at all. + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) for the full history of changes, including the `2.x → 3.x` release notes (ReBAC, OpenTelemetry, runtime split, query plans). diff --git a/bench/bench.js b/bench/bench.js index e53bbcb..d42a21c 100644 --- a/bench/bench.js +++ b/bench/bench.js @@ -102,6 +102,73 @@ async function main() { rich.checkResources({ principal, resources: manyResources })), ); + results.push( + await bench('checkResources — 10 resources, includeMeta', () => + rich.checkResources({ principal, resources: manyResources, includeMeta: true })), + ); + + // Role-policy layer: principal + role policies with a 2-level parentRoles + // chain (exercises #evaluateRolePolicy's memo/inheritance machinery). + const layeredPolicies = [ + { + principalPolicy: { + principal: 'root', + version: 'default', + rules: [{ resource: 'expense', actions: [{ action: '*', effect: Effect.Allow }] }], + }, + }, + { + rolePolicy: { + role: 'JUNIOR', + version: 'default', + parentRoles: ['SENIOR'], + rules: [{ resource: 'expense', allowActions: ['view', 'approve'] }], + }, + }, + { + rolePolicy: { + role: 'SENIOR', + version: 'default', + parentRoles: ['LEAD'], + rules: [{ resource: 'expense', allowActions: ['view', 'approve'] }], + }, + }, + { + rolePolicy: { + role: 'LEAD', + version: 'default', + rules: [{ resource: 'expense', allowActions: ['view'] }], + }, + }, + ]; + const layered = new Kerberos(layeredPolicies, []); + results.push( + await bench('isAllowed — role policy + 2-level parentRoles chain', () => + layered.isAllowed({ principal: { id: 'joe', roles: ['JUNIOR'] }, action: 'view', resource })), + ); + + // Scoped lookup: a 3-segment request scope walks the scope chain (4 lookups + // per source) before falling back to the base policy. + const scoped = new Kerberos(simplePolicies, []); + const scopedResource = { ...resource, scope: 'acme.emea.sales' }; + results.push( + await bench('isAllowed — 3-segment scoped request (chain walk)', () => + scoped.isAllowed({ principal, action: 'view', resource: scopedResource })), + ); + + // Validation-backend scenario: the same simple check with Zod configured — + // measures the args-validation cost on top of evaluation. + try { + const { z } = require('zod'); + const validated = new Kerberos(simplePolicies, [], { z }); + results.push( + await bench('isAllowed — simple role match + Zod validation', () => + validated.isAllowed({ principal, action: 'view', resource })), + ); + } catch { + console.log('(zod not installed — skipping the validation-backend scenario)'); + } + // Cache-backed scenario: dynamic $expr policy resolved through a Map cache. let jsep; try { @@ -139,6 +206,20 @@ async function main() { cached.isAllowed({ principal, action: 'view', resource: docResource })), ); + // Cache-backed batch: exercises the per-batch singleflight lookups memo + // (each distinct policy resolves once per batch, not once per resource). + const cachedBatchResources = []; + for (let i = 0; i < 50; i++) { + cachedBatchResources.push({ + resource: { id: `doc${i}`, kind: 'document', attr: { status: 'OPEN' } }, + actions: ['view'], + }); + } + results.push( + await bench('checkResources — 50 resources, cache-backed', () => + cached.checkResources({ principal, resources: cachedBatchResources })), + ); + // 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'); diff --git a/bench/compare.js b/bench/compare.js new file mode 100644 index 0000000..52b0c39 --- /dev/null +++ b/bench/compare.js @@ -0,0 +1,149 @@ +/** + * Cross-library comparison benchmark: the same authorization scenario — + * role-gated actions plus an ownership (ABAC) condition — implemented in + * Kerberos, CASL (@casl/ability) and casbin. + * + * Run: pnpm bench:compare + * + * Honesty notes, also published with the results in docs/guide/benchmarks.md: + * - the libraries have different feature sets; the scenario is the overlap + * (RBAC + one attribute condition), NOT a claim of equivalence — none of + * the others have policy versions/scopes, query plans or ReBAC; + * - CASL abilities are built PER USER: `check (prebuilt)` measures the pure + * check against a shared ability, `build + check` measures the realistic + * per-request path (define rules for the request's user, then check); + * - casbin's enforce() is async and model-interpreted; the in-memory model + * here (RBAC with an ABAC ownership matcher) is its idiomatic equivalent; + * - @cerbos/embedded and OPA-WASM are absent by necessity: their policy + * bundles cannot be built from open tooling alone (Cerbos Hub / the opa + * compiler), so honest numbers cannot be produced here. + */ +const { performance } = require('node:perf_hooks'); + +const { Kerberos, Effect } = require('../src/index.js'); +const { AbilityBuilder, createMongoAbility, subject } = require('@casl/ability'); +const { newEnforcer, newModelFromString, StringAdapter } = require('casbin'); + +const WARMUP_ITERATIONS = 2_000; +const MEASURE_MS = 1_000; + +async function bench(name, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i++) await fn(); + let iterations = 0; + const start = performance.now(); + while (performance.now() - start < MEASURE_MS) { + await fn(); + iterations += 1; + } + const elapsed = performance.now() - start; + const opsPerSec = Math.round((iterations / elapsed) * 1000); + console.log(`${name.padEnd(56)} ${opsPerSec.toLocaleString('en-US').padStart(12)} ops/sec`); + return { name, opsPerSec }; +} + +// The shared scenario: USERs may view documents they own; EDITORs may view +// and publish any document. The check asked of every library: may this USER +// view this document they own? +const user = { id: 'u1', roles: ['USER'] }; +const document = { id: 'd1', kind: 'document', attr: { ownerId: 'u1' } }; + +async function main() { + console.log('Cross-library comparison — RBAC + ownership condition'); + console.log(`Node ${process.version} · ${new Date().toISOString().slice(0, 10)}\n`); + const rows = []; + + // --- Kerberos ----------------------------------------------------------- + const kerberos = new Kerberos( + [ + { + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [ + { + actions: ['view'], + effect: Effect.Allow, + roles: ['USER'], + condition: { match: ({ P, R }) => R.attr.ownerId === P.id }, + }, + { actions: ['view', 'publish'], effect: Effect.Allow, roles: ['EDITOR'] }, + ], + }, + }, + ], + [], + ); + rows.push( + await bench('@alexify/kerberos · isAllowed', () => + kerberos.isAllowed({ principal: user, resource: document, action: 'view' })), + ); + + // --- CASL --------------------------------------------------------------- + function buildAbility(forUser, roles) { + const { can, build } = new AbilityBuilder(createMongoAbility); + if (roles.includes('USER')) can('view', 'document', { ownerId: forUser.id }); + if (roles.includes('EDITOR')) can(['view', 'publish'], 'document'); + return build(); + } + const prebuilt = buildAbility(user, user.roles); + const caslDoc = subject('document', { ownerId: 'u1' }); + rows.push(await bench('@casl/ability · check (prebuilt ability)', () => prebuilt.can('view', caslDoc))); + rows.push( + await bench('@casl/ability · build + check (per request)', () => { + const ability = buildAbility(user, user.roles); + return ability.can('view', subject('document', { ownerId: 'u1' })); + }), + ); + + // --- casbin ------------------------------------------------------------- + const model = newModelFromString(` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = (g(r.sub.Id, p.sub) || r.sub.Roles.includes(p.sub)) && p.obj == "document" && p.act == r.act && (p.sub != "USER" || r.obj.OwnerId == r.sub.Id) +`); + const adapter = new StringAdapter( + ['p, USER, document, view', 'p, EDITOR, document, view', 'p, EDITOR, document, publish'].join('\n'), + ); + const enforcer = await newEnforcer(model, adapter); + const casbinSub = { Id: 'u1', Roles: ['USER'] }; + const casbinObj = { OwnerId: 'u1' }; + rows.push(await bench('casbin · enforce (in-memory model)', () => enforcer.enforce(casbinSub, casbinObj, 'view'))); + + // Sanity: every library must actually ALLOW the scenario's check. + const kerberosOk = await kerberos.isAllowed({ principal: user, resource: document, action: 'view' }); + const caslOk = prebuilt.can('view', caslDoc); + const casbinOk = await enforcer.enforce(casbinSub, casbinObj, 'view'); + if (!kerberosOk || !caslOk || !casbinOk) { + throw new Error(`scenario mismatch: kerberos=${kerberosOk} casl=${caslOk} casbin=${casbinOk}`); + } + const kerberosDeny = await kerberos.isAllowed({ + principal: { id: 'u2', roles: ['USER'] }, + resource: document, + action: 'view', + }); + const casbinDeny = await enforcer.enforce({ Id: 'u2', Roles: ['USER'] }, casbinObj, 'view'); + const caslDeny = buildAbility({ id: 'u2' }, ['USER']).can('view', caslDoc); + if (kerberosDeny || casbinDeny || caslDeny) { + throw new Error(`deny-scenario mismatch: kerberos=${kerberosDeny} casl=${caslDeny} casbin=${casbinDeny}`); + } + + console.log('\n| Library · path | ops/sec |'); + console.log('| -------------- | -------:|'); + for (const row of rows) console.log(`| ${row.name} | ${row.opsPerSec.toLocaleString('en-US')} |`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bin/kerberos.js b/bin/kerberos.js new file mode 100755 index 0000000..90a706e --- /dev/null +++ b/bin/kerberos.js @@ -0,0 +1,351 @@ +#!/usr/bin/env node +'use strict'; + +/** + * The Kerberos policy CLI — policy-as-code workflows without hand-written + * test harnesses: + * + * kerberos test run Cerbos-TestSuite-format + * suites against the policies + * kerberos bundle -o bake a hash-stamped bundle + * + * Policies load through `@alexify/kerberos/loader` (Kerberos JSON and Cerbos + * YAML/JSON mix freely; `_schemas/` wires attribute schemas). Test suites are + * Cerbos's own `TestSuite` shape, in YAML or JSON, so policy tests stay + * engine-agnostic, reviewable artifacts. + * + * `{ $expr }` policies need jsep: the CLI resolves `jsep` and the + * `@jsep-plugin/object|ternary|new` plugins from the CURRENT project (the + * documented dynamic-policy setup) and tells you what to install when they + * are missing. + */ + +const path = require('node:path'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const { createRequire } = require('node:module'); + +const { Kerberos, createSafeExprCodec, Effect } = require('../index.js'); +const { promises: loader, KerberosLoaderError } = require('../loader.js'); +const { parseYamlDocuments } = require('../src/cerbos/yaml.js'); +const { KerberosImportError } = require('../src/cerbos/errors.js'); + +const EXIT_OK = 0; +const EXIT_TEST_FAILURES = 1; +const EXIT_USAGE = 2; + +const USAGE = `Usage: + kerberos test [--schemas none|warn|reject] [--json] + kerberos bundle --out [--reproducible] + kerberos --version | --help + +test Runs every *_test.{yaml,yml,json} suite (Cerbos TestSuite format) + under against the policies under . + --schemas wires /_schemas into attribute-schema + enforcement (default: none). --json prints a machine-readable report. +bundle Loads and writes a hash-stamped policy bundle + (see the /loader subpath). --reproducible omits the timestamp. +`; + +function fail(message, code = EXIT_USAGE) { + process.stderr.write(`kerberos: ${message}\n`); + process.exit(code); +} + +const useColor = process.stdout.isTTY && !process.env.NO_COLOR; +const green = (text) => (useColor ? `\x1b[32m${text}\x1b[0m` : text); +const red = (text) => (useColor ? `\x1b[31m${text}\x1b[0m` : text); +const dim = (text) => (useColor ? `\x1b[2m${text}\x1b[0m` : text); + +/** Minimal flag parser: positional args + --flag / --flag value. */ +function parseArgs(argv, flags) { + const positional = []; + const options = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + positional.push(arg); + continue; + } + const name = arg.slice(2); + const spec = flags[name]; + if (!spec) fail(`unknown option --${name}\n\n${USAGE}`); + if (spec === 'boolean') options[name] = true; + else options[name] = argv[++i] ?? fail(`--${name} needs a value`); + } + return { positional, options }; +} + +/** + * Resolves the documented jsep setup from the current project. Returns a + * codec, or null when jsep is absent (fine for policies without `$expr`). + */ +function resolveCodec() { + const requirers = []; + try { + requirers.push(createRequire(path.join(process.cwd(), 'package.json'))); + } catch { + // No resolvable project root — fall through to the CLI's own resolution. + } + requirers.push(require); + + let jsep = null; + for (const localRequire of requirers) { + try { + const mod = localRequire('jsep'); + jsep = mod.default ?? mod; + for (const plugin of ['@jsep-plugin/object', '@jsep-plugin/ternary', '@jsep-plugin/new']) { + try { + const pluginMod = localRequire(plugin); + jsep.plugins.register(pluginMod.default ?? pluginMod); + } catch { + // Optional plugin not installed — expressions needing it will fail with the codec's own error. + } + } + try { + jsep.addUnaryOp('typeof'); + } catch { + // Already registered. + } + break; + } catch { + // Try the next resolver. + } + } + return jsep ? createSafeExprCodec({ jsep }) : null; +} + +async function loadPolicies(dir, { schemas: schemasMode }) { + const codec = resolveCodec(); + let loaded; + try { + loaded = await loader.loadPolicyDirectory(dir, { codec: codec ?? undefined }); + } catch (error) { + if (!codec && /\$expr/.test(String(error?.message))) { + fail( + `${error.message}\n` + + 'These policies use { $expr } conditions, which need jsep. Install the documented setup:\n' + + ' npm i jsep @jsep-plugin/object @jsep-plugin/ternary @jsep-plugin/new', + ); + } + throw error; + } + if (loaded.policies.length + loaded.derivedRoles.length === 0) { + fail(`${dir}: no policy documents found`); + } + + const options = {}; + if (schemasMode && schemasMode !== 'none') { + if (!['warn', 'reject'].includes(schemasMode)) fail(`--schemas expects none, warn or reject (got ${schemasMode})`); + if (Object.keys(loaded.schemas).length === 0) { + fail(`--schemas ${schemasMode}: no _schemas/ directory found under ${dir}`); + } + let Ajv; + try { + Ajv = createRequire(path.join(process.cwd(), 'package.json'))('ajv'); + } catch { + try { + Ajv = require('ajv'); + } catch { + fail('--schemas needs ajv to compile JSON Schemas: npm i ajv'); + } + } + const AjvCtor = Ajv.default ?? Ajv; + options.ajv = new AjvCtor({ allErrors: true, strict: false }); + options.schemas = { enforcement: schemasMode, definitions: loaded.schemas }; + } + return { engine: new Kerberos(loaded.policies, loaded.derivedRoles, options), loaded }; +} + +// --------------------------------------------------------------------------- +// `kerberos test` — Cerbos TestSuite runner +// --------------------------------------------------------------------------- + +async function collectSuiteFiles(dir) { + const walk = async (current, relative) => { + const entries = (await fsp.readdir(current, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name, 'en'), + ); + const nested = await Promise.all( + entries.map(async (entry) => { + if (entry.name.startsWith('.') || entry.name.startsWith('_')) return []; + const entryRelative = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) return walk(path.join(current, entry.name), entryRelative); + return /_test\.(ya?ml|json)$/i.test(entry.name) ? [entryRelative] : []; + }), + ); + return nested.flat(); + }; + return walk(dir, ''); +} + +function parseSuiteText(text, file) { + if (file.endsWith('.json')) { + try { + return JSON.parse(text); + } catch (error) { + fail(`${file}: invalid JSON — ${error.message}`); + } + } + try { + const documents = parseYamlDocuments(text); + if (documents.length !== 1) fail(`${file}: expected exactly one YAML document, found ${documents.length}`); + return documents[0]; + } catch (error) { + if (error instanceof KerberosImportError) fail(`${file}: ${error.message}`); + throw error; + } +} + +function resolveRefs(kind, entry, fixtures, where) { + const single = entry[kind]; + const many = entry[`${kind}s`]; + const names = single !== undefined ? [single] : Array.isArray(many) ? many : null; + if (!names || names.length === 0) fail(`${where}: expectation names neither \`${kind}\` nor \`${kind}s\``); + return names.map((name) => { + if (!fixtures[name]) fail(`${where}: unknown ${kind} fixture \`${name}\``); + return { name, value: fixtures[name] }; + }); +} + +/** Expands one Cerbos TestSuite document into flat check cases. */ +function expandSuite(suite, file) { + const cases = []; + for (const [testIndex, test] of (suite.tests ?? []).entries()) { + const where = `${file} › ${test.name ?? `tests[${testIndex}]`}`; + if (test.skip) continue; + const inputActions = test.input?.actions; + if (!Array.isArray(inputActions) || inputActions.length === 0) fail(`${where}: input.actions is required`); + + for (const [expIndex, expectation] of (test.expected ?? []).entries()) { + const at = `${where} › expected[${expIndex}]`; + // Refuse to guess: an expectation feature this runner does not check + // (e.g. `outputs`) must not silently pass. + for (const key of Object.keys(expectation)) { + if (!['principal', 'principals', 'resource', 'resources', 'actions'].includes(key)) { + fail(`${at}: unsupported expectation key \`${key}\``); + } + } + if (!expectation.actions || Object.keys(expectation.actions).length === 0) { + fail(`${at}: expectation carries no actions`); + } + for (const principal of resolveRefs('principal', expectation, suite.principals ?? {}, at)) { + for (const resource of resolveRefs('resource', expectation, suite.resources ?? {}, at)) { + cases.push({ + label: `${test.name ?? testIndex} [${principal.name} → ${resource.name}]`, + principal: principal.value, + resource: resource.value, + actions: Object.keys(expectation.actions), + expected: expectation.actions, + }); + } + } + } + } + return cases; +} + +async function runTests(policiesDir, testsDir, options) { + if (!fs.existsSync(testsDir) || !fs.statSync(testsDir).isDirectory()) fail(`${testsDir}: not a directory`); + const { engine } = await loadPolicies(policiesDir, options); + const suiteFiles = await collectSuiteFiles(testsDir); + if (suiteFiles.length === 0) fail(`${testsDir}: no *_test.{yaml,yml,json} suites found`); + + // Suite files stream in concurrently; the report keeps sorted-file order. + const suiteTexts = await Promise.all(suiteFiles.map((file) => fsp.readFile(path.join(testsDir, file), 'utf8'))); + + const report = { suites: [], passed: 0, failed: 0 }; + for (const [fileIndex, file] of suiteFiles.entries()) { + const suite = parseSuiteText(suiteTexts[fileIndex], file); + const cases = expandSuite(suite, file); + const suiteReport = { file, name: suite.name ?? file, cases: [] }; + for (const testCase of cases) { + const { results } = await engine.checkResources({ + principal: testCase.principal, + resources: [{ resource: testCase.resource, actions: testCase.actions }], + }); + const actual = results[0].actions; + const mismatches = []; + for (const [action, expected] of Object.entries(testCase.expected)) { + if (actual[action] !== expected) { + mismatches.push({ action, expected, actual: actual[action] ?? Effect.Deny }); + } + } + const ok = mismatches.length === 0; + report[ok ? 'passed' : 'failed']++; + suiteReport.cases.push({ label: testCase.label, ok, mismatches }); + } + report.suites.push(suiteReport); + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + for (const suite of report.suites) { + process.stdout.write(`${suite.name} ${dim(`(${suite.file})`)}\n`); + for (const testCase of suite.cases) { + process.stdout.write(` ${testCase.ok ? green('✓') : red('✗')} ${testCase.label}\n`); + for (const mismatch of testCase.mismatches) { + process.stdout.write( + ` ${red(`${mismatch.action}: expected ${mismatch.expected}, got ${mismatch.actual}`)}\n`, + ); + } + } + } + const summary = `${report.passed} passed, ${report.failed} failed`; + process.stdout.write(`\n${report.failed ? red(summary) : green(summary)}\n`); + } + process.exit(report.failed ? EXIT_TEST_FAILURES : EXIT_OK); +} + +// --------------------------------------------------------------------------- +// `kerberos bundle` +// --------------------------------------------------------------------------- + +async function runBundle(policiesDir, options) { + const outFile = options.out; + if (!outFile) fail('bundle needs --out '); + // Bundles hold serialized documents — never load with a codec here. + const loaded = await loader.loadPolicyDirectory(policiesDir); + const bundle = await loader.writePolicyBundle(outFile, loaded, options.reproducible ? { createdAt: null } : {}); + process.stdout.write( + `wrote ${outFile}\n version: ${bundle.version}\n policies: ${bundle.counts.policies}\n derivedRoles: ${bundle.counts.derivedRoles}\n`, + ); +} + +// --------------------------------------------------------------------------- + +async function main() { + const [command, ...rest] = process.argv.slice(2); + + if (!command || command === '--help' || command === 'help') { + process.stdout.write(USAGE); + process.exit(command ? EXIT_OK : EXIT_USAGE); + } + if (command === '--version') { + process.stdout.write(`${require('../package.json').version}\n`); + process.exit(EXIT_OK); + } + + if (command === 'test') { + const { positional, options } = parseArgs(rest, { schemas: 'value', json: 'boolean' }); + if (positional.length !== 2) fail(`test needs and \n\n${USAGE}`); + await runTests(positional[0], positional[1], options); + return; + } + if (command === 'bundle') { + const { positional, options } = parseArgs(rest, { out: 'value', reproducible: 'boolean' }); + if (positional.length !== 1) fail(`bundle needs \n\n${USAGE}`); + await runBundle(positional[0], options); + return; + } + fail(`unknown command \`${command}\`\n\n${USAGE}`); +} + +main().catch((error) => { + if (error instanceof KerberosLoaderError || error instanceof KerberosImportError) { + fail(error.message); + } + process.stderr.write(`kerberos: ${error?.stack ?? error}\n`); + process.exit(EXIT_USAGE); +}); diff --git a/cerbos.d.ts b/cerbos.d.ts new file mode 100644 index 0000000..9730858 --- /dev/null +++ b/cerbos.d.ts @@ -0,0 +1,79 @@ +/** + * Type definitions for the `@alexify/kerberos/cerbos` subpath — the Cerbos + * policy importer: YAML/JSON policy documents plus a CEL → `$expr` expression + * translator. + * + * The importer refuses to guess: any Cerbos construct outside its supported + * subset throws {@link KerberosImportError} instead of being dropped or + * approximated. Its output is SERIALIZED documents (`{ $expr }` condition + * descriptors) — deserialize each with `deserializePolicy(doc, codec)` from + * the main entry before passing it to the `Kerberos` constructor. + */ + +/** + * Error thrown when a document, expression or YAML construct falls outside + * the importer's supported subset. + */ +export declare class KerberosImportError extends Error { + name: 'KerberosImportError'; + /** 1-based line number, present for YAML parsing errors. */ + line?: number; +} + +/** + * A serialized Kerberos policy document produced by the importer: one of + * `{ resourcePolicy }`, `{ principalPolicy }` or `{ rolePolicy }`, with + * conditions/variables/outputs as `{ $expr }` descriptors. + */ +export type ImportedPolicyDocument = Record; + +/** A serialized derived-roles document (`{ name, definitions }`). */ +export type ImportedDerivedRolesDocument = Record; + +/** + * Importer input: YAML/JSON text (a string may contain multiple `---` + * documents), an already-parsed document object, or an array of either. + */ +export type CerbosImportInput = string | Record | ReadonlyArray>; + +export interface CerbosImportOptions { + /** + * Features to discard instead of importing. `'schemas'` blocks translate by + * default (enforced via the engine's `schemas` option); drop them when you + * have no schema definitions to wire. Everything the importer cannot + * translate faithfully still always throws. + */ + drop?: ReadonlyArray<'schemas'>; +} + +export interface CerbosImportResult { + /** Serialized policy documents, ready for `deserializePolicy(doc, codec)`. */ + policies: ImportedPolicyDocument[]; + /** Serialized derived-roles documents, ready for `deserializePolicy(doc, codec)`. */ + derivedRoles: ImportedDerivedRolesDocument[]; +} + +/** + * Parses a YAML stream (the subset Cerbos policies are written in) into an + * array of documents — one per `---` section, comment-only sections omitted. + * Anchors, aliases, tags, directives and multi-line plain scalars throw. + */ +export declare function parseYamlDocuments(text: string): unknown[]; + +/** + * Translates one CEL expression into a `$expr`-compatible JavaScript + * expression string (for the documented jsep setup: object/ternary/new + * plugins plus `jsep.addUnaryOp('typeof')`). Throws {@link KerberosImportError} + * on CEL constructs with no faithful `$expr` counterpart (macros, `matches()`, + * Cerbos extension functions, `globals`, `request.auxData`, …). + */ +export declare function celToExpr(source: string): string; + +/** + * Imports Cerbos policy documents into Kerberos serialized documents. + * Policies with `disabled: true` are skipped, matching Cerbos's own loader. + */ +export declare function importCerbosPolicies( + input: CerbosImportInput, + options?: CerbosImportOptions, +): CerbosImportResult; diff --git a/cerbos.js b/cerbos.js new file mode 100644 index 0000000..d893728 --- /dev/null +++ b/cerbos.js @@ -0,0 +1 @@ +module.exports = require('./src/cerbos/index.js'); diff --git a/conformance/DIVERGENCES.md b/conformance/DIVERGENCES.md new file mode 100644 index 0000000..342f5f9 --- /dev/null +++ b/conformance/DIVERGENCES.md @@ -0,0 +1,123 @@ +# Where Kerberos.js and Cerbos differ + +"Cerbos-compatible" is a claim about the policy model and the decision/plan semantics — not a promise that every Cerbos feature exists here. This file records the gaps deliberately, so that a conformance failure can be told apart from a known difference. + +Each entry says how it is enforced: **corpus** (a test would fail if it changed), **loader** (`lib/load.js` throws rather than mistranslating), or **documented** (not machine-checked yet). + +## Expression language + +| | | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cerbos** | CEL, with Cerbos extension functions (`hierarchy`, `hasIntersection`, `spiffeID`, …), macros (`exists`, `all`), `timestamp()` / `duration()`. | +| **Kerberos** | JavaScript expressions — either live functions, or `{ $expr }` strings parsed by jsep and walked by an eval-free allowlist interpreter. | +| **Enforcement** | loader — anything outside the shared subset throws `ConformanceUnsupportedError`. | + +This is the largest and most deliberate difference. The conformance corpus is restricted to the intersection (see [README](./README.md#the-shared-expression-subset)); it is not evidence that arbitrary Cerbos policies port over. The `@alexify/kerberos/cerbos` subpath ships a real CEL→`$expr` importer for the translatable subset (macros, `matches()` and extension functions still throw — see the "Importing Cerbos policies" guide), and `importer.test.js` re-runs this whole suite through it. + +## Not implemented + +| Cerbos feature | Status | Enforcement | +| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| **`scopePermissions`** (`REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS`) | Not implemented. Kerberos implements only the `OVERRIDE_PARENT` behaviour: the first policy in the scope chain to decide an action wins. | loader | +| **`exportVariables` / `exportConstants`** (imported variable and constant sets) | Not implemented. Variables and constants are policy-scoped only. | loader | +| **`auxData`** (JWT claims in conditions) | Not implemented. Put the claims you need into `principal.attr` before calling. | documented | +| **Globals** (`G`) and `engine.globals` | Not implemented. Use policy constants (`C`). | documented | +| **Admin API, policy storage drivers, PDP server** | Out of scope by design — Kerberos is a library. Policies come from the constructor or a read-only cache. | documented | + +## Behavioural differences + +### Scope search is always lenient + +Cerbos's `engine.lenientScopeSearch` defaults to `false`, which makes a request naming a scope with no matching policy an error, and it requires the scope chain to have no gaps (if `a.b.c` exists, so must `a.b`, `a` and `""`). + +Kerberos always walks the chain and falls through to whatever it finds, with no gap requirement. Run the conformance PDP with `--set=engine.lenientScopeSearch=true` (as CI does) to compare like for like. + +_Enforcement: documented._ + +### Attribute schemas — same semantics, different wiring + +Implemented since v4: resource policies declare `schemas.principalSchema` / `resourceSchema` refs with `ignoreWhen.actions`, and validation failures surface as Cerbos-shaped `validationErrors` (`{ path, message, source }`); `reject` denies every action, `warn` only reports. The wiring differs by design: Cerbos resolves refs against schema _files_ served from `_schemas/` and picks the level in server config (`schema.enforcement`, default `none`); Kerberos maps refs to validators via the `schemas` engine option (`definitions` accepts JSON Schema / Zod / functions; `enforcement` defaults to `reject` **when the option is set**, and to inert when it is not — matching Cerbos's unconfigured default). The conformance corpus does not carry schemas (the PDP leg would need the files served), so parity is pinned by unit tests (`test/AttributeSchemas.test.js`) mirroring Cerbos's documented behaviour, not by the live-PDP leg. + +_Enforcement: documented (unit tests only)._ + +### Role-policy scope: Cerbos's docs and engine disagree — we follow the engine + +Cerbos's role-policies page calls the `scope` field an "optional **principal** scope", but its rule table places role-policy rows in the resource pass (`PolicyKind: KIND_RESOURCE`), matched against the **resource** scope chain and the resource `policyVersion`. A live 0.55.0 PDP confirms the source. With `RT@acme` allowlisting only `other` and `RT@base` allowlisting `ping`: + +| request | Cerbos | +| ------------------------------------------ | --------------------------------------------------------- | +| `principal.scope: acme`, resource unscoped | `ping` = `ALLOW` — the acme role policy did **not** apply | +| principal unscoped, `resource.scope: acme` | `ping` = `DENY` — the acme role policy **did** apply | + +Kerberos follows the observable engine behaviour. + +_Enforcement: corpus (`suites/scope_walk_test.yaml`, both directions)._ + +### Wildcard subset + +Cerbos compiles patterns with gobwas/glob (`:` separator; a bare `*` is rewritten to `**`), which also accepts `?`, `[...]` and `{a,b}` forms its docs never mention. Kerberos implements exactly the documented subset — bare `*`, segment-scoped `*`, and `**` — and treats anything fancier as literal text. Partial globs in the `roles` field and in `parentRoles` are docs-silent in Cerbos but engine-supported; Kerberos matches the engine (pinned in `suites/wildcards_test.yaml`). + +_Enforcement: corpus + `test/Matching.test.js`._ + +### Condition runtime errors — Cerbos skips the rule, Kerberos fails closed + +Verified on 0.55.0 with a DENY rule whose condition raises at runtime (`R.attr.missing.deep`, a CEL "no such key" and a JS `TypeError` respectively), over a resource policy that otherwise allows the action: + +| | default config | `strictEvaluation: true` | +| ------------ | --------------------------------------------- | ------------------------ | +| **Cerbos** | `EFFECT_ALLOW` — the erroring rule is skipped | `EFFECT_DENY` | +| **Kerberos** | `EFFECT_DENY` | n/a | + +Cerbos's engine page documents this and warns about it in as many words: the expression is _"treated as not satisfied and the evaluation carries on"_, so _"an `EFFECT_DENY` rule could be silently skipped"_. Its **conditions page contradicts this**, claiming that from v0.55 a DENY rule whose condition errors fails closed — the engine page and the v0.55.0 source are right, and the observed behaviour matches them. + +Kerberos has no per-rule skip-on-error mode. The error surfaces through the request-level `onError` option (`'throw'` propagates it, `'deny'` fails closed), and inside a `checkResources` batch a rejected resource is isolated to a fail-closed `EFFECT_DENY` carrying `reason: 'evaluation-error'`. The divergence is therefore in the safe direction, but it is a real difference in decisions. + +_Enforcement: corpus (`suites/conderr_test.yaml`, recorded via `cerbosActions`)._ + +### Plan operators + +Kerberos emits two operators Cerbos has no counterpart for: + +- `opaque` — a condition that could not be planned statically, so the caller must post-filter. Cerbos has no equivalent because CEL residuals are expressed differently. +- `relation` — a ReBAC dependency, materialized by `expandRelationOperands`. + +Conversely, Cerbos passes through _any_ CEL function name as an operator (`contains`, `startsWith`, `hasIntersection`, …), so its operator vocabulary is open-ended rather than a fixed set. + +_Enforcement: corpus — `lib/canonical.js` fails a comparison whose plan contains a Kerberos-only operator instead of silently comparing it._ + +### Filters are compared after canonicalization + +Neither engine promises an operand order — Cerbos emits comparison operands in source order, and the two engines flatten and dedupe `and` / `or` children by their own rules. Filters are therefore compared after sorting the children of `and` / `or` and the two operands of `eq` / `ne`. Nothing else is reordered. + +This is a difference in _representation_, not in meaning, but it means a byte-for-byte plan comparison against Cerbos will fail and should not be attempted. + +_Enforcement: corpus._ + +### Conflict resolution across roles — aligned in v4 + +Recorded here because it is the reason this suite exists, and because it changes decisions for anyone upgrading. + +Kerberos used to be **deny-overrides unconditionally**: any matching DENY won, whatever role it targeted. Cerbos >= 0.41 is **deny-overrides _within_ a principal role, allow-overrides _across_ roles** — its evaluation loop runs once per role and returns the first role that independently allows, which is deliberate anti-lockout behaviour so that holding an extra, less privileged role cannot take away access another role grants. + +Kerberos now implements the Cerbos rule. A DENY only bites when it covers the role carrying the ALLOW — which a wildcard (`roles: ['*']`) always does, and an enumerated role does explicitly. Derived roles are not a dimension of their own: they collapse into the principal roles listed in their `parentRoles`. + +Two traps found while establishing this, worth knowing if you ever re-verify: + +- **The behaviour changed in Cerbos 0.41.0** (0.40.0 returns DENY), coinciding with the rule-table engine rewrite. Cerbos's own docs lagged the code until 0.52.0 and the change was not listed as breaking. +- **`ghcr.io/cerbos/cerbos:latest` is stale and serves 0.40.0.** A parity check against `latest` validates the _old_ semantics and hides this entirely, which is why CI pins an explicit version. + +_Enforcement: corpus (`suites/ticket_test.yaml`, all four combinations), plus `test/ConflictResolution.test.js` and the multi-role principals in `test/PlanParity.test.js`._ + +## Documentation audit (Cerbos 0.55.0) + +The semantics implemented here were verified two ways: empirically against a live `ghcr.io/cerbos/cerbos:0.55.0` PDP (the conformance suites), and against Cerbos's documentation plus its v0.55.0 source. The doc audit confirmed, with citations: + +1. per-role conflict resolution (deny within a role, allow across roles, order-independent) — evaluation page + `internal/ruletable/check.go`; +2. derived-role rules collapsing into their `parentRoles` — `internal/ruletable/ruletable.go` ("merge derived roles as roles"); +3. role policies as a non-granting narrowing constraint requiring a resource policy — role-policies page, verbatim; +4. union across the principal's roles — by composition of (1) and per-role narrowing; +5. strictly per-role "no role policy = unrestricted" — `appendRolePolicyDenies` (the cross-bucket case is pinned in `scope_walk_test.yaml`); +6. principal-policy decisions being final — evaluation page, verbatim; +7. `parentRoles` intersection along the chain — role-policies page + recursive closure in the source. + +Where the docs and the engine disagree (role-policy scope source; the conditions page's 0.55 fail-closed claim), the engine wins and the disagreement is recorded above. diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..632e80d --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,75 @@ +# Cerbos conformance suite + +Kerberos.js claims to be Cerbos-compatible. This directory turns that claim into a check. + +One corpus of policies and expectations is executed by **both** engines: + +- always against Kerberos, from the recorded expectations — no Docker, no network, runs anywhere; +- additionally against a **real Cerbos PDP** when `CERBOS_URL` is set, which CI does. That second leg matters: without it a wrong expectation could make the two engines look compatible when neither matches Cerbos. + +`test/PlanParity.test.js` proves Kerberos's runtime and planner agree with _each other_. This suite is the other half — that both agree with _Cerbos_. + +## Running it + +```bash +pnpm test:conformance +``` + +Against a live PDP: + +```bash +docker run --rm -d --name cerbos -p 3592:3592 \ + -v "$PWD/conformance/policies:/policies:ro" \ + ghcr.io/cerbos/cerbos:0.55.0 server \ + --set=storage.disk.directory=/policies \ + --set=engine.lenientScopeSearch=true + +CERBOS_URL=http://localhost:3592 pnpm test:conformance +``` + +Because the corpus is written in Cerbos's own formats, the policies can also be checked by Cerbos directly: + +```bash +docker run --rm -v "$PWD/conformance/policies:/policies:ro" \ + ghcr.io/cerbos/cerbos:0.55.0 compile --skip-tests /policies +``` + +## Layout + +| Path | What it is | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `policies/*.yaml` | The shared corpus, in Cerbos policy format (`apiVersion: api.cerbos.dev/v1`). Served verbatim to a real PDP. | +| `suites/*_test.yaml` | Decision expectations, in Cerbos's [`TestSuite`](https://api.cerbos.dev/latest/cerbos/policy/v1/TestSuite.schema.json) format. | +| `suites/*_plan.yaml` | Query-plan expectations, shaped after Cerbos's internal `QueryPlannerTestSuite` golden files. | +| `importer.test.js` | Re-runs every suite against an engine built via the public `/cerbos` importer (real YAML parsing + CEL translation). | +| `lib/load.js` | Maps Cerbos policy documents onto Kerberos policies (test-harness structural mapper, not the importer). | +| `lib/suite.js` | Expands suite fixtures into flat cases. | +| `lib/canonical.js` | Canonicalizes plan filters before comparison. | +| `lib/pdp.js` | Live-PDP HTTP client. | +| `DIVERGENCES.md` | Where the two engines genuinely differ, and why. | + +## The shared expression subset + +Cerbos conditions are **CEL**; Kerberos conditions are JavaScript expressions parsed by jsep and walked by an allowlist interpreter. There is deliberately no CEL parser in this harness — the real importer lives in the package, on the [`@alexify/kerberos/cerbos` subpath](../docs/guide/cerbos-import.md), and `importer.test.js` re-runs every suite through it so the two loading paths cannot drift. + +Instead the corpus is restricted to expressions that are **simultaneously valid CEL and valid Kerberos `$expr`**, so one source string feeds both engines unchanged: + +```yaml +condition: + match: + expr: R.attr.ownerId == P.id +``` + +That intersection covers `P` / `R` / `V` / `C` member access, string and number literals, `== != < <= > >=`, `&& || !`, and the ternary. It does **not** cover CEL macros (`exists`, `all`), `in`, `timestamp()` / `duration()`, or any CEL extension function — those are deliberately out of the corpus rather than silently mistranslated. + +`lib/load.js` is a structural mapper, and it **refuses to guess**: any construct outside the supported subset throws `ConformanceUnsupportedError` instead of being dropped. A skipped rule would turn a real conformance failure into a false pass, which is the one outcome this suite must never produce. + +## Adding a case + +1. Put the policy in `policies/` using Cerbos document format and the shared expression subset. +2. Add expectations to a `suites/*_test.yaml` (decisions) or `suites/*_plan.yaml` (plans). +3. Run `pnpm test:conformance`. If it disagrees with Kerberos, decide which is wrong — the corpus or the engine — and if it turns out to be a genuine semantic difference, record it in [`DIVERGENCES.md`](./DIVERGENCES.md) rather than bending the expectation to match. + +Plan filters are compared after canonicalization, because neither engine promises an operand order: `and` / `or` children and the two operands of `eq` / `ne` are sorted by a stable serialization. Nothing else is reordered. A plan containing Kerberos's own `opaque` or `relation` operands has no Cerbos counterpart and fails the scope check rather than being silently compared. + +This directory is not published to npm — `package.json`'s `files` field is an explicit allowlist. diff --git a/conformance/decisions.test.js b/conformance/decisions.test.js new file mode 100644 index 0000000..aeffbdf --- /dev/null +++ b/conformance/decisions.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const { before, describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); +const path = require('node:path'); + +const { Kerberos, createSafeExprCodec, deserializePolicy } = require('../index.js'); +const jsep = require('jsep'); +const jsepObject = require('@jsep-plugin/object'); +const jsepTernary = require('@jsep-plugin/ternary'); +const jsepNew = require('@jsep-plugin/new'); + +const { loadCorpus } = require('./lib/load.js'); +const { loadSuites } = require('./lib/suite.js'); +const pdp = require('./lib/pdp.js'); + +const POLICY_DIR = path.join(__dirname, 'policies'); +const SUITE_DIR = path.join(__dirname, 'suites'); + +// Set CERBOS_URL to additionally run every case against a real Cerbos PDP +// serving conformance/policies. Without it the suite still runs in full against +// the expectations recorded in the corpus. +const CERBOS_URL = process.env.CERBOS_URL; + +jsep.plugins.register(jsepObject.default ?? jsepObject, jsepTernary.default ?? jsepTernary, jsepNew.default ?? jsepNew); +jsep.addUnaryOp('typeof'); +const codec = createSafeExprCodec({ jsep }); + +const { policies, derivedRoles } = loadCorpus(POLICY_DIR); +const kerberos = new Kerberos( + policies.map((policy) => deserializePolicy(policy, codec)), + derivedRoles.map((roles) => deserializePolicy(roles, codec)), +); + +const suites = loadSuites(SUITE_DIR); + +describe('Cerbos conformance — decisions', () => { + if (CERBOS_URL) { + before(async () => { + await pdp.waitUntilReady(CERBOS_URL); + }); + } + + it('loads the whole corpus (nothing silently skipped)', () => { + // The loader throws on anything outside the supported subset, so reaching + // here means every document translated. Guard the counts too: a corpus file + // that stopped being picked up would otherwise pass vacuously. + assert.ok(policies.length >= 2, `expected corpus policies, got ${policies.length}`); + assert.ok(derivedRoles.length >= 1, `expected derived roles, got ${derivedRoles.length}`); + assert.ok(suites.length >= 1, 'expected at least one test suite'); + assert.ok( + suites.every((entry) => entry.cases.length > 0), + 'every suite must expand to at least one case', + ); + }); + + for (const { file, cases } of suites) { + describe(file, () => { + for (const testCase of cases) { + it(testCase.label, async () => { + const { results } = await kerberos.checkResources({ + principal: testCase.principal, + resources: [{ resource: testCase.resource, actions: testCase.actions }], + }); + const actual = results[0].actions; + + assert.deepEqual( + actual, + testCase.expected, + `Kerberos decision differs from the corpus expectation\n` + + ` principal: ${JSON.stringify(testCase.principal)}\n` + + ` resource: ${JSON.stringify(testCase.resource)}`, + ); + + if (!CERBOS_URL) return; + + // The corpus expectation is only half the claim — assert the live PDP + // agrees with it too, so a wrong expectation cannot make both engines + // look compatible. + const [cerbosActions] = await pdp.checkResources(CERBOS_URL, { + principal: testCase.principal, + resources: [{ resource: testCase.resource, actions: testCase.actions }], + requestId: `${testCase.suite}/${testCase.test}`, + }); + const cerbosSubset = Object.fromEntries( + Object.keys(testCase.expected).map((action) => [action, cerbosActions[action]]), + ); + + if (testCase.cerbosExpected) { + // A recorded divergence (DIVERGENCES.md): pin BOTH engines to their + // own verified behaviour, and fail if they ever agree again — a + // stale divergence entry is as misleading as an undocumented one. + assert.deepEqual( + cerbosSubset, + testCase.cerbosExpected, + 'live Cerbos PDP no longer matches the recorded divergence — re-verify and update DIVERGENCES.md', + ); + assert.notDeepEqual( + actual, + cerbosSubset, + 'the engines now agree here, so this is no longer a divergence — drop `cerbosActions` and the DIVERGENCES.md entry', + ); + return; + } + + assert.deepEqual(cerbosSubset, testCase.expected, 'live Cerbos PDP differs from the corpus expectation'); + assert.deepEqual(actual, cerbosSubset, 'Kerberos and the live Cerbos PDP disagree'); + }); + } + }); + } +}); diff --git a/conformance/importer.test.js b/conformance/importer.test.js new file mode 100644 index 0000000..4e5dca1 --- /dev/null +++ b/conformance/importer.test.js @@ -0,0 +1,117 @@ +'use strict'; + +/** + * Runs the whole conformance corpus through the PUBLIC Cerbos importer + * (`@alexify/kerberos/cerbos`) instead of the structural test loader + * (lib/load.js), and asserts that the resulting engine decides and plans + * exactly like the corpus expectations. + * + * This is the compatibility claim of the importer in executable form: real + * Cerbos policy YAML, translated end-to-end (YAML parsing included, CEL + * conditions translated to `$expr` rather than passed through), produces the + * decisions a live Cerbos PDP was verified to produce. No PDP is needed here — + * decisions.test.js/plans.test.js already pin these expectations against one; + * this suite pins the importer against those same expectations. + */ + +const { describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { Kerberos, createSafeExprCodec, deserializePolicy } = require('../index.js'); +const { importCerbosPolicies } = require('../cerbos.js'); +const jsep = require('jsep'); +const jsepObject = require('@jsep-plugin/object'); +const jsepTernary = require('@jsep-plugin/ternary'); +const jsepNew = require('@jsep-plugin/new'); + +const { loadSuites } = require('./lib/suite.js'); +const { canonicalizeFilter, usesKerberosOnlyOperators } = require('./lib/canonical.js'); + +jsep.plugins.register(jsepObject.default ?? jsepObject, jsepTernary.default ?? jsepTernary, jsepNew.default ?? jsepNew); +jsep.addUnaryOp('typeof'); +const codec = createSafeExprCodec({ jsep }); + +const POLICY_DIR = path.join(__dirname, 'policies'); +const SUITE_DIR = path.join(__dirname, 'suites'); + +const policies = []; +const derivedRoles = []; +for (const file of fs.readdirSync(POLICY_DIR).sort()) { + if (!/\.ya?ml$/.test(file)) continue; + const imported = importCerbosPolicies(fs.readFileSync(path.join(POLICY_DIR, file), 'utf8')); + policies.push(...imported.policies); + derivedRoles.push(...imported.derivedRoles); +} + +const kerberos = new Kerberos( + policies.map((policy) => deserializePolicy(policy, codec)), + derivedRoles.map((roles) => deserializePolicy(roles, codec)), +); + +describe('Cerbos conformance — via the public importer', () => { + it('imports the whole corpus (nothing silently skipped)', () => { + assert.ok(policies.length >= 2, `expected corpus policies, got ${policies.length}`); + assert.ok(derivedRoles.length >= 1, `expected corpus derived roles, got ${derivedRoles.length}`); + }); + + describe('decisions', () => { + for (const { file, cases } of loadSuites(SUITE_DIR)) { + describe(file, () => { + for (const testCase of cases) { + it(testCase.label, async () => { + const { results } = await kerberos.checkResources({ + principal: testCase.principal, + resources: [{ resource: testCase.resource, actions: testCase.actions }], + }); + assert.deepEqual( + results[0].actions, + testCase.expected, + 'importer-loaded engine differs from the corpus expectation', + ); + }); + } + }); + } + }); + + describe('query plans', () => { + const planSuites = fs + .readdirSync(SUITE_DIR) + .filter((file) => file.endsWith('_plan.yaml')) + .sort() + .map((file) => ({ + file, + suite: importParseYaml(fs.readFileSync(path.join(SUITE_DIR, file), 'utf8')), + })); + + for (const { file, suite } of planSuites) { + describe(file, () => { + for (const [index, test] of (suite.tests ?? []).entries()) { + const actions = test.actions ?? (test.action ? [test.action] : null); + it(test.description ?? `tests[${index}]`, async () => { + const response = await kerberos.planResources({ + principal: suite.principal, + resource: test.resource, + ...(actions.length === 1 ? { action: actions[0] } : { actions }), + }); + assert.equal(usesKerberosOnlyOperators(response.filter), false); + assert.deepEqual( + canonicalizeFilter(response.filter), + canonicalizeFilter(test.want.filter), + `importer-loaded plan differs from the corpus expectation\n actual: ${JSON.stringify(response.filter)}`, + ); + }); + } + }); + } + }); +}); + +/** The suites are plain YAML too — read them with the importer's own parser. */ +function importParseYaml(text) { + const { parseYamlDocuments } = require('../src/cerbos/yaml.js'); + const [doc] = parseYamlDocuments(text); + return doc; +} diff --git a/conformance/lib/canonical.js b/conformance/lib/canonical.js new file mode 100644 index 0000000..cf3b426 --- /dev/null +++ b/conformance/lib/canonical.js @@ -0,0 +1,72 @@ +'use strict'; + +/** + * Canonicalizes a planResources filter so two engines can be compared on + * meaning rather than on incidental syntax. + * + * Neither engine promises a normal form for operand order: Cerbos emits + * comparison operands in source order, and both engines flatten/dedupe + * `and`/`or` children by their own rules. Deep-equalling raw JSON therefore + * produces false failures. This sorts the children of commutative operators by + * a stable serialization, which is meaning-preserving for the operators listed + * below and for nothing else — anything not listed keeps its operand order. + */ + +// Commutative *and* associative: children may be reordered freely. +const REORDERABLE = new Set(['and', 'or']); +// Commutative binary comparisons: the two operands may be swapped. +const SWAPPABLE = new Set(['eq', 'ne']); + +// Operators Kerberos emits that have no Cerbos counterpart. A plan containing +// one is out of scope for parity rather than a failure. +const KERBEROS_ONLY = new Set(['opaque', 'relation']); + +function stableKey(node) { + return JSON.stringify(node); +} + +function canonicalizeOperand(operand) { + if (!operand || typeof operand !== 'object') return operand; + + if (operand.expression) { + const { operator, operands = [] } = operand.expression; + let children = operands.map(canonicalizeOperand); + if (REORDERABLE.has(operator) || (SWAPPABLE.has(operator) && children.length === 2)) { + children = [...children].sort((a, b) => (stableKey(a) < stableKey(b) ? -1 : stableKey(a) > stableKey(b) ? 1 : 0)); + } + return { expression: { operator, operands: children } }; + } + + // `value` / `variable` leaves are already canonical; re-wrap so key order in + // the serialization cannot differ. + if ('variable' in operand) return { variable: operand.variable }; + if ('value' in operand) return { value: operand.value }; + return operand; +} + +function canonicalizeFilter(filter) { + if (!filter || typeof filter !== 'object') return filter; + if (filter.condition === undefined) return { kind: filter.kind }; + return { kind: filter.kind, condition: canonicalizeOperand(filter.condition) }; +} + +/** Collects every operator appearing in a filter, for scope checks. */ +function collectOperators(operand, into = new Set()) { + if (!operand || typeof operand !== 'object') return into; + if (operand.expression) { + into.add(operand.expression.operator); + for (const child of operand.expression.operands ?? []) collectOperators(child, into); + } + return into; +} + +/** True when the plan uses a Kerberos extension Cerbos cannot express. */ +function usesKerberosOnlyOperators(filter) { + if (!filter?.condition) return false; + for (const operator of collectOperators(filter.condition)) { + if (KERBEROS_ONLY.has(operator)) return true; + } + return false; +} + +module.exports = { canonicalizeFilter, collectOperators, usesKerberosOnlyOperators }; diff --git a/conformance/lib/load.js b/conformance/lib/load.js new file mode 100644 index 0000000..2f59a38 --- /dev/null +++ b/conformance/lib/load.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Loads the shared corpus — Cerbos policy documents — into Kerberos policies. + * + * This is deliberately NOT a Cerbos importer (that is a separate, larger piece + * of work: it needs a real CEL parser). It is a structural mapper that relies on + * the two document formats being nearly identical, and it refuses to guess: + * anything outside the supported subset throws rather than being dropped or + * approximated, because a silently-skipped rule would turn a conformance + * failure into a false pass. + * + * Conditions are passed through as `{ $expr }` strings unchanged. The corpus is + * restricted to the CEL ∩ jsep subset (see conformance/README.md), so the same + * source text is evaluated by both engines. A string outside that subset fails + * loudly on the Kerberos side as `KerberosExprError`. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const YAML = require('yaml'); + +class ConformanceUnsupportedError extends Error { + constructor(message) { + super(message); + this.name = 'ConformanceUnsupportedError'; + } +} + +const POLICY_KINDS = ['resourcePolicy', 'principalPolicy', 'rolePolicy', 'derivedRoles']; + +// Cerbos document keys that carry no meaning for Kerberos and can be dropped. +const IGNORED_TOP_LEVEL = new Set(['apiVersion', 'description', 'metadata', 'disabled']); + +// Cerbos features Kerberos deliberately does not implement. Listing them +// explicitly (instead of falling through to a generic "unknown key") keeps the +// error message actionable and doubles as documentation of the gap. +const KNOWN_UNSUPPORTED = { + schemas: 'attribute schema enforcement', + scopePermissions: 'scopePermissions (REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS)', + exportVariables: 'exported variable sets', + exportConstants: 'exported constant sets', +}; + +function unsupported(what, where) { + throw new ConformanceUnsupportedError(`${where}: ${what} is not supported by the conformance corpus`); +} + +/** Cerbos writes `{ expr }` / `{ all: { of: [...] } }`; Kerberos wants `{ $expr }` / `{ all: [...] }`. */ +function translateMatch(match, where) { + if (match === null || typeof match !== 'object') unsupported(`malformed condition (${typeof match})`, where); + + const keys = Object.keys(match); + if (keys.length !== 1) unsupported(`condition with ${keys.length} keys (${keys.join(', ')})`, where); + const [key] = keys; + + if (key === 'expr') { + if (typeof match.expr !== 'string') unsupported('non-string expr', where); + return { $expr: match.expr }; + } + if (key === 'all' || key === 'any' || key === 'none') { + const branch = match[key]; + const list = branch && typeof branch === 'object' && Array.isArray(branch.of) ? branch.of : null; + if (!list) unsupported(`\`${key}\` without an \`of:\` list`, where); + return { [key]: list.map((entry, i) => translateMatch(entry, `${where}.${key}[${i}]`)) }; + } + return unsupported(`condition operator \`${key}\``, where); +} + +function translateCondition(condition, where) { + if (condition === undefined) return undefined; + if (!condition || typeof condition !== 'object' || !('match' in condition)) { + unsupported('condition without `match`', where); + } + return { match: translateMatch(condition.match, `${where}.match`) }; +} + +/** Cerbos output expressions are bare CEL strings; Kerberos wants `{ $expr }`. */ +function translateOutput(output, where) { + if (output === undefined) return undefined; + if (typeof output === 'string') return { $expr: output }; + if (output && typeof output === 'object' && output.when) { + const when = {}; + for (const [key, value] of Object.entries(output.when)) { + if (key !== 'ruleActivated' && key !== 'conditionNotMet') unsupported(`output.when.${key}`, where); + if (typeof value !== 'string') unsupported(`non-string output.when.${key}`, where); + when[key] = { $expr: value }; + } + return { when }; + } + return unsupported('unrecognized output shape', where); +} + +/** `{ local: { name: 'expr' }, import: [...] }` → `{ name: { $expr } }`. */ +function translateBindings(bindings, where, label) { + if (bindings === undefined) return undefined; + if (Array.isArray(bindings.import) && bindings.import.length > 0) { + unsupported(`imported ${label} sets`, where); + } + const local = bindings.local ?? {}; + const out = {}; + for (const [name, value] of Object.entries(local)) { + // Constants are literal JSON in both engines; variables are expressions. + out[name] = label === 'constant' ? value : { $expr: value }; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function assertNoUnsupportedKeys(body, where, allowed) { + for (const key of Object.keys(body)) { + if (KNOWN_UNSUPPORTED[key]) unsupported(KNOWN_UNSUPPORTED[key], where); + if (!allowed.has(key)) unsupported(`unrecognized key \`${key}\``, where); + } +} + +function shared(body, where) { + return { + ...(translateBindings(body.variables, where, 'variable') && { + variables: translateBindings(body.variables, where, 'variable'), + }), + ...(translateBindings(body.constants, where, 'constant') && { + constants: translateBindings(body.constants, where, 'constant'), + }), + }; +} + +const TRANSLATORS = { + resourcePolicy(body, where) { + assertNoUnsupportedKeys( + body, + where, + new Set(['version', 'resource', 'scope', 'rules', 'importDerivedRoles', 'variables', 'constants']), + ); + return { + resourcePolicy: { + version: body.version, + resource: body.resource, + ...(body.scope !== undefined && { scope: body.scope }), + ...(body.importDerivedRoles && { importDerivedRoles: body.importDerivedRoles }), + ...shared(body, where), + rules: body.rules.map((rule, i) => { + const at = `${where}.rules[${i}]`; + return { + ...(rule.name && { name: rule.name }), + actions: rule.actions, + effect: rule.effect, + ...(rule.roles && { roles: rule.roles }), + ...(rule.derivedRoles && { derivedRoles: rule.derivedRoles }), + ...(translateCondition(rule.condition, at) && { condition: translateCondition(rule.condition, at) }), + ...(translateOutput(rule.output, at) && { output: translateOutput(rule.output, at) }), + }; + }), + }, + }; + }, + + principalPolicy(body, where) { + assertNoUnsupportedKeys(body, where, new Set(['principal', 'version', 'scope', 'rules', 'variables', 'constants'])); + return { + principalPolicy: { + principal: body.principal, + version: body.version, + ...(body.scope !== undefined && { scope: body.scope }), + ...shared(body, where), + rules: body.rules.map((rule, i) => ({ + resource: rule.resource, + actions: rule.actions.map((action, j) => { + const at = `${where}.rules[${i}].actions[${j}]`; + return { + ...(action.name && { name: action.name }), + action: action.action, + effect: action.effect, + ...(translateCondition(action.condition, at) && { condition: translateCondition(action.condition, at) }), + ...(translateOutput(action.output, at) && { output: translateOutput(action.output, at) }), + }; + }), + })), + }, + }; + }, + + rolePolicy(body, where) { + assertNoUnsupportedKeys( + body, + where, + new Set(['role', 'version', 'scope', 'parentRoles', 'rules', 'variables', 'constants']), + ); + return { + rolePolicy: { + role: body.role, + // Cerbos role policies have no `version`; Kerberos requires one. + version: body.version ?? 'default', + ...(body.scope !== undefined && { scope: body.scope }), + ...(body.parentRoles && { parentRoles: body.parentRoles }), + ...shared(body, where), + rules: body.rules.map((rule, i) => { + const at = `${where}.rules[${i}]`; + return { + ...(rule.name && { name: rule.name }), + resource: rule.resource, + allowActions: rule.allowActions, + ...(translateCondition(rule.condition, at) && { condition: translateCondition(rule.condition, at) }), + }; + }), + }, + }; + }, + + derivedRoles(body, where) { + assertNoUnsupportedKeys(body, where, new Set(['name', 'definitions', 'variables', 'constants'])); + return { + name: body.name, + ...shared(body, where), + definitions: body.definitions.map((def, i) => { + const at = `${where}.definitions[${i}]`; + return { + name: def.name, + parentRoles: def.parentRoles, + ...(translateCondition(def.condition, at) && { condition: translateCondition(def.condition, at) }), + }; + }), + }; + }, +}; + +/** Translates one parsed Cerbos document into `{ kind, document }`. */ +function translateDocument(doc, where) { + for (const key of Object.keys(doc)) { + if (IGNORED_TOP_LEVEL.has(key) || POLICY_KINDS.includes(key)) continue; + if (KNOWN_UNSUPPORTED[key]) unsupported(KNOWN_UNSUPPORTED[key], where); + if (key === 'variables' || key === 'constants') { + unsupported('top-level (legacy) variables/constants — use the policy-scoped form', where); + } + unsupported(`unrecognized top-level key \`${key}\``, where); + } + + const kind = POLICY_KINDS.find((candidate) => doc[candidate] !== undefined); + if (!kind) unsupported('document declares no policy body', where); + return { kind, document: TRANSLATORS[kind](doc[kind], `${where}.${kind}`) }; +} + +/** + * Reads every policy document in `dir` and returns Kerberos constructor + * arguments plus the raw Cerbos documents (which the live-PDP run serves). + */ +function loadCorpus(dir) { + const policies = []; + const derivedRoles = []; + const raw = []; + + for (const file of fs.readdirSync(dir).sort()) { + if (!/\.ya?ml$/.test(file)) continue; + const text = fs.readFileSync(path.join(dir, file), 'utf8'); + const documents = YAML.parseAllDocuments(text).filter((doc) => doc.toJS() !== null); + // Cerbos rejects a policy file carrying more than one YAML document + // ("more than one YAML document detected"). Refuse it here too, so the + // offline run cannot pass on a corpus a real PDP would not even load. + if (documents.length > 1) { + throw new ConformanceUnsupportedError( + `${file}: more than one YAML document in a policy file — Cerbos loads one policy per file`, + ); + } + for (const [index, doc] of documents.entries()) { + const parsed = doc.toJS(); + if (!parsed) continue; + const where = `${file}[${index}]`; + const { kind, document } = translateDocument(parsed, where); + raw.push({ file, document: parsed }); + if (kind === 'derivedRoles') derivedRoles.push(document); + else policies.push(document); + } + } + + return { policies, derivedRoles, raw }; +} + +module.exports = { ConformanceUnsupportedError, loadCorpus, translateDocument }; diff --git a/conformance/lib/pdp.js b/conformance/lib/pdp.js new file mode 100644 index 0000000..4f8649b --- /dev/null +++ b/conformance/lib/pdp.js @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Thin client for a live Cerbos PDP, used only when CERBOS_URL is set. + * + * Kept dependency-free (global fetch, Node >= 18) and deliberately dumb: it + * shapes the request, and every comparison happens in the test file so that a + * divergence is reported as data rather than hidden behind a helper. + */ + +const DEFAULT_TIMEOUT_MS = 10_000; + +async function post(baseUrl, endpoint, body, timeoutMs = DEFAULT_TIMEOUT_MS) { + const response = await fetch(`${baseUrl.replace(/\/$/, '')}${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Cerbos ${endpoint} responded ${response.status}: ${text.slice(0, 500)}`); + } + return JSON.parse(text); +} + +/** Polls `/_cerbos/health` until the PDP reports SERVING. */ +async function waitUntilReady(baseUrl, { attempts = 60, delayMs = 1000 } = {}) { + let lastError; + for (let i = 0; i < attempts; i += 1) { + try { + const response = await fetch(`${baseUrl.replace(/\/$/, '')}/_cerbos/health`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok && (await response.text()).includes('SERVING')) return true; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + throw new Error(`Cerbos at ${baseUrl} never became ready: ${lastError?.message ?? 'no SERVING response'}`); +} + +/** POST /api/check/resources — note `actions` is per resource entry, not top level. */ +async function checkResources(baseUrl, { principal, resources, requestId }) { + const body = { + ...(requestId && { requestId }), + principal, + resources: resources.map(({ resource, actions }) => ({ actions, resource })), + }; + const response = await post(baseUrl, '/api/check/resources', body); + return (response.results ?? []).map((result) => result.actions ?? {}); +} + +/** POST /api/plan/resources — `resource` here carries no `id`. */ +async function planResources(baseUrl, { principal, resource, actions, requestId }) { + const response = await post(baseUrl, '/api/plan/resources', { + ...(requestId && { requestId }), + principal, + resource, + actions, + }); + return response.filter ?? null; +} + +module.exports = { checkResources, planResources, waitUntilReady }; diff --git a/conformance/lib/suite.js b/conformance/lib/suite.js new file mode 100644 index 0000000..a460387 --- /dev/null +++ b/conformance/lib/suite.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Reads Cerbos TestSuite documents and expands them into flat cases. + * + * A suite entry may name a single `principal`/`resource` or lists of them; the + * cross product is expanded here so both the Kerberos run and the live-PDP run + * iterate exactly the same cases in the same order. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const YAML = require('yaml'); + +function resolveRefs(kind, entry, fixtures, where) { + const single = entry[kind]; + const many = entry[`${kind}s`]; + const names = single !== undefined ? [single] : Array.isArray(many) ? many : null; + if (!names || names.length === 0) { + throw new Error(`${where}: expectation names neither \`${kind}\` nor \`${kind}s\``); + } + return names.map((name) => { + if (!fixtures[name]) throw new Error(`${where}: unknown ${kind} fixture \`${name}\``); + return { name, value: fixtures[name] }; + }); +} + +/** @returns {Array<{suite, test, principalName, resourceName, principal, resource, actions, expected}>} */ +function expandSuite(suite, file) { + const cases = []; + for (const [testIndex, test] of (suite.tests ?? []).entries()) { + const where = `${file} › ${test.name ?? `tests[${testIndex}]`}`; + if (test.skip) continue; + const inputActions = test.input?.actions; + if (!Array.isArray(inputActions) || inputActions.length === 0) { + throw new Error(`${where}: input.actions is required`); + } + + for (const [expIndex, expectation] of (test.expected ?? []).entries()) { + const at = `${where} › expected[${expIndex}]`; + const principals = resolveRefs('principal', expectation, suite.principals ?? {}, at); + const resources = resolveRefs('resource', expectation, suite.resources ?? {}, at); + if (!expectation.actions || Object.keys(expectation.actions).length === 0) { + throw new Error(`${at}: expectation carries no actions`); + } + + for (const principal of principals) { + for (const resource of resources) { + cases.push({ + suite: suite.name, + test: test.name ?? `tests[${testIndex}]`, + label: `${test.name ?? testIndex} [${principal.name} → ${resource.name}]`, + principalName: principal.name, + resourceName: resource.name, + principal: principal.value, + resource: resource.value, + actions: Object.keys(expectation.actions), + expected: expectation.actions, + // Present only for a recorded divergence: what a real Cerbos PDP + // returns instead. See DIVERGENCES.md. + cerbosExpected: expectation.cerbosActions ?? null, + }); + } + } + } + } + return cases; +} + +function loadSuites(dir, suffix = '_test.yaml') { + const suites = []; + for (const file of fs.readdirSync(dir).sort()) { + if (!file.endsWith(suffix)) continue; + const parsed = YAML.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + suites.push({ file, suite: parsed, cases: expandSuite(parsed, file) }); + } + return suites; +} + +module.exports = { expandSuite, loadSuites }; diff --git a/conformance/plans.test.js b/conformance/plans.test.js new file mode 100644 index 0000000..0c8af1f --- /dev/null +++ b/conformance/plans.test.js @@ -0,0 +1,102 @@ +'use strict'; + +const { before, describe, it } = require('node:test'); +const { strict: assert } = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const YAML = require('yaml'); + +const { Kerberos, createSafeExprCodec, deserializePolicy } = require('../index.js'); +const jsep = require('jsep'); +const jsepObject = require('@jsep-plugin/object'); +const jsepTernary = require('@jsep-plugin/ternary'); +const jsepNew = require('@jsep-plugin/new'); + +const { loadCorpus } = require('./lib/load.js'); +const { canonicalizeFilter, usesKerberosOnlyOperators } = require('./lib/canonical.js'); +const pdp = require('./lib/pdp.js'); + +const CERBOS_URL = process.env.CERBOS_URL; + +jsep.plugins.register(jsepObject.default ?? jsepObject, jsepTernary.default ?? jsepTernary, jsepNew.default ?? jsepNew); +jsep.addUnaryOp('typeof'); +const codec = createSafeExprCodec({ jsep }); + +const { policies, derivedRoles } = loadCorpus(path.join(__dirname, 'policies')); +const kerberos = new Kerberos( + policies.map((policy) => deserializePolicy(policy, codec)), + derivedRoles.map((roles) => deserializePolicy(roles, codec)), +); + +const SUITE_DIR = path.join(__dirname, 'suites'); +const planSuites = fs + .readdirSync(SUITE_DIR) + .filter((file) => file.endsWith('_plan.yaml')) + .sort() + .map((file) => ({ file, suite: YAML.parse(fs.readFileSync(path.join(SUITE_DIR, file), 'utf8')) })); + +describe('Cerbos conformance — query plans', () => { + if (CERBOS_URL) { + before(async () => { + await pdp.waitUntilReady(CERBOS_URL); + }); + } + + it('found plan suites to run', () => { + assert.ok(planSuites.length > 0, 'no *_plan.yaml suites found'); + assert.ok( + planSuites.every((entry) => (entry.suite.tests ?? []).length > 0), + 'every plan suite must declare tests', + ); + }); + + for (const { file, suite } of planSuites) { + describe(file, () => { + for (const [index, test] of (suite.tests ?? []).entries()) { + const actions = test.actions ?? (test.action ? [test.action] : null); + const label = test.description ?? `tests[${index}]`; + + it(label, async () => { + assert.ok(actions, `${label}: declares neither \`action\` nor \`actions\``); + + const response = await kerberos.planResources({ + principal: suite.principal, + resource: test.resource, + ...(actions.length === 1 ? { action: actions[0] } : { actions }), + }); + + assert.equal( + usesKerberosOnlyOperators(response.filter), + false, + 'plan uses a Kerberos-only operator (opaque/relation) and cannot be compared to Cerbos', + ); + + assert.deepEqual( + canonicalizeFilter(response.filter), + canonicalizeFilter(test.want.filter), + `Kerberos filter differs from the corpus expectation\n actual: ${JSON.stringify(response.filter)}`, + ); + + if (!CERBOS_URL) return; + + const cerbosFilter = await pdp.planResources(CERBOS_URL, { + principal: suite.principal, + resource: test.resource, + actions, + requestId: `plan/${file}/${index}`, + }); + assert.deepEqual( + canonicalizeFilter(cerbosFilter), + canonicalizeFilter(test.want.filter), + 'live Cerbos PDP differs from the corpus expectation', + ); + assert.deepEqual( + canonicalizeFilter(response.filter), + canonicalizeFilter(cerbosFilter), + 'Kerberos and the live Cerbos PDP produce different filters', + ); + }); + } + }); + } +}); diff --git a/conformance/policies/conderr.yaml b/conformance/policies/conderr.yaml new file mode 100644 index 0000000..30ef3ab --- /dev/null +++ b/conformance/policies/conderr.yaml @@ -0,0 +1,23 @@ +--- +# Condition-error semantics probe. +# +# `R.attr.missing.deep` is a runtime error in BOTH engines: a CEL "no such +# key" in Cerbos, a TypeError in Kerberos. What each does with that error is +# a recorded divergence — see DIVERGENCES.md. +apiVersion: 'api.cerbos.dev/v1' +resourcePolicy: + version: default + resource: conderr + rules: + - name: anyone-may-view + actions: ['view'] + effect: EFFECT_ALLOW + roles: ['*'] + + - name: deny-with-erroring-condition + actions: ['view'] + effect: EFFECT_DENY + roles: ['*'] + condition: + match: + expr: R.attr.missing.deep == true diff --git a/conformance/policies/crossbucket_role_ra.yaml b/conformance/policies/crossbucket_role_ra.yaml new file mode 100644 index 0000000..66e2de3 --- /dev/null +++ b/conformance/policies/crossbucket_role_ra.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RA + rules: + - resource: y + allowActions: ['other'] diff --git a/conformance/policies/crossbucket_role_rb.yaml b/conformance/policies/crossbucket_role_rb.yaml new file mode 100644 index 0000000..b5e4fbb --- /dev/null +++ b/conformance/policies/crossbucket_role_rb.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RB + rules: + - resource: y + allowActions: ['ping'] diff --git a/conformance/policies/crossbucket_y.yaml b/conformance/policies/crossbucket_y.yaml new file mode 100644 index 0000000..815161c --- /dev/null +++ b/conformance/policies/crossbucket_y.yaml @@ -0,0 +1,8 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: y + rules: + - actions: ['ping'] + effect: EFFECT_ALLOW + roles: ['RA'] diff --git a/conformance/policies/document.yaml b/conformance/policies/document.yaml new file mode 100644 index 0000000..7ca626a --- /dev/null +++ b/conformance/policies/document.yaml @@ -0,0 +1,45 @@ +--- +# Shared corpus policy. Written in Cerbos's own document format so a real +# Cerbos PDP can serve it verbatim; the Kerberos loader (conformance/lib/load.js) +# reads the same file. Conditions use the CEL ∩ jsep subset — see +# conformance/README.md — so one `expr` string feeds both engines. +apiVersion: 'api.cerbos.dev/v1' +resourcePolicy: + version: default + resource: document + importDerivedRoles: + - document_roles + rules: + - name: admin-all + actions: ['*'] + effect: EFFECT_ALLOW + roles: ['ADMIN'] + + - name: owner-view + actions: ['view'] + effect: EFFECT_ALLOW + derivedRoles: ['OWNER'] + + - name: owner-edit-while-open + actions: ['edit'] + effect: EFFECT_ALLOW + derivedRoles: ['OWNER'] + condition: + match: + expr: R.attr.status == 'OPEN' + + - name: anyone-view-public + actions: ['view'] + effect: EFFECT_ALLOW + roles: ['USER'] + condition: + match: + expr: R.attr.public == true + + - name: archived-is-read-only + actions: ['edit', 'delete'] + effect: EFFECT_DENY + roles: ['*'] + condition: + match: + expr: R.attr.archived == true diff --git a/conformance/policies/document_roles.yaml b/conformance/policies/document_roles.yaml new file mode 100644 index 0000000..f10cf01 --- /dev/null +++ b/conformance/policies/document_roles.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: 'api.cerbos.dev/v1' +derivedRoles: + name: document_roles + definitions: + - name: OWNER + parentRoles: ['USER'] + condition: + match: + expr: R.attr.ownerId == P.id + + - name: SAME_TEAM + parentRoles: ['USER'] + condition: + match: + expr: R.attr.team == P.attr.team diff --git a/conformance/policies/expense.yaml b/conformance/policies/expense.yaml new file mode 100644 index 0000000..9a66ca8 --- /dev/null +++ b/conformance/policies/expense.yaml @@ -0,0 +1,29 @@ +--- +# Kept deliberately plannable: every condition reduces to comparisons over +# `R.attr` / `P`, so `planResources` produces a filter tree rather than an +# `opaque` operand. Used by the query-plan suite. +apiVersion: 'api.cerbos.dev/v1' +resourcePolicy: + version: default + resource: expense + rules: + - name: admin-all + actions: ['*'] + effect: EFFECT_ALLOW + roles: ['ADMIN'] + + - name: user-view-own-or-approved + actions: ['view'] + effect: EFFECT_ALLOW + roles: ['USER'] + condition: + match: + expr: R.attr.ownerId == P.id || R.attr.status == 'APPROVED' + + - name: user-approve-large-with-clearance + actions: ['approve'] + effect: EFFECT_ALLOW + roles: ['USER'] + condition: + match: + expr: P.attr.clearance >= 3 && R.attr.amount < 1000 diff --git a/conformance/policies/report.yaml b/conformance/policies/report.yaml new file mode 100644 index 0000000..8373983 --- /dev/null +++ b/conformance/policies/report.yaml @@ -0,0 +1,17 @@ +--- +# Role-policy semantics probe. +# +# Role policies are a NARROWING FILTER over the resource policy: they never +# grant on their own, they union across the principal's roles, and a role with +# no applicable role policy is unrestricted. Each of those is invisible with a +# single-role principal, so the suite pairs this permissive resource policy with +# two disjoint role policies. +apiVersion: 'api.cerbos.dev/v1' +resourcePolicy: + version: default + resource: report + rules: + - name: everyone-may-do-everything + actions: ['view', 'edit', 'delete'] + effect: EFFECT_ALLOW + roles: ['*'] diff --git a/conformance/policies/report_archivist.yaml b/conformance/policies/report_archivist.yaml new file mode 100644 index 0000000..7420b38 --- /dev/null +++ b/conformance/policies/report_archivist.yaml @@ -0,0 +1,9 @@ +--- +# Targets a DIFFERENT kind on purpose: holding a role that has a role policy +# restricts that role everywhere, not only for the kinds its rules mention. +apiVersion: 'api.cerbos.dev/v1' +rolePolicy: + role: ARCHIVIST + rules: + - resource: document + allowActions: ['view'] diff --git a/conformance/policies/report_reader.yaml b/conformance/policies/report_reader.yaml new file mode 100644 index 0000000..ce851bb --- /dev/null +++ b/conformance/policies/report_reader.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: 'api.cerbos.dev/v1' +rolePolicy: + role: READER + rules: + - resource: report + # `archive` is deliberately NOT granted by the resource policy: a role + # policy allowlisting it must still not be able to grant it. + allowActions: ['view', 'archive'] diff --git a/conformance/policies/report_writer.yaml b/conformance/policies/report_writer.yaml new file mode 100644 index 0000000..f99a9df --- /dev/null +++ b/conformance/policies/report_writer.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: 'api.cerbos.dev/v1' +rolePolicy: + role: WRITER + rules: + - resource: report + allowActions: ['edit'] diff --git a/conformance/policies/scopewalk_k.yaml b/conformance/policies/scopewalk_k.yaml new file mode 100644 index 0000000..6ab5c35 --- /dev/null +++ b/conformance/policies/scopewalk_k.yaml @@ -0,0 +1,8 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: k + rules: + - actions: ['*'] + effect: EFFECT_ALLOW + roles: ['*'] diff --git a/conformance/policies/scopewalk_narrow2.yaml b/conformance/policies/scopewalk_narrow2.yaml new file mode 100644 index 0000000..35545e5 --- /dev/null +++ b/conformance/policies/scopewalk_narrow2.yaml @@ -0,0 +1,8 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: narrow2 + rules: + - actions: ['nothing'] + effect: EFFECT_ALLOW + roles: ['NOBODY'] diff --git a/conformance/policies/scopewalk_p_ivy.yaml b/conformance/policies/scopewalk_p_ivy.yaml new file mode 100644 index 0000000..1301cf0 --- /dev/null +++ b/conformance/policies/scopewalk_p_ivy.yaml @@ -0,0 +1,9 @@ +apiVersion: api.cerbos.dev/v1 +principalPolicy: + principal: ivy + version: default + rules: + - resource: narrow2 + actions: + - action: grant + effect: EFFECT_ALLOW diff --git a/conformance/policies/scopewalk_p_ivy_acme.yaml b/conformance/policies/scopewalk_p_ivy_acme.yaml new file mode 100644 index 0000000..b990095 --- /dev/null +++ b/conformance/policies/scopewalk_p_ivy_acme.yaml @@ -0,0 +1,13 @@ +apiVersion: api.cerbos.dev/v1 +principalPolicy: + principal: ivy + version: default + scope: acme + rules: + - resource: narrow2 + actions: + - action: grant + effect: EFFECT_ALLOW + condition: + match: + expr: R.attr.ok == true diff --git a/conformance/policies/scopewalk_p_jane.yaml b/conformance/policies/scopewalk_p_jane.yaml new file mode 100644 index 0000000..0de043b --- /dev/null +++ b/conformance/policies/scopewalk_p_jane.yaml @@ -0,0 +1,9 @@ +apiVersion: api.cerbos.dev/v1 +principalPolicy: + principal: jane + version: default + rules: + - resource: k + actions: + - action: ping + effect: EFFECT_ALLOW diff --git a/conformance/policies/scopewalk_p_jane_acme.yaml b/conformance/policies/scopewalk_p_jane_acme.yaml new file mode 100644 index 0000000..f4e1934 --- /dev/null +++ b/conformance/policies/scopewalk_p_jane_acme.yaml @@ -0,0 +1,10 @@ +apiVersion: api.cerbos.dev/v1 +principalPolicy: + principal: jane + version: default + scope: acme + rules: + - resource: k + actions: + - action: ping + effect: EFFECT_DENY diff --git a/conformance/policies/scopewalk_role_rs_acme.yaml b/conformance/policies/scopewalk_role_rs_acme.yaml new file mode 100644 index 0000000..31867d5 --- /dev/null +++ b/conformance/policies/scopewalk_role_rs_acme.yaml @@ -0,0 +1,7 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RS + scope: acme + rules: + - resource: sc + allowActions: ['view'] diff --git a/conformance/policies/scopewalk_role_rs_base.yaml b/conformance/policies/scopewalk_role_rs_base.yaml new file mode 100644 index 0000000..28f3452 --- /dev/null +++ b/conformance/policies/scopewalk_role_rs_base.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RS + rules: + - resource: sc + allowActions: ['edit'] diff --git a/conformance/policies/scopewalk_role_rt_acme.yaml b/conformance/policies/scopewalk_role_rt_acme.yaml new file mode 100644 index 0000000..9c0675e --- /dev/null +++ b/conformance/policies/scopewalk_role_rt_acme.yaml @@ -0,0 +1,7 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RT + scope: acme + rules: + - resource: k + allowActions: ['other'] diff --git a/conformance/policies/scopewalk_role_rt_base.yaml b/conformance/policies/scopewalk_role_rt_base.yaml new file mode 100644 index 0000000..eec7028 --- /dev/null +++ b/conformance/policies/scopewalk_role_rt_base.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RT + rules: + - resource: k + allowActions: ['ping'] diff --git a/conformance/policies/scopewalk_sc_acme.yaml b/conformance/policies/scopewalk_sc_acme.yaml new file mode 100644 index 0000000..858c2c1 --- /dev/null +++ b/conformance/policies/scopewalk_sc_acme.yaml @@ -0,0 +1,18 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: sc + scope: acme + rules: + - actions: ['edit'] + effect: EFFECT_DENY + roles: ['A'] + - actions: ['view'] + effect: EFFECT_ALLOW + roles: ['A'] + - actions: ['share'] + effect: EFFECT_ALLOW + roles: ['A'] + condition: + match: + expr: R.attr.ok == true diff --git a/conformance/policies/scopewalk_sc_base.yaml b/conformance/policies/scopewalk_sc_base.yaml new file mode 100644 index 0000000..9587630 --- /dev/null +++ b/conformance/policies/scopewalk_sc_base.yaml @@ -0,0 +1,20 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: sc + rules: + - actions: ['edit'] + effect: EFFECT_ALLOW + roles: ['A'] + - actions: ['view'] + effect: EFFECT_DENY + roles: ['A'] + - actions: ['share'] + effect: EFFECT_ALLOW + roles: ['A'] + - actions: ['edit'] + effect: EFFECT_ALLOW + roles: ['B'] + - actions: ['view', 'edit', 'share'] + effect: EFFECT_ALLOW + roles: ['RS'] diff --git a/conformance/policies/ticket.yaml b/conformance/policies/ticket.yaml new file mode 100644 index 0000000..1fd052e --- /dev/null +++ b/conformance/policies/ticket.yaml @@ -0,0 +1,62 @@ +--- +# Conflict-resolution probe. +# +# This policy exists to pin *rule combination* semantics, which is the area +# where a reimplementation is most likely to drift from Cerbos without anyone +# noticing: the individual rules are trivial, and the only thing under test is +# what happens when an ALLOW and a DENY both match one action. +# +# See DIVERGENCES.md — the multi-role case is the primary open question for the +# first run against a live PDP. +apiVersion: 'api.cerbos.dev/v1' +resourcePolicy: + version: default + resource: ticket + rules: + # Same action, same principal, different roles: SUPPORT allows, AUDITOR denies. + - name: support-may-close + actions: ['close'] + effect: EFFECT_ALLOW + roles: ['SUPPORT'] + + - name: auditors-may-never-close + actions: ['close'] + effect: EFFECT_DENY + roles: ['AUDITOR'] + + # Same shape as `close`, but the DENY is a blanket one. Narrows how far the + # cross-role divergence reaches. + - name: support-may-escalate + actions: ['escalate'] + effect: EFFECT_ALLOW + roles: ['SUPPORT'] + + - name: nobody-escalates + actions: ['escalate'] + effect: EFFECT_DENY + roles: ['*'] + + # Same shape again, but the DENY enumerates the allowing role too. + - name: support-may-delete + actions: ['delete'] + effect: EFFECT_ALLOW + roles: ['SUPPORT'] + + - name: neither-role-deletes + actions: ['delete'] + effect: EFFECT_DENY + roles: ['SUPPORT', 'AUDITOR'] + + # Same action, same single role: ALLOW listed before DENY. + - name: support-may-comment + actions: ['comment'] + effect: EFFECT_ALLOW + roles: ['SUPPORT'] + + - name: nobody-comments-on-locked + actions: ['comment'] + effect: EFFECT_DENY + roles: ['SUPPORT'] + condition: + match: + expr: R.attr.locked == true diff --git a/conformance/policies/wild_glob.yaml b/conformance/policies/wild_glob.yaml new file mode 100644 index 0000000..f7b2e04 --- /dev/null +++ b/conformance/policies/wild_glob.yaml @@ -0,0 +1,20 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: glob + rules: + - actions: ['view:*'] + effect: EFFECT_ALLOW + roles: ['U1'] + - actions: ['v*w'] + effect: EFFECT_ALLOW + roles: ['U2'] + - actions: ['*'] + effect: EFFECT_ALLOW + roles: ['U4'] + - actions: ['edit:*'] + effect: EFFECT_DENY + roles: ['U4'] + - actions: ['deep:*'] + effect: EFFECT_ALLOW + roles: ['U5'] diff --git a/conformance/policies/wild_gr.yaml b/conformance/policies/wild_gr.yaml new file mode 100644 index 0000000..7b848bf --- /dev/null +++ b/conformance/policies/wild_gr.yaml @@ -0,0 +1,8 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: gr + rules: + - actions: ['*'] + effect: EFFECT_ALLOW + roles: ['*'] diff --git a/conformance/policies/wild_principal.yaml b/conformance/policies/wild_principal.yaml new file mode 100644 index 0000000..995433d --- /dev/null +++ b/conformance/policies/wild_principal.yaml @@ -0,0 +1,9 @@ +apiVersion: api.cerbos.dev/v1 +principalPolicy: + principal: ryan + version: default + rules: + - resource: 'gl*' + actions: + - action: touch + effect: EFFECT_ALLOW diff --git a/conformance/policies/wild_role_rg.yaml b/conformance/policies/wild_role_rg.yaml new file mode 100644 index 0000000..935b9cc --- /dev/null +++ b/conformance/policies/wild_role_rg.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RG + rules: + - resource: gr + allowActions: ['view:*'] diff --git a/conformance/policies/wild_role_rh.yaml b/conformance/policies/wild_role_rh.yaml new file mode 100644 index 0000000..f24b4c8 --- /dev/null +++ b/conformance/policies/wild_role_rh.yaml @@ -0,0 +1,6 @@ +apiVersion: api.cerbos.dev/v1 +rolePolicy: + role: RH + rules: + - resource: 'g*' + allowActions: ['view'] diff --git a/conformance/policies/wild_roles.yaml b/conformance/policies/wild_roles.yaml new file mode 100644 index 0000000..9829717 --- /dev/null +++ b/conformance/policies/wild_roles.yaml @@ -0,0 +1,9 @@ +apiVersion: api.cerbos.dev/v1 +derivedRoles: + name: glob_roles + definitions: + - name: STARRED + parentRoles: ['adm*'] + condition: + match: + expr: 1 == 1 diff --git a/conformance/policies/wild_rolesfield.yaml b/conformance/policies/wild_rolesfield.yaml new file mode 100644 index 0000000..61b4af6 --- /dev/null +++ b/conformance/policies/wild_rolesfield.yaml @@ -0,0 +1,8 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: rg + rules: + - actions: ['view'] + effect: EFFECT_ALLOW + roles: ['team_*'] diff --git a/conformance/policies/wild_wp.yaml b/conformance/policies/wild_wp.yaml new file mode 100644 index 0000000..882ae48 --- /dev/null +++ b/conformance/policies/wild_wp.yaml @@ -0,0 +1,9 @@ +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: wp + importDerivedRoles: [glob_roles] + rules: + - actions: ['view'] + effect: EFFECT_ALLOW + derivedRoles: ['STARRED'] diff --git a/conformance/suites/conderr_test.yaml b/conformance/suites/conderr_test.yaml new file mode 100644 index 0000000..1a46c03 --- /dev/null +++ b/conformance/suites/conderr_test.yaml @@ -0,0 +1,37 @@ +--- +name: ConditionErrorConformance +description: >- + A DENY rule whose condition raises a runtime error. Cerbos (with its default + strictEvaluation: false) skips the rule and the ALLOW stands; Kerberos fails + closed. A recorded divergence — `cerbosActions` pins the other engine so the + difference cannot rot unnoticed. + +principals: + anyone: + id: u1 + roles: ['USER'] + +resources: + doc: + id: c1 + kind: conderr + attr: {} + +tests: + - name: KNOWN DIVERGENCE — an erroring DENY condition + description: >- + Cerbos's engine page states the affected expression is "treated as not + satisfied and the evaluation carries on", warning that "an EFFECT_DENY + rule could be silently skipped" — verified: EFFECT_ALLOW. Its conditions + page claims the opposite for v0.55; the engine wins. Kerberos has no + per-rule skip: the error surfaces per `onError`, and inside a + checkResources batch it is isolated to a fail-closed EFFECT_DENY. + input: + principals: [anyone] + resources: [doc] + actions: [view] + expected: + - principal: anyone + resource: doc + actions: { view: EFFECT_DENY } + cerbosActions: { view: EFFECT_ALLOW } diff --git a/conformance/suites/document_test.yaml b/conformance/suites/document_test.yaml new file mode 100644 index 0000000..c8fb09e --- /dev/null +++ b/conformance/suites/document_test.yaml @@ -0,0 +1,142 @@ +--- +# Cerbos TestSuite format (api.cerbos.dev TestSuite.schema.json), so this file +# can be handed to `cerbos compile --tests` against a real PDP unchanged. +name: DocumentPolicyConformance +description: Decision parity for RBAC, ABAC and derived roles on the `document` kind. + +principals: + admin: + id: admin1 + roles: ['ADMIN'] + attr: + team: platform + owner: + id: u1 + roles: ['USER'] + attr: + team: design + teammate: + id: u2 + roles: ['USER'] + attr: + team: design + outsider: + id: u3 + roles: ['USER'] + attr: + team: finance + +resources: + open_doc: + id: d1 + kind: document + attr: + ownerId: u1 + team: design + status: OPEN + public: false + archived: false + closed_doc: + id: d2 + kind: document + attr: + ownerId: u1 + team: design + status: CLOSED + public: false + archived: false + public_doc: + id: d3 + kind: document + attr: + ownerId: u9 + team: legal + status: OPEN + public: true + archived: false + archived_doc: + id: d4 + kind: document + attr: + ownerId: u1 + team: design + status: OPEN + public: true + archived: true + +tests: + - name: admin gets the wildcard allow + input: + principals: [admin] + resources: [open_doc, closed_doc, public_doc] + actions: [view, edit, delete] + expected: + - principal: admin + resource: open_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_ALLOW } + - principal: admin + resource: closed_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_ALLOW } + - principal: admin + resource: public_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_ALLOW } + + - name: owner may view always and edit only while open + input: + principals: [owner] + resources: [open_doc, closed_doc] + actions: [view, edit, delete] + expected: + - principal: owner + resource: open_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_DENY } + - principal: owner + resource: closed_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_DENY, delete: EFFECT_DENY } + + - name: a non-owner on the same team is not an owner + input: + principals: [teammate] + resources: [open_doc] + actions: [view, edit] + expected: + - principal: teammate + resource: open_doc + actions: { view: EFFECT_DENY, edit: EFFECT_DENY } + + - name: any USER may view a public document + input: + principals: [teammate, outsider] + resources: [public_doc] + actions: [view, edit] + expected: + - principals: [teammate, outsider] + resource: public_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_DENY } + + - name: an explicit DENY overrides an ALLOW that also matches + description: >- + archived_doc is public and owned by u1, so both `anyone-view-public` and + `owner-edit-while-open` match — but `archived-is-read-only` denies edit + and delete for every role. This pins deny-overrides for BOTH engines. + input: + principals: [owner, admin] + resources: [archived_doc] + actions: [view, edit, delete] + expected: + - principal: owner + resource: archived_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_DENY, delete: EFFECT_DENY } + - principal: admin + resource: archived_doc + actions: { view: EFFECT_ALLOW, edit: EFFECT_DENY, delete: EFFECT_DENY } + + - name: an action no rule targets is denied + input: + principals: [owner] + resources: [open_doc] + actions: [archive] + expected: + - principal: owner + resource: open_doc + actions: { archive: EFFECT_DENY } diff --git a/conformance/suites/expense_plan.yaml b/conformance/suites/expense_plan.yaml new file mode 100644 index 0000000..a77f073 --- /dev/null +++ b/conformance/suites/expense_plan.yaml @@ -0,0 +1,85 @@ +--- +# Query-plan corpus, shaped after Cerbos's own QueryPlannerTestSuite golden +# files: one principal per suite, and each test records the `want.filter` the +# engine must produce. Filters are compared after canonicalization (see +# conformance/lib/canonical.js) because neither engine promises an operand order. +description: Query-plan parity for the `expense` kind. + +principal: + id: u1 + roles: ['USER'] + attr: + clearance: 4 + +tests: + - description: an unresolved OR over two resource attributes stays conditional + resource: { kind: expense } + action: view + want: + 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 + + - description: >- + the principal-only half of the condition is decided at plan time + (clearance 4 >= 3 folds away), leaving just the resource predicate + resource: { kind: expense } + action: approve + want: + filter: + kind: KIND_CONDITIONAL + condition: + expression: + operator: lt + operands: + - variable: request.resource.attr.amount + - value: 1000 + + - description: multiple actions plan the conjunction + resource: { kind: expense } + actions: [view, approve] + want: + filter: + kind: KIND_CONDITIONAL + condition: + expression: + operator: and + operands: + - 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 + - expression: + operator: lt + operands: + - variable: request.resource.attr.amount + - value: 1000 + + - description: an action reachable by no rule is denied without a filter + resource: { kind: expense } + action: shred + want: + filter: + kind: KIND_ALWAYS_DENIED diff --git a/conformance/suites/report_test.yaml b/conformance/suites/report_test.yaml new file mode 100644 index 0000000..3d2bced --- /dev/null +++ b/conformance/suites/report_test.yaml @@ -0,0 +1,105 @@ +--- +name: RolePolicyConformance +description: >- + Role policies as a narrowing filter over the resource policy: union across + roles, no grant of their own, and no restriction from a role that has no + applicable role policy. + +principals: + reader: + id: r1 + roles: ['READER'] + writer: + id: w1 + roles: ['WRITER'] + reader_writer: + id: rw1 + roles: ['READER', 'WRITER'] + reader_plus_unpolicied: + id: rp1 + roles: ['READER', 'PLAIN'] + archivist: + id: a1 + roles: ['ARCHIVIST'] + archivist_plus_unpolicied: + id: ap1 + roles: ['ARCHIVIST', 'PLAIN'] + +resources: + report: + id: rep1 + kind: report + +tests: + - name: one role policy narrows the resource policy to its allowlist + input: + principals: [reader, writer] + resources: [report] + actions: [view, edit, delete] + expected: + - principal: reader + resource: report + actions: { view: EFFECT_ALLOW, edit: EFFECT_DENY, delete: EFFECT_DENY } + - principal: writer + resource: report + actions: { view: EFFECT_DENY, edit: EFFECT_ALLOW, delete: EFFECT_DENY } + + - name: multiple role policies union rather than intersect + description: >- + READER permits only `view`, WRITER only `edit`. Holding both must permit + both — an intersection would leave the principal with less access than + either role grants alone. `delete` is in neither allowlist, so the filter + still removes it. + input: + principals: [reader_writer] + resources: [report] + actions: [view, edit, delete] + expected: + - principal: reader_writer + resource: report + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_DENY } + + - name: a role with no applicable role policy imposes no restriction + description: >- + PLAIN has no role policy, so it is unconstrained; the union therefore + permits everything the resource policy allows and READER's allowlist stops + applying. Adding a role can widen access, never narrow it. + input: + principals: [reader_plus_unpolicied] + resources: [report] + actions: [view, edit, delete] + expected: + - principal: reader_plus_unpolicied + resource: report + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_ALLOW } + + - name: a role policy cannot grant what the resource policy does not allow + description: >- + READER allowlists `archive`, but no resource-policy rule grants it. Role + policies only ever take away. + input: + principals: [reader, reader_writer] + resources: [report] + actions: [archive] + expected: + - principals: [reader, reader_writer] + resource: report + actions: { archive: EFFECT_DENY } + + - name: a role policy that never targets this kind permits nothing here + description: >- + ARCHIVIST's role policy only mentions `document`. Holding the role still + constrains it on `report`, where it allowlists nothing — a role policy + restricts its role everywhere, not only for the kinds it names. Pairing it + with an unconstrained role lifts the filter again. + input: + principals: [archivist, archivist_plus_unpolicied] + resources: [report] + actions: [view, edit, delete] + expected: + - principal: archivist + resource: report + actions: { view: EFFECT_DENY, edit: EFFECT_DENY, delete: EFFECT_DENY } + - principal: archivist_plus_unpolicied + resource: report + actions: { view: EFFECT_ALLOW, edit: EFFECT_ALLOW, delete: EFFECT_ALLOW } diff --git a/conformance/suites/scope_walk_test.yaml b/conformance/suites/scope_walk_test.yaml new file mode 100644 index 0000000..08c9a72 --- /dev/null +++ b/conformance/suites/scope_walk_test.yaml @@ -0,0 +1,227 @@ +--- +name: ScopeWalkConformance +description: >- + Per-(action, role) scope-chain evaluation with scopePermissions = + OVERRIDE_PARENT (the default): the first scope that decides an action for a + role seals it, a failed condition falls through, principal policies walk the + PRINCIPAL scope chain while role policies ride the RESOURCE chain, and role + policies contribute synthetic denies per scope. Includes the cross-bucket + case where a resource allow reaching only a policied role cannot borrow + another role's allowlist. + +principals: + role_a: + id: pa + roles: ['A'] + role_ab: + id: pab + roles: ['A', 'B'] + role_rs: + id: prs + roles: ['RS'] + ivy_acme: + id: ivy + roles: ['Z'] + scope: acme + jane_acme: + id: jane + roles: ['Z'] + scope: acme + jane_base: + id: jane + roles: ['Z'] + rt_acme: + id: prt + roles: ['RT'] + scope: acme + rt_base: + id: prt2 + roles: ['RT'] + ra_rb: + id: pab2 + roles: ['RA', 'RB'] + ra_only: + id: pra + roles: ['RA'] + rb_only: + id: prb + roles: ['RB'] + +resources: + sc_acme: + id: s1 + kind: sc + scope: acme + sc_acme_open: + id: s1 + kind: sc + scope: acme + attr: + ok: true + sc_acme_closed: + id: s1 + kind: sc + scope: acme + attr: + ok: false + sc_gap: + id: s1 + kind: sc + scope: acme.eu + sc_base: + id: s1 + kind: sc + narrow2_closed: + id: n1 + kind: narrow2 + attr: + ok: false + narrow2_open: + id: n1 + kind: narrow2 + attr: + ok: true + k_base: + id: k1 + kind: k + k_acme: + id: k1 + kind: k + scope: acme + y_doc: + id: y1 + kind: y + +tests: + - name: a deny at the specific scope seals the role; an allow seals against a base deny + description: >- + acme denies edit for A and allows view for A; the base policy says the + opposite. The first scope to decide each (action, role) wins. + input: + principals: [role_a] + resources: [sc_acme] + actions: [edit, view] + expected: + - principal: role_a + resource: sc_acme + actions: { edit: EFFECT_DENY, view: EFFECT_ALLOW } + + - name: a failed condition is no decision — the walk falls through + input: + principals: [role_a] + resources: [sc_acme_closed, sc_acme_open] + actions: [share] + expected: + - principal: role_a + resources: [sc_acme_closed, sc_acme_open] + actions: { share: EFFECT_ALLOW } + + - name: the walk is per role — another role can win at the base scope + description: >- + A is denied edit at acme (sealed); B has nothing at acme and falls + through to the base allow. Across roles the allow wins. + input: + principals: [role_ab] + resources: [sc_acme] + actions: [edit] + expected: + - principal: role_ab + resource: sc_acme + actions: { edit: EFFECT_ALLOW } + + - name: a scope gap behaves like its nearest ancestor + input: + principals: [role_a] + resources: [sc_gap] + actions: [edit, view] + expected: + - principal: role_a + resource: sc_gap + actions: { edit: EFFECT_DENY, view: EFFECT_ALLOW } + + - name: scoped role policies contribute synthetic denies at each scope + description: >- + RS@acme allowlists only view, RS@base only edit. At the acme resource + scope every action dies at some scope of the walk — view survives acme + but hits the base deny row, edit dies at acme — and at the base scope + only edit survives. Allowlists do NOT union across scopes. + input: + principals: [role_rs] + resources: [sc_acme, sc_base] + actions: [view, edit, share] + expected: + - principal: role_rs + resource: sc_acme + actions: { view: EFFECT_DENY, edit: EFFECT_DENY, share: EFFECT_DENY } + - principal: role_rs + resource: sc_base + actions: { view: EFFECT_DENY, edit: EFFECT_ALLOW, share: EFFECT_DENY } + + - name: principal policies fall through a failed condition on the principal chain + description: >- + ivy@acme allows `grant` only when R.attr.ok; the base policy allows it + unconditionally. With ok=false the scoped rule decides nothing and the + base allow applies. + input: + principals: [ivy_acme] + resources: [narrow2_closed, narrow2_open] + actions: [grant] + expected: + - principal: ivy_acme + resources: [narrow2_closed, narrow2_open] + actions: { grant: EFFECT_ALLOW } + + - name: principal policies follow the PRINCIPAL scope + description: >- + jane@acme denies ping. The scoped policy applies when the PRINCIPAL + carries the scope, not when the resource does. + input: + principals: [jane_acme, jane_base] + resources: [k_base, k_acme] + actions: [ping] + expected: + - principal: jane_acme + resource: k_base + actions: { ping: EFFECT_DENY } + - principal: jane_base + resource: k_acme + actions: { ping: EFFECT_ALLOW } + + - name: role policies follow the RESOURCE scope + description: >- + RT@acme allowlists only `other`; RT@base allowlists ping. Cerbos's docs + say role-policy scope is the principal's, but its rule table (and a live + PDP) match it against the RESOURCE scope chain — see DIVERGENCES.md. + input: + principals: [rt_acme] + resources: [k_base] + actions: [ping] + expected: + - principal: rt_acme + resource: k_base + actions: { ping: EFFECT_ALLOW } + + - name: role policies follow the RESOURCE scope (deny side) + input: + principals: [rt_base] + resources: [k_acme] + actions: [ping] + expected: + - principal: rt_base + resource: k_acme + actions: { ping: EFFECT_DENY } + + - name: an allow reaching only a policied role cannot borrow another role's allowlist + description: >- + The resource allows ping to RA only; RA's role policy does not allowlist + ping (synthetic deny in RA's bucket), and RB's allowlist is useless + because no resource rule reaches RB. Strictly per-role — holding both + roles is still a deny. + input: + principals: [ra_rb, ra_only, rb_only] + resources: [y_doc] + actions: [ping] + expected: + - principals: [ra_rb, ra_only, rb_only] + resource: y_doc + actions: { ping: EFFECT_DENY } diff --git a/conformance/suites/ticket_test.yaml b/conformance/suites/ticket_test.yaml new file mode 100644 index 0000000..be5c799 --- /dev/null +++ b/conformance/suites/ticket_test.yaml @@ -0,0 +1,104 @@ +--- +name: ConflictResolutionConformance +description: >- + Pins how an ALLOW and a DENY that both match one action are combined: deny + overrides allow WITHIN a role, allow overrides deny ACROSS roles. + +principals: + support: + id: s1 + roles: ['SUPPORT'] + auditor: + id: a1 + roles: ['AUDITOR'] + support_auditor: + id: sa1 + roles: ['SUPPORT', 'AUDITOR'] + +resources: + open_ticket: + id: t1 + kind: ticket + attr: + locked: false + locked_ticket: + id: t2 + kind: ticket + attr: + locked: true + +tests: + - name: a single role with only an ALLOW gets the allow + input: + principals: [support] + resources: [open_ticket] + actions: [close, comment] + expected: + - principal: support + resource: open_ticket + actions: { close: EFFECT_ALLOW, comment: EFFECT_ALLOW } + + - name: a single role with only a DENY gets the deny + input: + principals: [auditor] + resources: [open_ticket] + actions: [close] + expected: + - principal: auditor + resource: open_ticket + actions: { close: EFFECT_DENY } + + - name: within one role a conditional DENY overrides an earlier ALLOW + description: >- + Both `support-may-comment` and `nobody-comments-on-locked` match for a + SUPPORT principal on a locked ticket, and the ALLOW is listed first. Deny + wins, so rule order does not decide the outcome. + input: + principals: [support] + resources: [locked_ticket] + actions: [comment] + expected: + - principal: support + resource: locked_ticket + actions: { comment: EFFECT_DENY } + + - name: a DENY scoped to another role does not veto the allowing role + description: >- + Anti-lockout. The AUDITOR deny is scoped to a role that does not carry the + allow, so holding AUDITOR in addition to SUPPORT cannot take away what + SUPPORT grants. This case previously diverged — Kerberos was deny-overrides + unconditionally — and is the reason the suite exists. + input: + principals: [support_auditor] + resources: [open_ticket] + actions: [close] + expected: + - principal: support_auditor + resource: open_ticket + actions: { close: EFFECT_ALLOW } + + - name: a blanket DENY still overrides an ALLOW from any role + description: >- + Bounds the divergence above. `roles: ['*']` covers the allowing role too, + so both engines deny — blanket denies are not affected. + input: + principals: [support_auditor, support] + resources: [open_ticket] + actions: [escalate] + expected: + - principals: [support_auditor, support] + resource: open_ticket + actions: { escalate: EFFECT_DENY } + + - name: a DENY that also enumerates the allowing role wins + description: >- + Bounds the divergence from the other side: once the DENY covers SUPPORT + as well, Cerbos's per-role resolution denies too, and the engines agree. + input: + principals: [support_auditor] + resources: [open_ticket] + actions: [delete] + expected: + - principal: support_auditor + resource: open_ticket + actions: { delete: EFFECT_DENY } diff --git a/conformance/suites/wildcards_test.yaml b/conformance/suites/wildcards_test.yaml new file mode 100644 index 0000000..39d358a --- /dev/null +++ b/conformance/suites/wildcards_test.yaml @@ -0,0 +1,137 @@ +--- +name: WildcardConformance +description: >- + Glob matching in every field that supports it (Cerbos semantics, from + internal/util/globs_common.go): a bare `*` matches anything, `:` is the + segment separator so `view:*` matches `view:public` but neither the bare + `view` nor `view:a:b`, and mid-segment globs like `v*w` stay within one + segment. Fields covered: resource-policy actions and roles, principal-policy + resource and action, role-policy resource and allowActions, derived-role + parentRoles. + +principals: + u1: + id: p1 + roles: ['U1'] + u2: + id: p2 + roles: ['U2'] + u4: + id: p4 + roles: ['U4'] + u5: + id: p5 + roles: ['U5'] + ryan: + id: ryan + roles: ['Z'] + rg_holder: + id: p6 + roles: ['RG'] + rh_holder: + id: p7 + roles: ['RH'] + admin_like: + id: p8 + roles: ['admin'] + team_member: + id: p9 + roles: ['team_red'] + +resources: + glob_doc: + id: g1 + kind: glob + gr_doc: + id: g2 + kind: gr + wp_doc: + id: g3 + kind: wp + rolesfield_doc: + id: g4 + kind: rg + +tests: + - name: action glob view:* honours the segment separator + input: + principals: [u1] + resources: [glob_doc] + actions: ['view:public', 'view', 'view:a:b'] + expected: + - principal: u1 + resource: glob_doc + actions: { 'view:public': EFFECT_ALLOW, view: EFFECT_DENY, 'view:a:b': EFFECT_DENY } + + - name: a mid-segment glob matches within one segment only + input: + principals: [u2] + resources: [glob_doc] + actions: ['view', 'view:public'] + expected: + - principal: u2 + resource: glob_doc + actions: { view: EFFECT_ALLOW, 'view:public': EFFECT_DENY } + + - name: a bare * crosses segments, and a DENY glob beats it + input: + principals: [u4] + resources: [glob_doc] + actions: ['other:x', 'edit:a'] + expected: + - principal: u4 + resource: glob_doc + actions: { 'other:x': EFFECT_ALLOW, 'edit:a': EFFECT_DENY } + + - name: deep globs stop at the next separator + input: + principals: [u5] + resources: [glob_doc] + actions: ['deep:a', 'deep:a:b'] + expected: + - principal: u5 + resource: glob_doc + actions: { 'deep:a': EFFECT_ALLOW, 'deep:a:b': EFFECT_DENY } + + - name: principal policies glob the resource kind + input: + principals: [ryan] + resources: [glob_doc] + actions: [touch] + expected: + - principal: ryan + resource: glob_doc + actions: { touch: EFFECT_ALLOW } + + - name: role policies glob allowActions and the resource kind + input: + principals: [rg_holder, rh_holder] + resources: [gr_doc] + actions: ['view:public', 'view', 'edit'] + expected: + - principal: rg_holder + resource: gr_doc + actions: { 'view:public': EFFECT_ALLOW, view: EFFECT_DENY, edit: EFFECT_DENY } + - principal: rh_holder + resource: gr_doc + actions: { 'view:public': EFFECT_DENY, view: EFFECT_ALLOW, edit: EFFECT_DENY } + + - name: derived-role parentRoles glob principal roles + input: + principals: [admin_like] + resources: [wp_doc] + actions: [view] + expected: + - principal: admin_like + resource: wp_doc + actions: { view: EFFECT_ALLOW } + + - name: the roles field of a resource rule globs too + input: + principals: [team_member] + resources: [rolesfield_doc] + actions: [view] + expected: + - principal: team_member + resource: rolesfield_doc + actions: { view: EFFECT_ALLOW } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 618e352..0a127d5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,10 +1,54 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); + +/** + * Supplies the playground with the engine as a browser bundle. + * + * The package is CommonJS and lives outside node_modules (there is no workspace + * self-link), so Vite pre-bundles neither in dev nor via the default rollup + * commonjs `include`. Rather than guess at interop settings, this builds the + * bundle with esbuild using exactly the options `scripts/size.js` uses — the + * `browser` condition applies the package.json runtime swap + * (`src/runtime/node.js` → `src/runtime/browser.js`), so the page ships the same + * artifact `pnpm size` reports, and a broken swap fails the docs build loudly + * because `node:crypto` cannot resolve for the browser platform. + */ +function kerberosBrowserBundle() { + const virtualId = 'virtual:kerberos-browser'; + const resolvedId = `\0${virtualId}`; + let cached: string | null = null; + + return { + name: 'kerberos-browser-bundle', + resolveId(id: string) { + return id === virtualId ? resolvedId : null; + }, + async load(id: string) { + if (id !== resolvedId) return null; + if (cached) return cached; + const esbuild = await import('esbuild'); + const result = await esbuild.build({ + entryPoints: [`${packageRoot}browser.js`], + bundle: true, + format: 'esm', + platform: 'browser', + conditions: ['browser'], + write: false, + logLevel: 'silent', + }); + cached = result.outputFiles[0].text; + return cached; + }, + }; +} + const ogTitle = 'Kerberos.js — embedded authorization engine for Node.js & the browser'; const ogDescription = 'Zero-dependency, in-process authorization engine for JavaScript. Cerbos-style RBAC + ABAC policies, ' + - 'Zanzibar-inspired ReBAC relations and Cerbos-compatible query plans — no server to deploy, ~25 KB min+gzip.'; + 'Zanzibar-inspired ReBAC relations and Cerbos-compatible query plans — no server to deploy, ~29 KB min+gzip.'; const repo = 'https://github.com/Alexis-Technologies/kerberos'; const base = '/'; const hostname = 'https://kerberosjs.vercel.app/'; @@ -104,6 +148,7 @@ export default withMermaid( // ─── Top navigation ────────────────────────────────────────────── nav: [ { text: 'Guide', link: '/guide/why', activeMatch: '/guide/' }, + { text: 'Playground', link: '/playground', activeMatch: '/playground' }, { text: 'API', link: '/api/kerberos', activeMatch: '/api/' }, { text: 'Reference', link: '/reference/plan-operators', activeMatch: '/reference/' }, { @@ -134,6 +179,7 @@ export default withMermaid( text: 'Core features', items: [ { text: 'Configuration', link: '/guide/configuration' }, + { text: 'TypeScript', link: '/guide/typescript' }, { text: 'Outputs', link: '/guide/outputs' }, { text: 'Decision metadata', link: '/guide/decision-metadata' }, { text: 'Schema validation', link: '/guide/schema-validation' }, @@ -145,6 +191,8 @@ export default withMermaid( items: [ { text: 'Caching & dynamic policies', link: '/guide/caching' }, { text: 'Serialization & security', link: '/guide/serialization' }, + { text: 'Cerbos policy import', link: '/guide/cerbos-import' }, + { text: 'Policy files & bundles', link: '/guide/policy-loader' }, { text: 'ReBAC (Relations)', link: '/guide/rebac' }, { text: 'Built-in resolver', link: '/guide/relations-resolver' }, { text: 'Query plans', link: '/guide/query-plans' }, @@ -195,5 +243,12 @@ export default withMermaid( next: 'Next page', }, }, + + vite: { + plugins: [kerberosBrowserBundle()], + optimizeDeps: { + include: ['jsep', '@jsep-plugin/object', '@jsep-plugin/ternary', '@jsep-plugin/new'], + }, + }, }), ); diff --git a/docs/.vitepress/theme/components/Playground.vue b/docs/.vitepress/theme/components/Playground.vue new file mode 100644 index 0000000..c801183 --- /dev/null +++ b/docs/.vitepress/theme/components/Playground.vue @@ -0,0 +1,303 @@ + + +