# Field-level access (/docs/schema/access-control/fields)

---
title: Field-level access
description: The `fields` key inside `.access()` allows or denies one field at a time, independently of the rule that guards the row.
kind: guide
package: questpie
---

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.

```ts title="src/questpie/server/collections/customers.ts"
.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.

```ts
fields: {
	salary: {
		read: ({ user, doc }) => user?.role === "hr" || doc?.userId === user?.id,
	},
}
```

<Callout type="info" title="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.
</Callout>

## Precedence with field modifiers

Three sources feed the same map, and for a given field and flag the later one
wins:

1. `.access({ ... })` on the field definition itself.
2. `access.fields` on the collection.
3. `.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](/docs/schema/access-control) for the row rules these sit
  beside.
- [Fields](/docs/schema/fields) for the modifiers named above.
- [Hooks](/docs/schema/hooks) for the full lifecycle the ordering belongs to.
