-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: add orm/multitenant row-level scope example #8561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: latest
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/multitenant_example?schema=public" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
Comment on lines
+11
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if Prisma 7.0.0 exists on npm and inspect its exports
npm view prisma@7.0.0 version 2>/dev/null
npm view `@prisma/client`@7.0.0 version 2>/dev/null
npm view `@prisma/adapter-pg`@7.0.0 version 2>/dev/null
npm view `@types/pg`@8.20.0 version 2>/dev/null
npm view pg@8.20.0 version 2>/dev/nullRepository: prisma/prisma-examples Length of output: 192 🌐 Web query:
💡 Result: As of July 13, 2026, the latest version of Prisma ORM is 7.8.0 [1][2][3]. Prisma 7.0.0 exists; it was released on November 19, 2025, and introduced the Rust-free Prisma Client as the default [4][5][6]. The Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== files ==\n'
git ls-files 'orm/multitenant/*' | sort
printf '\n== package.json ==\n'
cat -n orm/multitenant/package.json
printf '\n== schema/prisma config ==\n'
for f in orm/multitenant/prisma.config.ts orm/multitenant/prisma/schema.prisma orm/multitenant/seed.ts orm/multitenant/index.ts; do
if [ -f "$f" ]; then
printf '\n--- %s ---\n' "$f"
cat -n "$f"
fi
doneRepository: prisma/prisma-examples Length of output: 2969 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== search for PrismaClient / adapter-pg usage ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
'PrismaClient|adapter-pg|PrismaPg|defineConfig|prisma-client-js|prisma-client' orm/multitenantRepository: prisma/prisma-examples Length of output: 1527 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import json, subprocess, sys
for pkg in ["prisma@7.0.0", "`@prisma/client`@7.0.0", "`@prisma/adapter-pg`@7.0.0"]:
try:
out = subprocess.check_output(["npm","view",pkg,"peerDependencies","dependencies","exports","type","version"], stderr=subprocess.DEVNULL, text=True)
print(f"\n## {pkg}\n{out}")
except subprocess.CalledProcessError as e:
print(f"\n## {pkg}\nERROR")
PYRepository: prisma/prisma-examples Length of output: 3660 🌐 Web query:
💡 Result: In Prisma 7, you cannot connect to a PostgreSQL database using Citations:
Pass the Postgres adapter into 🤖 Prompt for AI Agents |
||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "22.15.32", | ||
| "@types/pg": "8.20.0", | ||
| "prisma": "7.0.0", | ||
| "tsx": "4.21.0", | ||
| "typescript": "5.8.2" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'), | ||
| }, | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Use
🛡️ Proposed fix model Order {
id String `@id` `@default`(cuid())
reference String
- amount Float
+ amount Decimal `@db.Decimal`(12, 2)
status OrderStatus `@default`(PENDING)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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 | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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()) | ||||||||||||||||||||
|
Comment on lines
+74
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Seed failures exit with code 0 due to swallowed rejection.
🛡️ Proposed fix main()
- .catch(console.error)
+ .catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+ })
.finally(() => prisma.$disconnect())📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
"What this prevents" section overstates the extension's coverage.
The extension doesn't cover
findUnique,update,delete, orupsert, so the claims about preventing cross-tenant reads and silent leaks viafindUnique({ where: { id } })are inaccurate. Either add the missing operations to the extension (seetenant-scope.tscomment) or qualify this section to document which operations are and aren't covered.🤖 Prompt for AI Agents