From 822271cf0767e31a4b360a578de2e1335579e6ce Mon Sep 17 00:00:00 2001 From: cancatma-hola Date: Mon, 13 Jul 2026 22:11:31 +0300 Subject: [PATCH] feat: add orm/multitenant row-level scope example Demonstrates two patterns for tenant isolation in Prisma: 1. Manual scoping: tenantWhere() + assertTenantAccess() helpers - tenantWhere(tenantId) returns a where clause fragment - assertTenantAccess() guards cross-tenant reads after non-tenant lookups 2. Automatic scoping: createTenantClient() PrismaClient extension - Injects tenantId into every query on tenant-scoped models - Zero per-query boilerplate in request handlers Schema: Tenant -> User (@@unique[email,tenantId]) + Order (@@unique[reference,tenantId]) Includes seed.ts (2 tenants, realistic data) and a runnable index.ts demo. --- orm/multitenant/.env.example | 1 + orm/multitenant/README.md | 76 ++++++++++++++++++++++ orm/multitenant/package.json | 23 +++++++ orm/multitenant/prisma.config.ts | 12 ++++ orm/multitenant/prisma/schema.prisma | 51 +++++++++++++++ orm/multitenant/src/index.ts | 72 +++++++++++++++++++++ orm/multitenant/src/seed.ts | 76 ++++++++++++++++++++++ orm/multitenant/src/tenant-scope.ts | 96 ++++++++++++++++++++++++++++ orm/multitenant/tsconfig.json | 11 ++++ 9 files changed, 418 insertions(+) create mode 100644 orm/multitenant/.env.example create mode 100644 orm/multitenant/README.md create mode 100644 orm/multitenant/package.json create mode 100644 orm/multitenant/prisma.config.ts create mode 100644 orm/multitenant/prisma/schema.prisma create mode 100644 orm/multitenant/src/index.ts create mode 100644 orm/multitenant/src/seed.ts create mode 100644 orm/multitenant/src/tenant-scope.ts create mode 100644 orm/multitenant/tsconfig.json diff --git a/orm/multitenant/.env.example b/orm/multitenant/.env.example new file mode 100644 index 000000000000..9b19b95d5469 --- /dev/null +++ b/orm/multitenant/.env.example @@ -0,0 +1 @@ +DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/multitenant_example?schema=public" diff --git a/orm/multitenant/README.md b/orm/multitenant/README.md new file mode 100644 index 000000000000..1fd76f950f19 --- /dev/null +++ b/orm/multitenant/README.md @@ -0,0 +1,76 @@ +# Multi-tenant Row-Level Scope with Prisma + +This example demonstrates how to enforce tenant isolation at the data access layer using Prisma. Two patterns are covered: + +1. **Manual scoping** — `tenantWhere()` + `assertTenantAccess()` helpers added to individual queries +2. **Automatic scoping** — a `PrismaClient` extension (`createTenantClient`) that injects `tenantId` into every query automatically + +## Schema + +``` +Tenant ──┬── User (@@unique([email, tenantId])) + └── Order (@@unique([reference, tenantId])) +``` + +Each row on `User` and `Order` carries a `tenantId` column. All queries must filter by this column — either manually or via the extension. + +## Setup + +**Prerequisites:** PostgreSQL running locally (or use `DATABASE_URL` with a hosted instance). + +```bash +cp .env.example .env +# Edit .env and set DATABASE_URL + +npm install +npm run migrate # runs: prisma migrate dev --name init +npm run seed # creates two tenants with sample data +npm run dev # runs the demo queries +``` + +## Patterns + +### Pattern 1: Manual scoping + +```ts +import { tenantWhere, assertTenantAccess } from './src/tenant-scope.js' + +// Scoped query — only this tenant's pending orders +const orders = await prisma.order.findMany({ + where: { + ...tenantWhere(tenantId), + status: 'PENDING', + }, +}) + +// Guard after fetching by a non-tenant key +const order = await prisma.order.findFirst({ where: { reference } }) +assertTenantAccess(order, tenantId, 'order') // throws if wrong tenant +``` + +Use this pattern when you need fine-grained control over individual queries, or when only some queries need scoping. + +### Pattern 2: Automatic scoping via client extension + +```ts +import { createTenantClient } from './src/tenant-scope.js' + +const db = createTenantClient(prisma, tenantId) + +// tenantId is injected automatically — no manual spreading needed +const orders = await db.order.findMany({ where: { status: 'PENDING' } }) +const users = await db.user.findMany() +``` + +Use this pattern in a per-request context (Next.js Server Actions, Express middleware, etc.) where you resolve `tenantId` once from the session and want all downstream queries to be automatically scoped. + +## What this prevents + +- Cross-tenant data reads (a user from Tenant A fetching Tenant B's orders) +- Missing `WHERE tenantId = ?` clauses that return all-tenants data +- Silent cross-tenant leaks via non-tenant lookup keys (e.g. `findUnique({ where: { id } })`) + +## See also + +- [Prisma Client Extensions](https://www.prisma.io/docs/orm/prisma-client/client-extensions) +- [Multi-tenant applications guide](https://www.prisma.io/docs/guides/other/multi-tenancy) diff --git a/orm/multitenant/package.json b/orm/multitenant/package.json new file mode 100644 index 000000000000..b6cb65981280 --- /dev/null +++ b/orm/multitenant/package.json @@ -0,0 +1,23 @@ +{ + "name": "multitenant", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "seed": "tsx src/seed.ts", + "migrate": "prisma migrate dev --name init" + }, + "dependencies": { + "@prisma/adapter-pg": "7.0.0", + "@prisma/client": "7.0.0", + "dotenv": "16.6.1", + "pg": "8.20.0" + }, + "devDependencies": { + "@types/node": "22.15.32", + "@types/pg": "8.20.0", + "prisma": "7.0.0", + "tsx": "4.21.0", + "typescript": "5.8.2" + } +} diff --git a/orm/multitenant/prisma.config.ts b/orm/multitenant/prisma.config.ts new file mode 100644 index 000000000000..f3cadbea85f5 --- /dev/null +++ b/orm/multitenant/prisma.config.ts @@ -0,0 +1,12 @@ +import { defineConfig, env } from 'prisma/config' +import 'dotenv/config' + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}) diff --git a/orm/multitenant/prisma/schema.prisma b/orm/multitenant/prisma/schema.prisma new file mode 100644 index 000000000000..0638abec331c --- /dev/null +++ b/orm/multitenant/prisma/schema.prisma @@ -0,0 +1,51 @@ +generator client { + provider = "prisma-client" + output = "./generated" +} + +datasource db { + provider = "postgresql" +} + +model Tenant { + id String @id @default(cuid()) + name String @unique + slug String @unique + users User[] + orders Order[] +} + +model User { + id String @id @default(cuid()) + email String + name String? + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + orders Order[] + + // Same email can exist across different tenants + @@unique([email, tenantId]) + @@index([tenantId]) +} + +model Order { + id String @id @default(cuid()) + reference String + amount Float + status OrderStatus @default(PENDING) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + + @@unique([reference, tenantId]) + @@index([tenantId]) +} + +enum OrderStatus { + PENDING + PROCESSING + COMPLETED + CANCELLED +} diff --git a/orm/multitenant/src/index.ts b/orm/multitenant/src/index.ts new file mode 100644 index 000000000000..468b06673fb4 --- /dev/null +++ b/orm/multitenant/src/index.ts @@ -0,0 +1,72 @@ +/** + * Multi-tenant example: row-level scoping with Prisma + * + * Demonstrates two patterns for tenant isolation: + * 1. Manual scoping with tenantWhere() + assertTenantAccess() + * 2. Automatic scoping with a PrismaClient extension (createTenantClient) + * + * Run: + * npx prisma migrate dev --name init + * npx tsx src/seed.ts + * npx tsx src/index.ts + */ + +import { PrismaClient } from '../prisma/generated/index.js' +import { tenantWhere, assertTenantAccess, createTenantClient } from './tenant-scope.js' + +const prisma = new PrismaClient() + +async function main() { + // Resolve tenant from slug (in a real app this comes from the auth session) + const acme = await prisma.tenant.findUniqueOrThrow({ where: { slug: 'acme' } }) + const globex = await prisma.tenant.findUniqueOrThrow({ where: { slug: 'globex' } }) + + console.log('\n--- Pattern 1: Manual scoping with tenantWhere() ---\n') + + // Scoped query — only Acme orders returned, even though the table has Globex orders too + const acmeOrders = await prisma.order.findMany({ + where: { + ...tenantWhere(acme.id), + status: 'PENDING', + }, + select: { reference: true, amount: true, status: true }, + }) + console.log('Acme pending orders:', acmeOrders) + // → [ { reference: 'ACM-002', amount: 540, status: 'PENDING' } ] + + // Cross-tenant access check after fetching by a non-tenant key + const order = await prisma.order.findFirst({ where: { reference: 'GLX-001' } }) + try { + assertTenantAccess(order, acme.id, 'order') + } catch (err) { + console.log('Correctly blocked cross-tenant access:', (err as Error).message) + // → "Access denied: order belongs to a different tenant" + } + + console.log('\n--- Pattern 2: Automatic scoping with createTenantClient() ---\n') + + // All queries on this client are automatically filtered to Globex + const db = createTenantClient(prisma, globex.id) + + const globexOrders = await db.order.findMany({ + select: { reference: true, amount: true }, + }) + console.log('Globex orders (tenant auto-applied):', globexOrders) + // → [ { reference: 'GLX-001', amount: 3200 } ] + + const globexUsers = await db.user.findMany({ + select: { email: true, name: true }, + }) + console.log('Globex users (tenant auto-applied):', globexUsers) + // → [ { email: 'bob@globex.com', name: 'Bob' } ] + + // Count across all tenants vs. scoped + const totalOrders = await prisma.order.count() + const globexOrderCount = await db.order.count() + console.log(`\nTotal orders in DB: ${totalOrders}, Globex-scoped: ${globexOrderCount}`) + // → Total: 3, Globex-scoped: 1 +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) diff --git a/orm/multitenant/src/seed.ts b/orm/multitenant/src/seed.ts new file mode 100644 index 000000000000..8d76fd4b9e63 --- /dev/null +++ b/orm/multitenant/src/seed.ts @@ -0,0 +1,76 @@ +import { PrismaClient } from '../prisma/generated/index.js' + +const prisma = new PrismaClient() + +async function main() { + // Create two isolated tenants + const acme = await prisma.tenant.upsert({ + where: { slug: 'acme' }, + update: {}, + create: { name: 'Acme Corp', slug: 'acme' }, + }) + + const globex = await prisma.tenant.upsert({ + where: { slug: 'globex' }, + update: {}, + create: { name: 'Globex Inc', slug: 'globex' }, + }) + + // Create users for each tenant + const aliceAcme = await prisma.user.upsert({ + where: { email_tenantId: { email: 'alice@acme.com', tenantId: acme.id } }, + update: {}, + create: { email: 'alice@acme.com', name: 'Alice', tenantId: acme.id }, + }) + + const bobGlobex = await prisma.user.upsert({ + where: { email_tenantId: { email: 'bob@globex.com', tenantId: globex.id } }, + update: {}, + create: { email: 'bob@globex.com', name: 'Bob', tenantId: globex.id }, + }) + + // Create orders for each tenant + await prisma.order.upsert({ + where: { reference_tenantId: { reference: 'ACM-001', tenantId: acme.id } }, + update: {}, + create: { + reference: 'ACM-001', + amount: 1250.0, + status: 'COMPLETED', + tenantId: acme.id, + userId: aliceAcme.id, + }, + }) + + await prisma.order.upsert({ + where: { reference_tenantId: { reference: 'ACM-002', tenantId: acme.id } }, + update: {}, + create: { + reference: 'ACM-002', + amount: 540.0, + status: 'PENDING', + tenantId: acme.id, + userId: aliceAcme.id, + }, + }) + + await prisma.order.upsert({ + where: { reference_tenantId: { reference: 'GLX-001', tenantId: globex.id } }, + update: {}, + create: { + reference: 'GLX-001', + amount: 3200.0, + status: 'PROCESSING', + tenantId: globex.id, + userId: bobGlobex.id, + }, + }) + + console.log('Seeded:') + console.log(` Tenant "Acme Corp" (id: ${acme.id}) — 1 user, 2 orders`) + console.log(` Tenant "Globex Inc" (id: ${globex.id}) — 1 user, 1 order`) +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) diff --git a/orm/multitenant/src/tenant-scope.ts b/orm/multitenant/src/tenant-scope.ts new file mode 100644 index 000000000000..6101fd7aafc7 --- /dev/null +++ b/orm/multitenant/src/tenant-scope.ts @@ -0,0 +1,96 @@ +import { PrismaClient } from '../prisma/generated/index.js' + +/** + * Returns a Prisma `where` clause that always scopes queries to a single tenant. + * Merge with any additional conditions using object spread. + * + * @example + * // Fetch only this tenant's pending orders + * const orders = await prisma.order.findMany({ + * where: { + * ...tenantWhere(tenantId), + * status: 'PENDING', + * }, + * }) + */ +export function tenantWhere(tenantId: string): { tenantId: string } { + return { tenantId } +} + +/** + * Throws if `resource.tenantId` does not match the requesting `tenantId`. + * Call this after any `findUnique` / `findFirst` that fetches by a non-tenant + * key (e.g. by `id` or `reference`) to prevent cross-tenant information leaks. + * + * @example + * const order = await prisma.order.findUnique({ where: { id: orderId } }) + * assertTenantAccess(order, currentTenantId) // throws if wrong tenant + */ +export function assertTenantAccess( + resource: { tenantId: string } | null, + tenantId: string, + resourceName = 'resource', +): asserts resource is { tenantId: string } { + if (!resource) { + throw new Error(`${resourceName} not found`) + } + if (resource.tenantId !== tenantId) { + throw new Error(`Access denied: ${resourceName} belongs to a different tenant`) + } +} + +/** + * A thin PrismaClient extension that automatically adds `tenantId` to every + * query on tenant-scoped models. This is a convenience wrapper — you can also + * use `tenantWhere()` / `assertTenantAccess()` directly. + * + * Usage: + * const db = createTenantClient(prisma, tenantId) + * const orders = await db.order.findMany() // always scoped to tenantId + */ +export function createTenantClient(prisma: PrismaClient, tenantId: string) { + return prisma.$extends({ + query: { + order: { + 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 count({ args, query }) { + args.where = { ...args.where, tenantId } + return query(args) + }, + async create({ args, query }) { + args.data = { ...args.data, tenantId } + return query(args) + }, + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId } + return query(args) + }, + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId } + return query(args) + }, + }, + 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 create({ args, query }) { + args.data = { ...args.data, tenantId } + return query(args) + }, + }, + }, + }) +} diff --git a/orm/multitenant/tsconfig.json b/orm/multitenant/tsconfig.json new file mode 100644 index 000000000000..0f77d34a1e5f --- /dev/null +++ b/orm/multitenant/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src", "prisma.config.ts"] +}