Migrating from AccessControl v2 to v3
✨ See what you can do more with v3 → WHATS-NEW.md
v3 is a focused modernization. The everyday API — grant/deny, can,
createAny/readOwn/…, permission.granted / .attributes / .filter() —
is unchanged, so most code keeps working. This guide covers the breaking points
and shows the “same result in v3” for each.
1. ESM-only
Section titled “1. ESM-only”v3 ships as ES Modules.
// v2 (CommonJS)const { AccessControl } = require('accesscontrol');
// v3 (ESM)import { AccessControl } from 'accesscontrol';Use a modern Node/bundler. If you’re stuck on CommonJS, stay on v2.
2. Grants Model Shape (getGrants() Output)
Section titled “2. Grants Model Shape (getGrants() Output)”The stored shape changed so possession is a field and each action maps to an array of rules (enabling conditions, deny rules, and multiple rules per action). The everyday builder API that produces it is the same.
// v2 — possession folded into the action key, attributes inline{ admin: { video: { 'read:any': ['*'], 'delete:own': ['*'] } } }
// v3 — action key + possession field + rule array{ admin: { video: { read: [{ possession: 'any', attributes: ['*'] }], delete: [{ possession: 'own', attributes: ['*'] }],} } }If you persisted the object form from getGrants() in v2, re-export it from
v3 once (ac.getGrants() / ac.getGrantsList()) and store the new shape. The
flat-list form is recommended for databases — see “immutable copies” below.
3. Inheritance in the Flat List Uses $extend Rows
Section titled “3. Inheritance in the Flat List Uses $extend Rows”When defining grants as a flat array, inheritance now travels as its own row.
// v3 flat listnew AccessControl([ { role: 'editor', resource: 'post', action: 'read:any', attributes: ['*'] }, { role: 'admin', $extend: ['editor'] }, // inheritance row]);The programmatic form is unchanged: ac.grant('admin').extend('editor') or
ac.extendRole('admin', 'editor').
4. Name Handling: Case-preserving + Restricted Charset
Section titled “4. Name Handling: Case-preserving + Restricted Charset”v2 normalized names loosely. v3 is case-preserving and validates against
[A-Za-z0-9_-] (plus the reserved separators below). Two consequences:
Adminandadminare now distinct roles (no silent lowercasing). Pick a convention and stick to it.:(action/possession),/(group/category), and$are reserved and rejected inside a name. Spaces and dots are rejected too.
ac.grant('Admin').readAny('post'); // distinct from 'admin'ac.grant('send mail'); // ❌ throws (space) — use 'send-mail' / 'sendMail'Need international names? Opt into Unicode (mind the homograph caveat):
new AccessControl(grants, { engine: { charset: Charset.UNICODE } }). See
Strict Mode, Errors & Names.
5. Deny-overrides (Inheritance Override Fix, #34)
Section titled “5. Deny-overrides (Inheritance Override Fix, #34)”An explicit deny now restricts inherited grants too — deny always wins.
A smaller child grant no longer shrinks an inherited grant (grants are purely
additive); to take access away, deny it.
ac.grant('user').readAny('post', ['*']);ac.grant('moderator').extend('user');ac.deny('moderator').readAny('post', ['secret']); // carve a field back
ac.can('moderator').readAny('post').attributes; // ['*', '!secret']deny does not cascade across possession: deny create:any still leaves
create:own.
6. Enforced Ownership & strict (Mostly Opt-in)
Section titled “6. Enforced Ownership & strict (Mostly Opt-in)”own checks can now actually verify ownership (see WHATS-NEW). This is
backward-compatible by default: with no ownership resolver configured, own
behaves exactly like v2 (it selects the own attribute set; you enforce
ownership). You only get enforcement when you opt in:
const ac = new AccessControl(grants, { policy: { ownerField: 'ownerId' } });// now `own` checks compare ctx.user.id to ctx.<resource>.ownerIdstrict.roles defaults on and throws on an unknown role at check time — the
same as v2’s throw-on-unknown-role. Set policy: { strict: { roles: false } }
for lenient behavior.
7. getGrants() Returns Immutable Copies
Section titled “7. getGrants() Returns Immutable Copies”getGrants() / getGrantsList() / getRequirements() return frozen deep copies.
Don’t mutate them — go through grant/deny/setGrants/extendRole. For
databases, prefer the flat list:
const rows = ac.getGrantsList(); // DB-friendly rowsconst restored = new AccessControl(rows); // round-trips identically8. Constructor Options & Method Aliases
Section titled “8. Constructor Options & Method Aliases”- The constructor takes a second argument:
new AccessControl(grants, { engine, policy, context })—engine(library mechanics:pathPrefix,allowRegex,charset,safeErrors),policy(your model:ownerField/owner,strict, allow-lists),context(data conditions read via$.). - Redundant method aliases were removed in favor of the canonical names. The one
intentional alias is
.do()(alias of.action()), which also covers CRUD:ac.can('admin').do('update', 'post'). - New
engineknobs for production:allowRegex(thematchesoperator is now opt-in),safeErrors(redacted error messages, default on) with a stableerr.code, andcharset. NewtryCan()is a fail-closedcan()— prefer it on the request path. See What’s New §10 and Security Considerations.
Quick Reference
Section titled “Quick Reference”| v2 | v3 |
|---|---|
require('accesscontrol') | import { AccessControl } from 'accesscontrol' |
{ 'read:any': ['*'] } (stored) | { read: [{ possession: 'any', attributes: ['*'] }] } |
| inheritance row (implicit) | { role, $extend: [...] } |
| names lowercased | names case-preserving, [A-Za-z0-9_-] |
| child grant shrinks inherited | grants additive; use deny (deny-overrides) |
own = attribute set only | own enforced when ownerField/owner configured |
mutable getGrants() | frozen copy; use getGrantsList() for DBs |
✨ See what you can do more with v3 → WHATS-NEW.md