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:
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
241
src/lib/__tests__/tenant-isolation.test.ts
Normal file
241
src/lib/__tests__/tenant-isolation.test.ts
Normal 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
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