Access control
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.
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.
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.
// 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 403What 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.
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.
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 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.
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.
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.
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.
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.
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, per-field rules and where they sit in a write.
- Beyond CRUD, the
transition,serveandintrospectrules. - Writing rules, throwing your own error and sharing one rule across collections.
- Soft delete owns
purge, which is default-deny and never inherits fromdelete. - Hooks run around these checks. Access decides, hooks act.