Conditional permissions in rbac-fs
Plenty of real permission rules aren't a flat yes/no. "A manager can approve any invoice" is flat. "An employee can approve their own expense report, but not someone else's" isn't — it depends on data at the moment of the check, not just the role.
The legacy form: when
await rbac.createRole('employee', {
conditions: [{ resource: 'report', actions: ['view'], when: 'owner_id == user.id' }],
});
await rbac.can(user, 'report', 'view', { owner_id: user.id }); // true
await rbac.can(user, 'report', 'view', { owner_id: 'someone-else' }); // falseOne clause, one comparison. It covers the common "is this mine" case well, and it still works unchanged in every current version — no migration needed if this is all you use.
The composable form: a condition tree
A single when clause can't express "device is mobile and location is in an approved list" — that needs combining two separate checks. The condition tree adds and/or/not nodes over a fixed operator vocabulary:
await rbac.createRole('mobile-approver', {
conditions: [
{
resource: 'invoice.line-items',
actions: ['approve'],
condition: {
and: [
{ op: 'eq', path: 'device', value: 'mobile' },
{ op: 'in', path: 'location', value: ['US', 'IN', 'EU'] },
],
},
},
],
});Nestable to arbitrary depth — and/or/not nodes can contain other and/or/not nodes, not just leaf comparisons.
Why not just use eval() or a scripting language
Because a role file is data that can be hand-edited or generated by a UI, and eval()-ing arbitrary strings from a file on disk turns "someone edited a JSON file" into "someone can run arbitrary code in your process." rbac-fs's operator set is fixed and enumerable — eq, neq, gt/gte/lt/lte, in/notIn, exists/notExists, contains, startsWith/endsWith — every one of them is a safe, non-Turing-complete comparison. See Security Guardrails for the full reasoning.
The escape hatch: custom operators
Some rules genuinely need app-specific logic no fixed operator list can anticipate — a geofence radius, a business-hours window. rbac-fs handles that by letting you register a *named function you wrote*, not a string the engine parses and executes:
const rbac = new RBAC({
operators: {
withinRadius: ({ context, args }) => haversineKm(context.userLocation, context.siteLocation) <= args.km,
},
});A condition referencing { op: 'custom', name: 'withinRadius', args: { km: 5 } } calls your real function — nothing is ever parsed out of the role file and executed as code.
See Building a condition tree with rbac-fs for a hands-on walkthrough of writing one from scratch.