feat(01-03): Prisma tenant middleware, PostgreSQL RLS, and isolation tests
- Create src/lib/prisma-tenant.ts:
- withTenantContext(tenantId) / createTenantPrisma — Prisma $extends client
- Intercepts findMany, findFirst, findUnique, create, createMany, update,
updateMany, delete, deleteMany, upsert, count, aggregate, groupBy on User
- Auto-injects tenantId filter on all reads, writes, and deletes
- setTenantRLS() helper for explicit RLS enforcement in transactions
- TENANT_SCOPED_MODELS constant for future extensibility
- Create prisma/migrations/20260304104214_initial_schema — baseline migration
capturing schema created by initial db push
- Create prisma/migrations/20260304104245_add_rls_policies:
- ALTER TABLE User ENABLE ROW LEVEL SECURITY
- CREATE POLICY tenant_isolation_user USING app.current_tenant_id session var
- Defense-in-depth architecture comments explaining primary vs secondary enforcement
- Create src/lib/__tests__/tenant-isolation.test.ts (6 tests, all passing):
- Test 1: Tenant A context returns only Tenant A's users (zero from B)
- Test 2: Tenant B context returns only Tenant B's users (zero from A)
- Test 3: create() auto-sets tenantId, invisible to other tenant
- Test 4: findUnique by Tenant B's ID under Tenant A context returns null
- Additional: findFirst cross-tenant blocked, count() is tenant-scoped
This commit is contained in:
207
src/lib/prisma-tenant.ts
Normal file
207
src/lib/prisma-tenant.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =============================================================================
|
||||
// Tenant-Scoped Prisma Client
|
||||
// =============================================================================
|
||||
//
|
||||
// ARCHITECTURE:
|
||||
// This module provides a Prisma client extended with query-level extensions
|
||||
// that automatically inject the current tenant's ID into all reads, writes,
|
||||
// and deletes on tenant-scoped models.
|
||||
//
|
||||
// This is the PRIMARY enforcement layer for multi-tenancy.
|
||||
// PostgreSQL RLS (see migration: add-rls-policies) is DEFENSE-IN-DEPTH —
|
||||
// it catches application bugs if this middleware is somehow bypassed.
|
||||
//
|
||||
// USAGE:
|
||||
// const tenantPrisma = withTenantContext(tenantId);
|
||||
// const users = await tenantPrisma.user.findMany(); // auto-filters by tenantId
|
||||
//
|
||||
// EXTENDING:
|
||||
// When adding new tenant-scoped models (Subscriber, Invoice, Plan, etc.),
|
||||
// add the model name to TENANT_SCOPED_MODELS below and extend the $extends
|
||||
// block in withTenantContext() following the same pattern.
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* List of Prisma model names that are scoped to a tenant.
|
||||
* Models NOT in this list (like Tenant itself) bypass tenant filtering.
|
||||
*
|
||||
* Extend this list as new models are added in later phases:
|
||||
* e.g., "subscriber", "invoice", "servicePlan", "payment"
|
||||
*/
|
||||
export const TENANT_SCOPED_MODELS = ["user"] as const;
|
||||
|
||||
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||
|
||||
/**
|
||||
* Returns a Prisma client extended with tenant-scoped query filtering.
|
||||
*
|
||||
* All operations on tenant-scoped models automatically include the tenantId:
|
||||
* - findMany / findFirst: where clause gets tenantId injected
|
||||
* - findUnique: converted to findFirst with tenantId guard
|
||||
* - create: data gets tenantId set
|
||||
* - update / updateMany: where clause gets tenantId added
|
||||
* - delete / deleteMany: where clause gets tenantId added
|
||||
* - upsert: where clause gets tenantId added, create data gets tenantId set
|
||||
*
|
||||
* @param tenantId - The tenant UUID to scope all queries to
|
||||
*/
|
||||
export function withTenantContext(tenantId: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
user: {
|
||||
async findMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findFirst({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findFirstOrThrow({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
// findUnique requires an exact unique key — we can't simply inject
|
||||
// tenantId there. Route through findFirst to enforce tenant scoping.
|
||||
async findUnique({ args, query }) {
|
||||
// Check if tenantId is already part of a compound unique key
|
||||
// If so, let Prisma handle it. Otherwise, use findFirst with tenant guard.
|
||||
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
|
||||
// Use findFirst to enforce tenant boundary
|
||||
return prisma.user.findFirst({
|
||||
...args,
|
||||
where: { ...args.where, tenantId },
|
||||
});
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findUniqueOrThrow({ args, query }) {
|
||||
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
|
||||
const result = await prisma.user.findFirst({
|
||||
...args,
|
||||
where: { ...args.where, tenantId },
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Record not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async create({ args, query }) {
|
||||
// Remove the nested `tenant` relation object when injecting tenantId
|
||||
// (they are mutually exclusive in Prisma's UncheckedCreateInput)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tenant: _tenant, ...rest } = args.data as typeof args.data & { tenant?: unknown };
|
||||
args.data = { ...rest, tenantId } as typeof args.data;
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async createMany({ args, query }) {
|
||||
if (Array.isArray(args.data)) {
|
||||
args.data = args.data.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tenant: _t, ...rest } = item as typeof item & { tenant?: unknown };
|
||||
return { ...rest, tenantId } as typeof item;
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tenant: _t, ...rest } = args.data as typeof args.data & { tenant?: unknown };
|
||||
args.data = { ...rest, tenantId } as typeof args.data;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async update({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async updateMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async delete({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async deleteMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async upsert({ args, query }) {
|
||||
args.where = { ...args.where, tenantId } as typeof args.where;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tenant: _t, ...createRest } = args.create as typeof args.create & { tenant?: unknown };
|
||||
args.create = { ...createRest, tenantId } as typeof args.create;
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async count({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async aggregate({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async groupBy({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for withTenantContext — exported under both names as per plan spec.
|
||||
* Use either name; they are identical.
|
||||
*/
|
||||
export const createTenantPrisma = withTenantContext;
|
||||
|
||||
/**
|
||||
* Sets the PostgreSQL session variable used by RLS policies for the duration
|
||||
* of the provided transaction or connection.
|
||||
*
|
||||
* RLS ARCHITECTURE NOTE:
|
||||
* The Prisma client connects as the database OWNER, which bypasses RLS
|
||||
* by default (PostgreSQL superusers and table owners bypass RLS unless
|
||||
* FORCE ROW LEVEL SECURITY is set). This means RLS is NOT the primary
|
||||
* enforcement — withTenantContext() (application-level Prisma extensions)
|
||||
* is the primary enforcement layer.
|
||||
*
|
||||
* This function enables RLS enforcement for code paths that explicitly opt in,
|
||||
* or in future scenarios where a non-owner role is used for queries.
|
||||
*
|
||||
* Usage (inside a Prisma transaction):
|
||||
* await prisma.$transaction(async (tx) => {
|
||||
* await setTenantRLS(tx, tenantId);
|
||||
* // ... queries here will be RLS-enforced
|
||||
* });
|
||||
*
|
||||
* @param tx - A Prisma client or transaction client
|
||||
* @param tenantId - The tenant UUID to set as the current RLS context
|
||||
*/
|
||||
export async function setTenantRLS(
|
||||
tx: { $executeRawUnsafe: (query: string, ...args: unknown[]) => Promise<unknown> },
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
// SET LOCAL scopes the variable to the current transaction
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL "app.current_tenant_id" = $1`,
|
||||
tenantId
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user