Field-level access
The `fields` key inside `.access()` allows or denies one field at a time, independently of the rule that guards the row.
Some columns should never leave the server, and some should never change after
the first write. A row rule cannot express either, because it decides about the
whole row. access.fields decides about one field.
Per field, per operation
Keys are field paths. A dotted path reaches into a nested field. Each entry
takes read, create and update, and each of those is a boolean or a
predicate.
.access({
read: true,
fields: {
// Only staff see the internal note.
internalNotes: { read: ({ user }) => user?.role === "admin" },
// The VAT number can be set once and never edited.
"billing.vatId": { update: false },
},
})Field rules only allow or deny. They cannot return a row filter, because the row has already been decided by then.
What a denial does
| Flag | When it resolves to false |
|---|---|
read: false | the field is dropped from the response |
create: false | sending the field on create throws forbidden with the fieldPath |
update: false | changing the field throws forbidden with the fieldPath |
Only fields actually present in the input are checked. On update, a value equal to the one already stored is skipped, so resubmitting a whole record without touching a frozen field goes through.
The context a field rule gets
A field rule receives a small object, not the full app context: user is your
generated session user, doc is the row on read and update and is undefined on
create, operation is "read", "create" or "update", and req is the
incoming request when there is one. Anything you added in
appConfig({ context }) is spread in alongside them.
fields: {
salary: {
read: ({ user, doc }) => user?.role === "hr" || doc?.userId === user?.id,
},
}Meta fields are never read-filtered
id, _title, createdAt, updatedAt and deletedAt skip the read filter,
so a rule on one of them has no effect on the response.
Precedence with field modifiers
Three sources feed the same map, and for a given field and flag the later one wins:
.access({ ... })on the field definition itself.access.fieldson the collection..inputFalse()and.outputFalse()on the field definition.
.inputFalse() contributes { create: false, update: false } and
.outputFalse() contributes { read: false }. Both are applied last, so a
field marked .inputFalse() stays unwritable even if access.fields allows
it. Keep the collection's access.fields as your policy surface and use the
modifiers for fields that are structurally read-only, like an autoNow()
timestamp.
Where the checks sit in a write
A create runs beforeOperation, the row rule, beforeValidate, the field
write rules, validation, beforeChange, the INSERT.
An update runs beforeOperation, loads the rows, tests each against the row
rule, then beforeValidate, validation, the field write rules,
beforeChange, the UPDATE.
The two differ on one point. A denied field on create never reaches the Zod schema. On update the schema has already parsed the patch by the time the field rule denies it.
The read filter runs on the way out, and its position differs by direction. On a
plain read the filter strips denied fields before afterRead sees the row. On a
write the order is reversed, and afterRead sees the full row before the filter
runs. An afterRead hook that depends on a denied field therefore works after a
create and not after a read.
Globals
A global takes the same fields key, with read and update only. There is no
create on a singleton.
Related
- Access control for the row rules these sit beside.
- Fields for the modifiers named above.
- Hooks for the full lifecycle the ordering belongs to.