conditionsarchitecture

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' }); // false

One 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.