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.
// Instead of using Prisma directly
const posts = await prisma.post.findMany()
// Use the context (which includes access control)
const posts = await context.db.post.findMany()
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 merges the resulting filter into the Prismawhere/include— rows and relations a session can't see never leave the database. - 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 wrapper:
context.db.post.update() - Access control engine checks operation-level access
- Access filters are merged with Prisma where clauses
- 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:
Post: list({
access: {
operation: {
query: true, // Anyone can query
create: ({ session }) => !!session?.userId, // Must be signed in
update: isAuthor, // Only author can update
delete: isAdmin, // Only admins can delete
},
},
})
Return Types:
- Boolean:
true(allow all) orfalse(deny all) - Prisma Filter: Filter which records the user can access
- Async Function: All access functions can be async
Filter-Based Access
Return a Prisma filter to scope which records a user can access:
query: ({ session }) => {
if (!session) {
// Anonymous users only see published posts
return { status: { equals: 'published' } }
}
// Authenticated users see published posts OR their own drafts
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
}
The filter is automatically merged with the operation's where clause.
Field-Level Access
Control access to individual fields:
fields: {
internalNotes: text({
access: {
read: isAuthor, // Only author can read
create: isSignedIn, // Any signed-in user can set
update: isAuthor, // Only author can update
},
}),
password: password({
access: {
read: false, // Never readable (automatically enforced)
},
}),
}
Field-level rules are boolean-only. An operation-level rule may return a Prisma filter to scope rows; a field-level rule decides allow/deny for one field (using session and, where available, the fetched item). Returning a filter from a field rule does not scope anything — don't reuse filter-returning helpers on fields.
Access Functions
Access functions receive a context object with:
interface AccessContext {
session: Session | null // Current user session
listKey: string // e.g., "Post"
operation: 'query' | 'create' | 'update' | 'delete'
originalInput?: any // The input data for create/update
item?: any // The existing item for update/delete (includes all fields)
context: Context // Full context for database queries
}
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:
query: ({ session }) => ({
AND: [
{ status: { equals: 'published' } },
{ visibility: { equals: 'public' } },
{
OR: [{ publishedAt: { lte: new Date() } }, { authorId: { equals: session?.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.
const post = await context.db.post.findUnique({ where: { id: '123' } })
if (!post) {
// Either:
// 1. Post doesn't exist, OR
// 2. User doesn't have access
// The user can't tell which!
return { error: 'Post not found' }
}
Why silent failures?
- Prevents information leakage
- Consistent API (no try/catch needed)
- Simpler application code
- Better security by default
System Fields
Fields id, createdAt, updatedAt are automatically:
- Added to Prisma schema
- Excluded from access control (always readable)
- Excluded from field-level write operations
You cannot override access control for system fields.
Nested connect is gated by the owning relationship field's access
When a write uses a nested connect (or the connect branch of connectOrCreate) to link an existing related row, the 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.
lists: {
Author: list({
fields: { name: text() },
access: {
operation: {
// Without a `query` rule, read access is DENY-BY-DEFAULT...
update: () => true, // ...even though update is permissive.
},
},
}),
Post: list({
fields: {
title: text(),
author: relationship({ ref: 'Author' }),
},
access: { operation: { query: () => true, update: () => true } },
}),
}
// This nested connect is DENIED, because Author has no `query` rule
// (deny-by-default), even though Author's `update` is permissive:
await context.db.post.update({
where: { id },
data: { author: { connect: { id: authorId } } },
})
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.
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, validateInput, etc.)
- Database operation
- Field-level read access check (filter readable fields in response)
For read operations (query):
- List-level operation access check
- Merge access filters with where clause
- 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, // Require auth by default
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 = ({ session, item }) => {
if (session?.role === 'admin') return true
return isAuthor({ session, item })
}
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:
email: text({
access: {
read: ({ session, item }) => {
// Users can read their own email, admins can read all emails
if (session?.role === 'admin') return true
return session?.userId === item?.id
},
},
})
Cross-List Access Checks
Use the context to query other lists:
delete: async ({ session, item, context }) => {
// Check if user is org admin
const membership = await context.db.orgMembership.findFirst({
where: {
userId: session?.userId,
orgId: item?.orgId,
role: 'admin',
},
})
return !!membership
}
Time-Based Access
update: ({ session, item }) => {
// Can only edit within 24 hours of creation
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
// ❌ Bad: Doesn't check if session exists
update: ({ session, item }) => item.authorId === session.userId
// ✅ Good: Checks session exists first
update: ({ session, item }) => {
if (!session?.userId) return false
return item.authorId === session.userId
}
Over-Permissive Defaults
// ❌ Bad: Too permissive
access: {
operation: {
query: true,
create: true, // Anyone can create!
},
}
// ✅ Good: Explicit about permissions
access: {
operation: {
query: true,
create: isSignedIn,
},
}
Not Testing Access Denial
Always test that access is denied when it should be:
// Test that non-authors can't update
const post = await context.db.post.update({
where: { id: postId },
data: { title: 'New Title' },
})
// Should be null because user isn't the author
expect(post).toBe(null)
Next Steps
- Hooks System - Add data transformation
- Field Types - Explore field options
- Context API - Full context reference