Best Practices
Short, opinionated guidance for the decisions that come up most often.
can vs tryCan
Section titled “can vs tryCan”Both query the same model. The difference is what happens on an error
(invalid input, a strict violation, an async‑required check on the sync path):
can() throws, tryCan() denies.
| Use | When |
|---|---|
can() | Boot/config validation and tests — you want a typo or misconfiguration to throw loudly. |
tryCan() | The request hot path — a failure must never become “allow”. |
// request handler — fail closedif (!ac.tryCan(req.user.role, ctx).action(action, resource).granted) { return res.status(403).end();}// startup smoke test — fail loudac.can('admin').readAny('report'); // throws if 'admin'/'report' are typosBoth can() and tryCan() still emit the access (and error) events, so
your audit log is identical either way.
where vs require
Section titled “where vs require”.where(condition)— a conditional grant (ABAC). It can only add access, under a condition..require(condition)— a mandatory gate, independent of grants. It can only restrict:granted = (a grant matches) AND (every applicable gate passes).
ac.grant('manager') .where('$.order.value <= 100000') // managers, but only small orders .updateAny('order', ['*']);
ac.require('$.env == prod'); // everyone, every check, prod onlyac.category('billing') .require('$.ip cidr 10.0.0.0/8'); // billing/* only from the VPNModel Ownership, Don’t Hand-roll It
Section titled “Model Ownership, Don’t Hand-roll It”If a rule is “the user owns the record”, encode it as ownership + context, not as
an if next to the check. Configure it once, pass the record, let the engine
decide.
const ac = new AccessControl({}, { policy: { ownerField: 'ownerId' } });ac.grant('user').updateOwn('order', ['*']);
const order = await db.getOrder(id); // { ownerId: ... }ac.can('user', { user: req.user, order }).updateOwn('order').granted;Ship Booleans, Not the Policy
Section titled “Ship Booleans, Not the Policy”Access control runs on the server. The client should never receive your
grants — only decisions. Compute them with tryCan() (which never throws on
the view path) and send a small capability map:
const caps = { canEditPost: ac.tryCan(role).updateAny('post').granted, canSeeRevenue: ac.tryCan(role).readAny('dashboard:revenue').granted};res.json(caps); // the UI shows/hides from these flagsThe client learns what it can do, not how the policy is built.
Don’t
- Never send
getGrants()/getGrantsList()— or a single role’s slice of them — to the browser. - Never re-instantiate
AccessControlin the client to “check locally”. The policy leaks, and a client-side check can’t be trusted anyway.
Do
-
Decide on the server and send booleans (a capability map), or render server-side (SSR) so the markup arrives already gated.
-
For a data-driven menu, model the surface as a resource and return only the allowed items:
ac.grant('guest').read('menu', ['home']).grant('user').read('menu', ['home', 'profile', 'videos']);const items = ac.can(role).read('menu').attributes; // ['home','profile','videos']res.json(items); // client renders only these
engine vs policy vs context
Section titled “engine vs policy vs context”new AccessControl(grants, { engine, policy, context }). Three buckets, three
concerns — think library, your domain, data:
engine— library mechanics & security:pathPrefix,allowRegex,charset,safeErrors,errorCodePrefix.policy— your authorization model:ownerField/owner,strict, action/resource allow‑lists.context— ambient data for conditions (env,ip,user, the record), merged with (and overridden by) the per‑check context fromcan(role, context)/.with().
const ac = new AccessControl(grants, { engine: { allowRegex: false, charset: Charset.ASCII, safeErrors: true }, policy: { ownerField: 'ownerId', strict: { roles: true } }, context: { env: process.env.NODE_ENV }});Turn On Strict in Development
Section titled “Turn On Strict in Development”strict.roles is on by default (an unknown role throws). actions and
resources are off by default — an ungranted action/resource simply denies.
Turn them on while developing to catch typos, ideally with setup() declaring
your vocabulary so only true typos throw (a declared‑but‑ungranted name still
returns granted:false):
const ac = new AccessControl(grants, { policy: { strict: { actions: true, resources: true } }});ac.setup({ actions: ['publish', 'approve'] }); // declare custom vocabularyPair this with can() (not tryCan()) in tests so the throw surfaces.
Lock the Model After Building It
Section titled “Lock the Model After Building It”If your grants are fixed at boot, lock() the instance. It deep‑freezes the
model; any later mutation throws. This turns “someone mutated the policy at
runtime” from a possibility into an error.
const ac = new AccessControl();ac.grant('user').readAny('post', ['*', '!authorId']);ac.grant('admin').extend('user').updateAny('post', ['*']);ac.require('$.env == prod');
ac.lock(); // no more grant/deny/extend/setup/require/setGrantsPersist as Rows, Rebuild on Boot
Section titled “Persist as Rows, Rebuild on Boot”Store the flat list (DB‑friendly) and rehydrate; it round‑trips identically.
await db.savePolicy(ac.getGrantsList()); // one row per rule + $extend rowsconst ac = new AccessControl(await db.loadPolicy());See Serialization & Databases.
Quality & Testing
Section titled “Quality & Testing”AccessControl is held to a high bar because a wrong answer here is a vulnerability, not a bug:
- 100% coverage (statements, branches, functions, lines).
- Mutation tested (Stryker, ≥ 88% and rising) — proves the tests actually catch regressions, not just execute lines.
- An adversarial security suite and a seeded property fuzzer assert invariants 100%/mutation can’t (see Security › What testing can and cannot prove).
- Zero production advisories (
npm audit --omit=dev); single, pinned runtime dependency.