An embedded, zero-dependency authorization engine for Node.js and the browser: Cerbos-style policies (RBAC + ABAC), a SpiceDB-inspired "Zanzibar-lite" resolver (ReBAC) and Cerbos-compatible query plans — all in-process, no server to deploy, ~38 KB min+gzip. The API deliberately stays as close to Cerbos as possible: if you know Cerbos, you already know Kerberos.js.
import { Kerberos, Effect } from '@alexify/kerberos';
const kerberos = new Kerberos([{
resourcePolicy: {
resource: 'expense',
version: 'default',
rules: [{ actions: ['view'], effect: Effect.Allow, roles: ['USER'],
condition: { match: ({ P, R }) => R.attr.ownerId === P.id } }],
},
}], []);
await kerberos.isAllowed({
principal: { id: 'sally', roles: ['USER'] },
action: 'view',
resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally' } },
}); // → trueAuthorization as a library, not a service. Cerbos and SpiceDB are excellent engines, but each runs as a separate Go server: another deployment, another network hop on every check, another thing that can be down. In a JavaScript stack, Kerberos.js gives you the same policy models with zero infrastructure — decisions are a function call, policies ship (and roll back) atomically with your code, and there is no PDP to keep in sync. A centralized service remains the right choice for polyglot stacks — see When NOT to use it.
Policies are data plus the full power of JavaScript. In-process policies use plain JS functions for conditions, variables and outputs — no expression-language ceiling. Policies stored in a cache/database use the same shapes with safe, eval-free $expr expressions. Local testing needs no emulator: the /tests subpath runs Cerbos-style declarative test suites against the real engine.
One engine everywhere. The browser build contains zero Node builtins, so the same policies that guard your API also gate your UI (hide buttons, filter menus) — without maintaining a second source of truth. Serverless and edge runtimes get the same benefit: no cold-start dependency on an external PDP.
How Kerberos.js compares with the policy engines (Cerbos, SpiceDB, OPA) and the JavaScript authorization libraries it is benchmarked against. ✅ built in ·
| Architecture | Kerberos.js | Cerbos | SpiceDB | OPA | CASL | casbin | AccessControl | easy-rbac | @rbac/rbac |
|---|---|---|---|---|---|---|---|---|---|
| Runs as | library | service (PDP) | service + database | service, Go library or WASM | library | library | library | library | library |
| Languages | JS / TS | 8 SDKs | 6 SDKs + HTTP | any (REST), Go | JS / TS | 10+ ports | JS / TS | JS / TS | JS / TS |
| In the browser | ✅ | ❌ | .wasm |
✅ | process |
||||
| Runtime dependencies | 0 | — (server) | — (server) | 2 (WASM SDK) | 4 | 10 | 2 | 0 | 1 (zod)² |
| Check API | async | async (network) | async (network) | async (REST) · sync (WASM) | sync | async + enforceSync |
sync + async | async | async |
| License | MIT | Apache-2.0 | Apache-2.0 | Apache-2.0 | MIT | Apache-2.0 | MIT | MIT | MIT |
| Policy model | Kerberos.js | Cerbos | SpiceDB | OPA | CASL | casbin | AccessControl | easy-rbac | @rbac/rbac |
|---|---|---|---|---|---|---|---|---|---|
| Policy format | Cerbos-style documents | YAML / JSON documents | schema DSL + relationship tuples | Rego | JS builder / JSON rules | model .conf + policy rows |
builder / grants JSON | role map | role map |
| Roles with inheritance | ✅ parentRoles |
✅ parentRoles |
✅ modeled as relations | ✅ role hierarchy | ✅ extend |
✅ inherits |
✅ inherits |
||
| Attribute conditions | ✅ JS functions / $expr |
✅ CEL | ✅ CEL caveats | ✅ Rego | ✅ MongoDB-style, resource only | ✅ matcher expressions | ✅ .where() expressions |
✅ JS when |
✅ JS when |
| Relationships (ReBAC) | ✅ built-in resolver | ❌ pass them as attributes | ✅ the core model | graph.reachable, model it yourself |
❌ | ❌ | ❌ | ❌ | |
| Derived roles | ✅ | ✅ | ❌ plain Rego rules | ❌ | ❌ | ❌ own ownership only |
❌ | ❌ | |
| Explicit deny | ✅ deny beats allow | ✅ deny beats allow | cannot, rule order decides |
✅ configurable effect | ✅ within a role chain³ | ❌ allow-only | ❌ allow-only | ||
| Field-level permissions | ❌ | ✅ fields |
❌ | ✅ attribute filtering | ❌ | ❌ | |||
| Scopes / tenants | ✅ scope chain⁴ | ✅ scope chain | ❌ model tenants as objects | ❌ structure packages yourself | ❌ | ✅ domains | ❌ | ||
| Policy versions | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Outputs with the decision | ✅ | ✅ | ❌ | ✅ any JSON | ❌ reason string only | ❌ | ❌ | ❌ | ❌ |
| Queries and data | Kerberos.js | Cerbos | SpiceDB | OPA | CASL | casbin | AccessControl | easy-rbac | @rbac/rbac |
|---|---|---|---|---|---|---|---|---|---|
| Policies as data, changed at runtime | ✅ any cache, eval-free $expr |
✅ disk, git, blob, DB, Hub | ✅ schema + relationships via API | ✅ bundles, policy API (WASM: rebuild) | ✅ JSON rules | ✅ DB adapters + watchers | ✅ grants from DB rows | updateRoles; when stays code |
|
| Batch checks | ✅ checkResources |
✅ CheckResources |
✅ CheckBulkPermissions |
❌ | ✅ batchEnforce |
❌ | ❌ | ❌ | |
| Database filtering | ✅ planResources + Cerbos ORM adapters |
✅ PlanResources + ORM adapters |
LookupResources returns ids |
✅ Compile API → SQL (server) | ✅ rulesToCondition, Prisma, Mongoose |
❌ | ❌ | ❌ | ❌ |
| "Who can access this?" | lookupSubjects) |
❌ | ✅ LookupSubjects |
✅ implicit-permission APIs | ❌ | ❌ | |||
| Explains a decision | ✅ includeMeta |
✅ includeMeta |
✅ debug trace | ✅ explain / trace (server) | ✅ relevantRuleFor |
✅ enforceEx |
❌ | ❌ | |
| Consistency tokens | ❌ | — (stateless) | ✅ ZedTokens | — | — | — | — | — | — |
| Tooling and operations | Kerberos.js | Cerbos | SpiceDB | OPA | CASL | casbin | AccessControl | easy-rbac | @rbac/rbac |
|---|---|---|---|---|---|---|---|---|---|
| Policy test runner | ✅ test DSL + CLI (Cerbos suite format) | ✅ cerbos compile |
✅ zed validate |
✅ opa test |
❌ | ❌ | ❌ | ❌ | ❌ |
| Attribute schema validation | ✅ JSON Schema / Zod | ✅ JSON Schema | ❌ | ❌ | ❌ | ❌ | ❌ | ||
| Audit / decision logs | ✅ structured logger + events | ✅ | ✅ decision logs (server) | ❌ | ✅ access events |
❌ | |||
| OpenTelemetry | ✅ traces + metrics | ✅ | ✅ traces | ✅ (server) | ❌ | ❌ | ❌ | ❌ | ❌ |
| Integrations | ✅ React, ORM adapters | — (clients) | ✅ React, Vue, Angular, Prisma, Mongoose | ✅ server middlewares, Casbin.js | ✅ Express, NestJS, Fastify | ||||
| Managed control plane | — | ✅ Cerbos Hub (free tier + paid) | ✅ AuthZed Cloud (paid) | — | — | — | — | — | |
| Reads Cerbos policies | ✅ YAML + CEL importer | ✅ native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
- casbin's README shows a browser import, but its main entry does not bundle for the browser without Node polyfills (
pnpm size:compare). Casbin.js is its separate frontend package. - @rbac/rbac describes itself as zero-dependency but depends on
zod. - AccessControl's deny wins within a role and the roles it inherits. When a check names several roles, a grant from one of them beats a deny from another.
- Kerberos.js implements Cerbos's
OVERRIDE_PARENTscope behaviour. The parental-consent mode and the other gaps are listed in DIVERGENCES.md.
"OPA" covers both the server and the WebAssembly SDK (@open-policy-agent/opa-wasm): rows marked "(server)" are not available in the SDK. Cerbos runs in the browser through @cerbos/embedded-client, whose WebAssembly bundles are built by Cerbos Hub. Compared versions: Kerberos.js 4.3, Cerbos 0.55, SpiceDB 1.56, OPA 1.21 with opa-wasm 1.10, CASL 7.0, casbin 5.51 (Node), AccessControl 3.1, easy-rbac 4.0 and @rbac/rbac 2.2, checked in October 2026 against each project's documentation and installed package. Throughput, cold start and bundle size of the same libraries are on the benchmarks page.
Where each one fits:
- Kerberos.js — a JS/TS stack with no authorization infrastructure, decisions in the browser or at the edge, Cerbos-style policies and query plans.
- Cerbos — polyglot backends and centrally governed policies, with Cerbos Hub as the control plane.
- SpiceDB — large relationship graphs that need Zanzibar-grade consistency.
- OPA — one general-purpose policy language across your stack, not only application authorization.
- CASL — permissions defined in code and shared with the UI, with field-level rules and ORM filtering.
- casbin — the same access model across many languages, with storage adapters.
- AccessControl, easy-rbac, @rbac/rbac — a role map with a few conditions, without policy documents.
Note
Compatibility with Cerbos is checked in CI by a conformance suite 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 (native CEL evaluation — the importer translates CEL instead — scopePermissions parental consent, auxData, globals and the Admin API) are catalogued in DIVERGENCES.md.
- Polyglot backends — if Go/Python/Java services need the same decisions, a central PDP (Cerbos) beats reimplementing policies per language.
- Zanzibar-grade consistency — the built-in ReBAC resolver reads current in-memory/cache state and deliberately has no revision tokens; if the New Enemy Problem matters for your threat model, use SpiceDB.
- Non-engineering policy ownership — policies here live in code/storage you control; if compliance teams need a managed policy workflow and UI, that is Cerbos Hub's territory.
| Area | What you get |
|---|---|
| Policy engine | Resource / principal / role policies (with parentRoles inheritance), derived roles, conditions, variables & constants, outputs, scopes & policy versions |
| APIs | isAllowed, checkResources, planResources (Cerbos-compatible query plans) |
| Dynamic policies | Cache-agnostic storage with a safe, eval-free $expr codec (jsep AST allowlist) |
| ReBAC | Relation-backed derived roles + a built-in Zanzibar-lite resolver (@alexify/kerberos/relations) |
| Observability | Audit logs (console / structured / Pino), OpenTelemetry traces + metrics, decision metadata |
| DX | Pluggable validation (Zod / JSON Schema + Ajv / TypeBox), testing DSL (/tests), typed authoring via an optional app schema, browser build, live playground |
| Compatibility | A conformance suite 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 |
Version 4.x — see the CHANGELOG for everything that changed since
3.1.0: verified Cerbos compatibility (a conformance corpus replayed against a live PDP, plus the/cerbosYAML + CEL policy importer), attribute-schema enforcement, policy-as-code tooling (the/loadersubpath and thekerberosCLI), typed policy authoring, and the code-review hardening waves — aConditionsfail-open fix, restored Node-ESM named exports, and a synchronous evaluation driver (~2.5× on simpleisAllowed).4.2.0closes the parity gaps a differential campaign against a live PDP turned up — CEL-style strict comparisons and Cerbos's resource-kind sanitization — see its upgrade notes.4.3.0is a performance release: rules indexed by action and role, derived roles and the scope walk evaluated only as far as the decision needs (which also aligns unreached-scope behaviour with Cerbos), and ReBAC batches that share unfinished subproblems.
- Installation
- Quick Start
- Policy Types
- Scopes and Policy Versions
- API Reference
new Kerberos(...)·isAllowed·checkResources·planResources· Events · Errors · Exports
- TypeScript
- Configuration Options
- Outputs
- Decision metadata (includeMeta)
- Caching / Storing Policies
- Importing Cerbos Policies
- Loading Policies from Files
- ReBAC (Relations)
- Query Plans (planResources)
- Testing
- Schema Validation
- OpenTelemetry
- Hooks & events
- Benchmarks
- Changelog · License · Used by
npm install @alexify/kerberosRequires Node.js ≥ 18 (or any modern browser through a bundler). The package is CommonJS; both require('@alexify/kerberos') and import { Kerberos } from '@alexify/kerberos' (via Node/bundler ESM interop) work — the examples below use import.
Zero runtime dependencies. Measured with pnpm size (esbuild browser bundle, fully minified with identifier mangling, then gzipped):
| Entry | min | min+gzip |
|---|---|---|
@alexify/kerberos (main entry, query planner included) |
134.3 KB | 38.3 KB |
@alexify/kerberos/relations (opt-in ReBAC resolver) |
71.6 KB | 20.0 KB |
@alexify/kerberos/cerbos (opt-in Cerbos importer) |
34.0 KB | 10.9 KB |
@alexify/kerberos/loader (Node-only; browser bundlers get a throwing stub) |
1.0 KB | 0.5 KB |
Every subpath (/relations, /cerbos, /loader, /tests) is only bundled if you import it. Optional tooling (jsep, zod, ajv, @sinclair/typebox, @opentelemetry/api) is never included — you install what you use.
The package ships two entrypoints: a Node.js entry (index.js, uses node:crypto and the monotonic process.hrtime clock directly) and a browser entry (browser.js) declared via the package.json browser field and the browser condition in exports. Browser bundlers pick the browser build automatically — no configuration needed for webpack 5, Vite, esbuild (platform: 'browser'), Parcel or Bun. Rollup users need @rollup/plugin-node-resolve with browser: true.
The browser build contains zero Node.js builtins — the only platform-specific code (generateCallId, getNow) is swapped to a browser implementation backed by globalThis.crypto.randomUUID and globalThis.performance.
Notes:
- In insecure contexts (plain HTTP), where
crypto.randomUUIDis unavailable, call IDs fall back to aMath.random-based pseudo UUID. Call IDs are correlation identifiers, not security tokens, so this is safe. - The package is CommonJS, so browser usage requires a bundler (no bare
<script>tag). - Node.js itself ignores the
browserfield entirely — server-side usage (with or without a bundler) always resolves the Node entry.
A resource policy with a derived role (a role computed per request — here, "the owner of this expense"), checked through both public APIs:
import { Kerberos, Effect } from '@alexify/kerberos';
const expensePolicy = {
resourcePolicy: {
resource: 'expense', // applies to resources of kind 'expense'
version: 'default',
importDerivedRoles: ['common_roles'],
rules: [
{ actions: ['*'], effect: Effect.Allow, roles: ['ADMIN'] },
{ actions: ['view', 'delete'], effect: Effect.Allow, derivedRoles: ['OWNER'] },
{
actions: ['view'],
effect: Effect.Allow,
roles: ['USER'],
condition: { match: ({ R }) => R.attr.status === 'OPEN' },
},
],
},
};
const commonRoles = {
name: 'common_roles',
definitions: [
{ name: 'OWNER', parentRoles: ['USER'], condition: { match: ({ P, R }) => R.attr.ownerId === P.id } },
],
};
const kerberos = new Kerberos([expensePolicy], [commonRoles]);
// Single decision:
await kerberos.isAllowed({
principal: { id: 'sally', roles: ['USER'] },
action: 'delete',
resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally', status: 'OPEN' } },
}); // → true (OWNER derived role)
// Batch decisions:
const response = await kerberos.checkResources({
principal: { id: 'frank', roles: ['USER'] },
resources: [
{ resource: { id: 'expense1', kind: 'expense', attr: { ownerId: 'sally', status: 'OPEN' } }, actions: ['view', 'delete'] },
],
});
// {
// kerberosCallId: 'b9c4362d-…', // generated UUID for audit correlation
// results: [{
// resource: { id: 'expense1', kind: 'expense' },
// actions: { view: 'EFFECT_ALLOW', delete: 'EFFECT_DENY' },
// outputs: [],
// }],
// }From here: principal and role policies for overrides and allowlists, planResources for "which resources can this principal access" filters, dynamic policies for cache-stored rules, and ReBAC for relationship-based access.
Kerberos.js supports three policy types:
resourcePolicy: selected byresource.kind,resource.policyVersion, andresource.scopeprincipalPolicy: selected byprincipal.id,principal.policyVersion, andprincipal.scoperolePolicy: selected by eachprincipal.roles[], and — like resource policies —resource.policyVersionandresource.scope
You can pass either type on its own or mix them in the same constructor call:
const kerberos = new Kerberos(
[
expenseResourcePolicy,
sallyPrincipalPolicy,
userRolePolicy,
],
[commonRoles]
);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 for a complete example.
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:
rules: [
{ actions: ['close'], effect: Effect.Allow, roles: ['SUPPORT'] },
{ actions: ['close'], effect: Effect.Deny, roles: ['AUDITOR'] },
];
// principal roles ['SUPPORT', 'AUDITOR'] -> EFFECT_ALLOWA deny that is meant to hold regardless has to cover the role carrying the allow — either with the '*' wildcard or by naming it:
{ actions: ['close'], effect: Effect.Deny, roles: ['*'] } // always denies
{ actions: ['close'], effect: Effect.Deny, roles: ['SUPPORT', 'AUDITOR'] } // denies both rolesDerived 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 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.
const sallyPrincipalPolicy = {
principalPolicy: {
principal: 'sally',
version: 'default',
scope: 'acme.corp',
constants: {
restrictedVendor: 'Flux Water Gear',
},
variables: {
isRestrictedVendor: ({ R, C }) => R.attr.vendor === C.restrictedVendor,
},
rules: [
{
resource: 'expense',
actions: [
{
name: 'deny-restricted-vendor-view',
action: 'view',
effect: Effect.Deny,
condition: {
match: ({ V }) => V.isRestrictedVendor,
},
},
{
name: 'allow-delete-override',
action: 'delete',
effect: Effect.Allow,
},
],
},
],
},
};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 — 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
ResourcePolicyat all, nothing is allowed. - Multiple role policies union. A principal may do what any of its roles permits — provided the resource policy's allow reaches that same role (another role's allowlist cannot revive it). Holding an extra role can widen access, never narrow it.
- A role without any role policy is unrestricted. Its decisions come straight from the resource policy. A role that has a role policy, however, is constrained for every resource kind wherever that policy sits on the scope chain — a kind its rules never mention gets nothing for that role.
// 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)const userRolePolicy = {
rolePolicy: {
role: 'USER',
version: 'default',
scope: 'acme.corp',
constants: {
restrictedVendor: 'Flux Water Gear',
},
variables: {
isRestrictedVendor: ({ R, C }) => R.attr.vendor === C.restrictedVendor,
},
rules: [
{
resource: 'expense',
allowActions: ['create'],
},
{
resource: 'expense',
allowActions: ['view'],
condition: {
match: ({ V }) => V.isRestrictedVendor === false,
},
},
],
},
};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.
When mixed policy types are present, Kerberos resolves each action in this order:
- Principal policies first. Walk the principal's scope chain: the first
PrincipalPolicywhose rule fires for the action decides it (EFFECT_DENYbeatsEFFECT_ALLOWwithin a policy; a rule whose condition fails decides nothing and falls through to the parent scope). That decision is final — role policies never narrow a principal-policy override. - Then one decision walk over resource and role policies, run per principal role and down the resource's scope chain. At each scope a role sees the
ResourcePolicyrules that fired and reach it — throughroles, or through a derived role whoseparentRolescover it — plus a deny for every action that aRolePolicyat that scope (for the role or one of itsparentRolesancestors) does not allowlist.EFFECT_DENYbeatsEFFECT_ALLOWwithin a scope, the first scope that decides seals that role, and anEFFECT_ALLOWreached by any role wins. - Imported derived roles are resolved lazily: a condition-backed definition is evaluated only when a rule of the requested actions asks about it (once per request, however many scopes import it), and a relation-backed definition (the
relation:field) resolves through the configuredrelationsresolver (ReBAC) only when such a rule references it —list-first with parallelcheckfallback, one shared memo per request. - If nothing decides the action, 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 at a fixed policyVersion, and checks in-memory policies first, then the optional cache.
flowchart TD
A(["Request: principal ·<br/>resource · action"]) --> P{{"PrincipalPolicy chain<br/>principal.id, along the<br/>principal's scope chain"}}
P -->|"a rule fired:<br/>ALLOW / DENY"| DONE(["Action effect resolved"])
P -->|"no rule fired"| DR
subgraph DR ["Derived roles, on demand"]
SYNC["Condition-backed:<br/>parentRoles + condition,<br/>on first ask"]
REL["Relation-backed:<br/>relations resolver,<br/>list-first, shared memo"]
end
DR --> WALK{{"Decision walk<br/>per principal role,<br/>resource scope chain:<br/>resource rules +<br/>role-policy deny rows"}}
WALK -->|"an ALLOW reached<br/>some role"| DONE
WALK -->|"no role allowed"| DEF(["Default: EFFECT_DENY"])
DEF --> DONE
Within the role layer: a role policy only takes away — it denies the actions it does not allowlist, and the allow must still come from a resource rule reaching the same role. Across roles the principal may do what any role allows (union); a role with no role policy at all is unrestricted. 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). The architecture guide walks through the full procedure with a worked example.
This keeps Kerberos.js aligned with the Cerbos-style principal override model described in the Cerbos principal policies documentation while extending the runtime with role-centric policy evaluation similar to Cerbos role policies.
Kerberos.js supports scoped policies and policy versions, allowing you to organize policies for different environments or versions.
Policy selection depends on the policy type:
ResourcePolicyresource.kindresource.policyVersion(defaults to'default'when omitted)resource.scope
PrincipalPolicyprincipal.idprincipal.policyVersion(defaults to'default'when omitted)principal.scope
RolePolicy- each
principal.roles[]entry (plus everyparentRolesancestor) resource.policyVersion(defaults to'default'when omitted)resource.scope
- each
Scope behavior follows the Cerbos-style model:
- If
scopeis not provided for the relevant side of the lookup, Kerberos.js evaluates only the base policy without a scope. - If
scopeis provided, Kerberos.js searches from the most specific scope to the least specific scope, and finally falls back to the base policy. - Example search chain for
scope: 'acme.corp':acme.corp -> acme -> ''
When both policy types are loaded, Kerberos first resolves principal overrides using the principal's scope/version chain, and falls back to the resource layer — resource policies and role policies, both keyed by the resource's version and scope — when no principal policy decides the action.
| Policy | Looked up by | Version | Scope chain |
|---|---|---|---|
resourcePolicy |
resource.kind |
resource.policyVersion |
resource.scope |
principalPolicy |
principal.id |
principal.policyVersion |
principal.scope |
rolePolicy |
each principal.roles[] (+ parentRoles ancestors) |
resource.policyVersion |
resource.scope |
derivedRoles |
name, from the resource policy's importDerivedRoles |
— (unversioned) | — |
Three consequences:
- The version is fixed along the whole scope chain — the walk never crosses versions — and there is no fallback: asking for a version no policy carries resolves to nothing rather than to
'default'. - A request may mix versions.
principal.policyVersion: 'v2'with an unversioned resource evaluates the principal policy atv2and the resource/role layer at'default'. - In
checkResourcesthe principal chain is resolved once per batch while every resource uses its ownpolicyVersion;planResourcesechoes the resource's version back aspolicyVersion.
Cerbos 0.41+ differs here: it selects principal policies by the resource's version, a regression against its own documentation and its pre-0.41 engine. When the same policies are served by both engines, send the same value in both fields. See DIVERGENCES.md.
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.
- A scope the walk never reaches for an action is not evaluated at all: its conditions do not run — so an erroring condition there cannot fail the request — and its outputs are not emitted. This is what Cerbos does too (verified on a live PDP).
- 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.)
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.
Resource kind names are compared after Cerbos's own sanitization (namer.SanitizedResource): for a name shaped like foo, foo:bar, foo-bar or foo/bar, every run of characters outside [A-Za-z0-9_.] becomes _ — on the policy field and on resource.kind alike. So gk* does match the kind gka:b (which is gka_b at match time), doc:* matches no kind at all (a pattern is never sanitized, and no sanitized kind keeps a :), and ka-b, ka_b and ka/b are one and the same resource — declaring policies for two of those spellings throws Duplicate resource policy. Principal ids and role names are not sanitized.
Example:
const results = await kerberos.checkResources({
reqId: 'test-request',
principal: {
id: 'alice',
policyVersion: '20210210', // Optional: selects the principal policy version
scope: 'acme.corp', // Optional: principal policy scope chain
roles: ['employee'],
attr: {
department: 'accounting',
geography: 'GB'
}
},
resources: [
{
resource: {
id: 'XX125',
kind: 'leave_request',
policyVersion: '20210210', // Optional: specify resource policy version
scope: 'acme.corp', // Optional: specify resource scope
attr: {
department: 'accounting',
owner: 'john'
}
},
actions: ['view:public', 'approve', 'create']
}
],
includeMeta: true // Optional: include metadata in response
});| Parameter | Type | Description |
|---|---|---|
policies |
Array<ResourcePolicy | PrincipalPolicy | RolePolicy | object> |
Static policies loaded into memory. Plain objects are auto-detected by their resourcePolicy / principalPolicy / rolePolicy key. May be empty when policies are resolved from a cache. |
derivedRoles |
Array<DerivedRoles | object> |
Optional derived-role definition sets. |
options |
object |
Optional configuration — see Configuration Options. |
Evaluates a single action against a single resource and returns a boolean.
args.principal— the principal (id,roles, optionalpolicyVersion,scope,attr).args.action— the action to check.args.resource— the resource (id,kind, optionalpolicyVersion,scope,attr).args.reqId— optional correlation id echoed in logs.args.includeMeta— whentrue, enables decision tracing (visible in audit logs).
const allowed = await kerberos.isAllowed({
principal: { id: 'user1', roles: ['USER'], policyVersion: 'default', scope: 'acme.corp' },
action: 'view',
resource: { id: 'expense1', kind: 'expense', attr: { amount: 5000, status: 'OPEN' } },
reqId: 'optional-correlation-id', // optional
});Evaluates multiple resources and actions in a single request.
args.principal— the principal (id,roles, optionalpolicyVersion,scope,attr).args.resources— array of{ resource, actions }entries.args.reqId— optional correlation id echoed in the response and logs.args.includeMeta— whentrue, includes evaluation metadata.effectAsBoolean— whentrue, action results aretrue/falseinstead ofEFFECT_ALLOW/EFFECT_DENY.
const response = await kerberos.checkResources({
principal: { id: 'user1', roles: ['USER'] },
resources: [{ resource: { id: 'expense1', kind: 'expense' }, actions: ['view', 'create'] }],
});
// {
// kerberosCallId: 'b9c4362d-…', // always present, for audit correlation
// reqId: '…', // present only if provided in the request
// results: [{ resource, actions, outputs, meta? }],
// }Builds a resources query plan: instead of a yes/no decision for one resource, it returns a filter describing which resources of a kind the principal may act on — ready to translate into a database query. See Query Plans.
args.principal— the principal (id,roles, optionalpolicyVersion,scope,attr).args.resource— the resource kind (kind, optionalpolicyVersion,scope,attr). Noid:attrcarries only the known attributes; everything else stays unknown and surfaces in the filter.args.actionorargs.actions— exactly one of them; multiple actions plan the conjunction (Cerbos AND semantics). The wildcard'*'cannot be planned.args.reqId/args.includeMeta— as incheckResources;includeMetaaddsfilterDebug,matchedScopesand theresolutiontrace.
const plan = await kerberos.planResources({
principal: { id: 'user1', roles: ['USER'] },
resource: { kind: 'expense' },
action: 'view',
});
// {
// kerberosCallId: '…', action: 'view', resourceKind: 'expense', policyVersion: 'default',
// filter: { kind: 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL', condition? },
// }on, once, off and removeAllListeners(event?) (all chainable) plus listenerCount(event) subscribe to the engine's lifecycle events — request:start / request:end / request:error, decision, plan, relations:resolved, cache:hit / cache:miss / cache:error. Listeners are contained (a throwing listener never affects a decision) and there is no public emit. Lifecycle hooks (awaited, may veto) are configured through the hooks option instead. See Hooks & events.
kerberos.on('decision', ({ callId, resource, actions }) => metrics.record(callId, resource.kind, actions));All error classes are exported from the main entry. Evaluation-phase errors follow the onError option; KerberosValidationError always throws.
| Class | Thrown when |
|---|---|
KerberosValidationError |
Malformed method arguments or request shapes (always propagates — a programming error, not a deny). |
KerberosCacheError |
A transient cache.get failure persists after the cacheRetry attempts. |
KerberosCodecError |
A cached policy/tuple document is corrupt or fails to deserialize (for policies it is logged and counts as a miss; for ReBAC tuple documents it throws — see Dynamic tuples). |
KerberosExprError |
A { $expr } string uses a construct outside the safe allowlist, exceeds codec limits, or fails to parse. |
KerberosRelationsError |
The built-in ReBAC resolver hits maxDepth, a throwing caveat, or invalid relation data. |
KerberosHookError |
A lifecycle hook threw, exceeded hooksTimeoutMs (timedOut: true) or returned an invalid replacement — hook names it, cause is the original error. Follows onError in the engine, always propagates from the resolver; see Hooks & events. |
| Export | Purpose |
|---|---|
Kerberos |
Main authorization engine. |
Effect |
{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' } — a frozen const object, not an enum. |
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. |
PlanKind |
{ AlwaysAllowed, AlwaysDenied, Conditional } — query plan filter kinds. |
expandRelationOperands |
Materializes ReBAC relation operands of a query plan into id filters. |
toCerbosQueryPlan |
Converts a plan to the @cerbos/core SDK shape for the official Cerbos ORM adapters. |
KerberosValidationError, KerberosCacheError, KerberosCodecError, KerberosExprError, KerberosRelationsError, KerberosHookError |
Typed error classes. |
registerAjvKeywords, createAjvAdapter |
Validation helpers. |
resolveValidationAdapter, toValidationAdapter, parseWithValidation |
Backend dispatch used by every DSL module — pick an adapter (explicit → Zod → TypeBox+Ajv → JSON Schema+Ajv → passthrough) and parse with it. |
createCacheReader |
Wraps any get(key) store as the engine's read-only policy fallback layer. |
JsonSchemas, TypeBoxSchemas, ZodSchemas, KerberosJsonSchemas, ResourcePolicyJsonSchemas, PrincipalPolicyJsonSchemas, RolePolicyJsonSchemas, … |
Schema builders for the three backends. |
ALL_ACTIONS, ALL_ROLES, ALL_RESOURCES, DEFAULT_VERSION, BASE_SCOPE |
Wildcard/default tokens ('*', 'default', ''). |
Subpath @alexify/kerberos/relations (opt-in ReBAC — kept out of the main entry so non-ReBAC bundles do not grow):
| Export | Purpose |
|---|---|
RelationResolver |
The built-in Zanzibar-lite resolver (check / list / lookupSubjects / lookupResources). |
RelationSchema |
Compiles the relation-schema DSL standalone (validated schemas reusable across resolvers). |
Relations*Schemas |
Schema builders for the resolver's shapes (three validation backends). |
parseRelationSchemaShape, parseObjectRef, parseSubjectRef, parseTuple |
Standalone parsers/validators for schema documents, type:id refs and tuples. |
buildAdmissionKey |
Builds the type + relation + subjectType admission key the compiled schema indexes by. |
Subpath @alexify/kerberos/tests (dev/test only — not loaded by the main entry):
| Export | Purpose |
|---|---|
KerberosTest, KerberosTests |
Cerbos-style declarative test runner. |
PrincipalMock, PrincipalsMock, ResourceMock, ResourcesMock |
Named fixtures for test suites. |
*ZodSchemas, *JsonSchemas, *TypeBoxSchemas |
Schema builders for the test harness. |
Subpath @alexify/kerberos/loader (Node-only file/directory loader + versioned bundles; browser bundlers substitute throwing stubs):
| Export | Purpose |
|---|---|
loadPolicyDirectory, loadPolicyFile |
Read Kerberos JSON / Cerbos YAML+JSON policy files (+ _schemas/) into constructor inputs. |
createPolicyBundle, writePolicyBundle, loadPolicyBundle |
Hash-stamped (SHA-256, content-addressed) policy bundles with load-time integrity verification. |
promises |
The asynchronous driver — the same four functions returning promises, reading files concurrently (concurrency, default 64). |
KerberosLoaderError |
Typed error for I/O, format and bundle-integrity failures (carries file). |
Subpath @alexify/kerberos/cerbos (the Cerbos policy importer — kept out of the main entry):
| Export | Purpose |
|---|---|
importCerbosPolicies |
Cerbos YAML/JSON documents → { policies, derivedRoles } serialized Kerberos documents. |
celToExpr |
Translates one CEL expression into a $expr-compatible JavaScript expression string. |
parseYamlDocuments |
The zero-dependency YAML-subset parser, standalone. |
KerberosImportError |
Typed error for unsupported constructs (carries line for YAML errors). |
Kerberos.js ships hand-maintained types. By default every position is open — kind and action are string, attr is Record<string, unknown> — 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.
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<AppSchema>(policies, derivedRoles);Both keys are optional — declare only resources if you do not want to enumerate roles.
The resource kind drives everything else. action, attr, and the condition callbacks all narrow to the kind you named:
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:
const policy: KerberosPolicy<AppSchema> = {
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:
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.
Exported so you can build your own typed wrappers (an Express middleware, a React hook) over the same schema:
| Type | Resolves to |
|---|---|
ResourceKindOf<S> |
Union of declared resource kinds. |
ActionOf<S, K> |
Actions for kind K; every action across all kinds when K is omitted. |
ResourceAttrOf<S, K> |
Attribute bag of kind K. |
PrincipalRoleOf<S> / PrincipalAttrOf<S> |
Declared principal roles / attributes. |
RequestPrincipal<S>, RequestResource<S, K>, BaseRequest<S, K> |
Request shapes. |
PolicyEvalRequest<S, K> |
The { P, R, V, C } envelope a condition/variable/output callback receives. |
CheckResourcesArgs<S>, CheckResourcesResponse<S, E>, PlanResourcesArgs<S, K>, PlanResourcesResponse<S> |
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.
Effect and PlanKind are const objects rather than TypeScript enums, so the raw wire strings that a stored policy or a serialized plan actually carries stay assignable:
const rule = { actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['user'] }; // ✅ no `Effect.Allow` neededThe Kerberos constructor accepts an optional third parameter with configuration options:
const kerberos = new Kerberos(policies, derivedRoles, {
logger: true, // Legacy console audit logging with summary + table + debug(json)
onError: 'deny', // 'throw' (default) or 'deny' — fail-closed evaluation errors
telemetry, // Optional: OpenTelemetry traces + metrics ({ api } or { tracer, meter })
cache, // Optional: any cache solution exposing get(key) (keyv, cacheable, ...)
cacheRetry: { attempts: 3 }, // Optional: retry policy for transient cache.get failures
codec, // Optional: (de)serialization codec for dynamic policies ({ jsep } or { deserialize })
relations, // Optional: ReBAC resolver for relation-backed derived roles
hooks: { beforeRequest, afterRequest }, // Optional: lifecycle hooks (awaited; throw to veto, return args to enrich)
hooksTimeoutMs: 500, // Optional: a hook that never settles fails as KerberosHookError instead of hanging
maxListeners: 10, // Optional: listener-leak warning threshold per event name (0 disables)
z, // Optional: validate with Zod
ajv, // Optional: validate with Ajv
typebox: Type, // Optional: switch Ajv validation to TypeBox builders
getCallId: () => `custom-${Date.now()}`, // Custom call ID generator (optional)
});-
logger(boolean | KerberosLogger): Enable audit logging.truekeeps the legacy console behavior withgroup + summary + table + debug(json)falseor omitted disables logging- a custom
console-like logger keeps the legacy table/json flow - a structured logger such as
Pinoreceives one structured audit entry per evaluated action - Logging is pure observability: it never changes decisions or error behavior (that is
onError's job), and a throwing logger is swallowed — it can never affect authorization.
-
onError('throw' | 'deny', default'throw'): What happens when policy evaluation fails at runtime (a throwing condition function, a failing cache backend, a ReBAC resolver error).'throw'propagates the error to the caller;'deny'fails closed:isAllowedresolves tofalse,checkResourcesto one all-DENY result per requested resource (positional parity with the request, like the per-resource fail-closed path — entries that cannot be echoed back from malformed arguments are skipped),planResourcesto aKIND_ALWAYS_DENIEDfilter.- Malformed arguments are programming errors and always throw
KerberosValidationError, regardless of this option.
// Fail-closed setup: evaluation errors deny instead of throwing. const kerberos = new Kerberos(policies, derivedRoles, { onError: 'deny' });
-
telemetry(KerberosTelemetryOptions): Enable OpenTelemetry traces and metrics. Pass{ api }(the@opentelemetry/apimodule) or{ tracer, meter }instances — see OpenTelemetry. -
cache(CacheLike): An optional cache used as a fallback source for dynamic/stored policies. Any object exposing aget(key)method is accepted (keyv, cacheable, cache-manager, ...). See Caching / Storing policies. -
cacheRetry({ attempts?, delayMs?, jitter?, timeoutMs?, onExhausted? }, default{ attempts: 3, delayMs: 25, jitter: true }): Retry policy forcache.getfailures. Attempts are spaced by full-jitter exponential backoff (delayMsbase, doubling per attempt;delayMs: 0restores immediate retries); deterministic adapter errors (TypeError/SyntaxError) are never retried.timeoutMs(off by default) bounds each read attempt so a hung backend fails instead of hanging authorization. After the attempts are exhausted the failure surfaces asKerberosCacheError(and then followsonError) — unlessonExhausted: 'miss'opts into degraded mode: the read counts as a cache miss and evaluation falls through to the remaining static sources, so a cache outage no longer disables statically-resolvable decisions (the degradation stays visible via thekerberos.cache.requestserrormetric and a guarded error log entry).attempts: 1disables retrying. -
cacheKeyPrefix(string, default''): Prefix prepended to every cache key (policies and derived roles). Use it to namespace tenants or environments sharing one store — derived-roles documents are otherwise a single globalderivedRoles:<name>namespace, so two tenants publishing the same definition name on a shared store would silently overwrite each other. -
relationsTimeoutMs(number, off by default): Bounds eachrelations.check/relations.listcall; a resolver that neither resolves nor rejects fails asKerberosRelationsError(followingonError) instead of hanging the request. -
audit({ includeMeta?: boolean }): Engine-level audit enrichment. With{ includeMeta: true }and a logger attached, decision tracing runs for every request, so audit entries always carrymeta.resolutionand thepolicy-missreason — audit completeness stops depending on each call site remembering the per-requestincludeMetaflag. The response stays gated on the request flag. -
maxConcurrency(number, unbounded by default): Caps how many resources of acheckResourcesbatch evaluate at once. Without it a 10k-resource batch launches 10k concurrent evaluation chains (each issuing its own cache reads) — memory spikes, event-loop saturation and a thundering herd on the cache backend. The built-inRelationResolveraccepts the same option for itslookupResourcescandidate-verification fan-out. -
codec(PolicyCodec): How cached policy documents are transformed before construction:{ jsep }enables the built-in safe$exprevaluator,{ deserialize }plugs in your own logic, and when omitted cached values are passed to policy constructors as-is — seecodecoption — three modes. -
schemas({ enforcement?, definitions? }): Attribute schema enforcement — Cerbosschemasparity. Resource policies declareschemas.principalSchema/resourceSchemarefs (with optionalignoreWhen.actionsglobs); this option maps the refs to validators and picks the level:'reject'(default when set) denies requests whose attributes fail validation,'warn'reports without changing decisions,'none'disables (the Cerbos default when unconfigured). Failures are returned as Cerbos-shapedvalidationErrors({ path, message, source }) oncheckResourcesresults — regardless ofincludeMeta— and reach the audit log. A definition may be a JSON Schema object (compiled with theajvoption), a Zod schema, or a validator function. See Attribute schemas. -
relations(KerberosRelationsResolver): ReBAC resolver used by relation-backed derived roles — any object with acheck(args, opts)method (and an optional batchedlist). See ReBAC (Relations). -
hooks(KerberosHooks): Lifecycle hooks —beforeRequest,afterRequest,beforeResource,afterResource,onError— awaited inside the request flow. A throwing hook vetoes the request asKerberosHookError(followingonError);beforeRequestmay return replacement arguments to enrich the request (re-validated, markedenrichedon audit entries, spans and events);onError, a failed request'safterRequestand a failed resource'safterResourceare swallowed and never mask the original error. Unknown names / non-functions throw at construction. See Hooks & events. -
hooksTimeoutMs(number, off by default): Bounds every awaited hook invocation; a hook that neither resolves nor rejects fails asKerberosHookError(timedOut: true, then following that hook's throwing/swallowing rule) instead of hanging authorization — the hook counterpart ofrelationsTimeoutMs. -
maxListeners(number, default10): Listener-leak detection for the events façade — the first subscription past this count on one event name logs aconsole.warn(subscribe once at startup, not per request). Never a limit;0disables the warning. The built-inRelationResolveraccepts the same option. -
z: Enables validation using the built-in Zod schema builders. -
ajv: Enables validation using the built-in JSON Schema builders compiled with Ajv. -
typebox: When used together withajv, switches validation to the built-in TypeBox builders. -
getCallId(function): Custom function to generate call IDs for audit tracking.- Default behavior: Uses
crypto.randomUUID()in Node.js,window.crypto.randomUUID()in browsers, or falls back to a pseudo UUID generator - Custom example:
() => \req-${Date.now()}-${Math.random()}``
- Default behavior: Uses
If you want machine-readable audit logs in production, pass a Pino instance as the logger option:
import pino from 'pino';
import { Kerberos } from '@alexify/kerberos';
const logger = pino({ level: 'info' });
const kerberos = new Kerberos(policies, derivedRoles, {
logger,
});With Pino, Kerberos emits structured audit entries that include callId, reqId, reqKind, principalId, principalRoles (the role set the decision was based on — roles change over time, so past entries stay explainable), resourceId, action, effect, outputs, and meta. Fail-closed denials are part of the stream too: a resource whose evaluation failed inside a checkResources batch (and the onError: 'deny' fallback of isAllowed) logs its DENY decisions marked reason: 'evaluation-error', and planResources results (PlanResources.result, with the filter kind) go out at info level like other decision entries — only lifecycle *.start/*.finish events sit at debug. This mode is better suited for production ingestion than the default console table output.
It also emits lifecycle logs such as IsAllowed.start, IsAllowed.error, IsAllowed.finish, CheckResources.start, CheckResources.finish and PlanResources.*. Errors are always logged, but whether they are rethrown or converted into a fail-closed response is decided solely by the onError option — never by the logger.
Every request (isAllowed / checkResources / planResources) automatically generates a unique kerberosCallId for audit tracking:
- Node.js: Uses
crypto.randomUUID() - Browser: Uses
window.crypto.randomUUID() - Fallback: Pseudo UUID v4 generator if crypto APIs are unavailable
- Custom: Provide your own
getCallIdfunction for custom ID formats
This ID is included in both the response and audit logs for correlation.
Kerberos.js supports outputs functionality similar to Cerbos. You can define output expressions that are evaluated when policy rules are activated or when conditions are not met. These outputs are included in the API response and can be used to provide detailed information about policy decisions.
You can add output functions to your policy rules:
const policyWithOutputs = {
resourcePolicy: {
version: 'default',
resource: 'system_access',
rules: [
{
name: 'working-hours-only',
actions: ['*'],
effect: Effect.Deny,
roles: ['*'],
condition: {
match: () => {
const now = new Date();
return now.getHours() > 18 || now.getHours() < 8;
}
},
output: {
when: {
ruleActivated: ({ P, R }) => ({
principal: P.id,
resource: R.id,
timestamp: new Date().toISOString(),
message: "System can only be accessed between 0800 and 1800"
}),
conditionNotMet: ({ P, R }) => ({
principal: P.id,
resource: R.id,
timestamp: new Date().toISOString(),
message: "System can be accessed at this time"
})
}
}
},
{
name: 'admin-access',
actions: ['*'],
effect: Effect.Allow,
roles: ['admin'],
output: {
when: {
ruleActivated: ({ P }) => ({
message: "Admin access granted",
admin: P.id
})
}
}
}
]
}
};The checkResources method returns outputs in the response:
const results = await kerberos.checkResources({
principal: {
id: 'john',
roles: ['user']
},
resources: [
{
resource: {
id: 'bastion_002',
kind: 'system_access'
},
actions: ['login']
}
]
});
console.log(results);
// {
// results: [
// {
// resource: { id: 'bastion_002', kind: 'system_access' },
// actions: { login: 'EFFECT_DENY' },
// outputs: [
// {
// src: 'resource.system_access.vdefault#working-hours-only',
// val: {
// principal: 'john',
// resource: 'bastion_002',
// timestamp: '2023-06-02T20:53:58.319Z',
// message: 'System can only be accessed between 0800 and 1800'
// }
// }
// ]
// }
// ]
// }Output functions are JavaScript functions that receive the request context and return any value:
// Basic function syntax
({ P, R, V, C }) => {
// Your logic here
return {
principal: P.id,
resource: R.kind,
timestamp: new Date().toISOString()
};
}Available context parameters:
- P: Principal object with
id,roles, andattr - R: Resource object with
idandkind - V: Variables (computed values)
- C: Constants (static values)
Output functions are called when:
- ruleActivated: The rule matches and its condition is satisfied
- conditionNotMet: The rule matches but its condition is not satisfied
The output src field reflects the policy type that produced it:
- Resource policy example:
resource.expense.vdefault#rule-name - Principal policy example:
principal.sally.vdefault#rule-name
When includeMeta: true is set, the response includes additional metadata about policy evaluation:
const results = await kerberos.checkResources({
principal: {
id: 'alice',
scope: 'acme.corp',
roles: ['employee']
},
resources: [
{
resource: {
id: 'XX125',
kind: 'leave_request',
policyVersion: '20210210',
scope: 'acme.corp'
},
actions: ['view:public', 'approve']
}
],
includeMeta: true
});
console.log(results);
// {
// reqId: 'test-request',
// kerberosCallId: 'b9c4362d-b92a-4c2b-9d49-845f00d7a372',
// results: [
// {
// resource: {
// id: 'XX125',
// kind: 'leave_request',
// policyVersion: '20210210',
// scope: 'acme.corp'
// },
// actions: {
// 'view:public': 'EFFECT_ALLOW',
// 'approve': 'EFFECT_DENY'
// },
// outputs: [
// {
// src: 'resource.leave_request.v20210210/acme.corp#rule-001',
// val: 'create_allowed:john'
// }
// ],
// meta: {
// actions: {
// 'view:public': {
// matchedPolicy: 'resource.leave_request.v20210210/acme.corp',
// matchedRule: 'resource.leave_request.v20210210/acme.corp#rule-001',
// matchedScope: 'acme.corp'
// },
// 'approve': {
// matchedPolicy: 'resource.leave_request.v20210210/acme.corp',
// reason: 'condition-not-met'
// }
// },
// effectiveDerivedRoles: [
// 'employee_that_owns_the_record',
// 'any_employee'
// ],
// resolution: [
// { source: 'principal', id: 'alice', version: 'default',
// scopesSearched: ['acme.corp', 'acme', ''], matchedScope: null },
// { source: 'resource', id: 'leave_request', version: '20210210',
// scopesSearched: ['acme.corp', 'acme', ''], matchedScope: 'acme.corp' }
// ]
// }
// }
// ]
// }Per action, meta.actions[action] includes:
- matchedPolicy: The policy source that produced the decision — a resource source such as
resource.expense.vdefault/acme.corp, a principal source such asprincipal.sally.vdefault/acme.corp, or a role source such asrole.USER.vdefault - matchedRule: The exact rule that produced the decision
- matchedScope: The scope of the matched policy (present for scoped policies)
- reason (denied actions only): why nothing allowed the action —
'rule-miss'(no rule targeted the action / matched the principal's roles),'condition-not-met'(a rule targeted it but its condition failed),'policy-miss'(no applicable policy existed at all) or'evaluation-error'(the resource's evaluation rejected inside acheckResourcesbatch and failed closed — paired witherrorNameso an outage is distinguishable from a policy DENY)
At the result level:
- effectiveDerivedRoles: derived roles that activated for this resource — every active imported derived role, as Cerbos reports it (relation-backed ones only when a rule of the request references them, since only those are resolved)
- resolution (decision trace): every policy lookup that was attempted —
{ source, id, version, scopesSearched, matchedScope, origin? }entries (withorigin: 'cache'for cache-resolved policies),{ source: 'derivedRoles', name, matched, origin? }entries for every imported derived-roles set (matched: false= the import resolved nowhere — e.g. an evicted or corrupt cache document silently stopping rules from matching), plus{ source: 'relations', name, relation, matched, reason? }entries for the referenced relation-backed derived roles. The same trace appears inplanResourcesmeta.
Kerberos.js can resolve policies dynamically from a remote store (Redis, MongoDB, PostgreSQL, in-memory, ...) instead of loading every policy up front. Following the same delegating philosophy as the logger option, Kerberos stays agnostic: it does not implement caching, TTL or invalidation logic itself. You pass a cache, and Kerberos simply calls cache.get(key) when it needs a policy. Everything else — storage, layering, expiry, and multi-host invalidation — is delegated to dedicated solutions such as keyv, cacheable (CacheSync) and qified.
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:
- 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
cacheis configured — callawait cache.get(key). - On a hit, the JSON document is handled according to the
codecoption (see below). - Every policy found participates in per-action scope evaluation — a more specific policy decides first, and actions it does not decide fall through to less specific ones.
- If nothing matches, the action falls back to
EFFECT_DENY(unchanged behavior).
Note
Precedence is per scope: an in-memory policy wins at its own scope, but no longer shadows a more specific cached policy at a deeper scope. Hybrid deployments (static org-wide defaults in code + per-tenant overrides in the store) resolve the way scope specificity implies.
Cache keys follow this layout:
| Policy type | Key format |
|---|---|
| Resource policy | resource:<kind>:<version>:<scope> |
| Principal policy | principal:<id>:<version>:<scope> |
| Role policy | role:<role>:<version>:<scope> |
| Derived roles | derivedRoles:<name> |
<version> defaults to default, and <scope> is empty for unscoped policies (e.g. resource:expense:default:).
The only requirement is a single get method, so any cache backend works:
type CacheLike = {
get(key: string): unknown | Promise<unknown>;
};The codec option controls how a value returned from the cache is transformed before being passed to the policy constructor:
| Provided option | Behaviour |
|---|---|
codec: { jsep } |
Kerberos uses the built-in AST allowlist evaluator with the pre-configured jsep instance you supply. { $expr: "..." } descriptors are resolved into runtime evaluator functions. |
codec: { deserialize } |
Your own custom deserialization function is called on the raw cached value. |
(omit codec) |
The cached value is passed as-is to the policy constructor — no { $expr } transformation. Use this when your stored JSON documents don't contain expression descriptors (e.g. plain rules with static effect and roles). |
jsepis not a dependency of@alexify/kerberos. It is deliberately kept out so you only pay for it when you need expression-based policies. Install it (and any plugins) separately and pass the instance to Kerberos.
npm install jsep @jsep-plugin/object @jsep-plugin/ternary @jsep-plugin/newBecause a remote store can be Redis/Mongo/Postgres/etc., dynamic policies must be JSON documents. JSON has no concept of a JavaScript function, so conditions, variables and outputs are authored as expression descriptors { "$expr": "..." } instead of JS functions:
// In-memory policy (function form):
condition: { match: ({ R, P }) => R.attr.ownerId === P.id }
// Dynamic/stored policy (JSON, $expr form):
"condition": { "match": { "$expr": "R.attr.ownerId == P.id" } }A full stored resource policy document looks like:
{
"resourcePolicy": {
"version": "default",
"resource": "document",
"importDerivedRoles": ["doc_roles"],
"variables": { "isOpen": { "$expr": "R.attr.status == 'OPEN'" } },
"rules": [
{ "actions": ["*"], "effect": "EFFECT_ALLOW", "roles": ["ADMIN"] },
{ "actions": ["view"], "effect": "EFFECT_ALLOW", "derivedRoles": ["OWNER"] },
{
"name": "edit-when-open",
"actions": ["edit"],
"effect": "EFFECT_ALLOW",
"derivedRoles": ["OWNER"],
"condition": { "match": { "$expr": "V.isOpen" } },
"output": { "when": { "ruleActivated": { "$expr": "({ owner: R.attr.ownerId, by: P.id })" } } }
}
]
}
}Expressions are evaluated against the same request context as functions: P (principal), R (resource), V (variables), C (constants), plus a curated set of safe language builtins (see below).
The default codec exposes a small, allowlisted subset of JavaScript that is useful in policy conditions without opening an eval trust boundary:
| Category | Supported constructs |
|---|---|
| Math | Math.abs, Math.min, Math.max, Math.floor, Math.ceil, Math.round, Math.pow, ... |
| Date | new Date(), new Date(value), Date.now(), Date.parse(...), Date.UTC(...), and read-only instance methods such as .getTime(), .getHours(), .toISOString() |
| Coercion / parsing | parseInt(...), parseFloat(...), Number(...), String(...), Boolean(...), isNaN(...), isFinite(...) |
| Value helpers | Safe string/array methods such as .includes(), .startsWith(), .slice(), ... |
Anything outside this list — arbitrary constructors (new Function, new Object, ...), global roots like process / require / globalThis, or member keys such as constructor / __proto__ — is rejected by the AST allowlist interpreter.
Example: a time-window condition (equivalent to the in-memory expense delete rule) in { $expr } form:
{
"condition": {
"match": {
"$expr": "(Date.now() - new Date(R.attr.createdAt).getTime()) < 3600000 && R.attr.status == 'OPEN'"
}
}
}import { Keyv } from 'keyv';
import jsep from 'jsep';
import jsepObject from '@jsep-plugin/object';
import jsepTernary from '@jsep-plugin/ternary';
import jsepNew from '@jsep-plugin/new';
import { Kerberos, serializePolicy } from '@alexify/kerberos';
// 1. Configure jsep once — register plugins and any extra unary operators.
jsep.plugins.register(jsepObject, jsepTernary, jsepNew);
jsep.addUnaryOp('typeof');
const keyv = new Keyv();
// 2. Serialize policies into JSON-safe documents (validates $expr ASTs via jsep).
await keyv.set('derivedRoles:doc_roles', serializePolicy({
name: 'doc_roles',
definitions: [
{ name: 'OWNER', parentRoles: ['USER'], condition: { match: { $expr: 'R.attr.ownerId == P.id' } } },
],
}, { jsep }));
await keyv.set('resource:document:default:', serializePolicy({
resourcePolicy: {
version: 'default',
resource: 'document',
importDerivedRoles: ['doc_roles'],
rules: [
{ actions: ['view'], effect: 'EFFECT_ALLOW', derivedRoles: ['OWNER'] },
],
},
}, { jsep }));
// 3. Pass the same jsep instance so Kerberos can evaluate $expr at runtime.
const kerberos = new Kerberos([], [], { cache: keyv, codec: { jsep } });
const allowed = await kerberos.isAllowed({
principal: { id: 'u1', roles: ['USER'] },
action: 'view',
resource: { id: 'doc1', kind: 'document', attr: { ownerId: 'u1' } },
});
// -> trueserializePolicy(shape, { jsep }) validates every { $expr } string via full AST parse and returns a JSON-safe document. Passing { jsep } is optional — without it the function still rejects raw JS functions but skips AST validation (expressions are validated at deserialize time instead). You can also store hand-written JSON directly.
For production deployments running multiple Kerberos instances, the recommended setup combines:
keyv— the storage engine (Redis, Mongo, Postgres, ...);cacheable— high-performance layer 1 / layer 2 caching withCacheSync;qified— the pub/sub transport that propagatesCacheSyncinvalidation messages across hosts.
This is the recommended way to invalidate your policies across multiple hosts. When a policy changes, update the store;
cacheable'sCacheSyncbroadcasts the invalidation overqifiedpub/sub so every Kerberos instance drops its stale layer-1 copy. Kerberos itself only ever callscache.get— it never has to know about invalidation.
import { Cacheable } from 'cacheable';
import { createKeyv } from '@keyv/redis';
import { Qified } from 'qified';
import { createQified } from '@qified/redis';
import jsep from 'jsep';
import jsepObject from '@jsep-plugin/object';
import jsepTernary from '@jsep-plugin/ternary';
import jsepNew from '@jsep-plugin/new';
import { Kerberos } from '@alexify/kerberos';
// Configure jsep once per process.
jsep.plugins.register(jsepObject, jsepTernary, jsepNew);
jsep.addUnaryOp('typeof');
// Layer 2 (distributed) storage + layer 1 (in-process) cache.
const secondary = createKeyv('redis://localhost:6379');
// CacheSync over qified pub/sub keeps every host's layer-1 cache coherent.
const cacheSync = createQified({ uri: 'redis://localhost:6379' });
const cacheable = new Cacheable({
secondary,
cacheId: 'kerberos-policies',
cacheSync, // distributed invalidation via qified pub/sub
});
const kerberos = new Kerberos([], [], { cache: cacheable, codec: { jsep } });
// Reads transparently use layer 1 -> layer 2; writes/invalidations are handled
// by cacheable + qified, not by Kerberos.
const allowed = await kerberos.isAllowed({
principal: { id: 'u1', roles: ['USER'] },
action: 'view',
resource: { id: 'doc1', kind: 'document', attr: { ownerId: 'u1' } },
});Kerberos deliberately does not serialize raw JavaScript function bodies and never uses eval / new Function / fn.toString(). That classic "stringify a function, then eval it back" approach is unsafe and brittle:
new Function(body)is equivalent toeval(body). A denylist of dangerous tokens is weaker than an allowlist by design — it can be bypassed via bracket notation (P['cons' + 'tructor']), unicode escapes,with,Reflect,Proxytraps, and so on, with no end to the patches (see thenode-serializeRCE, CVE-2017-5941).fn.toString()produces engine/bundler-specific output (V8 vs SpiderMonkey, Babel/esbuild/SWC,[native code]), which silently breaks serialization across environments.- Re-
evaling on every cache hit pays a JIT-compilation cost exactly when load is highest.
Instead, the built-in codec (createSafeExprCodec({ jsep })) uses an AST allowlist interpreter built on the tiny, eval-free jsep parser. Safe-by-default resource limits are configurable per codec: createSafeExprCodec({ jsep, maxCachedExprs, maxExprLength, maxDepth, maxBuiltStringLength }) — defaults 1000 cached ASTs (LRU-touched bounded cache), 4096 chars per expression, nesting depth 32 (unrelated to the ReBAC resolver's own maxDepth: 50 walk limit), and 1_000_000 chars for strings built by expressions (repeat/padStart/padEnd — without the cap a tiny expression could allocate a ~0.5GB string per evaluation). How it works:
- Each
{ $expr }string is parsed once into an AST via yourjsepinstance, which is cached per (jsep instance, expression string) pair (parse-once). - Evaluation walks the AST per request with a strict allowlist — no
eval, nonew Function, no recompilation. - Identifiers resolve only against the
{ P, R, V, C }context and curated safe builtins (Math,Date,parseInt,parseFloat, ... — soconstructor,process,require,globalThissimply do not exist as roots). Member keys__proto__/prototype/constructorare blocked at the interpreter level regardless of how they are written. Method calls are limited to a whitelist of safe helpers on string/array/number/Datevalues, plusMath.*/Date.*static methods. Onlynew Date(...)is permitted as a constructor. - This keeps remote policies expressive (comparisons, logic, ternaries, member access, object/array literals, time windows via
Date, numeric helpers viaMath, parsing viaparseInt/parseFloat) while remaining non-Turing-complete and safe to load from a shared store.
Comparisons are strict. <, <=, > and >= compare two numbers, two strings or two booleans; every other pairing throws KerberosExprError instead of applying JavaScript's coercion, which would let a wrongly-typed attribute widen access ("500" < 1000, null < 1000, [999] < 1000 and true >= 1 are all true in plain JavaScript). This matches CEL, so imported Cerbos policies behave the same in both engines; the error then follows the engine's onError option, and inside a checkResources batch it is isolated to a fail-closed EFFECT_DENY for that resource. Equality and arithmetic are unchanged. Pass createSafeExprCodec({ jsep, relational: 'js' }) to restore the old coercing behaviour.
jsep is not bundled — you install it separately and pass the pre-configured instance, the same way you pass ajv for schema validation. This keeps @alexify/kerberos itself zero-dependency.
// Using the full createSafeExprCodec helper (serialize + deserialize):
import { createSafeExprCodec, serializePolicy } from '@alexify/kerberos';
const codec = createSafeExprCodec({ jsep });
// Serialize before storing:
await redis.set('resource:document:default:', JSON.stringify(codec.serialize(policyShape)));
// Kerberos deserializes automatically when codec.jsep (or codec.deserialize) is set:
const kerberos = new Kerberos([], [], { cache, codec });
// equivalently: codec: { jsep } — Kerberos creates the built-in evaluator internallyThe codec is fully pluggable. Supply { deserialize } to use your own deserialization logic:
const kerberos = new Kerberos([], [], { cache, codec: { deserialize: myDeserializeFn } });Other eval-free options such as jexl or cel-js (CEL, the same expression language Cerbos uses) are good fits. Function-serializing libraries like serialize-javascript can also be wrapped, but only if you fully trust the store and accept the eval-based trust boundary they require.
To skip deserialization entirely (e.g. your cached documents are already plain JSON without { $expr } descriptors), simply omit codec:
const kerberos = new Kerberos([], [], { cache }); // values passed as-is to policy constructorsThe @alexify/kerberos/cerbos subpath turns an existing Cerbos policy repository — YAML/JSON policy documents with CEL conditions — into Kerberos policies you can evaluate in-process, still with zero dependencies: the subpath ships its own parser for the YAML subset Cerbos policies are written in and its own CEL parser + translator.
import { importCerbosPolicies } from '@alexify/kerberos/cerbos';
import { Kerberos, createSafeExprCodec, deserializePolicy } from '@alexify/kerberos';
// The importer emits SERIALIZED documents ({ $expr } conditions), so the
// standard dynamic-policy codec setup applies (see "Caching / Storing Policies"):
const codec = createSafeExprCodec({ jsep });
const { policies, derivedRoles } = importCerbosPolicies(yamlTexts); // strings, parsed objects, or arrays
const kerberos = new Kerberos(
policies.map((doc) => deserializePolicy(doc, codec)),
derivedRoles.map((doc) => deserializePolicy(doc, codec)),
);Because the output is plain JSON with { $expr } descriptors, it is also exactly what the cache layer stores — import a Cerbos repo once and publish the results to Redis/keyv instead of constructing an engine directly.
The governing invariant: refuse to guess. Every Cerbos construct is either translated with faithful semantics or rejected with a KerberosImportError naming the construct and its location — nothing is dropped or approximated silently, because a skipped rule or a mistranslated condition would change authorization decisions without a trace. The single opt-in exception: importCerbosPolicies(input, { drop: ['schemas'] }) discards validation-only schemas blocks instead of throwing on them.
All four document kinds (resourcePolicy, principalPolicy, rolePolicy — Cerbos role policies have no version, so default is assumed — and derivedRoles), including scopes, importDerivedRoles, nested all/any/none condition combinators, variables.local / constants.local, and output.expr / output.when. Policies with disabled: true are skipped, matching Cerbos's own loader; scopePermissions: SCOPE_PERMISSIONS_OVERRIDE_PARENT (the Cerbos default, and exactly what Kerberos implements) is accepted. schemas: blocks translate verbatim (wire their definitions into the schemas engine option to enforce them; drop: ['schemas'] discards them instead). Always rejected: exportVariables/exportConstants and variables.import, REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS, script conditions, and unknown keys at any level.
celToExpr (exported standalone) parses real CEL — full expression grammar with precedence, ternary, raw/triple-quoted strings, hex/uint literals, comments — and emits JavaScript for the safe interpreter. Highlights:
| CEL | JavaScript ($expr) |
|---|---|
request.principal / request.resource (or P / R / V / C shorthand) |
P / R / V / C |
== / != |
=== / !== |
x in list |
list.includes(x) (a map receiver errors at evaluation — fail-loud) |
has(R.attr.x) |
typeof R.attr.x !== "undefined" (an explicit null is present, as in CEL) |
size(x) / x.size() |
x.length |
timestamp(x) / now() |
Date.parse(x) / Date.now() — timestamps are epoch-ms numbers, so <, ==, - work numerically |
duration("72h3m") |
constant-folded milliseconds |
t.getFullYear() … |
new Date(t).getUTCFullYear() … (CEL defaults to UTC; getDayOfMonth() gets the - 1) |
x.replace(a, b) |
x.split(a).join(b) (CEL replaces every occurrence) |
7 / 2 (int literals) |
Math.trunc(7 / 2) (CEL integer division truncates) |
Rejected by design, each with a named error: comprehension macros (exists/all/filter/map/exists_one — the interpreter has no lambdas), matches() (RE2), Cerbos extension functions (hasIntersection, hierarchy, spiffeID, …), globals, runtime, request.auxData, bytes literals, message construction, and any identifier the translator does not recognize. Documented deviations: lowerAscii/upperAscii map to full-Unicode case folding, and / with non-literal operands keeps JS numeric semantics (Cerbos attributes arrive as JSON numbers — CEL doubles — where the two agree).
The whole Cerbos conformance corpus — real Cerbos policy YAML whose expected decisions are pinned against a live Cerbos PDP in CI — additionally runs through the public importer (conformance/importer.test.js): YAML parsed by this parser, CEL translated by this translator, and every decision and query-plan expectation must still hold. The YAML parser is separately verified differentially against the reference yaml package over the same corpus.
The core package never touches the filesystem; the Node-only @alexify/kerberos/loader subpath is the boot-time bridge for policy-as-code repositories — and the bundle format is the GitOps artifact:
import { loadPolicyDirectory, writePolicyBundle, loadPolicyBundle } from '@alexify/kerberos/loader';
// Boot: load a directory (Kerberos JSON and Cerbos YAML/JSON can mix; `_schemas/` included).
const { policies, derivedRoles, schemas } = loadPolicyDirectory('./policies', { codec });
const kerberos = new Kerberos(policies, derivedRoles, { ajv, schemas: { definitions: schemas } });
// CI: bake the repo into one hash-stamped artifact…
const bundle = writePolicyBundle('./dist/policies.bundle.json', loadPolicyDirectory('./policies'));
// …whose `version` is the SHA-256 of its canonical content. Loading VERIFIES it:
const verified = loadPolicyBundle('./dist/policies.bundle.json', { codec }); // tampered/truncated → throwsDirectories load in deterministic sorted order, _-prefixed and hidden entries are skipped (the Cerbos repo convention), .yaml files and JSON documents carrying apiVersion route through the Cerbos importer automatically, and _schemas/**.json come back keyed for the schemas.definitions option. The top-level functions are synchronous; the promises namespace (Node's fs.promises idiom — same names, same shared core, byte-identical results) is the asynchronous driver, and promises.loadPolicyDirectory reads files concurrently (bounded by the concurrency option, default 64) so cold starts stay fast over large policy repositories without blocking the event loop: const { promises: loader } = require('@alexify/kerberos/loader'). Bundles hold serialized documents only, createPolicyBundle(content, { createdAt: null }) is byte-reproducible, and in browsers every loader function throws a clear error (fetch a bundle over the network instead). Errors are typed KerberosLoaderErrors naming the offending file.
Kerberos supports relationship-based access control (ReBAC) — "Google Drive-style" authorization where access flows through relationships (viewer of the parent folder, member of the team that owns the document) instead of attributes alone. The design is heavily inspired by SpiceDB (the mature open-source implementation of Google's Zanzibar), adapted to the Kerberos philosophy: in-process, zero-infra, static data blazing fast, dynamic data through the same read-only cache fallback used for policies.
It comes in two layers:
- The
relationsengine option — a delegation contract likelogger/cache/codec. ANY object with acheckmethod works, including a resolver backed by the join tables your database already has. - The built-in "Zanzibar-lite" resolver — the
RelationResolverclass from the@alexify/kerberos/relationssubpath (kept out of the main entry so non-ReBAC browser bundles do not grow).
A derived-role definition may declare a relation instead of a condition. When the engine resolves derived roles for a resource policy (its only async phase), it asks the configured relations resolver whether the principal holds that relation/permission on the request's resource — and the role activates like any other derived role:
const derivedRoles = {
name: 'doc_roles',
definitions: [
// Classic (condition-backed) definitions still work unchanged:
{ name: 'OWNER', parentRoles: ['USER'], condition: { match: ({ P, R }) => R.attr.ownerId === P.id } },
// Relation-backed: activates when relations.check grants `view`.
// `parentRoles` and `condition` become optional synchronous gates.
{ name: 'DOC_VIEWER', relation: 'view' },
],
};
const policy = {
resourcePolicy: {
version: 'default',
resource: 'document',
importDerivedRoles: ['doc_roles'],
rules: [{ actions: ['view'], effect: 'EFFECT_ALLOW', derivedRoles: ['DOC_VIEWER', 'OWNER'] }],
},
};The delegation contract (bring your own resolver — e.g. SQL joins over your own tables):
const kerberos = new Kerberos([policy], [derivedRoles], {
relations: {
// Required. `memo` is a request-scoped Map shared across a whole
// checkResources batch — use it to share subproblems if you want.
async check({ principal, resource, relation }, { memo }) {
return db.hasRelation(principal.id, resource.kind, resource.id, relation);
},
// Optional batch fast path — called first when present.
async list({ principal, resource, relations }, { memo }) {
return db.grantedRelations(principal.id, resource.kind, resource.id, relations); // Set<string>
},
},
});Resolver failures follow the onError semantics at request level (isAllowed, or a failure outside per-resource evaluation). Inside a checkResources batch, per-resource isolation always wins: a rejected resource fails closed to DENY for its actions without failing the batch — even with onError: 'throw' — and with includeMeta those error-shaped denials are marked { reason: 'evaluation-error', errorName } so an outage is never mistaken for a policy DENY. Every relation resolution is likewise visible in meta.resolution as { source: 'relations', name, relation, matched }.
import { RelationResolver } from '@alexify/kerberos/relations';
const relations = new RelationResolver({
schema: {
relationSchema: {
caveats: {
// ABAC-on-ReBAC: a named condition evaluated against { P, ctx }.
valid_ip: { match: ({ P, ctx }) => ctx.allowed_ips.includes(P.attr.ip) },
},
definitions: {
user: {},
group: { relations: { member: ['user', 'group#member'] } }, // nested groups
folder: {
relations: { parent: ['folder'], viewer: ['user', 'group#member'] },
permissions: { view: { anyOf: ['viewer', { via: 'parent', permission: 'view' }] } }, // recursive!
},
document: {
relations: {
parent: ['folder'],
owner: ['user'],
editor: ['user', { type: 'user', caveat: 'valid_ip' }], // caveated subjects
viewer: ['user', 'user:*', 'group#member'], // incl. the wildcard
auditor: ['user'],
},
permissions: {
edit: { anyOf: ['owner', 'editor'] }, // union (+)
view: { anyOf: ['edit', 'viewer', { via: 'parent', permission: 'view' }] }, // arrow (->)
audit: { allOf: ['viewer', 'auditor'] }, // intersection (&)
read_only: { exclude: { base: 'viewer', subtract: ['editor'] } }, // exclusion (-)
review_all: { via: 'parent', permission: 'view', all: true }, // intersection arrow (.all)
},
},
},
},
},
// Static tuples: canonical SpiceDB strings or objects (with caveat context).
tuples: [
'document:readme#owner@user:olga',
'document:readme#viewer@group:eng#member',
'group:eng#member@user:sara',
'document:readme#parent@folder:docs',
'document:readme#auditor@user:vera',
{ resource: 'document:readme', relation: 'editor', subject: 'user:cara', caveat: { name: 'valid_ip', context: { allowed_ips: ['10.0.0.1'] } } },
],
});
// Standalone SpiceDB-flavoured API:
await relations.check({ resource: 'document:readme', permission: 'view', subject: 'user:sara' }); // true
await relations.lookupSubjects({ resource: 'document:readme', permission: 'view' }); // who can view?
await relations.lookupResources({ subject: 'user:sara', permission: 'view', resourceType: 'document' }); // what can sara view?
// And it IS a `relations` resolver:
const kerberos = new Kerberos([policy], [derivedRoles], { relations });What it borrows from SpiceDB (see src/Relations/):
- the userset-rewrite algebra (
union/intersection/exclusion/ arrows incl..all, wildcard subjectsuser:*, subject relationsgroup#member) with fail-fast schema compilation (unknown references, relation↔permission collisions and invalid arrows throw at construction); - the recursive check with short-circuiting (union stops at the first ALLOW, intersection at the first DENY, exclusion is base-first and order-sensitive);
- per-request memoization of subproblems (
(resource#relation@subject)), shared across a wholecheckResourcesbatch; concurrent identical document reads coalesce (the in-process analog of SpiceDB's singleflight); - depth limiting instead of cycle tracking (
maxDepth, default 50) — visited-sets are semantically unsound under exclusions, so cyclic relationship data throws a typedKerberosRelationsError; - caveats (ABAC-on-ReBAC): named conditions bound to tuples with write-time context; at check time the written context takes precedence over the check-time
contextargument, and the condition sees{ P, ctx }. Caveats are ordinary KerberosConditions— for JSON/cache-stored schemas author them as{ match: { $expr: '...' } }and pass a codec built withcreateSafeExprCodec({ jsep, roots: ['P', 'ctx'] })(same eval-free guarantees as dynamic policies). A throwing or false caveat fails closed. There is deliberately no CEL and no partial evaluation of caveats (CONDITIONALresults) — in-process, the full context is available at check time (engine-level query planning is a separate, explicit API:planResources); - reverse lookups:
lookupSubjectswalks the permission tree forward and expands groups (wildcards come back as'user:*', or{ subject: 'user:*', exclusions: [...] }under exclusions; caveated tuples are treated as present — an upper bound);lookupResourcesuses compile-time reachability entrypoints plus candidate verification for intersection/exclusion/caveat paths (the LookupResources2 pattern). Both APIs cap their result atmaxResults(default 1000). The cap truncates: by default the firstmaxResultssorted entries come back with no error, so1000results is indistinguishable from1000 of 80 000. SetonTruncated: 'throw'to get a typedKerberosRelationsErrorinstead — recommended whenever lookup results feed a query-plan filter (expandRelationOperands), where a silently narrowed id list would drop authorized rows from the translated query; alternatively pass an{ ids, truncated: true }envelope toexpandRelationOperands, which then degrades that branch to the soundopaquepost-filter operator. Truncation is always recorded on the call's telemetry span askerberos.result.truncated.
The resolver takes the same telemetry option as the engine ({ api } or { tracer, meter }, see OpenTelemetry): one span per public call (Kerberos.relations.check / .list / .lookupSubjects / .lookupResources, with resource/relation attributes and identity attributes gated by includeIdentity), a kerberos.relations.checks counter (kerberos.relations.result: allow|deny), the shared kerberos.request.duration histogram, and tuple-document cache reads counted in kerberos.cache.requests with kerberos.cache.kind: relation. When the resolver runs inside a Kerberos engine that also has telemetry, resolver spans nest under the isAllowed/checkResources span automatically (active span context). As everywhere else, telemetry failures are swallowed and can never affect resolution.
const relations = new RelationResolver({ schema, tuples, telemetry: { api: require('@opentelemetry/api') } });The resolver accepts a hooks option with the request-level subset of the engine's lifecycle hooks — beforeRequest(ctx), afterRequest(ctx, summary), onError(error, ctx), where ctx = { kind: 'check' | 'list' | 'lookupSubjects' | 'lookupResources', callId, args } — and emits request:start / request:end / request:error, relation:checked (one per relation or permission checked: { callId, kind, resource, relation, subject, allowed }) and cache:hit / cache:miss / cache:error for tuple-document reads (kind: 'relation'). There is no onError option here, so a throwing beforeRequest always propagates as KerberosHookError (the engine converts it under its own onError when the resolver serves a relation-backed decision). Through the relations seam callId is the engine's kerberosCallId, so resolver events line up with engine events; standalone calls get a generated id. See Hooks & events for the full contract.
Exactly like dynamic policies, tuples can live in your cache/store — Kerberos only reads; storage, TTL, invalidation and multi-host sync are the backend's job (keyv → cacheable → qified works here too):
-
Forward documents (required): key
rel:<resourceType>:<id>:<relation>→ JSON array of subject entries:["user:emilia", "group:eng#member", { "subject": "user:bob", "caveat": { "name": "valid_ip", "context": { "allowed_ips": ["10.0.0.1"] } } }]
-
Reverse documents (opt-in, only needed for
lookupResourcesover cache-backed tuples): keyrel:rev:<subjectKey>(e.g.rel:rev:user:emilia,rel:rev:group:eng#member) → JSON array of{ "resource": "document:readme", "relation": "viewer" }entries. Enable withreverseIndex: true; without itlookupResourcesthrows a typed error when a cache is configured (check/list/lookupSubjectsnever need reverse documents).
Static tuples always win per (resource, relation) key — the cache is only consulted on a static miss, and sources for the same key are never merged. A corrupt document throws a typed KerberosCodecError (propagating per the engine's onError semantics) instead of resolving as empty — an "empty" read would silently widen access in exclusion positions (read_only = viewer − editor: a real editor whose editor document fails to parse would gain read_only). The same rule applies to a caveat whose condition throws (→ KerberosRelationsError): an evaluation error is never read as an answer; a caveat that cleanly evaluates to false simply does not match. Genuine absence (cache miss) still resolves as an empty set, and entries the schema does not admit are skipped with an operator log. Transient cache failures retry per cacheRetry and then surface as KerberosCacheError.
Session memo contract (opts.memo on check/list/lookupSubjects/lookupResources): pass one Map to share work across calls — document reads are shared whenever the same resolver instance is used, and decision entries are automatically scoped by resolver instance plus the identity of the principal/context objects, so reusing a memo across different principals, contexts or resolver instances is safe by construction (reuse the same object references to maximize sharing — that is exactly what the Kerberos engine does across a checkResources batch). A read that fails (rejects) is evicted from the memo automatically, so a transient backend failure never poisons a long-lived memo — the next call retries; successfully resolved reads stay memoized for the memo's lifetime, so treat the memo as request/batch-scoped when document freshness matters. Checks running concurrently on one memo — the resources of a checkResources batch, a lookupResources verification wave — also share subproblems that are still being computed: a check that needs one waits for it instead of repeating the walk. Waits are checked for cycles first, so cyclic data still ends at the depth guard instead of deadlocking, and if the computation being waited on fails, the waiting check computes the subproblem itself — sharing never changes an answer.
This is deliberately not full Zanzibar. The hard part of Zanzibar is distributed consistency — ZedTokens/zookies, snapshot reads, the New Enemy Problem — and an in-process engine sidesteps it rather than solving it:
- checks always read the current in-memory state plus whatever your cache returns right now;
- the staleness window for dynamic tuples equals your cache-invalidation window (e.g. qified pub/sub propagation). Until an invalidation propagates, a just-revoked subject may still pass on another host — if that window matters for your threat model, put revocation-sensitive checks behind static tuples, shorten TTLs, or use a centralized authorization service (SpiceDB) instead;
- there are no per-request consistency levels and no revision tokens.
isAllowed answers "may this principal act on this resource?". planResources answers the inverse — "which resources may this principal act on?" — by partially evaluating the policies against everything known at plan time (the full principal, resource.kind, any known attr) and returning a filter over the unknown resource fields. Translate that filter into a WHERE clause and the database returns exactly the permitted rows — no fetch-all-then-filter.
The response is shaped like the Cerbos PlanResources API (filter.kind + condition operand tree, same operator vocabulary), so Cerbos-ecosystem query-plan adapters (queryPlanToPrisma, etc.) understand the shape. Kerberos adds two operators of its own: opaque and relation.
const { Kerberos, createSafeExprCodec, deserializePolicy } = require('@alexify/kerberos');
const codec = createSafeExprCodec({ jsep });
const policy = deserializePolicy({
resourcePolicy: {
resource: 'expense',
version: 'default',
rules: [
{ actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['USER'],
condition: { match: { $expr: "R.attr.ownerId === P.id || R.attr.status === 'APPROVED'" } } },
],
},
}, codec);
const kerberos = new Kerberos([policy], []);
const plan = await kerberos.planResources({
principal: { id: 'u1', roles: ['USER'] },
resource: { kind: 'expense' },
action: 'view',
});
// plan.filter:
// {
// kind: 'KIND_CONDITIONAL',
// condition: { expression: { operator: 'or', operands: [
// { expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.ownerId' }, { value: 'u1' }] } },
// { expression: { operator: 'eq', operands: [{ variable: 'request.resource.attr.status' }, { value: 'APPROVED' }] } },
// ] } },
// }Unconditional outcomes short-circuit: filter.kind is KIND_ALWAYS_ALLOWED / KIND_ALWAYS_DENIED with no condition (skip the query, or return everything/nothing).
The planner mirrors Mixed Policy Evaluation — the decision walk — symbolically. Which rules can reach which principal role is already known at plan time (it depends only on the principal, resource.kind and the action); what stays unknown is only whether rule conditions over unknown R.attr / R.id hold — those become the residual filter:
flowchart TD
A(["planResources<br/>principal · kind ·<br/>known attr · action"]) --> P["Principal chain, folded<br/>PA: allows · PD: denies"]
A --> DRI
subgraph L ["layer: OR over roles"]
DRI["Derived roles inlined<br/>gate: constant<br/>condition: residual<br/>relation: operand"]
BKT["per role r, scope i:<br/>allowed_i =<br/>NOT D_i AND<br/>(A_i OR allowed_i+1)"]
DRI --> BKT
end
P --> COMB["plan = OR(<br/>AND(PA, NOT PD),<br/>AND(NOT PA, NOT PD,<br/>layer) )"]
BKT --> COMB
COMB --> NORM["Normalization<br/>folding · flattening ·<br/>dedup"]
NORM -->|TRUE| AA([KIND_ALWAYS_ALLOWED])
NORM -->|FALSE| AD([KIND_ALWAYS_DENIED])
NORM -->|residual tree| COND(["KIND_CONDITIONAL<br/>+ condition"])
In the bucket formula, D_i is the deny rules at scope i that reach role r plus the deny rows of r's role policies there, A_i is the allow rules reaching r, and allowed past the base scope is FALSE. Several actions plan as the AND of their per-action plans.
Every step keeps its runtime semantics: a principal policy that decides overrides everything (deny wins within it, a failed condition falls through to the parent scope); within a role and scope deny beats allow and the first deciding scope seals the role; role policies only contribute deny rows; an allow reached by any role wins; nothing decided means deny — the parity is enforced by a property-style test suite (test/PlanParity.test.js) that grid-samples unknown attributes and compares the filter against real isAllowed results.
condition is a tree of { expression: { operator, operands } } / { variable } / { value } operands. Variables are Cerbos-named: request.resource.id and request.resource.attr.<path>.
| Operators | Meaning |
|---|---|
and, or, not |
Boolean composition. |
eq, ne, lt, le, gt, ge |
Comparisons (===, !==, <, <=, >, >=). |
in |
List membership (list.includes(x)). |
add, sub, mult, div, mod |
Arithmetic (+, -, *, /, %). |
index, list |
Computed member access, list literals. |
opaque (Kerberos) |
Statically unplannable condition — post-filter. |
relation (Kerberos) |
ReBAC dependency — expand or post-check. |
The planner works on the codec's { $expr } ASTs, so plannable conditions are the ones the safe expression codec compiled — cache-loaded policies, or static policies passed through deserializePolicy(json, codec) first. Rules of thumb:
- Author conditions as
{ $expr: '…' }, not JS functions — a plain function is a black box and plans asopaque. - Prefer
===over==— both map toeq, but SQL=has no JS coercion semantics. - Compare booleans explicitly (
R.attr.isPublic === true): a bareR.attr.isPublicleaf is planned aseq(attr, true), which diverges for truthy non-boolean values. .includesmeans list membership — use it on array attrs (a residual receiver is assumed to be a list; a constant string receiver would mean substring semantics and plans asopaque).- Not plannable (always sound, degrade to
opaque):??,**, bitwise ops,typeof, ternaries whose test reads unknown attrs, method calls other than.includes,Math/Dateover unknown values, object/newexpressions over unknown values. - Filters are guaranteed JSON-safe. A folded constant that JSON transport would corrupt (
undefinedvanishes,NaN/Infinitybecomenull,Dateobjects become strings,BigIntthrows) is never emitted into an operand — the condition degrades toopaqueinstead. Comparing against possibly-missing principal attrs (R.attr.owner === P.attr.deptwith nodept) therefore plans asopaque, not as a broken operand. - An attr missing from
resource.attrmeans unknown, notundefined— it becomes a filter variable, never a folded value. Date.now()(and friends) evaluate at plan time — same trade-off as Cerbos. A cached/reused plan carries a frozen time boundary; re-plan when time matters.
variables are partially evaluated and inlined at their V.* use sites; C.* constants and everything derivable from P fold into literal values. Plain JS-function variables still fold when they only touch known fields (they are executed against a guard that marks any unknown-field access as opaque).
{ operator: 'opaque', operands: [{ value: { src, reason } }] } marks a spot the planner could not translate (reason: 'js-function' | 'unsupported-expression', src identifies the condition). A translator must treat it as unknown: fetch the candidate rows matching the rest of the filter, then post-filter each row with a real isAllowed call. Everything AND-ed around an opaque node still narrows the fetch.
Relation-backed derived roles plan as { operator: 'relation', operands: [{ value: { name, relation } }] } — the ABAC part of the filter is complete, the ReBAC part depends on relationship data. Materialize it with expandRelationOperands:
const { expandRelationOperands } = require('@alexify/kerberos');
const { RelationResolver } = require('@alexify/kerberos/relations');
const resolver = new RelationResolver({ schema, tuples });
const expanded = await expandRelationOperands(plan, ({ relation }) =>
resolver.lookupResources({ subject: `user:${principal.id}`, permission: relation, resourceType: 'document' }));
// every relation operand becomes: in(request.resource.id, ['doc1', 'doc7', …])
// (an empty id list folds the branch to FALSE — possibly the whole plan to KIND_ALWAYS_DENIED)The lookup is any ({ name, relation }) => ids function — resolver-agnostic, like the engine's relations seam. Without expansion, treat relation like opaque: post-check the rows. Mind the cardinality: a principal with access to a very large set of resources materializes a very large in-list — for those cases a post-check (or a resolver-side limit) can beat expansion.
Cerbos's own query-plan adapters — @cerbos/orm-prisma and @cerbos/orm-drizzle — accept Kerberos plans through one exported hop: toCerbosQueryPlan converts the HTTP-API operand encoding Kerberos emits ({ variable } / { expression }) into the flattened @cerbos/core SDK encoding the adapters consume ({ name } / { operator, operands }; the plan kinds are byte-identical):
import { toCerbosQueryPlan, expandRelationOperands } from '@alexify/kerberos';
import { queryPlanToPrisma } from '@cerbos/orm-prisma';
const plan = await kerberos.planResources({ principal, resource: { kind: 'document' }, action: 'view' });
const result = queryPlanToPrisma({
queryPlan: toCerbosQueryPlan(plan),
mapper: {
'request.resource.attr.ownerId': { field: 'ownerId' },
'request.resource.id': { field: 'id' },
},
});
// result.kind: ALWAYS_ALLOWED | ALWAYS_DENIED | CONDITIONAL (+ result.filters for Prisma's `where`)The two Kerberos-only operators follow the refuse-to-guess rule at this boundary:
relation(ReBAC dependency) — materialize it first:toCerbosQueryPlan(await expandRelationOperands(plan, lookup)); the expanded plan renders as a plainid IN (...)filter. Handing an unexpanded plan to the converter throws, namingexpandRelationOperands.opaque(statically unplannable condition) — the converter throws with a post-filtering directive; translate the rest of the query and filter the rows throughcheckResourcesafterwards.
This path is CI-verified against the real adapter packages (test/OrmAdapters.test.js): conditional/membership plans render the expected Prisma where objects and Drizzle SQL, and both special operators take exactly the routes above.
One caveat that is not ours: the adapter packages are CommonJS but depend on the ESM-only @cerbos/core, so loading them needs Node's require(esm) support — Node 20.19+ / 22.12+. On Node 18 they cannot be required at all, and the verification suite skips accordingly. toCerbosQueryPlan itself, like the rest of Kerberos, runs on Node 18; only the third-party adapters are gated.
Translators are deliberately not part of the package (same delegation philosophy as caching/validation). A hand-rolled SQL mapping is a ~40-line recursive walk:
const OPS = { and: 'AND', or: 'OR', eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
function toSql(operand, params) {
if ('value' in operand) return params.push(operand.value), `$${params.length}`;
if ('variable' in operand) {
if (operand.variable === 'request.resource.id') return 'id';
return operand.variable.replace('request.resource.attr.', ''); // map to your column names
}
const { operator, operands } = operand.expression;
if (operator === 'not') return `NOT (${toSql(operands[0], params)})`;
if (operator === 'in') return `${toSql(operands[0], params)} = ANY(${toSql(operands[1], params)})`;
if (OPS[operator]) return `(${operands.map((op) => toSql(op, params)).join(` ${OPS[operator]} `)})`;
throw new Error(`post-filter required: ${operator}`); // opaque / relation / index / list…
}
const params = [];
const where =
plan.filter.kind === 'KIND_ALWAYS_ALLOWED' ? 'TRUE'
: plan.filter.kind === 'KIND_ALWAYS_DENIED' ? 'FALSE'
: toSql(plan.filter.condition, params);Since the shape matches Cerbos, the Cerbos ORM adapters (Prisma, Drizzle, Mongoose, SQLAlchemy…) accept the filter for the shared operator vocabulary — route opaque/relation operands to a post-filter (or pre-expand relation as shown above).
Two operational notes:
- Plans disclose folded principal data. Partial evaluation inlines values derived from
P/C/Vinto the filter andfilterDebug— treat plans as output for trusted sinks (your translator/backend), not for untrusted clients. See SECURITY.md. - Plans are observable. Each call records the outcome: a structured
PlanResources.resultaudit entry (filter kind, opaque/relation counts), span attributes (kerberos.plan.kind,kerberos.plan.opaque_count, …) and thekerberos.planscounter — anALWAYS_ALLOWEDfilter (a fail-open query) never goes unnoticed.
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import { Kerberos } from '@alexify/kerberos';
import { KerberosTests } from '@alexify/kerberos/tests';
describe('KerberosTests', () => {
describe('Expense Policy (raw mode)', () => {
const expenseTestPolicy = {
name: 'Expenses test suite',
principals: {
sally: {
id: 'sally',
roles: ['USER'],
attr: {
department: 'SALES',
region: 'EMEA',
},
},
// ...
},
resources: {
expense1: {
id: 'expense1',
kind: 'expense',
attr: {
ownerId: 'sally',
createdAt: '2022-07-21T14:47:51.063Z',
vendor: 'Flux Water Gear',
region: 'EMEA',
amount: 500,
status: 'OPEN',
},
},
// ...
},
tests: [
{
name: 'Sales Roles',
input: {
principals: ['sally', 'sydney'],
resources: ['expense1', 'expense2'],
actions: ['view', 'view:approver', 'update', 'delete', 'approve'],
},
expected: [
{
principal: 'sally',
resource: 'expense1',
actions: {
view: 'EFFECT_ALLOW',
'view:approver': 'EFFECT_DENY',
delete: 'EFFECT_DENY',
update: 'EFFECT_ALLOW',
approve: 'EFFECT_DENY',
},
},
// ...
],
},
],
};
const kerberos = new Kerberos(policies, derivedRoles, { logger: true });
const tests = new KerberosTests(kerberos, [expenseTestPolicy]);
tests.run({}, { describe, it, assert });
// or -> tests.run({ effectAsBoolean: true }, { describe, it, assert });
});
});The package ships a kerberos CLI, so a pure policy repository — no engineering glue, no hand-written test harness — can test itself in CI:
npx kerberos test ./policies ./tests- Policies load exactly like
loadPolicyDirectory: Kerberos JSON and Cerbos YAML/JSON mix freely,{ $expr }conditions resolvejsep(+ the documented plugins) from your project. - Test suites are Cerbos's own
TestSuiteformat (*_test.yaml/*_test.json): named principal/resource fixtures plus expected effects per action — reviewable, engine-agnostic artifacts. --schemas reject|warnwires_schemas/into attribute-schema enforcement;--jsonprints a machine-readable report; the exit code is1on any failing case (2for usage/config errors).- The runner refuses to guess: an expectation feature it does not check (e.g.
outputs) fails the run instead of silently passing.
npx kerberos bundle ./policies --out dist/policies.bundle.json --reproduciblebakes the repo into a hash-stamped bundle for GitOps pipelines.
You can also test policies with outputs functionality:
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import { Kerberos, Effect } from '@alexify/kerberos';
describe('Outputs functionality', () => {
const outputsPolicy = {
resourcePolicy: {
version: "default",
resource: "system_access",
rules: [
{
name: "admin-access",
actions: ['*'],
effect: Effect.Allow,
roles: ['admin'],
output: {
when: {
ruleActivated: ({ P }) => ({
message: "Admin access granted",
admin: P.id
})
}
}
}
]
}
};
it('should return outputs when rules are activated', async () => {
const kerberos = new Kerberos([outputsPolicy]);
const results = await kerberos.checkResources({
principal: { id: "alice", roles: ["admin"] },
resources: [{ resource: { id: "system1", kind: "system_access" }, actions: ['login'] }]
});
// Check that we get outputs
assert.ok(results.results[0].outputs);
assert.strictEqual(results.results[0].outputs.length, 1);
const output = results.results[0].outputs[0];
assert.strictEqual(output.src, 'resource.system_access.vdefault#admin-access');
assert.strictEqual(output.val.message, 'Admin access granted');
assert.strictEqual(output.val.admin, 'alice');
});
});Kerberos.js supports multiple validation backends:
- Zod for consumers already using
zod - JSON Schema + Ajv for standards-based schema validation
- TypeBox + Ajv for typed schema builders backed by Ajv
Install only the validation stack you need:
npm install ajv
npm install @sinclair/typebox ajv
npm install zodimport { z } from 'zod';
import { Kerberos } from '@alexify/kerberos';
const kerberos = new Kerberos(policies, derivedRoles, { z });import Ajv from 'ajv';
import { Kerberos, registerAjvKeywords } from '@alexify/kerberos';
const ajv = registerAjvKeywords(new Ajv({ strict: false }));
const kerberos = new Kerberos(policies, derivedRoles, { ajv });import Ajv from 'ajv';
import { Type } from '@sinclair/typebox';
import { Kerberos, registerAjvKeywords } from '@alexify/kerberos';
const ajv = registerAjvKeywords(new Ajv({ strict: false }));
const kerberos = new Kerberos(policies, derivedRoles, {
ajv,
typebox: Type,
});Kerberos.js also exports first-class schema builders and Ajv adapters if you want to compile validators yourself:
import Ajv from 'ajv';
import {
JsonSchemas,
KerberosJsonSchemas,
PrincipalPolicyJsonSchemas,
ResourcePolicyJsonSchemas,
createAjvAdapter,
registerAjvKeywords,
} from '@alexify/kerberos';
const ajv = registerAjvKeywords(new Ajv({ strict: false }));
const requestValidator = createAjvAdapter(ajv, JsonSchemas.buildRequest());
const argsValidator = createAjvAdapter(ajv, KerberosJsonSchemas.buildCheckResourcesArgs());
const resourcePolicyValidator = createAjvAdapter(ajv, ResourcePolicyJsonSchemas.buildShape());
const principalPolicyValidator = createAjvAdapter(ajv, PrincipalPolicyJsonSchemas.buildShape());Kerberos policies can contain JavaScript functions in:
- conditions
- variables
- outputs
When using Ajv or TypeBox, Kerberos.js registers custom Ajv keywords so those function-bearing fields can still be validated at runtime. This keeps the DSL usable even though plain JSON Schema doesn't natively understand JavaScript functions.
Conditions read P.attr / R.attr — and garbage attributes silently flow into them (an undefined comparison quietly denies or allows). Cerbos guards this with per-kind attribute schemas; Kerberos implements the same model:
const kerberos = new Kerberos(
[{
resourcePolicy: {
version: 'default',
resource: 'expense',
schemas: {
principalSchema: { ref: 'principal.json' },
resourceSchema: { ref: 'expense.json', ignoreWhen: { actions: ['create'] } },
},
rules: [/* ... */],
},
}],
[],
{
ajv: new Ajv({ allErrors: true }),
schemas: {
enforcement: 'reject', // 'reject' | 'warn' | 'none'
definitions: {
'expense.json': { type: 'object', required: ['amount'], properties: { amount: { type: 'number' } } },
'principal.json': z.object({ department: z.string() }), // Zod works too
},
},
},
);Semantics (mirroring Cerbos):
reject— a request whose attributes fail validation is denied for every action (a principal policy cannot rescue it), with the failures reported asvalidationErrors: [{ path, message, source: 'SOURCE_PRINCIPAL' | 'SOURCE_RESOURCE' }]on thecheckResourcesresult andreason: 'invalid-attributes'underincludeMeta.warn—validationErrorsare reported (response + audit log) but decisions are unaffected.none/ option absent — schema references in policies are inert, matching Cerbos's own default.ignoreWhen.actions(Cerbos globs) skips validation only when every requested action matches — one non-matching action in the batch entry re-enables it.- With scoped policies, the most specific policy in the resource scope chain that declares
schemaswins. - A policy referencing a ref missing from
definitionsthrowsKerberosValidationError(always — a configuration error never reads as valid or invalid).
Definitions may be plain JSON Schema objects (compiled with the engine's ajv option), Zod-like schemas (anything with safeParse), or validator functions returning error messages. The Cerbos importer translates schemas: blocks verbatim, so an imported policy repo enforces the same rules once you wire its schema files into definitions.
Kerberos.js ships native OpenTelemetry support (traces + metrics) following the same delegating philosophy as logger and cache: the package never depends on @opentelemetry/api (not even as a peer dependency). You pass either the api module or pre-created instances:
import * as api from '@opentelemetry/api';
import { Kerberos } from '@alexify/kerberos';
// Preferred: pass the api module — Kerberos derives its own tracer/meter with
// the correct instrumentation scope ('@alexify/kerberos').
const kerberos = new Kerberos(policies, derivedRoles, { telemetry: { api } });
// Escape hatch: pre-created instances (either may be omitted).
const kerberos2 = new Kerberos(policies, derivedRoles, {
telemetry: { tracer: myTracer, meter: myMeter },
});Works out of the box with any registered SDK (e.g. NodeSDK from @opentelemetry/sdk-node); with no SDK registered, everything no-ops.
Spans — one per public call: Kerberos.isAllowed (decision attributes on the span), Kerberos.checkResources (one kerberos.decision event per resource × action) and Kerberos.planResources (plan attributes: kerberos.plan.kind, kerberos.plan.actions_count, kerberos.plan.opaque_count, kerberos.plan.relation_count); the built-in ReBAC resolver adds Kerberos.relations.check / .list / .lookupSubjects / .lookupResources when given its own telemetry option (see Resolver telemetry). When relation-backed derived roles resolve through the relations seam, the request span additionally carries kerberos.relations.count and kerberos.relations.duration_ms — so relation latency is attributable even with a custom resolver that has no instrumentation of its own. The span is started active, so spans created inside — e.g. an auto-instrumented Redis cache behind the cache option, or resolver spans under an engine span — nest correctly. Attributes include kerberos.call_id, kerberos.req_id, kerberos.resource.kind, kerberos.action, kerberos.allowed / kerberos.effect, kerberos.matched_policy / kerberos.matched_rule / kerberos.matched_scope, and identity attributes kerberos.principal.id / kerberos.resource.id. On errors the span gets ERROR status plus an exception event — error-handling behavior itself is controlled solely by the onError option, never by telemetry or logging.
Metrics — six instruments:
| Instrument | Type | Unit | Attributes |
|---|---|---|---|
kerberos.decisions |
Counter | {decision} |
kerberos.effect, kerberos.resource.kind |
kerberos.plans |
Counter | {plan} |
kerberos.plan.kind, kerberos.resource.kind |
kerberos.request.duration |
Histogram | ms |
kerberos.req_kind, error |
kerberos.cache.requests |
Counter | {request} |
kerberos.cache.result (hit/miss/error), kerberos.cache.kind (only for ReBAC tuple reads: relation) |
kerberos.relations.checks |
Counter | {check} |
kerberos.relations.result (allow/deny) |
kerberos.observability.failures |
Counter | {failure} |
kerberos.observability.sink (logger/telemetry/hooks/events) — swallowed sink failures. Authorization is never affected by a broken logger/exporter, but a non-zero rate here means audit or telemetry output is being lost; the engine also console.warns once per instance and sink on the first swallowed logger/hook/listener failure. |
Metric attributes deliberately exclude actions and principals to keep cardinality bounded — they assume a bounded set of resource kinds.
Notes:
- Identity attributes are on by default (parity with audit logs). Set
telemetry: { includeIdentity: false }to stripkerberos.principal.id/kerberos.resource.idfrom spans and events when traces are exported to backends where identity data is unwanted. - Telemetry failures (a broken tracer, exporter bugs) are swallowed internally — they can never affect authorization results.
@opentelemetry/apiis browser-compatible, so telemetry works in browser builds too.
Two complementary ways to plug into the engine's lifecycle:
- Hooks are configured up front (the
hooksoption), run inside the request flow and are awaited — use them to run your own logic around a decision (tenant guards, rate limits, a blocking audit sink that must complete before the response), to veto a request by throwing, or to enrich it by returning replacement arguments frombeforeRequest. - Events are subscribed from outside (
kerberos.on(name, listener)), fire synchronously after the fact and can never affect a decision — use them to feed metrics, alerting or dashboards without parsing audit logs.
Both live on Kerberos and on the built-in RelationResolver.
const kerberos = new Kerberos(policies, derivedRoles, {
onError: 'deny',
hooksTimeoutMs: 500, // optional: a hanging hook fails instead of hanging authorization
hooks: {
// Once per request, after the arguments validated. Throw to veto, return
// a replacement to enrich.
async beforeRequest(ctx) {
if (ctx.args.principal.attr?.suspended) throw new Error('suspended principal');
const groups = await directory.groupsOf(ctx.args.principal.id);
return { ...ctx.args, principal: { ...ctx.args.principal, roles: [...ctx.args.principal.roles, ...groups] } };
},
// Once per request — on success, on failure and on the fail-closed path.
async afterRequest(ctx, summary) {
await audit.write({ callId: ctx.callId, kind: ctx.reqKind, ...summary });
},
// Around each resource evaluation (isAllowed, checkResources).
beforeResource(ctx, info) {
metrics.increment('authz.resource', { kind: info.resource.kind });
},
afterResource(ctx, info, result) {
if (result.actions.delete === 'EFFECT_ALLOW') alerts.notify(ctx, info);
},
// When a request fails — receives the error before afterRequest.
onError(error, ctx) {
sentry.captureException(error, { extra: { callId: ctx.callId } });
},
},
});| Hook | Signature | When it runs |
|---|---|---|
beforeRequest |
(ctx) => void | args |
Once per isAllowed / checkResources / planResources call, after argument validation and before any evaluation. A returned object replaces the arguments. |
beforeResource |
(ctx, info) |
Before each resource evaluation — once for isAllowed, once per entry of a checkResources batch (never for planResources). |
afterResource |
(ctx, info, result) |
After each resource evaluation — successful, or failed (then result.reason is set and a throw is swallowed). |
afterRequest |
(ctx, summary) |
Once per request — on success, on failure, and on the onError: 'deny' fail-closed path. |
onError |
(error, ctx) |
When the request fails, before afterRequest. |
Hooks may be sync or async; they are awaited. A hook changes the outcome in exactly two ways: by throwing (a veto) or — beforeRequest only — by returning a replacement for the arguments. Every other return value is ignored (an expression-bodied arrow such as () => metrics.increment() is safe).
ctx— one frozen object shared by every hook of the request:{ reqKind: 'IsAllowed' | 'CheckResources' | 'PlanResources', callId, reqId?, args, enriched }.callIdis the request'skerberosCallId(the same one audit logs, telemetry spans and events carry);argsare the validated arguments of the method — the same principal/resource objects the engine evaluates (under Zod, the parsed output; without a validation backend, the caller's own objects). After an enrichmentargsreads the replacement andenrichedistrue. Assigningctx.argsthrows in strict mode: mutating the arguments in place is not the contract — it leaks into the caller's objects without a validation backend and leaves no trace in the audit log. Enrich by returning a replacement instead.info—{ index, total, resource, actions }(frozen): the resource's position in the request (0of1forisAllowed), the resource object and the requested actions.result—{ actions: { [action]: 'EFFECT_ALLOW' | 'EFFECT_DENY' }, outputs, validationErrors?, meta?, reason?, errorName? }(frozen), always with canonicalEFFECT_*strings (never theeffectAsBooleanview). For a resource whose evaluation failed, every action isEFFECT_DENY,reasonis'evaluation-error'anderrorNamenames the error — the same marker thedecisionevent and the audit entry carry.summary—{ success, durationMs, error?, failClosed?, enriched? }.successisfalsewhenever the request failed, including whenonError: 'deny'converted the failure into a fail-closed result —failClosed: truetells the two apart;enriched: truesaysbeforeRequestreplaced the arguments.
beforeRequest may return a replacement arguments object (sync or async). The engine re-validates it with the method's own validator (plus the method's invariants — planResources' action rules, and a checkResources replacement must keep the batch's resource count, since results are positional), then evaluates the replacement instead of the original. Everything downstream sees the enriched request: the decision, the per-resource hooks, the audit entry (enriched: true, principalRoles are the enriched roles), the span (kerberos.request.enriched), the decision / plan / request:end events (enriched: true) and afterRequest's summary.
hooks: {
async beforeRequest(ctx) {
const groups = await directory.groupsOf(ctx.args.principal.id);
return { ...ctx.args, principal: { ...ctx.args.principal, roles: [...ctx.args.principal.roles, ...groups] } };
},
}A replacement that fails validation (or breaks the batch shape) is the hook's failure: it surfaces as KerberosHookError (hook: 'beforeRequest', cause is the KerberosValidationError) and follows onError like a veto. The caller's original objects are never modified.
request:start (event)
validate arguments ← KerberosValidationError: no hook fires
beforeRequest(ctx) ← throw = veto · return object = enrich
beforeResource(ctx, info[0]) → evaluate → afterResource(ctx, info[0], result)
beforeResource(ctx, info[1]) → evaluate → afterResource(ctx, info[1], result)
decision (event, one per resource)
afterRequest(ctx, { success: true })
request:end (event)
A checkResources batch evaluates its resources concurrently (bounded by maxConcurrency), so the per-resource hooks of different resources interleave; each resource's beforeResource/afterResource pair is ordered, and info.index is the resource's position, not the invocation order. Per-resource hooks run inside the resource's maxConcurrency slot, so a slow afterResource is back-pressure on the batch.
The failure path:
beforeRequest(ctx)
beforeResource(ctx, info) → evaluate ✖ → afterResource(ctx, info, { reason: 'evaluation-error', … }) ← swallowed
request:error (event)
onError(error, ctx) ← always swallowed
afterRequest(ctx, { success: false, error }) ← always swallowed
request:end (event, success: false)
→ onError: 'throw' rethrows; onError: 'deny' returns the fail-closed result (summary.failClosed = true)
Inside a checkResources batch a failing resource does not fail the request: its afterResource sees the fail-closed result, the batch resolves, and afterRequest sees success: true.
| Hook that throws | What happens |
|---|---|
beforeRequest, afterRequest after a successful request |
Wrapped in KerberosHookError (hook names it, cause is your error) and handled like any evaluation error: onError: 'throw' propagates it, 'deny' returns the fail-closed result. |
beforeRequest returning an invalid replacement |
Same as a throw: KerberosHookError with the KerberosValidationError as cause. |
beforeResource, afterResource inside a checkResources batch |
Isolated to that resource, exactly like an evaluation error: all its actions come back EFFECT_DENY with reason: 'evaluation-error', errorName: 'KerberosHookError' under includeMeta; the other resources are unaffected and the batch resolves (so afterRequest sees success: true). |
beforeResource, afterResource in isAllowed |
The single resource is the request — follows onError. |
afterResource after a failed resource, afterRequest after a failed request, onError |
Swallowed: the original error is what surfaces. The failure is counted on kerberos.observability.failures{kerberos.observability.sink: 'hooks'} and console.warned once per instance. |
Any hook exceeding hooksTimeoutMs |
KerberosHookError with timedOut: true, then the row above that applies to that hook. |
Hooks never run for malformed arguments (KerberosValidationError), and the pairing invariant always holds: once beforeRequest ran, onError (on failure) and afterRequest run exactly once — even when the success-path afterRequest was itself the failure, it is not re-invoked. Unknown hook names and non-function values are rejected at construction with a TypeError. The message of a KerberosHookError embeds your hook's error message (and reaches the request:error event as a string) — keep secrets out of it.
Hooks run inside the request, so a hook that never settles hangs every authorization call behind it. hooksTimeoutMs (off by default, like relationsTimeoutMs) bounds every hook invocation: on expiry the hook fails as KerberosHookError { hook, timedOut: true } and then follows the error contract of that hook — a timed-out beforeRequest vetoes (following onError), a timed-out afterResource in a batch fails that resource, a timed-out onError is swallowed. The abandoned hook keeps running in the background; the timeout only unblocks the request. Synchronous hooks are never raced.
- Request-level hooks add one awaited call per request; the fully-synchronous evaluation driver (no cache, no relations) stays in use.
beforeResource/afterResourcewrap each evaluation in an async frame, so they are the only hooks with a per-resource cost.- With no hooks configured the runner is a no-op object and every call site short-circuits on one boolean.
- With telemetry enabled every hook invocation is recorded on the
kerberos.hooks.durationhistogram (bykerberos.hook), so a slow hook is attributable instead of looking like an engine regression.
kerberos
.on('decision', ({ callId, principal, resource, actions }) => {
for (const [action, effect] of Object.entries(actions)) {
metrics.increment('authz.decisions', { kind: resource.kind, action, effect });
}
})
.on('request:end', ({ reqKind, durationMs, success }) => {
metrics.timing('authz.request', durationMs, { reqKind, success });
})
.on('cache:error', ({ key, errorName }) => alerts.notify(`policy cache read failed: ${key} (${errorName})`));on / once / off / removeAllListeners are chainable; listenerCount(name) reports subscriptions. Unknown event names are a type error in TypeScript and a TypeError at runtime (a typo cannot register a listener that never fires). There is deliberately no public emit (events are the engine's outbound signal — a consumer must not be able to forge decision entries into an audit pipeline) and no bare 'error' event.
| Event | Payload | When |
|---|---|---|
request:start |
{ callId, reqKind, reqId? } |
Before argument validation, once per public call. |
request:end |
{ callId, reqKind, reqId?, durationMs, success, error?, errorName?, enriched? } |
Always, last. success: false on failure — also on the onError: 'deny' path. |
request:error |
{ callId, reqKind, reqId?, error, errorName } |
When the request failed (validation errors included). |
decision |
{ callId, reqKind, reqId?, index, principal, resource, actions, reason?, errorName?, enriched? } |
One per evaluated resource of isAllowed / checkResources; fail-closed decisions carry reason: 'evaluation-error'. |
plan |
{ callId, reqKind, reqId?, principal, resource, actions, filterKind, opaqueCount, relationCount, enriched? } |
After planResources built its filter. |
relations:resolved |
{ callId, principal, resource, relations, granted, mode: 'list' | 'check', durationMs } |
After the relations resolver answered for one resource. |
cache:hit / cache:miss / cache:error |
{ key, error?, errorName? } |
Per policy-cache read. No callId: the lookup path has no request context (parity with the Cache.* log entries). |
Rules every payload follows:
- Correlation — every request-scoped payload carries the request's
callId(thekerberosCallIdof the response, the audit entries and the telemetry span), so adecisioncan be joined to itsrequest:endand to the resolver's events (see below).enriched: truemarks requests whose arguments abeforeRequesthook replaced. - Identity only —
principalis{ id, roles }andresourceis{ kind, id, scope?, policyVersion? }; attribute bags are never included (they may hold secrets). Hooks get the full context; events get correlation data. Unlike telemetry'sincludeIdentity, events are not gated: listeners run in-process, so the consumer owns the PII policy — project the payload before shipping it anywhere. - No
Errorobjects — failures are a message string (error) pluserrorName, safe to ship to metrics or alerting as-is. - Fresh objects — each payload is built for that emission (only when someone listens); mutating it affects nothing.
- Contained listeners — emission is synchronous and fire-and-forget. A listener that throws, or returns a rejecting promise, never affects the decision: the failure is counted on
kerberos.observability.failures{kerberos.observability.sink: 'events'}andconsole.warned once per instance (asynchronously for a rejecting listener — after the response returned). - Cheap and synchronous listeners — synchronous listeners cost no promise (the emitter only allocates when a listener returns a thenable). An async listener is awaited by nobody: under load its pending work accumulates with no back-pressure, so do the I/O through your own bounded queue, not in the listener.
Subscribing is a startup concern. on() inside request handling is the classic leak: the listener list grows without bound and every emission runs all of it. The first subscription past maxListeners (default 10, per event name) logs one console.warn — never a limit, only a signal; raise maxListeners if you really need more, or set it to 0 to disable the warning.
The emitter is a small built-in class, the same on Node.js and in the browser — Kerberos is not a node:events EventEmitter (instanceof EventEmitter, events.once(kerberos, …) or addListener do not apply; wrap on/off if you need them).
The built-in RelationResolver emits request:start / request:end / request:error ({ callId, kind: 'check' | 'list' | 'lookupSubjects' | 'lookupResources', … }), relation:checked ({ callId, kind, resource: { kind, id }, relation, subject, allowed, enriched? } — one per relation or permission checked) and cache:hit / cache:miss / cache:error for tuple-document reads ({ key, kind: 'relation', callId, error?, errorName? }). When the engine calls the resolver through the relations seam, callId is the engine's kerberosCallId, so resolver and engine events line up; standalone calls get a generated id.
| Hooks | Events | |
|---|---|---|
| Registration | hooks constructor option, one function per name |
kerberos.on(name, fn), any number of listeners |
| Timing | Awaited inside the request | Synchronous, after the fact |
| Can change the outcome | Yes — veto by throwing (KerberosHookError, follows onError) or enrich by returning replacement arguments from beforeRequest |
Never — failures are contained |
| Data | Full validated arguments and results (frozen views) | Identity-only payloads |
| Fires for malformed arguments | No | request:start / request:error / request:end |
| Bounded by | hooksTimeoutMs |
nothing — keep listeners synchronous |
| Typical use | Guards, enrichment, blocking audit | Metrics, alerting, dashboards |
Every number below comes from scripts in this repository, recorded in bench/results/ and turned into these charts and tables by pnpm bench:report. They were measured on one machine (named under each chart). Treat them as relative guidance, not absolutes: the order and the orders of magnitude carry over to other hardware, the exact figures do not. The docs site has the same charts in interactive form.
Four scenarios, each implemented idiomatically in every library (bench/compare/adapters/): a plain role check, a role check with an ownership condition, a policy with 1,000 rules where the request matches the last one, and filtering a list of 100 documents with the ownership rule (pnpm bench:compare).
| Library | Role check ops/s |
Ownership condition ops/s |
1,000 rules ops/s |
Filter 100 documents batches/s |
|---|---|---|---|---|
| Kerberos.js isAllowed · checkResources · v4.3.0 |
817,000 | 810,000 | 820,000 | 12,600 |
| CASL ability built once per user · v7.0.1 |
40,400,000 | 14,900,000 | 39,500,000 | 103,000 |
| CASL ability built per request · v7.0.1 |
4,350,000 | 1,870,000 | 9,650 | 93,500 |
| casbin enforce · batchEnforce · v5.51.1 |
383,000 | 297,000 | 549 | 2,910 |
| AccessControl tryCan(…).do(…) · v3.1.1 |
1,170,000 | 955,000 | 1,160,000 | 10,100 |
| easy-rbac can(roles, operation, params) · v4.0.0 |
770,000 | 693,000 | 762,000 | 9,390 |
| @rbac/rbac can(role, operation, params) · v2.2.2 |
24,500,000 | 3,760,000 | 25,500,000 | 29,800 |
| OPA Rego compiled to WebAssembly, in-process · v1.10.0 |
468,000 | 428,000 | 42,000 | 14,900 |
| OPA server REST Data API, sidecar on localhost · 1.21.1 |
2,050 | 2,160 | 2,180 | 903 |
| Cerbos PDP gRPC SDK, sidecar on localhost · 0.55.0 |
1,500 | 1,400 | 1,530 | 286 |
Apple M3 Max · 16 cores · Node v24.14.1 · Docker 27.4.0 · median of 5 × 1 s samples, each library in a fresh process · 2026-10-03. Medians, rounded to three significant figures.
The libraries do different amounts of work per call, so read the charts as a price list, not a ranking:
- CASL, @rbac/rbac and AccessControl check an in-memory predicate or map: no policy documents, versions or scopes, no audit or telemetry path, no query planner. @rbac/rbac also memoizes role × operation lookups. Every timed call repeats the same request, so it is measured at its cache-hit speed.
- CASL builds an ability per user. The built once per user row times the check alone. The built per request row includes building the ability, which is what you pay unless you cache abilities between requests.
- A Kerberos.js call includes argument validation, the guarded audit, telemetry and hook seams, and the walk along the scope chain. Its rule index keeps the 1,000-rule policy as fast as the one-rule policy. casbin, OPA's WebAssembly build and per-request CASL all slow down as the policy grows.
- OPA server and Cerbos PDP run as sidecars. Every decision is a serialized request over localhost, so the transport dominates whatever the engine does. The numbers were taken on Docker Desktop, where the round-trip also crosses the VM's network bridge, so they are slower than on a Linux host. A network hop is never free, though, which is the cost Kerberos.js is built to avoid.
Method: every (library, scenario) pair runs in a fresh process; each adapter must reproduce the scenario's expected allow and deny decisions before it is timed; the median of five one-second samples after a one-second warmup is reported; synchronous checks are never awaited. OPA (openpolicyagent/opa:1.21.1, once compiled to WebAssembly and evaluated in-process, once as a server) and Cerbos (ghcr.io/cerbos/cerbos:0.55.0 over gRPC) run from pinned images. @cerbos/embedded is absent: its policy bundles can only be built by Cerbos Hub.
Cold start — load the library, build the ownership policy, first decision, in a fresh process:
| Library | Load | Build policy | First decision | Total |
|---|---|---|---|---|
| easy-rbac can(roles, operation, params) · v4.0.0 |
1.5 ms | 0.2 ms | 0.2 ms | 1.9 ms |
| CASL ability built once per user · v7.0.1 |
4.6 ms | 0.3 ms | 0.4 ms | 5.3 ms |
| @rbac/rbac can(role, operation, params) · v2.2.2 |
7.5 ms | 0.7 ms | 0.3 ms | 8.5 ms |
| Kerberos.js isAllowed · checkResources · v4.3.0 |
13.7 ms | 1.1 ms | 1.3 ms | 16.1 ms |
| casbin enforce · batchEnforce · v5.51.1 |
13.5 ms | 2.6 ms | 1.0 ms | 17.1 ms |
| AccessControl tryCan(…).do(…) · v3.1.1 |
15.3 ms | 0.8 ms | 1.0 ms | 17.1 ms |
| OPA Rego compiled to WebAssembly, in-process · v1.10.0 |
6.7 ms | 17.2 ms | 0.3 ms | 24.1 ms |
Bundle size for the browser (pnpm size:compare: esbuild, min+gzip, the same method as pnpm size). OPA's WebAssembly SDK also needs the compiled policy at runtime, so its bar includes the .wasm file for the ownership rule alone:
| Library | min | min+gzip | |
|---|---|---|---|
@alexify/kerberos (main entry) |
134.3 KB | 38.3 KB | |
@casl/ability |
18.3 KB | 6.6 KB | |
casbin |
125.3 KB | 33.9 KB | does not bundle for the browser (Node builtins) — measured as a Node bundle |
accesscontrol |
92.5 KB | 29.0 KB | |
easy-rbac |
4.8 KB | 1.8 KB | |
@rbac/rbac |
77.2 KB | 18.3 KB | |
@open-policy-agent/opa-wasm |
92.6 KB | 29.1 KB | + 51.7 KB gzip for the compiled policy (.wasm, ownership rule only) |
CASL and easy-rbac are small because they implement far less. casbin does not bundle for the browser at all.
The zero-dependency regression harness in bench/bench.js (up to 0.5 s of warmup, then five one-second samples per scenario, median reported):
| Scenario | ops/sec |
|---|---|
isAllowed — simple role match |
~796,000 |
isAllowed — derived roles + variables + condition |
~617,000 |
checkResources — 10 resources × 3 actions |
~59,400 |
checkResources — 10 resources, includeMeta |
~48,700 |
isAllowed — role policy + 2-level parentRoles chain |
~402,000 |
isAllowed — 3-segment scoped request (chain walk) |
~594,000 |
isAllowed — simple role match + Zod validation |
~450,000 |
isAllowed — simple role match + 1 sync decision listener |
~701,000 |
isAllowed — simple role match + request-level hooks |
~393,000 |
isAllowed — simple role match + per-resource hooks |
~337,000 |
checkResources — 10 resources × 3 actions + decision listener |
~53,900 |
isAllowed — cache-backed dynamic policy ($expr) |
~271,000 |
checkResources — 50 resources, cache-backed |
~8,280 |
planResources — $expr policy (variables + deny rule) |
~63,000 |
relations.check — direct tuple (flat) |
~731,000 |
relations.check — deep walk (3 arrows + nested groups) |
~107,000 |
isAllowed — relation-backed derived role (deep walk) |
~70,800 |
checkResources evaluates resources concurrently (Promise.allSettled): with a remote policy store, N resources cost one parallel wave of lookups instead of N sequential round-trips (measured ~8x faster with a 2ms-latency cache and 10 resources), and one failing resource never fails the batch — it fail-closes to EFFECT_DENY for its actions only. Cold start of Kerberos.js alone (pnpm bench:coldstart) splits into require, construction and the first decision.
The scaling sweeps (node bench/bench.js scaling) measure how decision cost grows with policy shape. Both releases were measured on the same machine with the same harness (--src points it at a checkout of the previous release):
| Scenario | 4.2.0 | 4.3.0 | Speed-up |
|---|---|---|---|
| 1 000 rules × distinct roles, match last | ~23,800 | ~721,000 | ×30.3 |
| 1 000 rules × distinct actions, match last | ~33,100 | ~740,000 | ×22.4 |
| 1 000 rules × distinct actions, match first | ~35,900 | ~749,000 | ×20.9 |
| 100 concurrent relations.check, shared memo, depth 16 | ~378 | ~3,220 | ×8.5 |
| checkResources 100 docs, relation depth 16 | ~330 | ~1,670 | ×5.1 |
| 64 principal roles, no role policies | ~39,000 | ~134,000 | ×3.4 |
| 32 derived-role definitions, 1 referenced | ~198,000 | ~632,000 | ×3.2 |
| scope depth 8, decided at the most specific scope | ~180,000 | ~282,000 | ×1.6 |
To regenerate everything: node bench/bench.js --json --samples 5, the same with scaling --src <previous release checkout>, pnpm bench:compare, node scripts/size-compare.js --json, then pnpm bench:report.
See CHANGELOG.md for the full history of changes, including the 3.x → 4.x release notes (verified Cerbos compatibility, the policy importer, the loader and CLI, and the security/performance hardening waves).