Which fields may they write?
@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.
In a typical application, a “write” operation isn’t all-or-nothing. You might allow a user to edit the body of a support ticket, but not its priority or its assigned agent.
If you handle this by manually stripping fields in your backend handler, you’re essentially duplicating your authorization rules. If a new field is added to the database, you must remember to add it to every “strip list” in your code, or you risk allowing users to overwrite sensitive data—a vulnerability known as mass-assignment.
@evanion/acl solves this by extending the authorization model to individual fields.
Securing field-level writes
Instead of manually filtering the data sent by a form, you can use canFields. This function decides if an action is allowed and, if so, which specific fields of the proposed write are permitted.
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?"}'Here is how the process works:
canFields(...): You pass the current object from the database and the proposed write from the user. The function returns a field decision (fd).- The Decision:
fd.actiontells you if the overall action is allowed.fd.fieldstells you the state of every field in the write (allowed, denied, or unevaluable). pickAllowedFields(fd, proposed): This helper takes the decision and the proposed write, and returns a new object containing only the fields that were explicitly allowed.
By passing the result of pickAllowedFields directly to your ORM, you ensure that no unapproved fields ever reach your database.
Defining field rules
You can control field permissions using the .fields() method. This method targets the action declared immediately before it in the chain.
There are four ways to define these rules:
| Strategy | Syntax | Result |
|---|---|---|
| Baseline and Cuts | .fields(['*', '!status']) | Allow everything except status |
| Allow-list | .fields(['body']) | Allow only body; deny everything else |
| Custom Config | .fields({ fields: ['body'], status: {...} }) | Allow specific fields and apply a config to others |
| Open | (no .fields() call) | Allow all fields provided in the write |
Advanced Field Constraints
For maximum control, you can provide a configuration object for specific fields. This allows you to enforce value-based rules:
targets: An allow-list of acceptable values. For example,status: { targets: ['open', 'locked'] }ensures the status can only be set to one of those two values.transitions: A state machine that controls how a value can change. You define the current value as a key and an array of allowed next values. An empty array indicates a terminal state that can never be changed.
import { } from '@evanion/acl';
type = { : string; : string; : 'open' | 'locked' };
const = <{ : string }, { : }>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id')).({
: ['body'],
: { : { : ['locked'], : [] } },
}),
)
.();
const = { : 'customer-41' };
const = {
: 'customer-41',
: 'In stock?',
: 'open',
} as ;
const = { ..., : 'locked' } as ;
// open -> locked is the one edge the config allows.
const = .(, 'question', 'update', , 'write', {
: 'locked',
});
.['status']; // -> 'allowed'
// locked names no edge, so nothing moves a question out of it.
const = .(
,
'question',
'update',
,
'write',
{ : 'open' },
);
.['status']; // -> 'transition-failed'
// A row loaded without `status` gives the config no edge to read.
const = .(
,
'question',
'update',
{ : 'customer-41' },
'write',
{ : 'locked' },
);
.['status']; // -> 'missing-field'
// A key only the posted form carries is still decided, by the name list.
const : <> = .('{"pinned":true}');
const = .(
,
'question',
'update',
,
'write',
,
);
.['pinned']; // -> 'not-listed'
.['askedBy']; // -> 'not-listed'Handling the “Unevaluable” field
Just as with object-level rules, field-level rules can be unevaluable. This happens if a field rule depends on a value that wasn’t fetched from the database.
For example, if a transition rule needs to know the current status to decide if a write is allowed, but the status field is missing from the row, the field decision is unevaluable.
A crucial distinction: pickAllowedFields treats unevaluable fields the same as denied fields—it withholds them from the write. This ensures that if you can’t prove a write is allowed, it is not performed.
Next steps: Field permissions and refusals
You now know how to protect your database from mass-assignment.
- Why was this refused? explains how to interpret field-level errors and provide feedback to users.
- Field permissions covers how to use the
readaxis ofcanFieldsto implement a projection layer, ensuring users only see the fields they are allowed to see.