Multitenancy com Prisma e PostgreSQL: Row-Level Security vs Schema por Tenant

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
Um bug de multitenancy não retorna 500. Retorna os dados do cliente errado. É o tipo de falha que destrói confiança, gera processo e não aparece em nenhum dashboard de erro. Por isso a decisão de como isolar tenants merece mais atenção do que a maioria dos times dedica.
Este post implementa duas estratégias completas de multitenancy com Prisma e PostgreSQL: isolamento por coluna com Row-Level Security (RLS) e isolamento por schema. Código funcional, migrations reais e os pontos onde cada abordagem quebra.
As três estratégias e quando cada uma faz sentido
Antes de escrever código, a decisão de arquitetura define o teto de complexidade do projeto inteiro.
| Critério | Coluna tenantId (shared DB) | Schema por tenant | Database por tenant |
|---|---|---|---|
| Isolamento de dados | Lógico (aplicação + RLS) | Físico por schema | Físico total |
| Complexidade de migrations | Baixa (1 migration para todos) | Média (N schemas para atualizar) | Alta (N databases) |
| Custo de infra | Baixo | Médio | Alto |
| Performance de queries cross-tenant | Simples (WHERE) | Requer qualified names | Requer conexões separadas |
| Risco de vazamento | Médio sem RLS, baixo com RLS | Baixo | Muito baixo |
| Limite prático de tenants | Milhares | Centenas | Dezenas |
| Compatibilidade com Prisma | Nativa | Requer troca de schema em runtime | Requer troca de datasource |
Database por tenant é viável para menos de 20 clientes enterprise com requisitos regulatórios rígidos (LGPD com isolamento total, por exemplo). Para a maioria dos SaaS, a escolha real fica entre coluna com RLS e schema por tenant. Este post cobre as duas.
Estratégia 1: coluna tenantId com Row-Level Security
A abordagem mais comum. Cada tabela carrega uma coluna tenantId e o PostgreSQL garante, via RLS, que nenhuma query acesse dados de outro tenant, mesmo que a aplicação tenha um bug.
Schema do Prisma
// schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"] // necessário apenas na estratégia 2
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now()) @map("created_at")
users User[]
projects Project[]
@@map("tenants")
}
model User {
id String @id @default(cuid())
email String
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id])
// índice composto garante que email é único DENTRO do tenant
@@unique([tenantId, email])
@@map("users")
}
model Project {
id String @id @default(cuid())
name String
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId])
@@map("projects")
}Migration SQL para habilitar RLS
O Prisma não gera políticas RLS automaticamente. Você precisa de uma migration SQL manual. Execute com prisma migrate usando um arquivo SQL customizado ou via prisma db execute.
-- migrations/20240101_enable_rls.sql
-- RLS funciona com uma variável de sessão que a aplicação define antes de cada query
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Política: só retorna linhas cujo tenant_id bate com a variável de sessão
-- current_setting com 'true' no segundo argumento retorna string vazia se a variável não existir,
-- em vez de lançar erro (evita quebrar migrations e queries administrativas)
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true));
CREATE POLICY tenant_isolation_projects ON projects
USING (tenant_id = current_setting('app.current_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true));
-- O superuser e o owner do banco ignoram RLS por padrão.
-- Crie um role separado para a aplicação para que RLS seja efetivo.
CREATE ROLE app_user LOGIN PASSWORD 'senha_segura';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;Esse ponto é crítico: se a aplicação conecta como superuser ou como owner das tabelas, o PostgreSQL ignora RLS silenciosamente. A aplicação precisa conectar com um role que não seja owner. Detalhei migrations manuais em Zero-Downtime Migrations e como reverter com segurança.
Middleware do Prisma para injetar o tenant
// src/lib/prisma-tenant.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// $executeRawUnsafe é necessário aqui porque SET não aceita parâmetros $1.
// O tenantId vem do token JWT já validado, não de input do usuário.
// Se o tenantId vier de fonte não confiável, valide com regex /^[a-z0-9]+$/ antes.
export async function withTenant<T>(
tenantId: string,
callback: (tx: PrismaClient) => Promise<T>
): Promise<T> {
return prisma.$transaction(async (tx) => {
// SET LOCAL só vale dentro desta transaction, evitando vazamento entre requests
await tx.$executeRawUnsafe(
`SET LOCAL app.current_tenant_id = '${tenantId}'`
);
return callback(tx as unknown as PrismaClient);
});
}Uso em um handler HTTP
A integração com Fastify fica direta. Se você usa outro framework, o padrão é o mesmo: extraia o tenantId do token e passe para withTenant. Para uma API completa com Fastify e Prisma, veja este post sobre API REST profissional.
// src/routes/projects.ts
import { FastifyInstance } from "fastify";
import { withTenant } from "../lib/prisma-tenant";
export async function projectRoutes(app: FastifyInstance) {
app.get("/projects", async (request, reply) => {
// tenantId extraído do JWT pelo middleware de autenticação
const tenantId = request.user.tenantId;
const projects = await withTenant(tenantId, async (tx) => {
// RLS filtra automaticamente: mesmo sem WHERE tenant_id,
// o PostgreSQL só retorna linhas do tenant correto
return tx.project.findMany({
orderBy: { name: "asc" },
});
});
return reply.send(projects);
});
}Para autenticação JWT com middleware, o post sobre NextAuth com middleware cobre o fluxo de extração de claims do token.
Estratégia 2: schema por tenant
Cada tenant recebe um schema PostgreSQL próprio (tenant_abc.users, tenant_xyz.users). As tabelas são idênticas, mas fisicamente separadas. O Prisma precisa trocar o schema em runtime.
// src/lib/prisma-schema-tenant.ts
import { PrismaClient } from "@prisma/client";
// Cache de clients por tenant evita criar conexões a cada request.
// Em produção, limite o tamanho desse Map para não estourar memória
// com milhares de tenants (use LRU cache para mais de 100 tenants).
const clientCache = new Map<string, PrismaClient>();
export function getPrismaForTenant(tenantSlug: string): PrismaClient {
const existing = clientCache.get(tenantSlug);
if (existing) return existing;
const databaseUrl = process.env.DATABASE_URL!;
// search_path define qual schema o PostgreSQL usa por padrão nas queries
const urlWithSchema = `${databaseUrl}&options=-c%20search_path%3Dtenant_${tenantSlug}`;
const client = new PrismaClient({
datasourceUrl: urlWithSchema,
});
clientCache.set(tenantSlug, client);
return client;
}Criação do schema para novos tenants
Quando um tenant é criado, você precisa provisionar o schema e rodar as migrations dentro dele.
// src/services/tenant-provisioning.ts
import { PrismaClient } from "@prisma/client";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const adminPrisma = new PrismaClient();
export async function provisionTenant(slug: string): Promise<void> {
// Valida slug para prevenir SQL injection no nome do schema
if (!/^[a-z0-9_]+$/.test(slug)) {
throw new Error("Slug do tenant contém caracteres inválidos");
}
const schemaName = `tenant_${slug}`;
await adminPrisma.$executeRawUnsafe(
`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`
);
// prisma migrate deploy aplica todas as migrations pendentes no schema alvo
const databaseUrl = `${process.env.DATABASE_URL}&options=-c%20search_path%3D${schemaName}`;
await execAsync(`DATABASE_URL="${databaseUrl}" npx prisma migrate deploy`);
}Esse processo de provisioning pode levar alguns segundos. Para SaaS com onboarding automatizado, coloque essa operação em uma fila assíncrona. O post sobre BullMQ e Redis mostra como estruturar jobs desse tipo.
O que NÃO fazer
Anti-pattern 1: filtrar tenant apenas na aplicação, sem RLS
// ERRADO: depende 100% da aplicação para isolar dados.
// Um dev esquece o WHERE e vaza dados de todos os tenants.
async function getProjects(tenantId: string) {
return prisma.project.findMany({
where: { tenantId }, // se alguém remove esse where, vaza tudo
});
}
// Um endpoint administrativo esquecido sem filtro:
app.get("/admin/all-projects", async () => {
return prisma.project.findMany(); // retorna projetos de TODOS os tenants
});O problema: qualquer endpoint que esqueça o WHERE tenantId expõe dados de outros clientes. Em uma codebase com 200 endpoints, a probabilidade de alguém esquecer é alta.
// CORRETO: RLS garante isolamento no banco, independente da query
async function getProjects(tenantId: string) {
return withTenant(tenantId, async (tx) => {
// mesmo sem WHERE, RLS filtra. Defesa em profundidade.
return tx.project.findMany();
});
}Anti-pattern 2: usar $executeRawUnsafe com input do usuário no SET
// ERRADO: tenantId vindo direto do header HTTP, sem validação
app.addHook("preHandler", async (request) => {
const tenantId = request.headers["x-tenant-id"] as string;
// SQL injection via header: x-tenant-id: '; DROP TABLE users; --
await prisma.$executeRawUnsafe(
`SET LOCAL app.current_tenant_id = '${tenantId}'`
);
});// CORRETO: tenantId vem do JWT validado + regex de segurança
app.addHook("preHandler", async (request) => {
const tenantId = request.user.tenantId; // extraído do JWT verificado
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
throw new Error("Tenant ID inválido");
}
// agora é seguro usar no SET
});Anti-pattern 3: conexão como superuser com RLS habilitado
-- Parece correto, mas o superuser IGNORA todas as políticas RLS.
-- A aplicação roda sem filtro nenhum e você nem percebe.
-- Teste SEMPRE com o role da aplicação, não com o superuser.Verifique o role ativo com SELECT current_user, current_setting('is_superuser'); em uma query de diagnóstico.
Middleware de tenant para Next.js com App Router
Se o frontend é Next.js, o tenant pode ser resolvido pelo domínio ou por um path prefix. O middleware do Next.js roda na edge e resolve o tenant antes de qualquer Server Component.
// middleware.ts (Next.js)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Resolve tenant pelo subdomínio: acme.app.com -> acme
const hostname = request.headers.get("host") ?? "";
const subdomain = hostname.split(".")[0];
// Tenants conhecidos poderiam vir de um cache KV na edge
const isValidTenant = /^[a-z0-9-]+$/.test(subdomain) && subdomain !== "www";
if (!isValidTenant) {
return NextResponse.redirect(new URL("/not-found", request.url));
}
// Injeta o tenantId no header para uso nos Server Components e Route Handlers
const response = NextResponse.next();
response.headers.set("x-tenant-id", subdomain);
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};No Server Component, leia o header com headers() e passe para a camada de dados. Para entender a diferença entre Server e Client Components nesse contexto, veja este comparativo.
Testando isolamento: query de verificação
Antes de ir para produção, rode essa verificação para confirmar que RLS está ativo e funcionando.
// src/tests/tenant-isolation.test.ts
import { PrismaClient } from "@prisma/client";
import { withTenant } from "../lib/prisma-tenant";
const prisma = new PrismaClient();
async function verifyIsolation() {
// Cria dois tenants com projetos
const tenantA = await prisma.tenant.create({
data: { name: "Tenant A", slug: "tenant-a" },
});
const tenantB = await prisma.tenant.create({
data: { name: "Tenant B", slug: "tenant-b" },
});
await prisma.project.create({
data: { name: "Projeto Secreto A", tenantId: tenantA.id },
});
await prisma.project.create({
data: { name: "Projeto Secreto B", tenantId: tenantB.id },
});
// Query como Tenant A: deve ver APENAS seus projetos
const projectsFromA = await withTenant(tenantA.id, async (tx) => {
return tx.project.findMany();
});
if (projectsFromA.length !== 1 || projectsFromA[0].name !== "Projeto Secreto A") {
throw new Error("VAZAMENTO: Tenant A viu dados que não deveria");
}
// Query como Tenant B: deve ver APENAS seus projetos
const projectsFromB = await withTenant(tenantB.id, async (tx) => {
return tx.project.findMany();
});
if (projectsFromB.length !== 1 || projectsFromB[0].name !== "Projeto Secreto B") {
throw new Error("VAZAMENTO: Tenant B viu dados que não deveria");
}
console.log("Isolamento verificado: cada tenant vê apenas seus dados");
}
verifyIsolation().catch(console.error);Esse teste deve rodar no CI antes de cada deploy. Se a pipeline de deploy usa Docker, o guia de Docker para devs mostra como subir um PostgreSQL efêmero para testes.
Decisão prática: qual estratégia escolher
Se o SaaS tem menos de 50 tenants com requisitos de isolamento regulatório (saúde, financeiro, governo): schema por tenant. O custo de migrations paralelas é gerenciável e o isolamento físico simplifica auditorias.
Se o SaaS tem mais de 50 tenants, ou se o número de tenants cresce de forma imprevisível (self-service signup): coluna com RLS. A complexidade operacional de manter centenas de schemas com migrations sincronizadas não compensa o isolamento extra.
Se a equipe é pequena (menos de 5 devs) e o produto está em fase de validação: comece com coluna tenantId sem RLS, mas com um middleware centralizado que injeta o WHERE automaticamente. Adicione RLS quando o produto tiver clientes pagantes. A arquitetura com Clean Architecture facilita essa adição posterior porque o acesso a dados já está encapsulado em repositórios.
O Prisma ainda não tem suporte nativo a RLS (existe uma RFC aberta no repositório oficial). A abordagem com $transaction e SET LOCAL funciona, mas exige disciplina para que toda query passe pelo wrapper withTenant. Se você quer uma camada extra de proteção, combine RLS com o filtro na aplicação. Redundância aqui é virtude, não desperdício.
FAQ
O Prisma Client Extension substitui o middleware de tenant?
Sim, a partir do Prisma 4.16+ você pode usar $extends com o hook query para injetar o tenantId automaticamente em todo findMany, create, update e delete. A vantagem sobre o middleware é que o Extension é tipado e composável. A desvantagem é que ele opera na camada da aplicação, não no banco: sem RLS, um $queryRaw escapa do filtro.
RLS impacta performance?
O PostgreSQL avalia a política RLS como um filtro adicional no planner. Com um índice em tenant_id, o custo é equivalente a um WHERE tenant_id = X explícito. Em tabelas com milhões de linhas, o índice composto (tenant_id, coluna_de_busca) é obrigatório para evitar sequential scans.
Como faço queries cross-tenant para dashboards administrativos?
Conecte com um role que tenha BYPASSRLS ou use SET LOCAL app.current_tenant_id = '' (a política com current_setting(..., true) retorna string vazia e não filtra nada). Esse acesso deve existir apenas em endpoints administrativos autenticados com permissão elevada, nunca expostos a tenants comuns.
Posso usar Supabase com essa abordagem?
Sim. O Supabase já usa RLS nativamente e expõe auth.uid() nas políticas. A diferença é que o Supabase injeta o contexto via JWT do PostgREST, enquanto a abordagem deste post usa SET LOCAL via Prisma. Para projetos Next.js com Supabase, o post sobre Supabase como backend completo detalha RLS com a SDK oficial.
Preciso de um tenantId em TODAS as tabelas?
Não. Tabelas de referência compartilhada (países, moedas, planos de assinatura) não precisam de tenantId. Tabelas que contêm dados de negócio do cliente (projetos, faturas, usuários, configurações) precisam. A regra: se o dado pertence a um cliente específico, ele leva tenantId e entra na política RLS.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


