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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,53 @@ jobs:
- run: pnpm install --frozen-lockfile
# A broken docs build (including dead links) should fail the PR, not the deploy.
- run: pnpm run docs:build

# Runs the shared corpus against BOTH engines. The suite is meaningful without
# Docker (it asserts Kerberos against the recorded expectations), but this job
# also stands up a real Cerbos PDP over the same policy directory so that a
# wrong expectation cannot make the two engines look compatible.
conformance:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile

# The Cerbos version is pinned deliberately: the `latest` tag is stale and
# still serves 0.40.0, which predates the 0.41.0 change to cross-role
# conflict resolution — testing against it would hide a known divergence.
# See conformance/DIVERGENCES.md.
#
# Fails fast with a readable compile error if the corpus is malformed,
# rather than surfacing as an opaque 400 from the running PDP.
- name: Compile the corpus with Cerbos
run: |
docker run --rm -v "${{ github.workspace }}/conformance/policies:/policies:ro" \
ghcr.io/cerbos/cerbos:0.55.0 compile --skip-tests /policies

- name: Start the Cerbos PDP
run: |
docker run --rm -d --name cerbos \
-v "${{ github.workspace }}/conformance/policies:/policies:ro" \
-p 3592:3592 \
ghcr.io/cerbos/cerbos:0.55.0 server \
--set=storage.disk.directory=/policies \
--set=storage.disk.watchForChanges=false \
--set=engine.lenientScopeSearch=true
for i in $(seq 1 60); do
if curl -sf http://localhost:3592/_cerbos/health | grep -q SERVING; then exit 0; fi
sleep 1
done
echo "Cerbos never became ready"; docker logs cerbos; exit 1

- run: pnpm test:conformance
env:
CERBOS_URL: http://localhost:3592

- if: always()
run: docker logs cerbos || true
8 changes: 7 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@
],
"node/no-exports-assign": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error"
"node/no-path-concat": "error",
"no-undef": "error",
"no-dupe-else-if": "error",
"getter-return": "error",
"no-setter-return": "error",
"no-unused-private-class-members": "error",
"no-constant-binary-expression": "error"
}
}
257 changes: 226 additions & 31 deletions CHANGELOG.md

Large diffs are not rendered by default.

56 changes: 39 additions & 17 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Alexis Technologies
Copyright (c) 2024-2026 Alexis Technologies

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
473 changes: 427 additions & 46 deletions README.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions bench/bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,73 @@ async function main() {
rich.checkResources({ principal, resources: manyResources })),
);

results.push(
await bench('checkResources — 10 resources, includeMeta', () =>
rich.checkResources({ principal, resources: manyResources, includeMeta: true })),
);

// Role-policy layer: principal + role policies with a 2-level parentRoles
// chain (exercises #evaluateRolePolicy's memo/inheritance machinery).
const layeredPolicies = [
{
principalPolicy: {
principal: 'root',
version: 'default',
rules: [{ resource: 'expense', actions: [{ action: '*', effect: Effect.Allow }] }],
},
},
{
rolePolicy: {
role: 'JUNIOR',
version: 'default',
parentRoles: ['SENIOR'],
rules: [{ resource: 'expense', allowActions: ['view', 'approve'] }],
},
},
{
rolePolicy: {
role: 'SENIOR',
version: 'default',
parentRoles: ['LEAD'],
rules: [{ resource: 'expense', allowActions: ['view', 'approve'] }],
},
},
{
rolePolicy: {
role: 'LEAD',
version: 'default',
rules: [{ resource: 'expense', allowActions: ['view'] }],
},
},
];
const layered = new Kerberos(layeredPolicies, []);
results.push(
await bench('isAllowed — role policy + 2-level parentRoles chain', () =>
layered.isAllowed({ principal: { id: 'joe', roles: ['JUNIOR'] }, action: 'view', resource })),
);

// Scoped lookup: a 3-segment request scope walks the scope chain (4 lookups
// per source) before falling back to the base policy.
const scoped = new Kerberos(simplePolicies, []);
const scopedResource = { ...resource, scope: 'acme.emea.sales' };
results.push(
await bench('isAllowed — 3-segment scoped request (chain walk)', () =>
scoped.isAllowed({ principal, action: 'view', resource: scopedResource })),
);

// Validation-backend scenario: the same simple check with Zod configured —
// measures the args-validation cost on top of evaluation.
try {
const { z } = require('zod');
const validated = new Kerberos(simplePolicies, [], { z });
results.push(
await bench('isAllowed — simple role match + Zod validation', () =>
validated.isAllowed({ principal, action: 'view', resource })),
);
} catch {
console.log('(zod not installed — skipping the validation-backend scenario)');
}

// Cache-backed scenario: dynamic $expr policy resolved through a Map cache.
let jsep;
try {
Expand Down Expand Up @@ -139,6 +206,20 @@ async function main() {
cached.isAllowed({ principal, action: 'view', resource: docResource })),
);

// Cache-backed batch: exercises the per-batch singleflight lookups memo
// (each distinct policy resolves once per batch, not once per resource).
const cachedBatchResources = [];
for (let i = 0; i < 50; i++) {
cachedBatchResources.push({
resource: { id: `doc${i}`, kind: 'document', attr: { status: 'OPEN' } },
actions: ['view'],
});
}
results.push(
await bench('checkResources — 50 resources, cache-backed', () =>
cached.checkResources({ principal, resources: cachedBatchResources })),
);

// Query planning: partial evaluation of a rich $expr policy (variables +
// constants + allow/deny rules) into a Cerbos-shaped filter.
const { createSafeExprCodec, deserializePolicy } = require('../src/index.js');
Expand Down
149 changes: 149 additions & 0 deletions bench/compare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Cross-library comparison benchmark: the same authorization scenario —
* role-gated actions plus an ownership (ABAC) condition — implemented in
* Kerberos, CASL (@casl/ability) and casbin.
*
* Run: pnpm bench:compare
*
* Honesty notes, also published with the results in docs/guide/benchmarks.md:
* - the libraries have different feature sets; the scenario is the overlap
* (RBAC + one attribute condition), NOT a claim of equivalence — none of
* the others have policy versions/scopes, query plans or ReBAC;
* - CASL abilities are built PER USER: `check (prebuilt)` measures the pure
* check against a shared ability, `build + check` measures the realistic
* per-request path (define rules for the request's user, then check);
* - casbin's enforce() is async and model-interpreted; the in-memory model
* here (RBAC with an ABAC ownership matcher) is its idiomatic equivalent;
* - @cerbos/embedded and OPA-WASM are absent by necessity: their policy
* bundles cannot be built from open tooling alone (Cerbos Hub / the opa
* compiler), so honest numbers cannot be produced here.
*/
const { performance } = require('node:perf_hooks');

const { Kerberos, Effect } = require('../src/index.js');
const { AbilityBuilder, createMongoAbility, subject } = require('@casl/ability');
const { newEnforcer, newModelFromString, StringAdapter } = require('casbin');

const WARMUP_ITERATIONS = 2_000;
const MEASURE_MS = 1_000;

async function bench(name, fn) {
for (let i = 0; i < WARMUP_ITERATIONS; i++) await fn();
let iterations = 0;
const start = performance.now();
while (performance.now() - start < MEASURE_MS) {
await fn();
iterations += 1;
}
const elapsed = performance.now() - start;
const opsPerSec = Math.round((iterations / elapsed) * 1000);
console.log(`${name.padEnd(56)} ${opsPerSec.toLocaleString('en-US').padStart(12)} ops/sec`);
return { name, opsPerSec };
}

// The shared scenario: USERs may view documents they own; EDITORs may view
// and publish any document. The check asked of every library: may this USER
// view this document they own?
const user = { id: 'u1', roles: ['USER'] };
const document = { id: 'd1', kind: 'document', attr: { ownerId: 'u1' } };

async function main() {
console.log('Cross-library comparison — RBAC + ownership condition');
console.log(`Node ${process.version} · ${new Date().toISOString().slice(0, 10)}\n`);
const rows = [];

// --- Kerberos -----------------------------------------------------------
const kerberos = new Kerberos(
[
{
resourcePolicy: {
version: 'default',
resource: 'document',
rules: [
{
actions: ['view'],
effect: Effect.Allow,
roles: ['USER'],
condition: { match: ({ P, R }) => R.attr.ownerId === P.id },
},
{ actions: ['view', 'publish'], effect: Effect.Allow, roles: ['EDITOR'] },
],
},
},
],
[],
);
rows.push(
await bench('@alexify/kerberos · isAllowed', () =>
kerberos.isAllowed({ principal: user, resource: document, action: 'view' })),
);

// --- CASL ---------------------------------------------------------------
function buildAbility(forUser, roles) {
const { can, build } = new AbilityBuilder(createMongoAbility);
if (roles.includes('USER')) can('view', 'document', { ownerId: forUser.id });
if (roles.includes('EDITOR')) can(['view', 'publish'], 'document');
return build();
}
const prebuilt = buildAbility(user, user.roles);
const caslDoc = subject('document', { ownerId: 'u1' });
rows.push(await bench('@casl/ability · check (prebuilt ability)', () => prebuilt.can('view', caslDoc)));
rows.push(
await bench('@casl/ability · build + check (per request)', () => {
const ability = buildAbility(user, user.roles);
return ability.can('view', subject('document', { ownerId: 'u1' }));
}),
);

// --- casbin -------------------------------------------------------------
const model = newModelFromString(`
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = (g(r.sub.Id, p.sub) || r.sub.Roles.includes(p.sub)) && p.obj == "document" && p.act == r.act && (p.sub != "USER" || r.obj.OwnerId == r.sub.Id)
`);
const adapter = new StringAdapter(
['p, USER, document, view', 'p, EDITOR, document, view', 'p, EDITOR, document, publish'].join('\n'),
);
const enforcer = await newEnforcer(model, adapter);
const casbinSub = { Id: 'u1', Roles: ['USER'] };
const casbinObj = { OwnerId: 'u1' };
rows.push(await bench('casbin · enforce (in-memory model)', () => enforcer.enforce(casbinSub, casbinObj, 'view')));

// Sanity: every library must actually ALLOW the scenario's check.
const kerberosOk = await kerberos.isAllowed({ principal: user, resource: document, action: 'view' });
const caslOk = prebuilt.can('view', caslDoc);
const casbinOk = await enforcer.enforce(casbinSub, casbinObj, 'view');
if (!kerberosOk || !caslOk || !casbinOk) {
throw new Error(`scenario mismatch: kerberos=${kerberosOk} casl=${caslOk} casbin=${casbinOk}`);
}
const kerberosDeny = await kerberos.isAllowed({
principal: { id: 'u2', roles: ['USER'] },
resource: document,
action: 'view',
});
const casbinDeny = await enforcer.enforce({ Id: 'u2', Roles: ['USER'] }, casbinObj, 'view');
const caslDeny = buildAbility({ id: 'u2' }, ['USER']).can('view', caslDoc);
if (kerberosDeny || casbinDeny || caslDeny) {
throw new Error(`deny-scenario mismatch: kerberos=${kerberosDeny} casl=${caslDeny} casbin=${casbinDeny}`);
}

console.log('\n| Library · path | ops/sec |');
console.log('| -------------- | -------:|');
for (const row of rows) console.log(`| ${row.name} | ${row.opsPerSec.toLocaleString('en-US')} |`);
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Loading
Loading