Conditions (ABAC)
A condition decides whether a grant applies at check time, against a
context — this is the ABAC half of the engine. Attach one with .where()
and supply data per check.
ac.grant('manager') .where('$.order.value <= 100000') .updateAny('order', ['*']);
ac.can('manager') .with({ order: { value: 5000 } }) .updateAny('order').granted; // true
ac.can('manager') .with({ order: { value: 250000 } }) .updateAny('order').granted; // false (condition fails)Supplying Context
Section titled “Supplying Context”Three equivalent ways to pass per‑check data:
ac.can('manager', { order }).updateAny('order'); // 2nd argac.can('manager').with({ order }).updateAny('order'); // fluentac.check({ role: 'manager', resource: 'order', action: 'update:any', context: { order } }); // one-shotAmbient defaults can be set on the instance and are merged with (overridden by) the per‑check context:
const ac = new AccessControl(grants, { context: { env: process.env.NODE_ENV } });Operators
Section titled “Operators”Operands are notation paths ($.order.value) read from the context, or
literals. Quote to force a string ("100" vs 100).
== / != are strict — they compare value and type with no coercion, so
100 (number) never equals "100" (string). The JS spellings === / !== are
accepted as aliases (normalized to == / != in the stored form).
| Group | Operators |
|---|---|
| Comparison | == != > >= < <= |
| Membership | in, contains |
| String | startsWith, endsWith, matches |
| Time | before, after, between, during |
| Network | cidr |
| Combinators | { and }, { or }, { not } |
ac.grant('user') .where('$.user.id == $.doc.ownerId') .updateOwn('doc');
ac.grant('ops') .where({ and: ['$.env == "prod"', '$.ip cidr 10.0.0.0/8'] }) .readAny('server');
ac.grant('night') .where('$.now.time between [22:00, 06:00]') // overnight window .createAny('report');The reserved $.now.* fields (year, month, day, weekday, hour, minute, time, date, epochMilliseconds) are auto‑injected; override context.now (a Date or string) for deterministic tests and context.tz for the timezone.
Temporal Scheduling — during
Section titled “Temporal Scheduling — during”The time operators above compare single fields. For real schedules — recurrences, calendar ranges, unions of windows — the during operator matches an instant against a dtrexp expression:
// weekday business hoursac.grant('editor') .where('$.now during "T0900:1800 E1:5"') .updateAny('post');
// the fluent shorthand — same stored form, composes with .where()ac.grant('editor').during('T0900:1800 E1:5').updateAny('post');
// weekends within a date range, or any union of windowsac.grant('oncall') .where('$.now during "E6:7 20260701:20261001 | T2200:0600 E1:5"') .readAny('alert');A dtrexp expression is compact and precise: T0900:1800 is a half-open time window (09:00 in, 18:00 out), E1:5 is Mon–Fri, D13 E5 is every Friday the 13th, | unions alternatives. The full syntax lives at dtrexp.org.
What to know:
- Any date-like left side.
$.nowchecks the moment of the access check; any other path works too, as long as it resolves to aDate, epoch milliseconds, an ISO string, or a{ epochMilliseconds }object — e.g.'$.booking.start during "T0900:1800 E1:5"'constrains the data, not the clock. A left side that isn’t date-like makes the condition false (fail-closed) — it never throws at check time. - The timezone is an evaluation parameter, never part of the expression.
duringevaluates incontext.tz(IANA name), the same reserved key the$.now.*fields use; with notzit uses the system zone — one timezone story across the whole condition system. - The expression must be a static quoted string. Grants are serialized JSON; the schedule is part of the policy, so a context path on the right side is rejected. Always quote the expression in sugar (they contain spaces and
|). - Validation happens when the grant is committed, not at check time. A malformed expression throws
INVALID_DTREXP(with dtrexp’s message and character position), and an expression that can never match — likeD30 M2, February 30th — throwsDTREXP_NEVER_MATCHES. A grants document from your database either loads whole or fails loudly at startup. - Parse once, evaluate many. Compiled expressions are cached (bounded), and a coverage check is a handful of integer comparisons — no occurrence iteration on the authorization path.
during works everywhere conditions do: in { and, or, not } combinators, in require() gates, on the sync and async check paths, and in serialized grants.
And denials know about schedules: when a check fails only because of a during schedule, permission.reason (and the access event) reports 'out_of_schedule' instead of the generic 'condition_failed' — “granted, but not now”, so your UI can say “outside your access hours” and mean it.
A Multi-clause Business Rule
Section titled “A Multi-clause Business Rule”Real policies often combine several conditions. Take: a senior buyer may
approve a purchase order only if they are not its creator, it’s in their
branch, its value exceeds 100,000, and it’s within today’s approval limit. Every
clause maps to a path comparison; { and } joins them:
ac.grant('buyer/senior') .where({ and: [ '$.user.id != $.order.creatorId', // not the creator '$.user.branch == $.order.branch', // same branch '$.order.value > 100000', // over the threshold '$.order.approvedToday < $.user.dailyLimit' // under today's limit ] }) .action('approve', 'order', ['*']);
ac.can('buyer/senior', { user: { id: 7, branch: 'NW', dailyLimit: 5 }, order: { creatorId: 9, branch: 'NW', value: 250000, approvedToday: 2 }}).do('approve', 'order').granted; // trueA clause that needs a live number (e.g. approvedToday) is supplied in the
check context. If it requires I/O (a DB count), compute it before the check or
use a custom condition function.
How Conditions Are Parsed, Cast & Stored
Section titled “How Conditions Are Parsed, Cast & Stored”A condition can be written four ways, and all of them are accepted anywhere a
condition is taken (.where(), .require(),
and the condition field of a stored rule):
'$.order.value <= 100000' // 1. string sugar (what you write)['$.order.value', '<=', 100000] // 2. canonical leaf [path, operator, value]{ and: [ /* …conditions… */ ] } // 3. combinator: and | or | not{ fn: 'ipAllowed', args: { cidr: '…' } } // 4. custom function (see Async)Internally there is one representation — the canonical JSON above. Strings
are compiled into it; combinators are compiled recursively; { fn } is passed
through untouched.
Parsing the String Form
Section titled “Parsing the String Form”A leaf string is tokenized into exactly three parts — path operator
value — by recognizing the operator keyword/symbol (see the table
above). The left side must be a $.-path; the right side (the
value) is then cast by its token:
| Token looks like | Becomes | Example |
|---|---|---|
true / false | boolean | $.active == true → true |
null | null | $.deletedAt == null → null |
| a number | number | $.value <= 100000 → 100000 |
$.something | path reference (compared field-to-field) | $.user.id == $.doc.ownerId |
[a, b] | array (each item cast the same way) | $.role in [admin, staff] |
HH:MM / YYYY-MM-DD (with time/date ops) | time / date | $.now.time between [22:00, 06:00] |
1.2.3.0/24 (with cidr) | CIDR range | $.ip cidr 10.0.0.0/8 |
| anything else | string | $.status == draft → 'draft' |
Values with spaces or characters that would confuse the tokenizer must be
quoted — '$.title == "in review"' → ['$.title', '==', 'in review'].
Nesting depth is bounded (deeply nested and/or/not throws), and the whole
thing is validated on the way in regardless of which form you used.
Why the Array Form Avoids Surprises
Section titled “Why the Array Form Avoids Surprises”Because the string is inferred, the array form is the precise one — you supply the exact value and type yourself, with no parsing and no escaping:
'$.code == 007' // → number 7 (leading zeros lost!)['$.code', '==', '007'] // → string '007'
'$.name == "O'Brien"' // needs careful quoting['$.name', '==', "O'Brien"] // just a valueThis is also why a value that looks like a path is treated as one in a string
($.a == $.b), whereas in the array you decide: ['$.a','==','$.b'] (compare
fields) vs ['$.a','==','"$.b"']-style quoting is unnecessary — pass the literal
you mean.
What Gets Stored
Section titled “What Gets Stored”The compiled canonical form is what every reader returns —
getGrants(), getGrantsList(), getRequirements(), and snapshot() — as
frozen deep copies. So your database / JSON column holds arrays and
combinator objects, never the sugar string:
ac.grant('manager').where('$.order.value <= 100000').updateAny('order');ac.getGrants().manager.order.update[0].condition;// → ['$.order.value', '<=', 100000]That stored shape is deliberately the one to persist, because it:
- needs no re-parsing on load — deterministic, and stored data can’t throw a parse error later;
- preserves exact types (
'007'stays a string,5stays a number); - needs no escaping, and is trivial to generate or query from code/SQL.
Rule of thumb: hand-write the string sugar; store and generate the array. They are interchangeable as input — the array is simply the normalized output the engine keeps.
Regular Expressions
Section titled “Regular Expressions”where vs require
Section titled “where vs require”.where() conditionally grants; .require()
is an independent gate that can only restrict. They compose:
granted = (a grant matches) AND (every applicable gate passes).
Custom / Async Conditions
Section titled “Custom / Async Conditions”Business logic that needs I/O lives in a registered function referenced as
{ fn, args } — see Async & Custom Functions.