QUESTPIE
GuidesMulti tenancy

Where isolation leaks

Five places a scope filter silently does not apply, why each one behaves that way, and what to write instead.

View markdown

Nothing on this page errors. You just see rows you did not expect. Each section is one path where the isolation you wrote was never in force.

GapWhat happens
A relation to an unscoped tableThe target's own rules run, and it has none
The user collectionShared by design, and it must stay that way
A backend call with no requestSystem mode, so no access rule runs
A null scopeFilters to IS NULL, not to everything
A where returned from createIgnored. Only false denies

A relation to an unscoped table

Scope lives in each collection's own access rules. When you hydrate a relation with with:, QUESTPIE runs the related collection's own read, carrying the parent's access mode and session. So the target's rules apply, and only the target's. The parent's scope filter is not inherited.

A child list keyed by a foreign key still stays inside the parents you loaded. The unscoped collection is the hole, because it is also readable on its own.

// comments has no scope rule of its own.
// Any signed-in user reads every tenant's comments.
await app.collections.comments.find({}, { accessMode: "user", session });

The fix is uniform. Scope every collection that holds tenant data, including ones you only reach through a relation.

Upload relations are the one exception

An f.upload() relation carries an internal inheritAccess flag, so it populates from the parent row's read decision. Field-level read rules still apply. Every other relation type re-runs its own target's access.

The relation dispatcher carries that flag as a JavaScript symbol on the nested options. JSON cannot carry a symbol, so an HTTP with parameter can never opt a relation out of its scope rule.

The user collection

user ships from the starter module. The admin module merges it to add the admin UI. It has no scope, and collections have no scoped option to add one.

That is deliberate. A user is one account that may belong to many tenants. You do not fork the row per tenant. It already restricts itself sensibly: an admin reads every user, and anyone else reads only their own row.

Model the relationship as its own collection and scope that instead.

src/questpie/server/collections/tenant-members.ts
import { type AnyPgColumn, uniqueIndex } from "questpie/drizzle-pg-core";

import { collection } from "#questpie/factories";

export default collection("tenantMembers")
	.fields(({ f }) => ({
		user: f.relation("user").required(),
		tenant: f.relation("tenants").required(),
		role: f
			.select([
				{ value: "admin", label: "Admin" },
				{ value: "editor", label: "Editor" },
				{ value: "viewer", label: "Viewer" },
			])
			.default("editor")
			.required(),
	}))
	.indexes(({ table }) => [
		uniqueIndex("tenant_members_unique").on(
			table.user as AnyPgColumn,
			table.tenant as AnyPgColumn,
		),
	]);

Then check membership in the resolver before you trust the header. See the scope resolver.

Do not put a scope rule on `user`

It filters the admin people list and every f.relation("user") picker, for everyone. Login survives, because Better Auth reads the table through Drizzle rather than through the collection. Put the boundary on membership instead.

A backend call with no request

Access rules run in user mode only. A CRUD call with no request defaults to system mode, which skips them. Your filter never fires.

// A script or seed. No request, so accessMode is "system".
// This returns every tenant's posts.
const { docs } = await app.collections.posts.find({});

That is correct for trusted code and dangerous when you forget which mode you are in. In a job, pass the tenant in the where yourself.

A null scope

{ tenant: null } is not "match everything". The where builder turns a null value into tenant IS NULL, so it matches unassigned rows only.

An access rule reading a scope that never arrived produces exactly that. Decide in the resolver what a missing header means. Reject it, or map it to a scope that matches nothing. Do not let null reach the rule.

A where returned from create

create rules check for false and nothing else. An object return is not compared against the input, so it grants the create.

.access({
	// Wrong. This allows every create.
	create: ({ tenantId }) => ({ tenant: tenantId }),
})

Stamp the field in a beforeValidate hook instead. Read and update still accept a filter object, and that is where the narrowing belongs.

What each operation does with a returned object

OperationThe object is
readANDed into the query. Rows are narrowed.
updateMatched against the loaded row. Mismatch is a 403.
deleteMatched against the loaded row. Mismatch is a 403.
createIgnored.

On this page