Multi-tenant scoping
Restrict every query to one tenant with a global scope plus a per-resource predicate — leak-proof and fail-closed.
flowpanel scopes a multi-tenant admin in two layers:
- A global
scopefunction resolves the current tenant from the request (e.g.companyIdfrom the session). - Each resource declares a
scopepredicate that AND-s the tenant condition into every read and by-id mutation.
The split is deliberate: the global function names the tenant once; each
resource maps that tenant onto its own column. The same captured condition
guards list, get, update, and delete, so a scoped resource can never
leak a row from another tenant.
1. Resolve the tenant globally
The global scope receives { req, session } and returns a plain object
(or null to disable scoping for this request):
import { defineAdmin } from "@flowpanel/kit";
export default defineAdmin({
// …adapter, auth…
scope: ({ session }) => {
const companyId = (session as { companyId?: string } | null)?.companyId;
return companyId ? { companyId } : null;
},
resources: [
/* … */
],
});2. Map the tenant onto each resource
The returned object is handed to every resource's scope predicate. The
predicate AND-s the tenant condition into the adapter's native query.
For Drizzle, call .where(...) on the query builder:
import { resource } from "@flowpanel/kit";
import { eq } from "drizzle-orm";
import * as schema from "@/db/schema";
resource(schema.invoices, {
columns: ["number", "total", "status"],
scope: (s, query) =>
query.where(eq(schema.invoices.companyId, (s as { companyId: string }).companyId)),
});For Prisma, merge the tenant key into the where object:
resource("Invoice", {
columns: ["number", "total", "status"],
scope: (s, where) => ({
...(where as object),
companyId: (s as { companyId: string }).companyId,
}),
});Fail-closed enforcement
When a global scope is active, every resource must either declare a
scope predicate or opt out explicitly:
resource(schema.auditLog, {
columns: ["action", "at"],
// This table is global on purpose — opt out of tenant scoping.
scope: "bypass",
});If a resource is missing both, the runtime refuses to run an unscoped query and throws rather than risk a cross-tenant leak. This is intentional: it's safer to surface a configuration error than to silently return another tenant's rows.
See Resources for the full resource option reference.