# Access control (/docs/schema/access-control)

---
title: Access control
description: One `.access({ ... })` call on a collection decides who may read, create, update and delete each row. The REST API, the typed client and the admin all obey the same object.
kind: guide
package: questpie
---

Who is allowed to touch this row? The rule lives next to the fields it guards,
so there is no second policy layer to keep in sync. This page covers the four
CRUD rules, what each one receives, and what happens when you leave one out.

## One rule per operation

`.access()` takes an object keyed by operation. Each value is a boolean, or a
function that receives the request context and decides.

```ts title="src/questpie/server/collections/posts.ts"
import { collection } from "#questpie/factories";

export const posts = collection("posts")
	.fields(({ f }) => ({
		title: f.text(255).required(),
		authorId: f.relation("user"),
		status: f.select([
			{ value: "draft", label: "Draft" },
			{ value: "published", label: "Published" },
		]),
	}))
	.access({
		// Signed-in readers also see their own drafts.
		read: ({ session }) =>
			session?.user
				? { OR: [{ authorId: session.user.id }, { status: "published" }] }
				: { status: "published" },
		create: ({ session }) => !!session?.user,
		// `data` is the row as it exists in the database right now.
		update: ({ session, data }) => data.authorId === session?.user?.id,
		delete: ({ session, data }) => data.authorId === session?.user?.id,
	});
```

That object now governs `GET/POST/PATCH/DELETE /api/posts`, every
`client.collections.posts.*` call, and the posts screen in the admin.

```ts
// Anonymous caller: published posts only, drafts are not in the result.
const { docs } = await client.collections.posts.find();

// Someone else's post.
await client.collections.posts.updateById({ id, data: { title: "Mine now" } });
// ApiError FORBIDDEN, HTTP 403
```

## What a rule returns

| Return    | Effect                                                                                                |
| --------- | ----------------------------------------------------------------------------------------------------- |
| `true`    | Allow.                                                                                                |
| `false`   | Deny with `ApiError.forbidden`, HTTP 403.                                                             |
| An object | A row filter. On `read` it becomes SQL. On `update` and `delete` the loaded row is tested against it. |

## Which rule runs

For one operation the framework takes the first of these it finds: the
collection's own `access[op]`, then the app-wide default from `config/app.ts`,
then a built-in fallback that requires an authenticated session. System mode
short-circuits ahead of all three. That is the chain for `read`, `create`,
`update` and `delete`. The other four operations each fall back somewhere else.

<Callout type="warn" title="Leaving an operation out does not make it public">
	An omitted rule requires a session. To open an operation to anonymous callers
	you have to say so: `read: true`. Routes work the other way round, so a route
	with no `.access()` is public until you add one. See
	[Routes](/docs/code/routes).
</Callout>

Calling `.access()` twice replaces the whole object rather than merging into it,
so set every operation you care about in one call. `a.merge(b)` folds two
definitions of the same collection together, and `b`'s access wins key by key.

## What each operation gets

Every rule function receives the app context (`db`, `session`, `collections`,
`queue`, `storage`, and anything you added in `appConfig({ context })`) plus a
few keys that depend on the operation.

| Operation | `data`           | `input`                         | Row filter                                           |
| --------- | ---------------- | ------------------------------- | ---------------------------------------------------- |
| `read`    | not loaded       | the query options               | merged into the SQL `WHERE`                          |
| `create`  | not loaded       | the raw body, before validation | ignored, so the write goes through. Return a boolean |
| `update`  | the existing row | the patch                       | matched against the loaded row                       |
| `delete`  | the existing row | the delete params, so `{ id }`  | matched against the loaded row                       |

`ctx.request` is set when the call arrives over HTTP and absent on direct server
calls. With `@questpie/admin` enabled, `isAdminRequest(request)` from
`@questpie/admin/shared` tells the admin panel apart from your own frontend.

## Row filters

On `read` the object is compiled into the query and `AND`-merged with whatever
`where` the caller sent. It accepts the same vocabulary as a `where` clause, so
`{ status: "published" }`, `{ views: { gt: 100 } }` and relation quantifiers all
work. A predicate the compiler cannot build throws instead of quietly returning
rows.

On `update` and `delete` the row is already loaded, so the object is matched in
JavaScript. That matcher understands field equality and `AND` / `OR` / `NOT` and
nothing else, so `{ views: { gt: 100 } }` never matches and the write is denied.
Do the lookup in the rule body and return a boolean when you need more. Bulk
writes check every row separately.

## Field-level rules

The same object takes a `fields` key that allows or denies one field at a time.
A denied read drops the field. A denied write throws `forbidden` carrying the
field path. **[Field-level access](/docs/schema/access-control/fields)** has the
syntax and the precedence rules.

## App-wide defaults

`config/app.ts` sets the fallback for every collection and global that leaves an
operation undefined.

```ts title="src/questpie/server/config/app.ts"
import { appConfig } from "#questpie/factories";

export default appConfig({
	access: {
		read: ({ session }) => !!session,
		create: ({ session }) => !!session,
		update: ({ session }) => !!session,
		delete: ({ session }) => !!session,
	},
});
```

It takes `read`, `create`, `update`, `delete`, `transition`, `serve` and
`introspect`. There is no `fields` key, because per-field rules belong to the
collection that owns the field. The starter module ships the object above, which
is where secure-by-default comes from. Your own `config/app.ts` replaces that
object whole rather than merging into it.

## The system bypass

`accessMode: "system"` makes every check return `true`, row rules and field
rules alike. A call from a script or a job runs that way by default. An HTTP
request runs as `"user"`, and everything it calls inherits that, so the public
API stays enforced. Seeds already run in system mode.

```ts title="src/questpie/server/lib/reports.ts"
import { createContext } from "#questpie";

// No argument, so this context is system mode.
const ctx = await createContext();
const { docs } = await ctx.collections.posts.find({});
```

Every CRUD method takes a context argument, so a single call can run in a
different mode from the code around it. That argument is the only reliable way
to ask for user mode: `ctx.collections.*` does not inherit it from `ctx`.

<Callout type="warn" title="Never derive the mode from a request">
	System mode is a complete bypass. Compute it from your own code, never from a
	header, a query parameter, or anything else the caller controls.
</Callout>

## Globals

A global holds one row, so its surface is `read`, `update`, `transition`,
`introspect` and `fields`. There is no `create`, `delete` or `serve`, and a
global rule has to return a boolean, because there is nothing to filter.
Anything else counts as a denial. On `update`, `ctx.data` is the current record,
and it is undefined before the first write.

```ts title="src/questpie/server/globals/site-settings.ts"
import { global } from "#questpie/factories";

export const siteSettings = global("siteSettings")
	.fields(({ f }) => ({
		siteName: f.text().required(),
		maintenanceMode: f.boolean(),
	}))
	.access({
		read: true,
		update: ({ session }) => session?.user?.role === "admin",
	});
```

## Next

- **[Field-level access](/docs/schema/access-control/fields)**, per-field rules
  and where they sit in a write.
- **[Beyond CRUD](/docs/schema/access-control/beyond-crud)**, the `transition`,
  `serve` and `introspect` rules.
- **[Writing rules](/docs/schema/access-control/writing-rules)**, throwing your
  own error and sharing one rule across collections.
- [Soft delete](/docs/schema/soft-delete) owns `purge`, which is default-deny and
  never inherits from `delete`.
- [Hooks](/docs/schema/hooks) run around these checks. Access decides, hooks act.
