Browse documentation

Multi-tenant scope

Bind every tenant-owned resource operation to the current tenant and verify fail-closed behavior.

Tenant scope has two parts: the root config resolves who the request belongs to, and each tenant-owned resource translates that scope into an adapter predicate.

Prerequisites

Authentication must provide a trusted tenant identifier. Decide which resources are tenant-owned and which are intentionally global. Do not infer tenancy from a client query parameter.

Resolve the tenant once

export default defineAdmin({
  adapter,
  auth,
  scope: async ({ session }) => {
    const tenantId = session?.user?.tenantId;
    if (!tenantId) throw new Error("No tenant selected");
    return { tenantId };
  },
  resources,
});

The resolved value is request-scoped and available to resource predicates, policies, actions, and widget queries.

Bind each tenant-owned resource

resource(schema.orders, {
  columns: ["number", "status", "total", "createdAt"],
  scope: ({ tenantId }, query) => query.where(eq(schema.orders.tenantId, tenantId)),
  create: {
    fields: [
      "number",
      "status",
      "total",
      {
        name: "tenantId",
        type: "hidden",
        hidden: true,
        defaultValue: async ({ scope }) => scope.tenantId,
      },
    ],
  },
})

The predicate's query argument is adapter-specific. With Drizzle it is a query builder — chain a where clause as above. With Prisma it is the where object — merge the tenant keys into it and return the result:

scope: ({ tenantId }, where) => ({ ...(where as object), tenantId }),

The predicate applies to lists, lookups, updates, deletes, restore, references, and related-resource reads—not only to the visible list.

Create needs special attention because no stored row exists to filter yet. Set the tenant through a trusted server default or write policy, and keep the tenant field outside the operator-controlled form.

Preserve fail-closed behavior

When global scope is active and a resource declares scope, supported adapters reject an operation if the predicate is not bound or adds no constraint. Do not catch that access error and retry an unscoped query.

Global resources should omit tenant scope deliberately and have their own explicit access policy. Make that exception visible in code review.

Verify isolation

Create fixtures for two tenants and test:

  1. each tenant lists only its own rows;
  2. a by-ID read of the other tenant's row fails;
  3. update, delete, restore, and row actions cannot target the other tenant;
  4. create cannot submit or override another tenant ID;
  5. references and drawer related tabs remain scoped;
  6. dashboard queries use ctx.scope in their own application queries.

FlowPanel can enforce the generated resource path, but a custom widget or page query remains your code. Apply the same tenant predicate there.

See Scope, realtime, and rate limits reference for generated types.