Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustração 3D de bloco de banco de dados em vidro fosco dividido entre rows com locks e schemas isolados representando mul...
Ouça este artigo
0:00Multitenancy 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érioColuna tenantId (shared DB)Schema por tenantDatabase por tenant
Isolamento de dadosLógico (aplicação + RLS)Físico por schemaFísico total
Complexidade de migrationsBaixa (1 migration para todos)Média (N schemas para atualizar)Alta (N databases)
Custo de infraBaixoMédioAlto
Performance de queries cross-tenantSimples (WHERE)Requer qualified namesRequer conexões separadas
Risco de vazamentoMédio sem RLS, baixo com RLSBaixoMuito baixo
Limite prático de tenantsMilharesCentenasDezenas
Compatibilidade com PrismaNativaRequer troca de schema em runtimeRequer 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

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.

SQL
-- 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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

TYPESCRIPT
// 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}'`
  );
});
TYPESCRIPT
// 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

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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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.

Marcos Soares

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.