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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 15 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading