Tenant Isolation Pattern
In multi-tenant mode (VELOCMS_MODE=multi) every tenant's content must be invisible to every other tenant. VeloCMS enforces this at two independent layers — the database layer (PocketBase API rules) and the application layer (MultiInstanceProvider filter injection). Relying on only one layer is unsafe: PocketBase API rules can be bypassed by server-side code using _superusers credentials, and the application layer is only as complete as the TENANT_SCOPED_COLLECTIONS registry. P-016 requires both.
The two isolation layers
| Layer | Where | What it does | Can be bypassed by |
|---|---|---|---|
| PocketBase API rules | PB collection schema | Rejects list/view/create/update/delete unless tenant_id.owner_id = @request.auth.id | Server-side _superusers auth |
| MultiInstanceProvider registry | src/lib/pocketbase/providers/multi-instance.ts | Auto-injects tenant_id = '{tenantId}' filter on every query | Not registering the collection (omission bug) |
PocketBase API rules
Every tenant-scoped collection must have all five API rules set. Empty rules mean unauthenticated reads are allowed — never acceptable for tenant content. The canonical rule set matches the owner via a relation chain: the posts.tenant_id field points to the tenants record, which has an owner_id field pointing to the users record. PocketBase evaluates the full chain on every request.
migrate((app) => {
const collection = new Collection({
name: "your_collection",
type: "base",
listRule: "tenant_id.owner_id = @request.auth.id",
viewRule: "tenant_id.owner_id = @request.auth.id",
createRule: "@request.auth.id != '' && tenant_id.owner_id = @request.auth.id",
updateRule: "tenant_id.owner_id = @request.auth.id",
deleteRule: "tenant_id.owner_id = @request.auth.id",
fields: [
{
name: "tenant_id",
type: "relation",
collectionId: "<tenants_collection_id>",
required: true,
maxSelect: 1,
},
// ... other fields
],
});
return app.save(collection);
});The TENANT_SCOPED_COLLECTIONS registry
The MultiInstanceProvider in src/lib/pocketbase/providers/multi-instance.ts maintains a TENANT_SCOPED_COLLECTIONS set. When the application code calls db.getCollection('posts'), the provider checks this set. If the collection is in the set, it wraps every query with an additional filter: tenant_id = '{tenantId}'. This is the application-layer defense. For it to work, the collection must be registered.
const TENANT_SCOPED_COLLECTIONS: ReadonlySet<CollectionName> = new Set<CollectionName>([
"posts",
"categories",
"media",
"theme_config",
"integrations",
"custom_scripts",
"site_settings",
"blog_members",
"team_members",
"installed_plugins",
"pages",
// ... 40+ more collections
// Added 2026-04-15: collections that existed but were missing from this
// registry, relying only on PB API rules. Fixed in commit 4fcd084.
"products",
"product_variants",
"newsletter_campaigns",
]);
// Collections NOT in this set pass through unchanged.
// platform-wide collections (users, tenants, subscriptions) must NOT be here.The GLOBAL_ALLOWLIST exception
Some collections are intentionally cross-tenant. product_licenses, for example, belongs to a buyer across tenants — injecting a tenant filter would break DRM. system prompt_templates marked is_system = true must be readable by all tenants. These collections are placed in GLOBAL_ALLOWLIST (a separate set in multi-instance.ts) and never added to TENANT_SCOPED_COLLECTIONS. The audit_logs collection is also global — it stores platform-wide events and the PB API rules use a separate owning-relation check.
The CollectionName union
src/lib/pocketbase/provider.ts exports a CollectionName union type that lists every valid collection name. TypeScript enforces this on every db.getCollection() call. When you add a new collection, it must be added to this union or the call will produce a type error. This acts as a compile-time reminder that the collection needs to be registered.
export type CollectionName =
| "posts"
| "categories"
| "media"
| "site_settings"
| "theme_config"
// ... existing collections
| "your_new_collection"; // add here when adding a migrationHistorical context: registry drift (2026-04-15)
During a security audit on 2026-04-15, 10 tenant-scoped collections were found to be absent from the TENANT_SCOPED_COLLECTIONS registry. They had been created across multiple sprints but were never registered. PocketBase API rules were enforcing isolation, but any server-side query using _superusers auth (which bypasses API rules) could read across tenant boundaries. The fix in commit 4fcd084 added all missing collections. The lesson: add to the registry at migration time, not as a follow-up.
Checklist: adding a new tenant-scoped collection
- PB migration: set all 5 API rules (list, view, create, update, delete) with tenant_id.owner_id check
- PB migration: add tenant_id field as required relation to tenants collection
- src/lib/pocketbase/provider.ts: add collection name to CollectionName union
- src/lib/pocketbase/providers/multi-instance.ts: add collection name to TENANT_SCOPED_COLLECTIONS set
- TypeScript interface: include tenant_id: string
- Zod schema: include tenant_id: z.string()
- Unit test: assert that MultiInstanceProvider injects tenant_id filter for this collection
- docs/schema.md: document the new collection
Single-instance mode
In VELOCMS_MODE=single mode, all collections live in one PocketBase instance. The SingleInstanceProvider does not inject tenant_id filters — there is only one tenant. PocketBase API rules still apply and still reference tenant_id.owner_id chains. The behavior is correct because there is only one owner. When switching a deployment from single to multi mode, no migration is required — the schema already has tenant_id on every collection.
Reference
| File | Role in isolation |
|---|---|
| src/lib/pocketbase/provider.ts | CollectionName union — compile-time collection registry |
| src/lib/pocketbase/providers/multi-instance.ts | TENANT_SCOPED_COLLECTIONS set — runtime filter injection |
| pb/pb_migrations/ | PB API rules — database-layer enforcement |
| docs/schema.md | Canonical collection documentation |
| .claude/rules/multi-tenant.md | Multi-tenant invariant rules for contributors |