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:
kevin-asprec
2026-03-04 18:45:13 +08:00
parent cf790c3257
commit 69eac9ffaa
5 changed files with 552 additions and 0 deletions

207
src/lib/prisma-tenant.ts Normal file
View 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
);
}