Access Control
The access-control engine is why Stack exists. Application code — yours or your AI agent's — never talks to the database directly; it talks to a secured context, and the engine applies your access rules to every query, create, update, and delete. Security stops being a property of each handler someone remembered to write and becomes a property of the framework: the secure path is the only path.
Overview
Every operation goes through the context wrapper, and every context operation passes access control before anything is returned.
Reads are composed on context.db.<List>, keyed by the PascalCase list name the config uses, and reach the database only through a terminal:
const posts = await context.db.Post.where({ status: { equals: 'published' } }).all()
There is no unscoped sibling of that call to reach for by accident. The deliberate bypass — context.unsafe — is a different name on a different object, and every use of it is meant to say why.
Two defaults set the tone:
- Deny by default. A list with no access rules isn't open — it's inaccessible. Nothing ships readable by accident.
- Silent failure. A denied operation returns
null(single record) or[](many), indistinguishable from "not found" — so callers can't probe for records they aren't allowed to see.
How It Works
Writes check operation-level access, filter writable fields, then persist. Reads are a two-phase pipeline:
- Access Filter (pre-query): the engine evaluates operation-level
queryaccess and ANDs the resulting filter into the read's ownwhereand into eachinclude— rows and relations a session can't see never leave the database. This only runs for relations the caller actually asked for, one hop at a time: a read with noincludefetches the row's own columns and computed fields only, matching the ORM's own semantics, and naming a relation fetches that relation's own columns and stops — reaching further means naming further. A relation nobody named never has its list'squeryaccess evaluated at all. See Queries & projections. - Field Visibility (post-query): on the returned rows, fields the session can't read are removed,
resolveOutputhooks run, and virtual fields are computed.
In order:
- Define access rules in your
opensaas.config.ts - Operations go through context:
context.db.Post.update({ where, data }) - Access control engine checks operation-level access
- Access filters are ANDed into the read's
where - Field-level access controls which fields are readable/writable
- Operations return
nullor[]on access denial (silent failures)
Access Control Types
Operation-Level Access
Controls whether a user can perform an operation at all:
Each slot takes a function, not a literal — query: true is a compile error, query: () => true is the rule that always allows:
Post: list({
fields: { title: text() },
access: {
operation: {
query: () => true,
create: ({ session }) => !!session?.userId,
update: isAuthor,
delete: isAdmin,
},
},
})
A rule returns one of two things, synchronously or as a Promise:
- A boolean —
trueallows the operation,falsedenies it outright. - A filter — the operation proceeds, scoped to the rows the filter matches.
create is the exception: it accepts a boolean result only. There is no existing row for a filter to scope, so a create rule that returns one throws InvalidCreateAccessResultError rather than being read as an allow. To gate a create on the incoming data, evaluate the condition in a resolveInput or validate hook, where the input is in scope.
Scoping by returning a filter
A rule scopes a read by returning a filter. There is no separate access: { filter } block, and no list-level access: { fields } — a list's access is either one function applied to all four operations, or an object whose only member is operation.
query: ({ session }) => {
if (!session?.userId) {
return { status: { equals: 'published' } }
}
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
}
The returned filter is ANDed into whatever where the caller composed, so a session never widens its own scope by asking for more.
The filter vocabulary is a closed set
A returned filter is written in the same grammar as .where(), and that grammar is finite. Scalar operators: equals, not, in, notIn, lt, lte, gt, gte, contains. Relation quantifiers: some, every, none. Logical keys: AND, OR, NOT. A bare value means equality; contains is case-insensitive. There is no startsWith, no endsWith, no mode, and no is/isNot — naming one is refused, not ignored.
undefined is refused, never dropped. A filter value of undefined raises a ValidationError instead of quietly vanishing from the predicate. This is the fail-closed rule that matters most in an access rule: the tempting shorthand
query: ({ session }) => ({ authorId: { equals: session?.userId } })
is an error for an anonymous session, not an unfiltered read of every post. Branch on the session and return a real predicate — or false — for the anonymous case, as the example above does.
Field-Level Access
Control access to individual fields:
fields: {
internalNotes: text({
access: {
read: ({ session, item }) => session?.userId === item.authorId,
create: ({ session }) => !!session?.userId,
update: ({ session, item }) => session?.userId === item.authorId,
},
}),
secret: password({
access: {
read: () => false,
},
}),
}
Field-level rules are boolean-only, and are still functions. read: false is a compile error; read: () => false is the rule. An operation-level rule may return a filter to scope rows; a field-level rule decides allow/deny for one field, from session, the fetched item and — on create/update — the inputData. A field rule that somehow returns a non-boolean throws rather than defaulting to allow, so filter-returning helpers cannot be reused here.
Access Functions
An operation-level rule is called with exactly three arguments. session is the app's session or null. item is the existing row — present for update and delete, absent for query and create, which is why its type is optional. context is the AccessContext, carrying session, db, plugins and storage.
import type { AccessControl } from '@opensaas/stack-core'
const rule: AccessControl = ({ session, item, context }) => {
return !!session
}
The list key and the operation are not passed in: a rule is already registered against one list under one slot, so both are known where you wrote it. A rule that genuinely needs to serve several slots is an ordinary function you name in each of them.
Common Patterns
Check if user is signed in:
const isSignedIn: AccessControl = ({ session }) => !!session?.userId
Check if user is the author:
const isAuthor: AccessControl = ({ session, item }) => {
if (!session?.userId) return false
return item?.authorId === session.userId
}
Check if user has a role:
const isAdmin: AccessControl = ({ session }) => {
return session?.role === 'admin'
}
Complex filter combining multiple conditions. The authorId branch only exists when there is a user id to compare against — an undefined there would be refused, not skipped:
const visibleToSession: AccessControl = ({ session }) => {
const published = {
AND: [
{ status: { equals: 'published' } },
{ visibility: { equals: 'public' } },
{ publishedAt: { lte: new Date() } },
],
}
const userId = session?.userId
if (typeof userId !== 'string') return published
return { OR: [published, { authorId: { equals: userId } }] }
}
Silent Failures
Stack returns null (for single records) or [] (for multiple records) when access is denied, rather than throwing errors. This prevents information leakage about whether records exist. There is no AccessDeniedError to catch: a denial is not an exception, it is an empty answer.
Every access-controlled read and write is therefore nullable at the call site. .first(), create, update and delete all return T | null, and .aggregate() answers 0 under every key. Check before you dereference:
const post = await context.db.Post.where({ id: { equals: postId } }).first()
if (!post) {
return { error: 'Post not found' }
}
null here means the post does not exist or this session may not see it, and the caller cannot tell which. That conflation is the point.
Why silent failures?
- Prevents information leakage
- Consistent API (no try/catch needed)
- Simpler application code
- Better security by default
Nested include depth limit
The Access Filter — the pass that scopes relation includes before the database is queried — only scopes an include up to a fixed nesting depth (READ_INCLUDE_MAX_DEPTH, currently 5 hops from the list you queried). This is a cost limit, not an inability to scope: since the walk only ever follows branches a request itself names, there is no unscoped subtree to fail open on past the cap. If a caller-supplied include names a relation nested past that depth, the engine declines to serve a tree this expensive rather than serving it anyway, so it throws AccessScopeDepthExceededError:
The error carries listKey, fieldKey and depth, which together name the hop that went too deep. The remedy is to split the read into separate, shallower ones:
import { AccessScopeDepthExceededError } from '@opensaas/stack-core'
try {
await context.db.Post.include('author', (author) =>
author.include('organisation', (org) => org.include('owner')),
).all()
} catch (error) {
if (error instanceof AccessScopeDepthExceededError) {
console.error(`include too deep at ${error.listKey}.${error.fieldKey} (${error.depth})`)
}
throw error
}
This only affects a caller-supplied include that explicitly reaches past the cap. An ordinary read with no include — or one that stays within the limit — is unaffected: the cap never turns a normal, shallow read into an error, and a schema deeper than the cap is still perfectly readable one hop at a time. See ADR-0022 for the full rationale.
System Fields
id is the only column the generator adds on its own. createdAt/updatedAt are off by default (ADR-0004): a list opts in by declaring the two fields itself or by setting db: { timestamps: true }, per list or on db for every list at once.
All three names, where the list has them, are:
- Excluded from access control (always readable)
- Excluded from field-level write operations
You cannot override access control for system fields.
connect is gated by the owning relationship field's access
Relation input on the side that owns the foreign key is { connect: { id } }, or null to clear the edge. Those are the only two shapes: there is no disconnect, and nested create/update/delete/connectOrCreate/set/ updateMany/deleteMany are refused. A write is one list's row.
A connect is gated by the owning relationship field's create/update field-level access (e.g. the access on Post.author), evaluated for the enclosing write's operation. If that field's field-level access denies the write, the connect is denied — exactly as for any other field on the write. This gate receives the same item (the row being updated) and inputData (the write payload) the parent write's field-access check uses, so a rule that depends on either evaluates identically wherever the field is enforced.
For context, the connect is also gated by read/query access on the target list (evaluated against the database): the caller must be able to read the row to connect it, because a connect references an existing row but does not modify its data, so it requires read access on the target, not update. Both checks must pass for a connect to succeed.
Below, Author defines a permissive update but no query rule, so its read access is deny-by-default:
lists: {
Author: list({
fields: { name: text() },
access: {
operation: {
update: () => true,
},
},
}),
Post: list({
fields: {
title: text(),
author: relationship({ ref: 'Author' }),
},
access: { operation: { query: () => true, update: () => true } },
}),
}
The connect below is therefore denied, and the whole write answers null — one indistinguishable answer, whether the author row is missing or merely unreadable:
const post = await context.db.Post.update({
where: { id: postId },
data: { author: { connect: { id: authorId } } },
})
where on a write is identity-only: exactly one key, id. A secondary unique column is a compile error, so a write can never target a row by a value the caller happens to know.
Behaviour change / migration note. Because the access engine is deny-by-default for an undefined access rule, a related list that defines a permissive
updatebut leavesqueryundefined now denies nestedconnect(previously such connects were allowed). This is the correct, read-gated direction and is consistent with how normal reads of that list already behave (they return empty). If you rely on connecting to such a list, define aqueryrule that permits the connect (for example a permissivequery: () => trueor a scoped filter).sudobypasses the check entirely.
Nested relation input is refused, not gated
A write payload holds one list's own scalars and its owned foreign keys. Every nested spelling on a relationship field — create, update, delete, connectOrCreate, disconnect, set, updateMany and deleteMany — is refused with NestedRelationInputError. The generated input types make each a compile error; the refusal is the runtime half, for a payload that reached the engine untyped (a server action's form data, an MCP tool call, a plugin) or one a resolveInput hook assembled after the types had their say.
The refusal is unconditional: unlike an access denial, sudo() does not lift it. Each of these was a second write against another list hidden inside one call — N hook chains staged against one atomic decision — so there is no elevated caller for whom the shape becomes safe.
import { NestedRelationInputError } from '@opensaas/stack-core'
try {
await context.db.Post.update({
where: { id },
data: { tags: { deleteMany: {} } },
})
} catch (err) {
if (err instanceof NestedRelationInputError) {
// err.listName / err.fieldKey / err.kinds identify the refused write.
}
}
Write the related rows against their own list, wrapped in context.transaction() when they must land together. To clear an edge, assign null to the relationship field that owns the foreign key — the replacement for disconnect. That is a foreign-key column on the row being written, so it carries the enclosing write's own operation and field-level access and names no target row to gate.
null is only ever that column's own spelling, so it goes where the column is. On a field that owns no foreign key — a to-many, the non-owning half of a one-to-one, a synthetic back-relation — null is refused with NonOwningRelationInputError exactly as connect is. Clearing such an edge is an update against the target list, assigning null to the field there that owns the column:
// Not `Author.posts`, which owns no column:
await context.db.Post.update({ where: { id: postId }, data: { author: null } })
Uniqueness and existence
A unique constraint is enforced by the database across every row, including rows the session cannot read. When a write collides with one, the resulting UniqueConstraintViolation (with its fieldErrors, for any constraint the generator emitted) tells the caller that some row holds that value. A scoped User list shows the shape:
import { list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
const User = list({
fields: { email: text({ isIndexed: 'unique' }) },
access: {
operation: {
query: ({ session }) => (session === null ? false : { id: { equals: session.userId } }),
update: ({ session }) => (session === null ? false : { id: { equals: session.userId } }),
},
},
})
A session that updates its own email to another user's address receives the violation, confirming that address is taken even though it cannot read that user. The same applies to create for any caller with create access. A restrictive foreign key (onDelete: 'restrict') likewise reveals, by blocking a delete, that rows the session cannot read still reference the target.
No check inside the stack can close this for a write whose success the caller observes: the database alone knows about the colliding row, and any outcome other than success differs from success. The stack keeps the field-mapped message on purpose (see ADR-0042). Mitigate in the schema and at the edge:
Scope the constraint to the access boundary. On a tenant-scoped list, make the pair unique so a caller can only collide with rows in its own tenant. This only holds when the access filter or a
resolveInputhook pins the tenant to the session's own; a caller who can choose any tenant can still probe other tenants' values:typescriptimport { list } from '@opensaas/stack-core' import { relationship, text } from '@opensaas/stack-core/fields' const Member = list({ fields: { tenant: relationship({ ref: 'Tenant.members' }), email: text(), }, db: { indexes: [{ fields: ['tenant', 'email'], unique: true }] }, })Return a uniform response where the caller does not need the written row (registration, invitations, password recovery): catch the violation in your endpoint and answer exactly as you would a success, so an existing value is indistinguishable from a new one. This is the mitigation when uniqueness must stay global.
Rate-limit create and update on endpoints that expose a unique field to untrusted callers.
Avoid
restricton references that cross the access boundary, where the domain allows it.onDeleteis set on the relationship field asdb: { onDelete: 'setNull' }(or'cascade').
Access Control Execution Order
For write operations (create/update):
- List-level operation access check
- Field-level write access check (filter writable fields)
- Hook execution (
resolveInput,validate, …) - Database operation
- Field-level read access check (filter readable fields in response)
For read operations (query):
- List-level operation access check
- AND the returned access filter into the read's
where - Database operation
- Field-level read access check (filter readable fields in response)
Best Practices
1. Default to Restrictive
Start with restrictive access and open up as needed:
access: {
operation: {
query: isSignedIn,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
}
2. Use Named Functions
Extract access functions for reusability:
const isAuthor: AccessControl = ({ session, item }) => {
return session?.userId === item?.authorId
}
const isAdminOrAuthor: AccessControl = (args) => {
if (args.session?.role === 'admin') return true
return isAuthor(args)
}
Forward the whole argument object rather than picking fields out of it: a rule that reconstructs { session, item } by hand drops context, and stops compiling the moment it needs it.
3. Always Check for Session
Guard against null sessions:
update: ({ session, item }) => {
if (!session?.userId) return false
return item?.authorId === session.userId
}
4. Test Access Control
Always test your access rules with different user scenarios:
- Anonymous users
- Authenticated users
- Authors vs non-authors
- Admin vs regular users
Advanced Patterns
Conditional Field Access
Fields can have different access rules based on context:
Admins read every address; everyone else reads only their own:
email: text({
access: {
read: ({ session, item }) => {
if (session?.role === 'admin') return true
return session?.userId === item.id
},
},
})
Cross-List Access Checks
A rule can read another list through context.db. That read is itself access controlled, so it answers null when the session cannot see the membership row — which is the same answer as "there is no such membership", and the right one either way. Guard the session first so no filter value can be undefined:
const isOrgAdmin: AccessControl<{ orgId: string }> = async ({ session, item, context }) => {
const userId = session?.userId
if (typeof userId !== 'string' || item === undefined) return false
const membership = await context.db.OrgMembership.where({
userId: { equals: userId },
orgId: { equals: item.orgId },
role: { equals: 'admin' },
}).first()
return membership !== null
}
Time-Based Access
Editable for the first 24 hours, and by the author alone:
const isRecentAndMine: AccessControl<{ authorId: string; createdAt: Date }> = ({
session,
item,
}) => {
if (item === undefined) return false
const dayInMs = 24 * 60 * 60 * 1000
const isRecent = Date.now() - item.createdAt.getTime() < dayInMs
return session?.userId === item.authorId && isRecent
}
Common Pitfalls
Forgetting to Check Session
Dereferencing a null session throws inside the rule, which fails the whole operation rather than denying it. Guard first, and return false:
update: ({ session, item }) => {
if (!session?.userId || item === undefined) return false
return item.authorId === session.userId
}
Building a Filter Out of an Optional
The single most expensive mistake on this page. session?.userId is string | undefined, and undefined in a filter is a ValidationError — so this rule does not "match nothing" for an anonymous caller, it fails the read:
query: ({ session }) => ({ authorId: { equals: session?.userId } })
Narrow to a value the vocabulary accepts, and decide explicitly what the anonymous case should see:
query: ({ session }) => {
const userId = session?.userId
if (typeof userId !== 'string') return { status: { equals: 'published' } }
return { authorId: { equals: userId } }
}
Over-Permissive Defaults
create: () => true lets anyone write. Spell out who may:
access: {
operation: {
query: () => true,
create: isSignedIn,
},
}
Not Testing Access Denial
Always test that access is denied when it should be. A non-author's update resolves to null rather than throwing, so a test that only checks for an absence of exceptions passes against a broken rule:
const post = await context.db.Post.update({
where: { id: postId },
data: { title: 'New Title' },
})
expect(post).toBe(null)
Next Steps
- Hooks System - Add data transformation
- Field Types - Explore field options
- Context API - Full context reference
- Anonymous & Pre-Account Access - Keep pre-account and anonymous flows on the access-scoped context instead of
sudo()