Query Plans (planResources)
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).
How a plan is composed
The planner mirrors Mixed Policy Evaluation symbolically, layer by layer. Which layer decides is already known at plan time (it depends only on the principal and resource.kind); what stays unknown is only whether rule conditions over unknown R.attr / R.id hold — those become the residual filter:
Every layer keeps its runtime semantics: principal rules override (Deny wins), the role layer is an allowlist with implicit deny and parentRoles intersection, the resource layer is Deny-over-Allow with default deny — the parity is enforced by a property-style test suite (test/PlanParity.test.js) that grid-samples unknown attributes and compares the filter against real isAllowed results.
Operators
condition is a tree of { expression: { operator, operands } } / { variable } / { value } operands. Variables are Cerbos-named: request.resource.id and request.resource.attr.<path>.
The vocabulary is Cerbos' operator set plus two Kerberos extensions (opaque, relation) — see Plan operators for the full table.
Writing plannable policies
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).
Opaque conditions (post-filtering)
{ 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 operands (ReBAC)
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.
Translating a plan
Translators are deliberately not part of the package (same delegation philosophy as caching/validation). A hand-rolled SQL mapping is a ~40-line recursive walk:
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.