Loading skill
Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Design and implement multi-tenant SaaS architectures with row-level security, tenant-scoped queries, shared-schema isolation, and safe cross-tenant admin patterns in PostgreSQL and TypeScript.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 28% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 48% | 0% |
| case-20 | ✗→✓ | ▲ Improved | 92% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 24% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 76% | 0% |
tenant_id columns to an existing single-tenant applicationDo NOT use this skill when:
tenant_id column on every table is the correct default. Schema-per-tenant adds operational overhead (migrations run N times). Database-per-tenant is only justified when tenants have regulatory data residency requirements.tenant_id to every tenant-scoped table. The column must be NOT NULL, type UUID or TEXT, and included in every composite index. Never allow a tenant-scoped table to exist without this column — a missing tenant_id is a data leak waiting to happen.current_setting('app.current_tenant_id'). This acts as a database-level safety net — even if application code forgets a WHERE clause, RLS blocks cross-tenant reads.tenant_id from the authenticated session or JWT claims. Set it on the database connection using SET LOCAL app.current_tenant_id = '...' inside a transaction. Every subsequent query in that request inherits the tenant scope automatically.where: { tenantId } into every findMany, findFirst, update, and delete call. If using Drizzle, create a base query builder that includes the tenant filter. Never rely on developers remembering to add the filter manually.tenant_id as a column. Write a linting rule or CI check that rejects any migration creating a table without tenant_id unless the table is explicitly marked as global (e.g., plans, feature_flags).SET LOCAL role = 'admin_bypass' or a dedicated database role. These routes must be protected by a separate admin authentication flow — never reuse tenant user sessions for admin access.sql-- Enable RLS on the table ALTER TABLE projects ENABLE ROW LEVEL SECURITY; ALTER TABLE projects FORCE ROW LEVEL SECURITY; -- Policy: users can only see rows where tenant_id matches the session variable CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Policy for INSERT: new rows must match the current tenant CREATE POLICY tenant_insert ON projects FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
typescriptimport { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function tenantMiddleware(req, res, next) { const tenantId = req.auth?.tenantId; // extracted from JWT during auth if (!tenantId) return res.status(403).json({ error: "No tenant context" }); const client = await pool.connect(); try { await client.query("BEGIN"); // Use set_config — SET LOCAL does not accept bind placeholders ($1) await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [tenantId]); req.db = client; req.tenantId = tenantId; // Cleanup on response finish — guarantees release even if handler skips next() res.on("finish", async () => { try { await client.query("COMMIT"); } catch { await client.query("ROLLBACK"); } client.release(); }); next(); } catch (err) { await client.query("ROLLBACK").catch(() => {}); client.release(); next(err); } }
typescriptimport { PrismaClient } from "@prisma/client"; // Tables that do NOT have tenant_id (global tables) const GLOBAL_TABLES = new Set(["Plan", "FeatureFlag", "SystemConfig"]); function createTenantPrisma(tenantId: string): PrismaClient { const prisma = new PrismaClient(); prisma.$use(async (params, next) => { if (GLOBAL_TABLES.has(params.model ?? "")) return next(params); // Initialize args.where — Prisma passes undefined args for calls like findMany() params.args = params.args ?? {}; params.args.where = params.args.where ?? {}; // Inject tenant filter on reads (skip findUnique — it only accepts unique-field selectors) if (["findMany", "findFirst", "count", "aggregate"].includes(params.action)) { params.args.where = { ...params.args.where, tenantId }; } // Inject tenant_id on creates if (["create", "createMany"].includes(params.action)) { params.args.data = params.args.data ?? {}; if (params.action === "createMany") { params.args.data = params.args.data.map((d: any) => ({ ...d, tenantId })); } else { params.args.data = { ...params.args.data, tenantId }; } } // Scope updates and deletes if (["update", "updateMany", "delete", "deleteMany"].includes(params.action)) { params.args.where = { ...params.args.where, tenantId }; } return next(params); }); return prisma; }
tenant_id filter. Even if your ORM middleware handles it, raw SQL queries bypass middleware entirely. Every raw query must include WHERE tenant_id = $1 or rely on RLS. A single unscoped SELECT * FROM invoices leaks every customer's billing data.tenant_id only in the application session without enforcing it at the database level. Application-layer filtering is a suggestion. RLS is enforcement. If a bug in your middleware skips the tenant filter, only RLS prevents the data leak. Run both layers.invoice #1042) let attackers enumerate other tenants' resources by incrementing the ID. Use UUIDs for all tenant-scoped primary keys. Reserve integer IDs for internal-only tables.GET /admin/metrics that queries across all tenants must never be reachable with a regular tenant JWT. Use a separate authentication mechanism (API key, admin role claim with a different issuer) for cross-tenant routes.ALTER TABLE commands may silently fail or affect only the "current tenant's" view. Use a dedicated superuser or bypassrls role for migrations.SET LOCAL. If you use SET LOCAL app.current_tenant_id inside a transaction, that setting is scoped to the transaction. But if a previous request's transaction was not properly committed or rolled back, the connection returns to the pool with stale tenant context. Always RESET app.current_tenant_id in the cleanup path.DELETE FROM tenants WHERE id = $1. Foreign key cascades may time out on large datasets. Instead, soft-delete the tenant (set deleted_at), revoke all user sessions, then run a background job that deletes tenant data in batches over hours or days.tenant_id and package it. Build a registry of all tenant-scoped tables (parse your migration files or maintain a manifest) so the export job doesn't miss tables added after the export feature was built.owner_tenant_id instead of tenant_id.tenant_id from. The job payload must include tenant_id, and the worker must set the database session variable before processing. Never run background jobs without tenant context — they will either fail on RLS or bypass it entirely.max_connections fast. Use a connection pooler like PgBouncer in transaction mode, or switch to shared-schema before hitting this wall.tenants table as the single source of truth. Every tenant_id foreign key in every table points back to tenants.id. Include columns for name, slug (for subdomain routing), plan_id, created_at, and deleted_at. This table is the root of your entire data model.tenant_id as the first column in every composite index. PostgreSQL uses leftmost prefix matching for composite indexes. An index on (tenant_id, created_at) serves both "all items for tenant X" and "items for tenant X sorted by date." An index on (created_at, tenant_id) only helps date-range queries across all tenants.acme.yourapp.com or yourapp.com/org/acme — both work. Map the subdomain or path to a tenant_id lookup at the edge (middleware or reverse proxy). This lookup should be cached (Redis or in-memory with 60s TTL) since it runs on every single request.tenant_id) and which are tenant-scoped. Use this list in your ORM middleware, your migration linter, and your data export job. If a table isn't in either list, the CI check should fail.ratelimit:{tenant_id}:{endpoint} with a sliding window counter.Other measured skills in the registry, with their headline benchmark lift.