Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions orm/multitenant/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/multitenant_example?schema=public"
76 changes: 76 additions & 0 deletions orm/multitenant/README.md
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 } })`)
Comment on lines +67 to +71

Copy link
Copy Markdown
Contributor

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, or upsert, so the claims about preventing cross-tenant reads and silent leaks via findUnique({ where: { id } }) are inaccurate. Either add the missing operations to the extension (see tenant-scope.ts comment) or qualify this section to document which operations are and aren't covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@orm/multitenant/README.md` around lines 67 - 71, Qualify the “What this
prevents” section in the README to list only operations actually covered by the
multitenant extension. Remove or clarify claims involving findUnique, update,
delete, and upsert, and explicitly document the unsupported operations rather
than implying complete cross-tenant protection.


## 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)
23 changes: 23 additions & 0 deletions orm/multitenant/package.json
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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/null

Repository: prisma/prisma-examples

Length of output: 192


🌐 Web query:

What is the latest version of Prisma ORM and does Prisma 7.0.0 exist? What is the prisma/config module API (defineConfig, env)? Does Prisma 7 use "prisma-client" as the generator provider instead of "prisma-client-js"? Is @prisma/adapter-pg auto-configured when installed as a dependency?

💡 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 prisma/config module provides an API to manage project configuration, including the defineConfig helper and env function [7][8]. The defineConfig helper is used to structure the configuration object (such as schema paths and datasource settings), while env() is used to safely access environment variables within that configuration file (e.g., prisma.config.ts) [7][9]. Regarding the generator provider, Prisma 7 uses prisma-client as the default generator provider [10][11]. The older prisma-client-js provider is deprecated as of Prisma 7 [10][12]. When using the prisma-client provider, the output field in the generator block is required, as the client is no longer generated inside node_modules by default [13][10][12]. The @prisma/adapter-pg package (and other driver adapters) is not auto-configured when installed as a dependency [14][15][16]. To use it, you must explicitly install the adapter and instantiate the PrismaClient by passing an instance of the adapter to the constructor, as shown in the following example [14][16][17]: import { PrismaClient } from "../generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter });

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
done

Repository: 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/multitenant

Repository: 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")
PY

Repository: prisma/prisma-examples

Length of output: 3660


🌐 Web query:

Prisma 7 provider = "prisma-client" new PrismaClient()without adapter. Does it connect to PostgreSQL automatically, or must@prisma/adapter-pg be passed to the constructor?

💡 Result:

In Prisma 7, you cannot connect to a PostgreSQL database using new PrismaClient() without providing an adapter [1][2]. Prisma 7 requires a driver adapter to be passed to the PrismaClient constructor [3][4]. If you attempt to instantiate new PrismaClient() without an adapter (or accelerateUrl), it will result in an error [1][4]. To connect to PostgreSQL, you must explicitly install and provide the @prisma/adapter-pg driver adapter [5][1]. The standard configuration for a Node.js environment involves the following steps: 1. Install the necessary dependencies: npm install @prisma/client @prisma/adapter-pg pg [1]. 2. Configure and pass the adapter to the constructor [5][6]: ts import { PrismaClient } from "./generated/prisma/client"; import { PrismaPg } from "`@prisma/adapter-pg`"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, }); export const prisma = new PrismaClient({ adapter }); This change is part of Prisma 7's move toward a "Rust-free" Prisma Client, which requires developers to provide the specific database drivers directly in their project code instead of having them embedded within the Prisma binary [5].

Citations:


Pass the Postgres adapter into PrismaClient
The Prisma 7 config is fine, but @prisma/adapter-pg and pg are still unused: src/index.ts and src/seed.ts both call new PrismaClient() without an adapter. Prisma 7 needs PrismaPg passed to the client for PostgreSQL, so either wire that in or remove the adapter deps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@orm/multitenant/package.json` around lines 11 - 14, Update the PrismaClient
construction in src/index.ts and src/seed.ts to instantiate PrismaPg from
`@prisma/adapter-pg` using the existing pg connection configuration, then pass
that adapter to PrismaClient. Ensure both runtime and seed paths use the
adapter, or remove the unused `@prisma/adapter-pg` and pg dependencies if adapter
wiring is not intended.

},
"devDependencies": {
"@types/node": "22.15.32",
"@types/pg": "8.20.0",
"prisma": "7.0.0",
"tsx": "4.21.0",
"typescript": "5.8.2"
}
}
12 changes: 12 additions & 0 deletions orm/multitenant/prisma.config.ts
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'),
},
})
51 changes: 51 additions & 0 deletions orm/multitenant/prisma/schema.prisma
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use Decimal instead of Float for monetary amounts.

Float can introduce floating-point precision errors for monetary values. Decimal is the recommended Prisma type for financial data.

🛡️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
amount Float
amount Decimal `@db.Decimal`(12, 2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@orm/multitenant/prisma/schema.prisma` at line 34, Update the `amount` field
in the Prisma schema from `Float` to `Decimal` so monetary values retain exact
precision. Preserve the field name and existing model structure.

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
}
72 changes: 72 additions & 0 deletions orm/multitenant/src/index.ts
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())
76 changes: 76 additions & 0 deletions orm/multitenant/src/seed.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

.catch(console.error) returns undefined, resolving the promise chain. The process exits with code 0 even when seeding fails, which hides failures in CI pipelines.

🛡️ Proposed fix
 main()
-  .catch(console.error)
+  .catch((error) => {
+    console.error(error)
+    process.exitCode = 1
+  })
   .finally(() => prisma.$disconnect())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@orm/multitenant/src/seed.ts` around lines 74 - 76, Update the main() promise
chain so seed failures remain rejected or explicitly set a nonzero process exit
status after logging the error; do not let catch(console.error) resolve
successfully. Preserve the prisma.$disconnect() cleanup in finally while
ensuring failed seeding exits with a nonzero code.

Loading