@prefabs.tech/access-control
v0.2.0
Published
Attribute-based permission checks for the browser and Node, with no dependencies
Readme
@prefabs.tech/access-control
Attribute-based permission checks in a single TypeScript file. It runs unchanged in the browser and in Node, and has no dependencies.
Installation
npm install @prefabs.tech/access-controlThe package ships an ES module and a CommonJS build, each with TypeScript declarations, so it loads with import or require(), including under Jest and with TypeScript's older moduleResolution: "node". It needs Node 22 or later.
Usage
You give it the list of grants a user holds and the permission you want to test, and it answers yes or no:
import { checkPermission } from "@prefabs.tech/access-control";
const grants = ["admin.user.*", "!admin.user.delete"];
checkPermission(grants, "admin.user.invite"); // true
checkPermission(grants, "admin.user.delete"); // false
checkPermission(grants, "reports.view"); // falsecheckPermission(grants, permission, context?) takes:
grants: what the user holds, built by your app from their roles and any grants given to them directly. Grants may use patterns (admin.*), denies (!admin.user.delete), conditions and sources.permission: the one concrete name being checked, such asadmin.user.delete. Never a pattern or a deny. To ask about a whole area, see Any permission under a heading.context: the object being acted on, for grants with conditions (see Conditions).
The first argument is often called "the user's permissions". This README calls its entries grants, to keep them apart from the name being checked. A storage layer may still keep grants in a table called permissions.
The library only evaluates grants. Where they are stored, how they are attached to users or roles, and how they reach the browser is up to you.
Terms
| Term | What it is | Where it lives |
| --- | --- | --- |
| Permission | The concrete name being checked, such as invoice.view | The check |
| Grant | What a user holds: a permission pattern, maybe a deny, conditions and a source | Stored with a role or the user |
| Conditions | The rule on a grant, such as { orgId: 42 }: which objects it applies to | The grant, written once |
| Context | The object being acted on, such as the invoice | The check, passed each time |
A grant with conditions applies when the context matches them: { permission: "invoice.view", conditions: { orgId: 42 } } allows checkPermission(grants, "invoice.view", invoice) when invoice.orgId is 42.
Attribute-based access control
A grant can be limited to certain objects by giving it conditions. To check such a grant, pass the object being acted on as the third argument of checkPermission, the context. The grant applies only when the context matches its conditions.
import { type Grant, checkPermission } from "@prefabs.tech/access-control";
const grants: Grant[] = [
{ permission: "invoice.view", conditions: { orgId: 42 } },
{ permission: "invoice.edit", conditions: { orgId: 42, status: ["draft", "pending"] } },
];
const invoice = { id: 9, orgId: 42, status: "paid" };
checkPermission(grants, "invoice.view", invoice); // true: same organisation
checkPermission(grants, "invoice.edit", invoice); // false: paid invoices can't be edited
checkPermission(grants, "invoice.view", { orgId: 7 }); // false: another organisationThe context is any plain object, and it can be nested ({ org: { id: 42 } } is matched by the condition key 'org.id'). Grants without conditions ignore it.
Only the user's own records
To limit a grant to objects that belong to the current user, write it once on the role with a placeholder for the user's id:
const editor = [{ permission: "post.edit", conditions: { userId: "$user.id" } }];When you build the user's permissions (at login, say), bindGrants replaces the placeholder with that user's actual id:
const grants = bindGrants(editor, { user: currentUser }); // currentUser.id is 7
// [{ permission: "post.edit", conditions: { userId: 7 } }]
checkPermission(grants, "post.edit", { id: 1, userId: 7 }); // true: their own post
checkPermission(grants, "post.edit", { id: 2, userId: 8 }); // false: someone else's
checkPermission(grants, "post.edit", { id: 3 }); // false: no userId at allThe bound grants are still plain data, so they can be sent to the browser and the same check can run there. Make sure currentUser.id is set before binding: a missing value becomes null, and the grant would then match objects whose userId is null.
Leaving the context out asks a looser question, "could they do this on some object?", which is meant for showing or hiding parts of the user interface. On the back end, always pass the real object. See Conditions for everything a condition can express and Checking with and without a context for the exact rules.
Permissions and grants
A permission is a dot-separated name such as admin.user.invite. An empty name, or one with an empty segment (admin..view), is invalid and is always refused.
* and a leading ! are grant syntax, described below. They must not appear in the permission being checked: there they have no meaning and are compared as plain text, so the result depends on which grants happen to match.
A grant is what a user holds. It is either a string or an object:
| Grant | Meaning |
| --- | --- |
| "admin.user.view" | That exact permission, on any object |
| "admin.user.*" | Everything under admin.user |
| "admin.*" | Everything under admin |
| "*" | Everything (superuser) |
| "admin.*.view" | view on anything directly under admin |
| "!admin.user.delete" | Deny |
| { permission, conditions } | Allow only on objects that match the conditions |
| { permission, effect: 'deny', conditions } | Deny only on objects that match the conditions |
| { permission, source: 'direct' } | Allow given to the user directly, deciding before their roles (see Direct and role grants) |
Wildcards follow two rules:
- A
*at the end matches one or more segments.admin.*coversadmin.user.viewbut notadminitself. - A
*anywhere else matches exactly one segment.admin.*.viewcoversadmin.user.viewbut notadmin.user.profile.view.
Deny beats allow among grants of the same source, whatever the order of the grants. With no matching grant at all, the answer is no.
Direct and role grants
An object grant can carry a source: "role" when it comes from one of the user's roles, "direct" when it was given to the user directly. String grants and grants without one are role grants. When any direct grant matches, the direct grants decide: among them a deny beats an allow, and otherwise an allow allows. Only when no direct grant matches do the role grants decide, the same way.
So a direct grant can lift a deny that a role gives, for one person.
const grants: Grant[] = mergeGrants(
[{ permission: "report.*" }, { permission: "report.delete", effect: "deny" }], // from a role
[{ permission: "report.delete", source: "direct" }], // given directly
);
checkPermission(grants, "report.delete", {}); // true: the direct allow decides| Role | Direct | Answer | | --- | --- | --- | | allow | deny | deny | | deny | allow | allow | | nothing | allow | allow | | nothing | deny | deny | | allow | nothing | allow | | deny | nothing | deny | | nothing | nothing | deny |
A grant "matches" as everywhere else in this README: its pattern covers the permission, and with a context its conditions hold. Without a context, a conditional allow matches and a conditional deny is ignored. A condition that throws doesn't match on an allow, so for a direct grant the roles decide, and matches on a deny, which then denies. A broad direct grant decides over a narrow role grant: a direct report.* lifts a role's deny of report.delete.
A list without any source answers exactly as before sources existed.
Conditions
A condition restricts a grant to certain objects. The object is described by the context you pass as the third argument to checkPermission.
Attribute conditions
A plain object. Every key must match.
const grants = [
{ permission: "admin.user.view", conditions: { orgId: [1, 2], "team.id": "ops" } },
];
checkPermission(grants, "admin.user.view", { orgId: 2, team: { id: "ops" } }); // true
checkPermission(grants, "admin.user.view", { orgId: "1", team: { id: "ops" } }); // true
checkPermission(grants, "admin.user.view", { orgId: 3, team: { id: "ops" } }); // false
checkPermission(grants, "admin.user.view", { orgId: 1 }); // false- A key can be a dot path (
team.id) into the context. - A scalar value means equality; an array means "one of".
- Numbers and numeric strings compare equal (
1matches"1"), because IDs often arrive as strings from routes and query parameters. - An attribute that is missing from the context never matches.
Function conditions
A function that receives the context and returns a boolean, for logic an attribute object cannot express.
const grants: Grant[] = [
{ permission: "admin.user.view", conditions: (ctx) => ctx.userId === 123456 || ctx.orgId === 2 },
];
checkPermission(grants, "admin.user.view", { userId: 1, orgId: 2 }); // true
checkPermission(grants, "admin.user.view", { userId: 1, orgId: 4 }); // false- Only a strict
truecounts as a match; other truthy values do not. - If the function throws an error, for example because the context is missing a property it reads, the check errs on the side of refusing access. On an allow grant, the error counts as "no match", so the grant does not give access. On a deny grant, the error counts as "match", so access is denied.
Conditional deny
const grants: Grant[] = [
"admin.user.*",
{ permission: "admin.user.delete", effect: "deny", conditions: { role: "owner" } },
];
checkPermission(grants, "admin.user.delete", { role: "member" }); // true
checkPermission(grants, "admin.user.delete", { role: "owner" }); // falseA deny whose conditions is {} has no conditions, so it denies everywhere, like a deny without conditions.
Checking with and without a context
Whether you pass a context changes the question being asked.
| Call | Question | Conditional allows | Conditional denies |
| --- | --- | --- | --- |
| checkPermission(grants, p, object) | Can they do this on this object? | Must match | Apply when they match |
| checkPermission(grants, p) | Could they do this on some object? | Count as yes | Ignored |
The form without a context is for the user interface, for example to decide whether to show a menu item (for a menu item that covers a whole area, see Any permission under a heading). On the back end, always pass the real object.
An empty object is not the same as no context: checkPermission(grants, p, {}) evaluates the conditions against an object with no attributes, so conditional allows fail.
Any permission under a heading
A menu item often leads to a whole area, such as Configuration, rather than to one permission. checkPermissionUnder(grants, heading, context?) answers "can they do anything under this heading?". The heading is written as a grant covering the area would be, a name and .*, and the result is true when at least one permission it covers would pass checkPermission.
import { checkPermissionUnder } from "@prefabs.tech/access-control";
const grants = ["configuration.client.view", "!configuration.client.delete"];
checkPermissionUnder(grants, "configuration.*"); // true: configuration.client.view
checkPermissionUnder(grants, "reporting.*"); // false: nothing under it
checkPermissionUnder(["*", "!configuration.*"], "configuration.*"); // false: all of it is denied- Under, not the heading itself. The trailing
*matches one or more segments, as in a grant, so a grant of exactlyconfigurationdoesn't count forconfiguration.*. - Denies and sources count as in
checkPermission. A deny covering the whole heading makes it false; a deny of part of it (!configuration.client.delete) leaves the rest. A direct grant decides before the roles. - The context works as in
checkPermission. Without one, conditional allows count as yes and conditional denies are ignored, which suits a menu. With one, conditions are evaluated against it. - Only a name and
.*.checkPermissionUnder(grants, "configuration")is false and reported as an invalid permission, so a heading can't be mistaken for a permission, nor a permission for a heading. A*anywhere else (*.view,configuration.*.edit,*) and a leading!are refused the same way.
On the back end, check the concrete permission of the action instead: a heading says only that something under it is allowed.
Malformed input
checkPermission never throws, so it can be called anywhere without a try/catch. Whatever it cannot make sense of, it refuses:
grantsthat is not an array.null,undefinedand[]are refused too, as no grants at all.- A permission being checked that is not a valid name, such as
null, a number or"admin..view". - A grant that is not a string or an object with a string
permission, such asnull, a number, or a hole in a sparse array. - A grant whose
effectis anything but"allow"or"deny", such as"Deny"ornull. Otherwise a misspelt deny would count as an allow. - A grant whose
sourceis anything but"role"or"direct", such as"user","Direct"ornull. - A grant whose
conditionsis not a function or a plain object, such as5,[],true, aDateor a class instance (these would otherwise match every object), or a string: a named condition thatresolveGrantshas not replaced with its function.nulland{}mean no conditions. A plain object created in another realm, such as an iframe, is refused as well, since it doesn't have this realm'sObject.prototype.
One malformed grant refuses the whole check, whatever the other grants say, including direct grants that would have decided. It may have been meant as a deny, and there is no telling what it covers.
Grant objects are read by their own properties only, so a value inherited from a prototype, for example after prototype pollution, is ignored. An attribute condition whose context throws when read (a getter, a revoked proxy) fails closed like a function condition that throws.
assertPermission throws a PermissionError for all of these, never a TypeError.
These refusals are silent unless you set a logger, which is the way to find out why a check that should pass doesn't.
Logging
checkPermission and checkPermissionUnder can report what they refused and which conditions failed. Set a logger once at start-up:
import { setLogger } from "@prefabs.tech/access-control";
// In the browser
setLogger((event) => console.warn(event.message));
// On Node, for example with pino
setLogger((event) => logger.warn(event, event.message));There is none by default. setLogger(null) turns reporting off, and anything other than a function or null throws a TypeError.
The logger receives a PermissionLogEvent. Its reason is one of:
| reason | When | The check |
| --- | --- | --- |
| invalid-permission | The permission being checked is not a valid name | Refused |
| invalid-grants | grants is not an array (null, undefined and [] are not reported) | Refused |
| invalid-grant | A grant is malformed; field says which part, when it is one | Refused |
| condition-error | A grant's condition threw | That grant fails closed |
| unknown-condition | A condition name is missing from the resolveGrants registry | That grant fails closed |
| unexpected-error | Anything else went wrong | Refused |
The other fields, each present only when it applies: message (always; a sentence built from the other fields), permission (the permission being checked, or the heading for checkPermissionUnder), grantIndex, grant (its pattern, with ! first for a deny), field (permission, effect, conditions or source), received (the type of the bad value, such as null, array or non-plain object), condition (the unknown name) and errorName (such as TypeError).
Safe to send from a browser. An event never holds the context, a grant's conditions, or the message of an error thrown by a condition function, since any of these can carry the very data being protected. A thrown value that is not an Error is reported by its type only. Strings longer than 200 characters are cut.
- No de-duplication. Every check reports, so a malformed grant in a list that a page checks on each render reports on each render.
checkPermissionUndermay test several permissions under the heading, so one failing condition can report once for each. Throttle in the logger if that matters. - The logger cannot break a check. If it throws, the error is ignored, and an async logger's rejection is handled. Checks made from inside the logger do not report, so a logger that calls
checkPermissioncannot recurse. - One logger per copy of the package. The logger is held by the module. On Node 22.12 or later,
importandrequire()load the same copy, so one call covers both. On earlier Node 22 releases, or with a bundler that resolves the two to different files, callsetLoggerfrom each.
Enforcing on the back end
assertPermission throws instead of returning false. The context is required.
import { assertPermission, PermissionError } from "@prefabs.tech/access-control";
try {
assertPermission(user.permissions, "admin.user.delete", { userId: target.id, role: target.role });
} catch (err) {
if (err instanceof PermissionError) {
// err.status === 403, err.permission === "admin.user.delete"
}
throw err;
}On Node 22.12 or later, import and require() load the same copy of the package, so instanceof PermissionError holds wherever the error was thrown. On earlier Node 22 releases, or with a bundler that resolves import and require() to different files, code that mixes the two gets two copies of the class; check err.name === "PermissionError" there instead.
Building the grant list
Three helpers prepare a grant list before it is checked.
mergeGrants(...lists)
Concatenates grant lists, for example from several roles plus grants given directly to the user. null and undefined lists are skipped. It keeps each grant as it is, source included; so do bindGrants and resolveGrants.
const grants = mergeGrants(roleA.permissions, roleB.permissions, user.permissions);bindGrants(grants, vars)
Replaces $… placeholders in attribute conditions with values from vars, so a role can say "only your own records" once and each user gets concrete values.
const role = [{ permission: "profile.edit", conditions: { userId: "$user.id" } }];
const grants = bindGrants(role, { user: { id: 7 } });
// [{ permission: "profile.edit", conditions: { userId: 7 } }]Run it on the server when building the user's permissions (at login, say), then send the bound list to the front end. String grants and function conditions are returned untouched.
A placeholder that cannot be resolved becomes null. It then matches only objects whose attribute is explicitly null, so make sure every placeholder a role uses is present in vars.
resolveGrants(stored, registry)
Functions cannot be stored or sent as JSON. A stored grant can name its condition instead, and resolveGrants swaps each name for the function in a registry. Both the server and the browser can do this with their own registry.
const stored = [{ permission: "admin.user.view", conditions: "sameOrg" }];
const grants = resolveGrants(stored, {
sameOrg: (ctx) => ctx.orgId === currentUser.orgId,
});A name that is not in the registry is treated like a condition that throws an error, so the check refuses access: an allow grant with an unknown name never gives access, and a deny grant with an unknown name always denies. A logger receives it as unknown-condition, with the name.
Resolve before checking. A grant whose conditions is still a string, because the list never went through resolveGrants, is malformed: it refuses the whole check, not only that grant, and a logger receives it as invalid-grant with field: "conditions".
Development
The tests use Node's built-in runner and run the TypeScript directly, so they need a Node version with type stripping enabled by default. The Node and pnpm versions used for development are pinned in mise.toml at the repository root (mise install); pnpm can also be provided by corepack from the packageManager field of the root package.json.
pnpm install
pnpm test # run the tests, with a coverage report
node --test --test-name-pattern='deny beats allow' __test__/permissions.test.ts # run one test
pnpm typecheck # type-check the source and tests
pnpm build # compile to dist/pnpm publish runs the type check, the tests and the build before publishing.
