diff --git a/prisma/migrations/20260304104214_initial_schema/migration.sql b/prisma/migrations/20260304104214_initial_schema/migration.sql new file mode 100644 index 0000000..66b4fe5 --- /dev/null +++ b/prisma/migrations/20260304104214_initial_schema/migration.sql @@ -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; diff --git a/prisma/migrations/20260304104245_add_rls_policies/migration.sql b/prisma/migrations/20260304104245_add_rls_policies/migration.sql new file mode 100644 index 0000000..3be8ae8 --- /dev/null +++ b/prisma/migrations/20260304104245_add_rls_policies/migration.sql @@ -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 + ); \ No newline at end of file diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/src/lib/__tests__/tenant-isolation.test.ts b/src/lib/__tests__/tenant-isolation.test.ts new file mode 100644 index 0000000..738888b --- /dev/null +++ b/src/lib/__tests__/tenant-isolation.test.ts @@ -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); + }); +}); diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts new file mode 100644 index 0000000..3fdd88f --- /dev/null +++ b/src/lib/prisma-tenant.ts @@ -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 }, + tenantId: string +): Promise { + // SET LOCAL scopes the variable to the current transaction + await tx.$executeRawUnsafe( + `SET LOCAL "app.current_tenant_id" = $1`, + tenantId + ); +}