Skip to Content
AuthorizationWhich fields may they write?

Which fields may they write?

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.

The concept. canFields decides the action and every field of a proposed write in one call. What you get. The object to hand the object-relational mapper, with every refused key already gone. Why you want it. A pick-list written by hand does not cover a column added next month, and that column goes through undecided. How the library gets you there. pickAllowedFields keeps the fields that decided allowed and drops the rest.

The two calls on the acl write path

canFields decides an action and every field of a proposed write in one call, and returns a state per field. pickAllowedFields keeps the fields that decided allowed, drops the rest, and turns the decision into the object you hand the object-relational mapper (ORM). A customer may change the body of their own question on the Brass: Birmingham listing. The shop’s lock on status is the shop’s, so a form that posts status alongside the body has to lose that key.

A hand-written pick-list in the handler stays correct until somebody adds a column. The form then posts a field nobody listed, and the write goes through.

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 that result to the ORM, and pass only that result.

The write path, both calls. canFields decides the action and gives every field a state; pickAllowedFields throws on a refused action and otherwise keeps the keys that decided allowed. A denied field and an unevaluable one are both withheld, so the value reaching the ORM is the value the decision approved.

The policy there declares question because that is the form on the screen. Chain a .for() per kind to put the field rules for listings and orders in the same document, under the same access. Do not build a policy per resource.

Why hand-filtering the acl field map is the bug

A filter that drops the denied keys of fd.fields writes fields the decision never approved. fd and proposed below are the two the fence above produced:

// Do not write this. const narrowed = Object.fromEntries( Object.entries(proposed).filter(([key]) => fd.fields[key] !== 'denied'), );

FieldState carries three values, and that filter accounts for two of them. The third is unevaluable: a field config the engine could not check, because the object handed to canFields did not carry that field’s current value. 'unevaluable' !== 'denied' is true, so the filter writes a value the decision declined to approve. The filter also writes any key the decision does not carry at all, because undefined !== 'denied'.

pickAllowedFields keeps the keys that decided allowed and withholds everything else.

What an acl field decision covers

A field decision covers four sources of names, because the matrix does not know the object’s shape:

  • every field the object carries,
  • every field named in the rules,
  • every name in the allow-list,
  • and on the write axis, every key of the proposed write.

That last one is the mass-assignment case. A key that exists nowhere but the write still gets a state, so a !pinned in the name list decides a posted pinned on a question row that has never carried one.

Naming the fields in an acl policy

p is the block helper each .for() hands you, the one the fence at the top of this page called .allow and .fields on. It takes three shapes:

p.fields(['*', '!status']); // everything except status p.fields(['body']); // exactly this one p.fields({ fields: ['*', '!askedBy'], status: { transitions: { open: ['locked'], locked: [] } }, });

* is the baseline and !name subtracts from it. Both are authoring syntax and never field names, wherever they turn up. A !name with no * is DenyWithoutBaselineError; a !name inside an explicit allow-list is BangInAllowListError, because a list naming what is allowed already denies everything else.

With no name list at all, every unnamed key is writable. A targets or transitions config restricts the field it names and no other:

  • targets allow-lists the proposed value, so status: { targets: ['open', 'locked'] } accepts either of those and refuses anything else.
  • transitions is a state machine over the current value: the key is what the object holds now, the array is what it may become. An empty array is a terminal state, so the locked: [] above refuses every write that moves status out of locked.

The two are mutually exclusive on one field.

The state machine the status config above declares. The config allows one write, from open to locked; locked names an empty array, so the engine refuses every write out of it. A call that carried no current status decides unevaluable.

On the write axis a per-field config decides its own field, and the name list never sees that field. So fields: ['*', '!status'] beside a status config leaves status to the config, and the !status takes back nothing.

Restricting a fieldOn the read axisOn the write axis
the name listdenies every name it excludesdenies every name it excludes and no config claims
targets on the fieldnothing; targets is a write conceptallow-lists the proposed value, ahead of the name list
transitions on the fieldnothing; a write concept as wellreads the edge out of the current value, ahead of the list
no name list and no configallowedallowed

The acl action gate

canFields returns two booleans named allowed, one nested inside the other, and they can disagree. fd.allowed is true when the action is allowed and every field is allowed. fd.action.allowed reports the action-level decision on its own.

fd.fields is filled in whatever the action says, so a blocked handler still learns which fields become editable once the action is unblocked. Caveats and pitfalls has both entries.

`fd.fields` is filled even when the action is refused

Every field carries a state whatever fd.action.allowed says, so a loop over fd.fields that never reads fd.allowed writes the fields of an action the engine refused. Read fd.allowed first, or call pickAllowedFields, which reads the action for you.

pickAllowedFields throws ActionNotAllowedError when the action is refused. No field of a refused action is writable. Where the action is allowed and the field rules deny some of the keys, the call returns the allowed subset.

Working the acl field axis

The control below runs one canFields call and shows both maps beside the object pickAllowedFields returns from them. It opens signed in as Sam Reyes, who asked the question, over an open row whose proposed write moves status to locked. The action is allowed and every field of that write is writable. Four moves take it elsewhere:

  • Sign in as Jo Vainio, who asked no question here. The action decides no-rule-matched, and pickAllowedFields throws ActionNotAllowedError.
  • Tick the pinned checkbox, which posts a key the name list never mentions. pinned decides denied with not-listed, and the write that reaches the ORM does not carry it.
  • Set status now to locked. The transitions config names an empty array of edges out of locked, so status decides denied with transition-failed.
  • Tick the checkbox saying the query did not select status. transitions reads the value the row holds now, so status decides unevaluable with missing-field and is withheld from the write.
Signed in as

Sam asked this question. Mika is a bookseller. Jo asked a different one.

The proposed write
The row as it stands

transitions reads the value the row holds now, so a row that arrived without it cannot be decided.

access.canFields(
  {
    "id": "c1",
    "name": "Sam Reyes",
    "roles": []
  },
  'question',
  'update',
  {
    "askedBy": "c1",
    "body": "In stock?",
    "status": "open"
  },
  'write',
  {
    "body": "Wingspan in stock?",
    "status": "locked"
  },
)

fd.action

key
question.update
allowed
true
reason
allow
rule
#0

fd.fields

fieldFieldStateFieldReason
askedBydeniednot-listed
bodyallowedallow
statusallowedallow

pickAllowedFields(fd, proposed)

{
  "body": "Wingspan in stock?",
  "status": "locked"
}
Signed in as Sam Reyes. The action is allowed, reason allow. askedBy denied, not-listed. body allowed, allow. status allowed, allow. pickAllowedFields returns { "body": "Wingspan in stock?", "status": "locked" }.

The policy behind the control names body in its allow-list and gives status a transitions config, so askedBy decides denied with the reason not-listed whoever is signed in.

Reading with acl

The read axis of canFields decides which fields of an already fetched row a subject may see. access below is the policy from the fence at the top of this page, and current is the question as the database holds it.

const view = access.canFields( { id: 'c1' }, 'question', 'read', current, 'read', );

The read axis takes no proposed write, and per-field targets and transitions are write concepts, so on read only the name list denies. The call decides a projection and queries nothing: a field decided denied here was still read out of the database.

Where to go next

Last updated on