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/CHANGELOG.md b/CHANGELOG.md index 3f92c03..c94f110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,115 @@ cross-request instance memo, audit-stream completeness (fail-closed denials, (~2.5× simple `isAllowed`), reverse-lookup truncation signaling (`onTruncated`), frozen policy shapes/tokens, and d.ts/export-parity guards. +### Added + +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index e11a82a..72fc930 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,14 +16,15 @@ 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 scripts bench -pnpm format # oxfmt src test scripts bench (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 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`). @@ -48,14 +49,15 @@ Every DSL concept (`Conditions`, `Constants`, `DerivedRoles`, `Outputs`, `Princi `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). + +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). 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. @@ -96,6 +98,10 @@ Two layers, both SpiceDB-inspired (see the "borrow vs skip" notes in `README.md` 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. +### 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. 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/`) VitePress site deployed to Vercel (`vercel.json` at the repo root pins the build command and output dir); `docs/` is never published to npm — the `files` field in `package.json` is an explicit list that omits it. Structure follows the migronaut sibling repo: a single `docs/.vitepress/config.mts`, a `theme/` that only extends `DefaultTheme` with a `custom.css` of brand CSS variables (Cerbos-style amber `#FFC11E` on ink `#1B1C1E`), local MiniSearch, and `docs/public/` for `robots.txt` / `llms.txt` / logo assets. diff --git a/README.md b/README.md index c4d8bd5..f38601b 100644 --- a/README.md +++ b/README.md @@ -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,6 +79,8 @@ 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) @@ -208,6 +214,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 +279,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 +324,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 +342,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 +352,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 +392,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,7 +531,7 @@ 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). | @@ -500,6 +558,110 @@ 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. | +## 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: @@ -814,15 +976,15 @@ 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). -> [!WARNING] -> The whole scope chain is walked **in memory first** — source precedence beats scope specificity. A static base-scope (`''`) policy therefore permanently shadows a *more specific* cached policy for the same `(kind/id/role, version)`: in a hybrid deployment (static org-wide defaults in code + per-tenant overrides in the store) the cached tenant override — including a tightening Deny — silently never loads. Don't combine a static policy and cached policies for the same id/version across scopes; keep each (id, version) fully static or fully cache-backed. +> [!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: @@ -1290,7 +1452,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 diff --git a/conformance/DIVERGENCES.md b/conformance/DIVERGENCES.md new file mode 100644 index 0000000..1e8d3ed --- /dev/null +++ b/conformance/DIVERGENCES.md @@ -0,0 +1,118 @@ +# 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. A CEL→`$expr` importer is tracked separately as a product item, not a bug. + +## Not implemented + +| Cerbos feature | Status | Enforcement | +| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| **Attribute schemas** (`schemas:` with `principalSchema` / `resourceSchema`, NONE/WARN/REJECT) | Not implemented. Kerberos validates _policy_ shapes through Zod/Ajv/TypeBox, but does not validate request attributes against a per-kind schema. Garbage attributes flow into conditions. | loader | +| **`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._ + +### 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..c1140ad --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,74 @@ +# 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. | +| `lib/load.js` | Maps Cerbos policy documents onto Kerberos policies. | +| `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 no CEL parser here — a Cerbos importer is a much larger piece of work and belongs in the package, not in a test harness. + +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/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 1d9389d..1636cd8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,50 @@ +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, ' + @@ -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' }, @@ -195,5 +241,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 @@ + + +