Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
Filtering what a user sees
@evanion/acl filters the rows an endpoint returns with canMany, and strips each surviving row’s denied fields with canFields and pickAllowedFields.
Filtering a list endpoint with @evanion/acl
To protect a list endpoint like GET /orders, you must decide which rows a customer may read and which fields within those rows are visible. You perform this by using canMany to filter the rows and canFields combined with pickAllowedFields to strip the denied fields.
To ensure consistency across these steps, you use access.authorize(subject) to bind the subject and a single instant to a handle. Because both the row filter and the field strip use that same instant, a rule relying on p.before or p.after cannot flip its decision between the two operations.
import { , } from '@evanion/acl';
type = { : string; : string[] };
type = {
: string;
: string;
: number;
: string;
};
const = <, { : }>()
.('order', () =>
.('read', .('object.customerId', 'subject.id'))
.(['*', '!internalNote']),
)
.();
const : [] = [
{ : 'o1', : 'c1', : 40, : 'gift wrap' },
{ : 'o2', : 'c2', : 15, : 'late payer' },
{ : 'o3', : 'c1', : 22, : 'repeat buyer' },
];
// One handle holds the subject and one instant for the whole response.
const = .({ : 'c1', : [] });
const = .('order', 'read', );
const =
.((, ) => []?.)
.(() =>
(.('order', 'read', , 'read'), ),
);
; // -> [{ id: 'o1', customerId: 'c1', total: 40 }, { id: 'o3', customerId: 'c1', total: 22 }]In this example, order o2 belongs to another customer; its decision is no-rule-matched, so the filter drops it. Because internalNote is denied on the read axis, pickAllowedFields excludes it from o1 and o3. The resulting response contains only the rows and fields allowed by the rules.
The pickAllowedFields function throws ActionNotAllowedError if the action itself is refused. Since the row filter runs first, the strip only processes rows where read is already allowed. If canMany marks a row as unevaluable, it is treated as allowed: false and dropped. If your list query selected only some columns, Can this user do this? explains the second fetch required by missing.
For an example of a filter that incorrectly implements the field step by passing a projection, see Caveats and pitfalls.
Handling Nested Objects in @evanion/acl
To manage permissions for deep data structures, @evanion/acl uses path-based field rules. A rule names a path to a value: address refers to a top-level key of a row, while address.street refers to the street nested inside that object. If a key literally contains a . or \, escape it with a backslash (e.g., the flat key a.b is written as a\.b). Decision maps follow this same spelling.
How to hide a nested key
To deny access to a specific nested field, you must first establish a baseline of allowed access using * or a listed ancestor of the path.
For a row containing { id, address: { city, street } }:
['*', '!address.street']allowsidandaddress.citybut deniesaddress.street.['id', 'address', '!address.street']allows theidand theaddressobject, except the street.
In both cases, pickAllowedFields returns the address without the street. Note that targets or transitions keys can also be paths, such as 'address.country'.
import { , } from '@evanion/acl';
import { } from 'vitest';
type = { : string; : string };
type = { : string; : };
// A rule names a path, and the exclusion hides exactly that key.
const = <{ : string }, { : }>()
.('order', () =>
.('read', .).(['*', '!address.street']),
)
.();
const = { : 'c1' };
const : = {
: 'o1',
: { : 'Malmo', : 'Storgatan 1' },
};
const = .(, 'order', 'read', , 'read');
.(.); // -> ['id', 'address.city', 'address.street']
.['address.street']; // -> 'denied'
.; // -> false
// The picked parent keeps the rest of the address.
(, ); // -> { id: 'o1', address: { city: 'Malmo' } }
// A segment through an array applies to every element, and the key carries no index.
type = { : string; : { : string; : number }[] };
const = <{ : string }, { : }>()
.('basket', () => .('read', .).(['*', '!lines.cost']))
.();
const : = {
: 'b1',
: [
{ : 'a', : 5 },
{ : 'b', : 7 },
],
};
const = .(
,
'basket',
'read',
,
'read',
);
.(.); // -> ['id', 'lines.sku', 'lines.cost']
(, ); // -> { id: 'b1', lines: [{ sku: 'a' }, { sku: 'b' }] }
// A path that is also a flat key with a dot in it is escaped.
const = <{ : string }, { : { 'a.b': string } }>()
.('order', () => .('read', .).(['*', '!a\\.b']))
.();
const = .(
,
'order',
'read',
{ 'a.b': 'x' },
'read',
);
.; // -> { 'a\\.b': 'denied' }
(.).(false);Working with arrays
When a path segment reaches an array, the rule applies to every element within that array. To hide the cost field on every line in a list, use !lines.cost; the key remains lines.cost without a specific index.
The behavior differs by axis:
- Read Axis: The array is returned with every element and its index. If an element’s children are all withheld, that element returns as
{}. - Write Axis: Any array that a rule attempts to enter is refused in its entirety, as the engine cannot reliably match a stored element to a proposed one.
Arrays containing holes, extra own properties, or elements the walk cannot traverse are refused on both axes. To modify these, write the elements through their own dedicated action.
Handling non-traversable values
The permission walk only enters plain objects and arrays. The following are treated as “leaves”:
- Primitives,
null,Date,Map, class instances, or objects with getters. - Cycles (which become
not-traversablewhere they repeat).
If a rule names a path below a leaf, the value is denied with the reason not-traversable. On read, the entire value is hidden; on write, it is refused. Because ORM rows are typically class instances with getters, a nested one is unwalkable. The top-level object is read as it is, whatever its class, and a wrapper that keeps data under an own property (Sequelize dataValues) leaks at the top level even with flat rules. Pass plain rows for the top level and nested values: toJSON(), get({ plain: true }) or .lean(). A raw dotted key on a read row is denied when its path reading is denied, and brackets in the key are ordinary characters.
To determine if a raw dotted key can be written in @evanion/acl, the system checks for both an explicit allowance and the absence of restrictions.
A raw dotted key is written when it is covered by a * or an allow entry naming its escaped column, provided nothing restricts its path. Conversely, the write is refused as not-traversable if any of the following apply:
- Path Exclusions: An exclusion covers the key’s path or any of its ancestors.
- Structural Blocks: A configuration exists on an ancestor, or a rule is positioned below the column’s path (or beside it under a ruled parent).
- Invalid Segments: The key contains a bracket, splits into an empty segment, or contains a
constructororprototypesegment.
For the complete logic governing these interactions, see Field permissions.
This logic also protects stored data: a write that would erase a stored key the caller is not permitted to write is refused. If a stored parent contains a denied or undecidable key, that parent is marked not-traversable and pickAllowedFields excludes it. For further details, see Field permissions.
Validation and build errors
To ensure permission integrity, @evanion/acl raises an InvalidPermissionError (naming the permission key and entries) if the build encounters:
- An empty segment (e.g.,
a..b). - A segment that is
__proto__,*, or starts with!. - An entry placed beside its own exclusion.
- An allow rule positioned below an excluded ancestor.
- A
targetsortransitionskey that has another rule below it. - A path exceeding 32 segments.
Additionally, a ! entry provided without a baseline raises a DenyWithoutBaselineError. Finally, because conditions are limited to a single field, a path like object.address.city inside a condition is refused at build time, as described in What can throw?.
Paging a filtered list with @evanion/acl
Because @evanion/acl decides on rows after they are fetched rather than producing a database filter, a query with LIMIT 20 may return fewer than 20 rows if some are filtered out. This can leave the client with a short page and no indication that more readable rows exist further down the table.
You can handle this using one of two strategies.
Over-fetch and refill
To ensure a full page, fetch a batch, filter it with canMany, and continue fetching until the page is full or the table is exhausted. In this scenario, the cursor returned to the client is the position after the last row examined, rather than a page number.
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>()
.('order', () => .('read', .('object.customerId', 'subject.id')))
.();
const : [] = [
{ : 'o1', : 'c2' },
{ : 'o2', : 'c1' },
{ : 'o3', : 'c2' },
{ : 'o4', : 'c2' },
{ : 'o5', : 'c1' },
{ : 'o6', : 'c1' },
{ : 'o7', : 'c2' },
];
// Stands in for `SELECT ... ORDER BY id LIMIT :limit OFFSET :cursor`.
const = (: number, : number): [] =>
.(, + );
function (: { : string }, : number, : number) {
const = .();
const : [] = [];
let = ;
while (. < ) {
const = (, );
if (. === 0) break;
const = .('order', 'read', );
for (const [, ] of .()) {
if (. === ) break;
+= 1;
if ([]?.) .();
}
}
return { , };
}
// Batches of two: o1 and o2 hold one readable row, o3 and o4 none, o5 and o6 one.
const = ({ : 'c1' }, 0, 2);
..(() => .); // -> ['o2', 'o5']
.; // -> 5
({ : 'c1' }, ., 2)..(() => .); // -> ['o6']When asked for two rows, c1 retrieves one readable row from the first batch (o1 and o2), none from the second, and the second readable row from the third. The returned next value is 5 (the position after o5), so the subsequent call resumes there to return o6.
This approach increases the number of queries and can result in unbounded work for subjects who can read very few rows. To prevent this, cap the number of rounds or total rows examined and return the current results with the cursor.
Put the coarse part of the rule in the query
To optimize performance, identify the coarse part of your read rules that the database can express—such as owner or tenant—and include it in the query. You then let @evanion/acl handle the fine-grained state-dependent decisions. This keeps the page mostly full because the database has already removed the majority of ineligible rows.
import { } from '@evanion/acl';
type = { : string; : string; : string };
const = <{ : string; : string[] }, { : }>()
.('order', () =>
.('read', .('object.customerId', 'subject.id'))
.('read', .('object.status', 'withdrawn')),
)
.();
const : [] = [
{ : 'o1', : 'c2', : 'paid' },
{ : 'o2', : 'c1', : 'paid' },
{ : 'o3', : 'c1', : 'withdrawn' },
{ : 'o4', : 'c1', : 'paid' },
];
// Stands in for `WHERE customer_id = :customerId ORDER BY id LIMIT :limit`.
const = (: string, : number): [] =>
.(() => . === ).(0, );
const = { : 'c1', : [] };
const = (., 2);
const = .(, 'order', 'read', );
.((, ) => []?.).(() => .); // -> ['o2']Here, the query restricts customer_id to the subject, while @evanion/acl applies the deny rule on status, dropping o3 from the page. If the remaining fine rules still leave the page too short, you can pair this method with the refill loop.
The query acts as a performance filter; @evanion/acl still decides every row. A query that is too broad leaks nothing, while one that is too narrow may hide rows the rules would have allowed. Ensure the query’s condition stays in step with the rule, for instance by testing both against the same fixtures.
When the decision does not depend on the row
To avoid unnecessary filtering, use access.readsObject(kind, action) to determine if a permission actually reads the row. If it returns false, the decision is identical for every row. A single can call without an object decides the entire page, meaning the page is either fully returned or entirely refused without running a filter.
import { } from '@evanion/acl';
type = { : string };
const = <{ : string; : string[] }, { : }>()
.('order', () =>
.('read', .('subject.roles', 'bookseller')),
)
.();
.('order', 'read'); // -> false
.({ : 'b1', : ['bookseller'] }, 'order', 'read').; // -> true
.({ : 'c1', : [] }, 'order', 'read').; // -> falsePerform this check once and then page normally. For more on readsObject, see Can this user do this?.