Tenant Isolation Pattern

10 min readUpdated 27 Apr 2026

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

LayerWhereWhat it doesCan be bypassed by
PocketBase API rulesPB collection schemaRejects list/view/create/update/delete unless tenant_id.owner_id = @request.auth.idServer-side _superusers auth
MultiInstanceProvider registrysrc/lib/pocketbase/providers/multi-instance.tsAuto-injects tenant_id = '{tenantId}' filter on every queryNot 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.

pb/pb_migrations/example_collection.js
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);
});
javascript

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.

src/lib/pocketbase/providers/multi-instance.ts (excerpt)
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.
typescript

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.

src/lib/pocketbase/provider.ts (excerpt)
export type CollectionName =
  | "posts"
  | "categories"
  | "media"
  | "site_settings"
  | "theme_config"
  // ... existing collections
  | "your_new_collection"; // add here when adding a migration
typescript

Historical 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

FileRole in isolation
src/lib/pocketbase/provider.tsCollectionName union — compile-time collection registry
src/lib/pocketbase/providers/multi-instance.tsTENANT_SCOPED_COLLECTIONS set — runtime filter injection
pb/pb_migrations/PB API rules — database-layer enforcement
docs/schema.mdCanonical collection documentation
.claude/rules/multi-tenant.mdMulti-tenant invariant rules for contributors