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

View File

@@ -0,0 +1,241 @@
/**
* Tenant Isolation Integration Tests
*
* These tests require a live PostgreSQL database connection.
* They verify that withTenantContext() prevents cross-tenant data leakage.
*
* WHAT IS TESTED:
* - Queries scoped to Tenant A return ZERO rows from Tenant B
* - Queries scoped to Tenant B return ZERO rows from Tenant A
* - createUser under Tenant A context auto-sets tenantId to Tenant A
* - Reading a specific Tenant B user by ID under Tenant A context returns null
*
* ISOLATION STRATEGY:
* Each test run creates fresh tenants/users with unique IDs.
* afterAll() cleans up all test data by deleting the test tenants
* (cascade deletes Users through the FK constraint).
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { TenantStatus } from "@prisma/client";
// ---------------------------------------------------------------------------
// Test data: created in beforeAll, cleaned up in afterAll
// ---------------------------------------------------------------------------
let tenantAId: string;
let tenantBId: string;
let userAId: string;
let userBId: string;
const TENANT_A_SLUG = `test-tenant-a-${Date.now()}`;
const TENANT_B_SLUG = `test-tenant-b-${Date.now()}`;
const USER_A_EMAIL = `user-a-${Date.now()}@test.example`;
const USER_B_EMAIL = `user-b-${Date.now()}@test.example`;
describe("Tenant Isolation (integration)", () => {
beforeAll(async () => {
// Create Tenant A with one admin user
const tenantA = await prisma.tenant.create({
data: {
name: "Test Tenant A",
slug: TENANT_A_SLUG,
ownerEmail: USER_A_EMAIL,
status: TenantStatus.ACTIVE,
},
});
tenantAId = tenantA.id;
const userA = await prisma.user.create({
data: {
email: USER_A_EMAIL,
passwordHash: "hashed-password-a",
firstName: "Alice",
lastName: "Tenant",
tenantId: tenantAId,
roles: ["ADMIN"],
isActive: true,
},
});
userAId = userA.id;
// Create Tenant B with one admin user
const tenantB = await prisma.tenant.create({
data: {
name: "Test Tenant B",
slug: TENANT_B_SLUG,
ownerEmail: USER_B_EMAIL,
status: TenantStatus.ACTIVE,
},
});
tenantBId = tenantB.id;
const userB = await prisma.user.create({
data: {
email: USER_B_EMAIL,
passwordHash: "hashed-password-b",
firstName: "Bob",
lastName: "Tenant",
tenantId: tenantBId,
roles: ["ADMIN"],
isActive: true,
},
});
userBId = userB.id;
});
afterAll(async () => {
// Delete tenants (cascade deletes their users via FK)
if (tenantAId) {
await prisma.tenant.delete({ where: { id: tenantAId } }).catch(() => {});
}
if (tenantBId) {
await prisma.tenant.delete({ where: { id: tenantBId } }).catch(() => {});
}
await prisma.$disconnect();
});
// -------------------------------------------------------------------------
// Test 1: Tenant A context returns only Tenant A's users
// -------------------------------------------------------------------------
it("Test 1: Tenant A context returns only Tenant A's user (zero from Tenant B)", async () => {
const tenantAPrisma = withTenantContext(tenantAId);
const users = await tenantAPrisma.user.findMany();
// Should find at least the one user we created for Tenant A
const tenantAUsers = users.filter((u) => u.tenantId === tenantAId);
const tenantBUsers = users.filter((u) => u.tenantId === tenantBId);
expect(tenantAUsers.length).toBeGreaterThanOrEqual(1);
expect(tenantBUsers.length).toBe(0);
// Specifically verify User A is returned
const foundUserA = users.find((u) => u.id === userAId);
expect(foundUserA).toBeDefined();
expect(foundUserA?.email).toBe(USER_A_EMAIL);
// Verify User B is NOT in the results
const foundUserB = users.find((u) => u.id === userBId);
expect(foundUserB).toBeUndefined();
});
// -------------------------------------------------------------------------
// Test 2: Tenant B context returns only Tenant B's users
// -------------------------------------------------------------------------
it("Test 2: Tenant B context returns only Tenant B's user (zero from Tenant A)", async () => {
const tenantBPrisma = withTenantContext(tenantBId);
const users = await tenantBPrisma.user.findMany();
const tenantAUsers = users.filter((u) => u.tenantId === tenantAId);
const tenantBUsers = users.filter((u) => u.tenantId === tenantBId);
expect(tenantBUsers.length).toBeGreaterThanOrEqual(1);
expect(tenantAUsers.length).toBe(0);
// Specifically verify User B is returned
const foundUserB = users.find((u) => u.id === userBId);
expect(foundUserB).toBeDefined();
expect(foundUserB?.email).toBe(USER_B_EMAIL);
// Verify User A is NOT in the results
const foundUserA = users.find((u) => u.id === userAId);
expect(foundUserA).toBeUndefined();
});
// -------------------------------------------------------------------------
// Test 3: Creating a user under Tenant A context auto-sets tenantId
// -------------------------------------------------------------------------
it("Test 3: Creating a user with Tenant A context auto-sets tenantId to Tenant A", async () => {
const tenantAPrisma = withTenantContext(tenantAId);
const AUTO_EMAIL = `auto-${Date.now()}@test.example`;
const newUser = await tenantAPrisma.user.create({
data: {
email: AUTO_EMAIL,
passwordHash: "hashed-auto",
firstName: "Auto",
lastName: "Created",
roles: ["OFFICE_STAFF"],
isActive: true,
// NOTE: tenantId is intentionally NOT provided here
// The middleware should inject it automatically
},
});
// Verify the tenantId was automatically set to Tenant A
expect(newUser.tenantId).toBe(tenantAId);
expect(newUser.email).toBe(AUTO_EMAIL);
// Verify this user is visible through Tenant A's context
const foundViaA = await tenantAPrisma.user.findFirst({
where: { email: AUTO_EMAIL },
});
expect(foundViaA).not.toBeNull();
expect(foundViaA?.tenantId).toBe(tenantAId);
// Verify this user is NOT visible through Tenant B's context
const tenantBPrisma = withTenantContext(tenantBId);
const foundViaB = await tenantBPrisma.user.findFirst({
where: { email: AUTO_EMAIL },
});
expect(foundViaB).toBeNull();
// Cleanup the auto-created user
await prisma.user.delete({ where: { id: newUser.id } }).catch(() => {});
});
// -------------------------------------------------------------------------
// Test 4: Reading Tenant B's user by ID with Tenant A context returns null
// -------------------------------------------------------------------------
it("Test 4: Reading Tenant B's user by ID with Tenant A context returns null (not found)", async () => {
const tenantAPrisma = withTenantContext(tenantAId);
// Attempt to read Tenant B's user (userBId) through Tenant A's context
const result = await tenantAPrisma.user.findUnique({
where: { id: userBId },
});
// Must be null — cross-tenant access is blocked
expect(result).toBeNull();
});
// -------------------------------------------------------------------------
// Additional: findFirst cross-tenant access is blocked
// -------------------------------------------------------------------------
it("findFirst with Tenant A context cannot find Tenant B users by email", async () => {
const tenantAPrisma = withTenantContext(tenantAId);
const result = await tenantAPrisma.user.findFirst({
where: { email: USER_B_EMAIL },
});
expect(result).toBeNull();
});
// -------------------------------------------------------------------------
// Additional: count is tenant-scoped
// -------------------------------------------------------------------------
it("count() under Tenant A context only counts Tenant A's users", async () => {
const tenantAPrisma = withTenantContext(tenantAId);
const tenantBPrisma = withTenantContext(tenantBId);
const countA = await tenantAPrisma.user.count();
const countB = await tenantBPrisma.user.count();
// Each tenant should have exactly 1 user (the one we created in beforeAll)
expect(countA).toBe(1);
expect(countB).toBe(1);
// Total without tenant context should be 2 (both tenants' users)
const totalCount = await prisma.user.count({
where: {
id: { in: [userAId, userBId] },
},
});
expect(totalCount).toBe(2);
});
});

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
);
}