Field permissions
Not on npm yet
npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
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'
..; // -> trueWhat canFields returns
canFields returns the field maps plus the action decision.
interface FieldDecision {
allowed: boolean; // the action is allowed AND every field is allowed
action: Decision; // the action-level decision the field maps hang off
fields: Record<string, FieldState>; // 'allowed' | 'denied' | 'unevaluable'
reasons: Record<string, FieldReason>;
}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 |
proposed-required | a configured field with no proposed value in the write |
Naming the fields
fields takes either a name list or an object carrying one plus the per-field
configs.
p.fields(['*', '!status']); // everything except status
p.fields(['title', 'price']); // exactly these two
p.fields({
fields: ['*', '!id'],
status: { transitions: { draft: ['published'], published: [] } },
visibility: { targets: ['public', 'private'] },
});* 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 four authoring mistakes.
| The document says | Construction raises |
|---|---|
a !name with no * baseline | DenyWithoutBaselineError |
a !name inside an explicit allow-list | BangInAllowListError |
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 = { : 'c1', : 'In stock?', : 'open' };
const = { : 'Wingspan in stock?', : 'locked' };
const = .(
{ : 'c1' },
'question',
'update',
,
'write',
,
);
.['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.
const view = access.canFields(subject, 'question', 'read', question, 'read');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.