Skip to Content
AuthorizationField Permissions

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' ..; // -> true

What 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.

FieldReasonEmitted when
allownothing refused it
not-listedthe name list does not carry it, or excludes it
targets-failedthe proposed value is not in the field’s targets
transition-failedthe current value has no edge to the proposed one
missing-fielda transitions field whose current value the object did not carry
proposed-requireda 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 saysConstruction raises
a !name with no * baselineDenyWithoutBaselineError
a !name inside an explicit allow-listBangInAllowListError
targets and transitions on one fieldTargetsTransitionsConflictError
a per-field config on a field named fieldsInvalidPermissionError

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.
The four sources that key the decision maps. The object's own keys, the per-field config names, the name list and, on the write axis only, the keys of the proposed write are unioned, and every name in that union gets a state. The keys of the proposed write are the source that catches mass assignment.

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 allowed by the decision.
  • denied and unevaluable are both withheld, and so is any key the decision does not carry.
  • pickAllowedFields throws ActionNotAllowedError when 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.

Last updated on