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.
Field permissions
canFields answers which parts of an object a subject may touch, on one of two
axes.
The two axes canFields decides
read is a projection and write is what a form submits. Field rules hang off
one permission, declared per object kind and per action. A .for('listing')
beside the policy below carries its own field rules in the same document.
import { } from '@evanion/acl';
const = <{ : string }, { : { : string } }>()
.('listing', () => .('read', .).(['*', '!status']))
.();
const = .(
{ : 's1' },
'listing',
'read',
{ : 'draft' },
'read',
);
.['status']; // -> 'denied'What canFields returns
canFields returns a FieldDecision: the field maps fields and reasons,
the action-level Decision they hang off as action, and allowed, which is
true when the action is allowed and every field is allowed.
import { } from '@evanion/acl';
import type { FieldDecision } from '@evanion/acl';
type = {
: string;
: string;
: number;
: string;
};
const = <{ : string }, { : }>()
.('listing', () =>
.('update', .('object.sellerId', 'subject.id'))
.(['title', 'price']),
)
.();
const = {
: 's1',
: 'Wingspan',
: 499,
: 'draft',
};
const = { : 449, : 'published' };
const : FieldDecision = .(
{ : 's1' },
'listing',
'update',
,
'write',
,
);
..; // -> true
.; // -> false
.; // -> { sellerId: 'denied', title: 'allowed', price: 'allowed', status: 'denied' }
.; // -> { sellerId: 'not-listed', title: 'allow', price: 'allow', status: 'not-listed' }Every name in fields carries one FieldState:
allowed: the subject may read the field, or write it with this value.denied: the rules refuse it.unevaluable: the decision needs data the call did not carry.
canFields fills the field maps whatever the action says, so a blocked caller
still learns which fields would be editable. Only the top-level allowed is
gated on the action, and that flag composes both halves. A caller who reads the maps as the gate skips the action entirely.
reasons carries one FieldReason per name.
FieldReason | Emitted when |
|---|---|
allow | nothing refused it |
not-listed | the name list does not carry it, or excludes it |
targets-failed | the proposed value is not in the field’s targets |
transition-failed | the current value has no edge to the proposed one |
missing-field | a transitions field whose current value the object did not carry, or a parent the rules descend into that the write includes and the stored object lacks |
proposed-required | a configured field with no proposed value in the write |
not-traversable | a rule needs to look into a value the engine cannot walk |
Naming the fields
fields takes either a name list or an object carrying one plus the per-field
configs.
import { } from '@evanion/acl';
type = {
: string;
: string;
: number;
: 'draft' | 'published';
: string;
};
const = <
{ : string },
{ : },
{ : 'read' | 'update' | 'publish' }
>()
.('listing', () =>
.('read', .)
.(['*', '!status']) // everything except status
.('update', .)
.(['title', 'price']) // exactly these two
.('publish', .)
.({
: ['*', '!id'],
: { : { : ['published'], : [] } },
: { : ['public', 'private'] },
}),
)
.();
const = { : 'u1' };
const : = {
: 'l1',
: 'Wingspan',
: 499,
: 'draft',
: 'private',
};
const = .(, 'listing', 'read', , 'read');
.['status']; // -> 'denied'
const = .(
,
'listing',
'update',
,
'write',
{
: 'Wingspan (second edition)',
: 'published',
},
);
.['title']; // -> 'allowed'
.['status']; // -> 'denied'
const = .(
,
'listing',
'publish',
,
'write',
{
: 'l2',
: 'published',
: 'secret',
},
);
.; // -> { id: 'not-listed', title: 'allow', price: 'allow', status: 'allow', visibility: 'targets-failed' }* is the baseline and !name subtracts from it. Both are authoring syntax and
never field names, wherever they turn up: in the rules, on the object, or in a
proposed write.
With no name list at all, every unnamed key is writable. A targets or
transitions config restricts the field it names and no other, so an author who
reads one as the writable-field list leaves the rest open.
targets and transitions
targets allow-lists the proposed value. transitions is a state machine over
the current value: the key is the value the object holds now, the array is what
it may become.
The object form keys the configs by field name and takes fields for the name
list, so a field called fields gets no config. The name list can still allow
or deny it.
Construction refuses these authoring mistakes.
| The document says | Construction raises |
|---|---|
a !path with no * or listed ancestor | DenyWithoutBaselineError |
| a malformed path, or two intents for a path | InvalidPermissionError |
targets and transitions on one field | TargetsTransitionsConflictError |
a per-field config on a field named fields | InvalidPermissionError |
Which fields are decided
The matrix does not know the object’s shape, so the decision covers four sources of names:
- every field the object carries;
- every field the rules name;
- every name in the list;
- on the write axis, every key of the proposed write.
That last source is the mass-assignment case: a key that exists nowhere but the write still gets a state.
The read axis takes no write. targets and transitions are write concepts,
and on read only the name list denies.
Passing a projection
canFields takes a partial object, so a caller holding a projection can ask. A
field the projection omits, that no rule names and that the write does not
propose, appears in neither fields nor reasons.
A name absent from the map is a question nobody asked, and pickAllowedFields
withholds it. Caveats and pitfalls has the filter that gets
this wrong.
access.readsObject(key, action) says whether the action behind the fields
needs the row at all.
Writing
Pass the proposed object to canFields on the write axis.
import { , } from '@evanion/acl';
const = <
{ : string },
{ : { : string; : string; : string } }
>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id'))
.(['*', '!status']),
)
.();
const = { : 'customer-41', : 'In stock?', : 'open' };
const = { : 'Wingspan in stock?', : 'locked' };
const = .(
{ : 'customer-41' },
'question',
'update',
,
'write',
,
);
..; // -> true
.['status']; // -> 'denied'
.((, )); // -> '{"body":"Wingspan in stock?"}'Pass what pickAllowedFields returns to the ORM, and pass only that.
- Every key it carries was marked
allowedby the decision. deniedandunevaluableare both withheld, and so is any key the decision does not carry.pickAllowedFieldsthrowsActionNotAllowedErrorwhen the action itself is refused.- A field the action allows and the field rules deny is a partial write, so that call returns the allowed subset and throws nothing.
__proto__ names no field, so it never enters a decision map and never leaves
the narrowing. The narrowed result is safe to spread onto a row.
Reading
The read axis answers which fields of an instance this subject may see.
import { , } from '@evanion/acl';
type = { : string; : string; : string };
const = <{ : string }, { : }>()
.('question', () =>
.('read', .).(['*', '!askerEmail']),
)
.();
// The row as the handler fetched it, email included.
const = {
: 'customer-41',
: 'Is Wingspan in stock?',
: '[email protected]',
};
const = .(
{ : 'customer-92' },
'question',
'read',
,
'read',
);
.['askerEmail']; // -> 'denied'
(, ); // -> { askedBy: 'customer-41', body: 'Is Wingspan in stock?' }canFields decides a projection on read and runs no query: the row was
already fetched, and a field decided denied here was still read out of the
database.
How to manage nested fields in @evanion/acl
To control access to deeply nested data, you use paths. While address refers to a top-level key of a row, address.street targets the street property inside that object.
Defining Path Rules
You define access by listing paths in a name list. An entry allows that path and everything below it, while an entry starting with ! hides that path and its descendants.
Because exclusions only work if there is something to exclude, a ! entry requires a * or a listed ancestor, in any order. For example:
['*', '!address.street']allows everything except the street.['id', 'address', '!address.street']allows the ID and the address, but hides the street.
These paths apply to both axes; for instance, a targets or transitions key can be a path like 'address.country'.
Path Constraints and Escaping
A path can have a maximum of 32 segments. Segments cannot be empty, cannot be *, cannot be __proto__, and cannot start with !.
If a key contains a literal . or \, you must escape it with a backslash:
- The flat key
a.bis written asa\.b. - The key
a\bis written asa\\b. The decision maps use this same spelling.
Resolving Leaves
The map tracks only the “leaves” that the rules reach. If you apply ['*', '!address.street'] to { id, address: { city, street } }, the resulting keys are id, address.city and address.street, and the last is denied. Note that address itself is not a key; a key and its descendant never both appear in the result.
Writing to Nested Objects
When you use pickAllowedFields to filter a row, it rebuilds every parent as a new object containing only the allowed children. This means a parent becomes a “partial” object if some of its children are withheld.
Object.assign(row, picked) replaces a full stored parent with one of these partial parents, which would erase any stored key the caller isn’t allowed to write. To prevent that, the engine denies the parent before pickAllowedFields runs when the stored parent holds such a key.
The engine determines traversability based on the stored object:
- Denied (
not-traversable): If a stored parent contains a key that is denied, cannot be decided, is not a field name, or is a symbol/non-enumerable property, the parent is markednot-traversableandpickAllowedFieldsomits it. - Unevaluable (
missing-field): If a rule descends into a parent that the write operation provides but the stored object lacks, the parent is markedmissing-field.
import { , } from '@evanion/acl';
import { } from 'vitest';
type = {
: string;
: { : string; ?: string; ?: string };
};
const = <{ : string }, { : }>()
.('row', () => .('update', .).(['id', 'address.city']))
.();
const = { : 'u1' };
// The stored address holds a street the editor may not write. A parent that
// would erase it is denied, so the write never reaches the row.
const : = {
: 'r1',
: { : 'Malmo', : 'Storgatan 1' },
};
const = { : 'r1', : { : 'Lund' } };
const = .(
,
'row',
'update',
,
'write',
,
);
.; // -> { id: 'allowed', address: 'denied' }
..; // -> 'not-traversable'
(, ); // -> { id: 'r1' }
// The stored address holds only the city, so the whole parent is writable.
const : = { : 'r1', : { : 'Malmo' } };
const = .(, 'row', 'update', , 'write', );
.; // -> { id: 'allowed', 'address.city': 'allowed' }
const = (, );
; // -> { id: 'r1', address: { city: 'Lund' } }
// Object.assign replaces row.address with the picked address whole. Here the
// stored address held only the city, so the replace drops nothing.
const = ();
.(, );
().({ : 'r1', : { : 'Lund' } });
// A stored key the editor may write, left out of the write, is erased by the
// replace. The write must carry every sibling it keeps.
const = <{ : string }, { : }>()
.('row', () =>
.('update', .).(['id', 'address.city', 'address.zip']),
)
.();
const = { : 'r1', : { : 'Malmo', : '211' } };
const = { : { : 'Lund' } };
const = .(
,
'row',
'update',
,
'write',
,
);
.; // -> true
const = ();
.(, (, ));
; // -> { id: 'r1', address: { city: 'Lund' } }
// A deep merge keeps the siblings the write left out. It recurses only into
// own plain objects, and skips the names that lead to a prototype.
const = new (['__proto__', 'constructor', 'prototype']);
const = (: unknown): is <string, unknown> =>
typeof === 'object' &&
!== null &&
[., null].(.());
function (
: <string, unknown>,
: <string, unknown>,
): void {
for (const of .()) {
if (.()) continue;
const = [];
const = .(, ) ? [] : ;
if (() && ()) (, );
else [] = ;
}
}
const = ();
(, (, ));
; // -> { id: 'r1', address: { city: 'Lund', zip: '211' } }
// A subtree the decision does not descend into is copied by reference, so it
// can carry an own __proto__ key from JSON.parse. The merge skips it.
const = <{ : string }, { : }>()
.('row', () => .('update', .).(['*']))
.();
const = .('{"address":{"__proto__":{"isAdmin":true}}}');
const = ();
(
,
(
.(, 'row', 'update', , 'write', ),
,
),
);
'isAdmin' in .; // -> false
// A raw dotted key whose path leaves a ruled node is refused, because Mongo $set
// and lodash set would apply it as a path.
const = <{ : string }, { : }>()
.('row', () =>
.('update', .).(['*', '!address.street']),
)
.();
const = { 'address.city': 'Lund' } as <>;
const = .(
,
'row',
'update',
,
'write',
,
);
.['address\\.city']; // -> 'not-traversable'
(.).(false);Recommendation: To change a single nested key without risking its siblings, give the nested object its own action and permission, or define it as its own object kind. This allows the caller to write the street or city via rules that name them directly.
What a picked parent replaces
To update a nested value, you must provide the entire parent object because a picked parent replaces the stored parent in its entirety. The engine only protects stored keys that the caller is explicitly forbidden from writing; any key the caller is allowed to write—but omits from the update—is erased by the replacement.
To prevent data loss during updates, you must choose how to apply the filtered data to your stored records.
Consider a scenario where your allowed rules are ['id', 'address.city', 'address.zip'] and the stored row is { address: { city, zip } }. If a user submits a write containing only { address: { city } }, the write is permitted, but a shallow Object.assign would drop the zip from the stored row. To avoid this, the write must either carry every sibling it intends to keep, or the application code must deep-merge the picked value into the row.
A deep merge recurses only into own plain objects and copies all other values as-is. To prevent prototype pollution, it explicitly skips __proto__, constructor, and prototype. Because pickAllowedFields copies subtrees that the decision does not descend into by reference, those subtrees could potentially carry an own __proto__ key (e.g., from JSON.parse). A subsequent merge would then set the prototype of the stored object. The example above concludes with such a merge.
Some tools handle this natively. An ORM with a nested update form, for instance, applies only the keys it is given and leaves other stored keys untouched, removing the need for a manual merge. For example, Prisma’s update({ data: { address: { update: picked.address } } }) updates the city while preserving the zip.
Even when every specific field is marked as allowed, @evanion/acl will refuse a write in two specific scenarios to prevent data loss or traversal errors.
Array-Based Configuration
If a targets or transitions configuration path passes through an array (for example, 'lines.cost'), the array is refused as not-traversable on every write. This occurs because the write axis is not designed to walk through arrays.
Nested Allow Entries
When using nested allow entries or configurations—such as address.city or 'address.country': { targets: ['SE'] }—the library may refuse the parent object as not-traversable. This happens if the stored parent contains a sibling that the caller is forbidden from writing (e.g., a street defined under !address.street). Because the write operation would replace the entire parent object, it would inadvertently erase the protected sibling.
Rows with dotted column names
A row can carry flat keys with a dot in them, such as SQL aliases or Sequelize raw columns.
When a raw key contains a ., it is decided twice: once as a flat key and once as a path. A dotted key is allowed only if the flat reading permits it and the path spelled by splitting the key at the dot is not excluded. The path reading acts as a filter that can only tighten the result.
For example, if your allowed list is ['a'], a flat key of { 'a.b': 1 } remains hidden, because the flat reading does not list it and the path reading never loosens a decision. Similarly, ['id', 'address.city'] hides a flat address.city column. To explicitly allow a flat column containing a dot, use a backslash to escape the character: address\.city. On the read axis, an allow entry using this escaped flat key tells the library to let the flat reading decide alone. This allows a specific flat column to remain visible even if an ancestor in its path has been excluded. For example, given the pattern ['*', '!address', 'address\.city'], the @evanion/acl package hides the nested address object but ensures the flat address.city column is shown.
Reading Segmented Keys
On the read axis, brackets are treated as ordinary characters within a segment. This means a key containing brackets splits on the . character like any other key.
For example, if your rules are ['*', '!data'], the key data.x[0] is denied because it starts with the denied data segment. Similarly, under ['*', '!address.street'], the key address.street[0] is marked as not-traversable.
A bracket does not split a key; the entire string, including the brackets, is a single segment. For example, under the rule ['*', '!data'], the key data[0] is allowed because the segment data[0] is not strictly equal to data. Consequently, a rule targeting data will not cover query-string style keys like data[0].
Traversal and Escaping
The path reading enforces boundaries using the not-traversable denial. A dotted key is denied if its spelled path ends at a rule node that has further rules below it, or if the path attempts to leave that node.
This behavior ensures strict isolation. For instance:
- Under
['*', '!address.street'], the flat keyaddress.xis denied asnot-traversable. - Under
!lines.cost, the keylines.0.costis similarly denied.
If a split is not “clean,” the library utilizes the clean prefix. Finally, any rule written with an escape, such as a\.b, refers specifically to the flat key a.b itself.
import { , } from '@evanion/acl';
// A row with flat columns whose names hold a dot, such as SQL aliases.
type = <string, unknown>;
const : = {
: 'r1',
'address.street': 'Storgatan 1',
'address.city': 'Malmo',
'other.col': 'x',
};
const = { : 'u1' };
// The exclusion names address.street, so the flat column of that name is hidden.
const = <{ : string }, { : }>()
.('row', () => .('read', .).(['*', '!address.street']))
.();
const = .(, 'row', 'read', , 'read');
.['address\\.street']; // -> 'not-listed'
// address.city is a flat column under a parent that has rules, so it is hidden too.
.['address\\.city']; // -> 'not-traversable'
// A dotted column no rule touches stays visible.
(, ); // -> { id: 'r1', 'other.col': 'x' }
// An allow entry that escapes the dot names the flat column, and the flat reading decides alone.
const = <{ : string }, { : }>()
.('row', () =>
.('read', .).(['id', 'address\\.street']),
)
.();
const = .(, 'row', 'read', , 'read');
(, ); // -> { id: 'r1', 'address.street': 'Storgatan 1' }
const = { 'address.street': 'Lilla Torg 2' };
// An allow entry naming the escaped column writes that column and no other dotted one.
const = <{ : string }, { : }>()
.('row', () =>
.('update', .).(['id', 'address\\.street']),
)
.();
const = { ..., 'other.col': 'y' };
const = .(
,
'row',
'update',
,
'write',
,
);
.['address\\.street']; // -> 'allow'
.['other\\.col']; // -> 'not-listed'
(, ); // -> { 'address.street': 'Lilla Torg 2' }
// An exclusion on an ancestor of the column's path still refuses it.
const = <{ : string }, { : }>()
.('row', () =>
.('update', .).(['*', '!address', 'address\\.street']),
)
.();
const = .(
,
'row',
'update',
,
'write',
,
);
.['address\\.street']; // -> 'not-traversable'
// * alone writes a dotted column no rule touches, and every other column too.
const = <{ : string }, { : }>()
.('row', () => .('update', .).(['*']))
.();
const = { ..., : 'r2' };
const = .(
,
'row',
'update',
,
'write',
,
);
(, ); // -> { 'address.street': 'Lilla Torg 2', 'other.col': 'y', id: 'r2' }
// On read a bracket is part of its segment: data.x[0] spells a path through
// data, and data[0] is a segment of its own.
const = <{ : string }, { : }>()
.('row', () => .('read', .).(['*', '!data']))
.();
const : = { : 'r1', 'data.x[0]': 1, 'data[0]': 2 };
const = .(
,
'row',
'read',
,
'read',
);
.['data\\.x[0]']; // -> 'not-listed'
(, ); // -> { id: 'r1', 'data[0]': 2 }Pass plain rows
The walk reads the top-level object exactly as it is, regardless of its class, though all nested values must be plain objects. If an ORM wrapper stores data under an own property (such as Sequelize’s dataValues), that wrapper will leak at the top level even when using flat rules.
To ensure consistent behavior, pass plain rows for both the top level and all nested values using methods like toJSON(), get({ plain: true }), or .lean().
Write-Axis Restrictions
On the write axis, the engine prevents “raw” keys from being used if a path writer would interpret them as a path. Because tools like Mongo’s $set and lodash’s set apply keys as paths to write below a parent, the engine imposes stricter restrictions to prevent accidental traversal.
A key is marked not-traversable on the write axis if:
- It contains brackets (
[or]). - Its dots do not split into a clean path.
- It attempts to leave the rule tree (e.g.,
address.citywhen the rules are!address.street). - It holds a
constructororprototypesegment, which a merge that splits the key would follow to a prototype.
The engine determines which dotted columns a write updates from how your entries name them. When an allow entry explicitly names an escaped column, the engine writes to that raw dotted key. For example, if an updateCity action has the fields ['id', 'address\.city'], the engine writes the flat address.city column and no other dotted column; any other, such as other.col, remains not-listed.
To specify which columns are written, you use a list of fields. The * symbol acts as a catch-all: it writes every dotted column that no other rule touches, along with every other column in the row.
Adding an escaped entry beside * (for example, ['*', 'address\.city']) does not narrow the operation; the * still captures everything. To write exactly one dotted column and no others, list that column escaped without the *, alongside the fields the action requires: ['id', 'address\.city'].
Even if a dotted key is explicitly listed, the engine refuses the write as not-traversable if the path to that column is restricted. This happens in three scenarios:
- An exclusion on the column’s path or any of its ancestors, as in
['*', '!address', 'address\.city']or['*', '!address.city', 'address\.city']. - A config on an ancestor in the path, such as
address: { targets: ['x'] }. - A rule node with further rules beneath it, where the path ends at that node (
address.city.xis in the list) or departs from it (!address.streetis in the list).
While these rules determine whether a path is traversable, the config on the column itself determines what is written. For instance, 'address.city': { targets: ['Lund'] } still controls which values the write is permitted to carry. The list cannot override structural invalidity: any key containing brackets, or dots that do not resolve into a clean path, is always refused.
The nested form cannot reach a flat column. Consider these two scenarios:
- Under
['id', 'address\.city'], the engine refusesaddressasnot-listed. - Under
['id', 'address.city'], if the stored row holds noaddressobject, the engine marksaddressasmissing-field.
In every refused case, pickAllowedFields drops the key, ensuring the write never reaches the column.
Handling Arrays
When a path segment reaches an array, the rule applies to every element within that array. For example, !lines.cost hides the cost field on every line; the resulting key is lines.cost without a specific index. The keys available under an array are the union of all keys carried by its elements.
Rules in @evanion/acl cannot index into arrays. If a rule attempts to index into an array (such as !items.0 or !items.0.price), the array is marked as denied and not-traversable on both axes, preventing any element from being read or written.
Note that this restriction only applies to actual arrays; a numeric segment used as a key in a plain object (such as byId.1.name) is treated as an ordinary key and remains valid.
Write Limitations:
Because the engine cannot reliably match a stored array element to a proposed one, any array that a rule descends into is refused entirely on the write axis. Additionally, any array with holes, extra own properties, or elements the walk cannot see into is refused on both axes. pickAllowedFields never drops individual array elements; you must write array elements through their own dedicated action.
Values the Walk Cannot See Into
The engine’s walk only penetrates plain objects and arrays. The following are treated as leaves:
- Primitives,
null,Date,Map, class instances, or objects with getters.
If a rule names a path below one of these leaves, the value is denied as not-traversable. On read, the entire value is hidden; on write, it is refused. Circular references are also marked not-traversable at the point of repetition.
ORM Warning: Since ORM rows are typically class instances with getters, a nested one is unwalkable. Convert an ORM row to a plain object before calling canFields, otherwise every value under a descending rule will be hidden. The top-level object is read as it is, whatever its class, so a wrapper that keeps its data under an own property leaks (see Pass plain rows above).
import { , } from '@evanion/acl';
type = { : string; : unknown; : unknown };
const = <{ : string }, { : }>()
.('shop', () =>
.('read', .).(['*', '!owner.email', '!opened.at']),
)
.();
const = { : 'u1' };
// A class instance and a Date are values the walk cannot see into.
class {
= '[email protected]';
}
const : = { : 's1', : new (), : new (0) };
const = .(, 'shop', 'read', , 'read');
.; // -> { id: 'allow', owner: 'not-traversable', opened: 'not-traversable' }
(, ); // -> { id: 's1' }
// A plain copy of the same row is walkable.
const = {
: 's1',
: { : '[email protected]', : 'A' },
: { : 1 },
};
const = .(, 'shop', 'read', , 'read');
(, ); // -> { id: 's1', owner: { name: 'A' } }Typed Paths
When using the typed builder, .fields() validates every path against the object kind’s type. If a path is unknown, it results in a compile error.
If a kind is typed as Record<string, unknown>, the builder accepts any path. If the kind is a generic type parameter, the builder cannot list its paths, so use the untyped builder.
To resolve a path, the system uses different logic for types and values: the type check follows the declared type, while the runtime walk follows the actual value.
If you define a path through a class-typed property—for example, owner.email where owner is an Owner instance—the code will compile because the type exists. However, the runtime walk cannot see into the class instance, so the value is denied as not-traversable.