prisma-tenant-extension
v0.1.5
Published
Automatic multi-tenant isolation for Prisma
Maintainers
Readme
prisma-tenant-extension
Automatic multi-tenant isolation for Prisma. This package provides a Prisma extension that automatically injects tenant filters into all your queries, ensuring data isolation between tenants.
Features
- Automatic tenant filtering - All queries automatically include tenant conditions
- Multi-level tenancy - Support for primary and secondary tenant fields (e.g., organization + workspace)
- Runtime model detection - Automatically detects which models have tenant fields via Prisma DMMF
- Escape hatches - Skip tenant checks when needed with
skipTenantCheckflag - Framework-agnostic - Works with any Node.js framework (Express, Fastify, Hono, etc.)
- Type-safe - Full TypeScript support
Installation
npm install prisma-tenant-extensionRequirements
- Node.js 18+
- Prisma 7.0.0+
Quick Start
1. Create a tenant context
import { createTenantContext } from 'prisma-tenant-extension';
// Define your tenant structure
export const tenantContext = createTenantContext<{
organizationId: string;
workspaceId?: string;
}>();2. Create the Prisma extension
import { PrismaClient } from '@prisma/client';
import { createTenantExtension } from 'prisma-tenant-extension';
import { tenantContext } from './tenant-context';
const tenantExtension = createTenantExtension({
context: tenantContext,
tenantField: 'organizationId',
secondaryTenantField: 'workspaceId', // optional
});
export const prisma = new PrismaClient().$extends(tenantExtension);3. Wrap requests with tenant context
Important: When using tenantContext.run(), you must use an async callback to ensure the context is preserved across async operations:
// Correct - use async callback
const users = await tenantContext.run({ organizationId }, async () => {
return prisma.user.findMany();
});
// Also correct - chain promises within run()
await tenantContext.run({ organizationId }, () => {
return prisma.user.findMany().then(users => {
// process users
});
});For Express middleware, the pattern works because next() synchronously invokes the route handler:
// Express middleware example
app.use((req, res, next) => {
const organizationId = req.headers['x-organization-id'] as string;
const workspaceId = req.headers['x-workspace-id'] as string;
tenantContext.run({ organizationId, workspaceId }, () => {
next(); // Route handler is called synchronously within run()
});
});
// Hono middleware example
app.use(async (c, next) => {
const organizationId = c.req.header('x-organization-id');
const workspaceId = c.req.header('x-workspace-id');
return tenantContext.run({ organizationId, workspaceId }, () => next());
});4. Use Prisma as normal
// Queries are automatically filtered by tenant
const posts = await prisma.post.findMany();
// SQL: SELECT * FROM posts WHERE organizationId = '...' AND workspaceId = '...'
const user = await prisma.user.findUnique({ where: { id: '123' } });
// SQL: SELECT * FROM users WHERE id = '123' AND organizationId = '...'Configuration
Basic configuration
const extension = createTenantExtension({
context: tenantContext,
tenantField: 'organizationId',
});With secondary tenant field
const extension = createTenantExtension({
context: tenantContext,
tenantField: 'organizationId',
secondaryTenantField: 'workspaceId',
});With explicit model lists
By default, the extension auto-detects which models have tenant fields by inspecting Prisma's DMMF at runtime. You can override this with explicit lists:
const extension = createTenantExtension({
context: tenantContext,
tenantField: 'organizationId',
modelsWithTenant: new Set(['User', 'Post', 'Comment']),
modelsWithSecondaryTenant: new Set(['Post', 'Comment']),
});Escape Hatches
Skip primary tenant check
Use skipTenantCheck to bypass the primary tenant filter:
// Get all users across all organizations (admin use case)
const allUsers = await prisma.user.findMany({
skipTenantCheck: true,
});Skip secondary tenant check
Use skipSecondaryTenantCheck to bypass only the secondary tenant filter:
// Get all posts in the organization, regardless of workspace
const orgPosts = await prisma.post.findMany({
skipSecondaryTenantCheck: true,
});Skip both checks
const everything = await prisma.post.findMany({
skipTenantCheck: true,
skipSecondaryTenantCheck: true,
});API Reference
createTenantContext<T>()
Creates a tenant context using AsyncLocalStorage.
interface TenantContext<T> {
storage: AsyncLocalStorage<T>;
run: <R>(store: T, callback: () => R) => R;
getStore: () => T | undefined;
requireStore: () => T; // Throws if no context
}createTenantExtension(config)
Creates a Prisma extension that auto-injects tenant filters.
interface TenantExtensionConfig<T> {
context: TenantContext<T>;
tenantField: keyof T & string;
secondaryTenantField?: keyof T & string;
modelsWithTenant?: Set<string>;
modelsWithSecondaryTenant?: Set<string>;
}detectModelsWithField(client, fieldName)
Utility to detect which models have a specific field. Used internally but exported for custom use cases.
const models = detectModelsWithField(prisma, 'organizationId');
// Set { 'User', 'Post', 'Comment' }clearModelCache()
Clears the internal model detection cache. Useful for testing or if your schema changes at runtime.
Supported Operations
The extension injects tenant filters into these Prisma operations:
findUnique/findUniqueOrThrowfindFirst/findFirstOrThrowfindManycountaggregategroupByupdate/updateManydelete/deleteManyupsert
Note: create and createMany are not filtered (you must provide tenant fields yourself when creating records).
TypeScript Support
The package is fully typed. When using escape hatch flags, TypeScript will recognize them:
// TypeScript knows about skipTenantCheck
await prisma.post.findMany({
where: { title: 'Hello' },
skipTenantCheck: true, // ✓ Valid
});Examples
Express with JWT authentication
import express from 'express';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { createTenantContext, createTenantExtension } from 'prisma-tenant-extension';
const tenantContext = createTenantContext<{
organizationId: string;
userId: string;
}>();
const prisma = new PrismaClient().$extends(
createTenantExtension({
context: tenantContext,
tenantField: 'organizationId',
})
);
const app = express();
app.use((req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).send('Unauthorized');
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
organizationId: string;
userId: string;
};
tenantContext.run(payload, next);
});
app.get('/posts', async (req, res) => {
// Automatically filtered by organizationId from JWT
const posts = await prisma.post.findMany();
res.json(posts);
});Admin endpoint that bypasses tenant check
app.get('/admin/all-users', requireAdmin, async (req, res) => {
// Admin can see all users across all organizations
const users = await prisma.user.findMany({
skipTenantCheck: true,
});
res.json(users);
});License
MIT
