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,60 @@
-- =============================================================================
-- Initial Schema Migration
-- Created as a baseline to capture the state created by `prisma db push`
-- during project initialization (01-01-PLAN.md).
--
-- This migration is marked as applied without running it (baseline approach)
-- since the schema already exists in the database from the initial db push.
-- =============================================================================
-- CreateEnum
CREATE TYPE "TenantStatus" AS ENUM ('ACTIVE', 'PENDING_SUSPENSION', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'OFFICE_STAFF', 'COLLECTOR', 'TECHNICIAN', 'CLIENT');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"ownerEmail" TEXT NOT NULL,
"businessAddress" TEXT,
"contactPhone" TEXT,
"status" "TenantStatus" NOT NULL DEFAULT 'ACTIVE',
"suspendedAt" TIMESTAMP(3),
"gracePeriodEndsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"tenantId" TEXT,
"roles" "Role"[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isSuperAdmin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_tenantId_key" ON "User"("email", "tenantId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,41 @@
-- =============================================================================
-- PostgreSQL Row-Level Security (RLS) Policies
-- Defense-in-depth tenant isolation for NetForge
-- =============================================================================
--
-- ARCHITECTURE NOTE:
-- The primary multi-tenancy enforcement is at the application layer via
-- Prisma query extensions in src/lib/prisma-tenant.ts (withTenantContext).
--
-- RLS policies here serve as DEFENSE-IN-DEPTH:
-- - They catch bugs where application code bypasses the Prisma middleware
-- - They provide an independent enforcement layer at the database level
-- - They document data isolation intent in the database schema itself
--
-- IMPORTANT: The Prisma client connects as the database OWNER, which by
-- default bypasses RLS (PostgreSQL superusers and table owners bypass RLS
-- unless FORCE ROW LEVEL SECURITY is set on the table). This means these
-- policies will NOT block queries from the standard Prisma client unless
-- FORCE ROW LEVEL SECURITY is enabled, or a non-owner role is used.
--
-- To enforce RLS for a query, use setTenantRLS() from src/lib/prisma-tenant.ts
-- within a transaction before executing queries. This sets the session variable
-- app.current_tenant_id which the policy USING clause reads.
-- =============================================================================
-- Enable RLS on the User table
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
-- Policy: tenant_isolation_user
-- Restricts access to rows matching the current tenant context.
-- The app.current_tenant_id session variable is set by setTenantRLS().
-- When no tenant context is set (super-admin operations), all rows are visible.
CREATE POLICY tenant_isolation_user ON "User"
USING (
"tenantId" = current_setting('app.current_tenant_id', true)::text
OR current_setting('app.current_tenant_id', true) IS NULL
OR current_setting('app.current_tenant_id', true) = ''
)
WITH CHECK (
"tenantId" = current_setting('app.current_tenant_id', true)::text
);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

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