SaaS multi-tenant seguro com Claude Code: RLS, autenticação, cobrança e logs
Implemente SaaS multi-tenant com Claude Code: tenant_id, RLS, auth, limites de plano, jobs, logs e testes.
Um SaaS multi-tenant é uma aplicação que atende várias organizações de clientes com a mesma base de código. Um tenant é a fronteira de dados do cliente: empresa, workspace, portal ou conta. Claude Code consegue alterar rotas, helpers de banco, jobs e telas rapidamente; por isso a fronteira do tenant precisa estar explícita antes da implementação.
O incidente que queremos evitar é simples: um usuário do Tenant A consegue ler projetos, faturas, arquivos, eventos de auditoria ou prompts de IA do Tenant B. Procurarwhere tenantIdno review não basta. Uma implementação segura propagatenant_idpor autenticação, sessão, banco, cobrança, jobs, busca e logs, e deixa o banco bloquear o vazamento quando uma query da aplicação erra.
Este guia usa Claude Code como parceiro de implementação e revisão, não como atalho para pular segurança. RLS significa Row Level Security: políticas do PostgreSQL que decidem quais linhas ficam visíveis ou graváveis no contexto atual. Consulte a documentação oficial deRow Security Policies, CREATE POLICY ecurrent_setting / set_config.
Como base, veja também autenticação com Claude Code, RBAC com Claude Code e migrações de banco de dados. Multi-tenancy junta esses três assuntos.
Escolha primeiro o modelo de isolamento
Há três modelos comuns. Escolha antes de pedir que Claude Code edite handlers ou schemas.
| Modelo | Quando usar | Força | Armadilha |
|---|---|---|---|
| Banco compartilhado, schema compartilhado | SaaS inicial ou médio com muitos clientes pequenos | Operação leve e analytics simples | Um filtro tenant ausente pode vazar dados |
| Banco compartilhado, schema por tenant | Poucos clientes grandes com separação maior | Permissões e migrações parcialmente separadas | Muitos schemas complicam deploy |
| Banco por tenant | Setores regulados ou contratos rígidos | Backup, chaves e falhas podem ser isolados | Custo e operação maiores |
A pergunta prática é se o modelo ainda funciona com 100 ou 1000 clientes. Para a maioria dos SaaS pequenos e médios, banco compartilhado e schema compartilhado são realistas quandotenant_id, RLS, logs de auditoria e restore por tenant são fortes. Se alguns clientes enterprise exigirem separação física, deixe um caminho para mover tenants específicos para outro banco.
Três exemplos tornam isso concreto. Primeiro, um CRM B2B: contas, contatos, oportunidades e notas pertencem a um tenant. Segundo, um portal de agência: a equipe da agência pode pertencer a vários tenants, mas o cliente final vê apenas seu portal. Terceiro, um SaaS com IA: execuções de prompt, tokens, arquivos enviados e armazenamento precisam ser contados por tenant para cobrança e limites de plano.
Comece com um prompt de revisão de design.
Quero tornar este SaaS multi-tenant.
Assuma PostgreSQL compartilhado e schema compartilhado.
Classifique as tabelas em tenant-owned, global reference e tabelas que exigem PostgreSQL RLS.
Atalhos perigosos são proibidos: nada de "WHERE da aplicação é suficiente", nada de logar request body completo, nada de jobs que inferem tenant_id depois.
Retorne áreas alteradas, restrições de banco, casos de teste e riscos de migração.
Propague tenant_id de uma fonte confiável
Não confie em um headerx-tenant-idenviado pelo navegador nem em um parâmetro de URL. O fluxo seguro é resolver um tenant candidato pelo host ou path, verificar no servidor se o usuário autenticado pertence a esse tenant, e passar somente otenantIdverificado para os serviços.
Auth.js mostra emExtending the Session como enriquecer sessão com dados do servidor. Mesmo assim, mantenha a sessão pequena. Ela identifica o usuário; membership e papel no tenant devem ser consultados no servidor porque podem mudar.
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
O detalhe importante é o que o código recusa: ele não aceita tenant vindo do cliente como verdade. APIs internas e workers devem seguir a mesma regra. Um tenant_id externo é uma alegação; membership, API key vinculada ou payload assinado precisam provar.
Use PostgreSQL RLS como barreira final
Filtros na aplicação ainda são úteis, mas uma omissão não deve virar vazamento. Em schema compartilhado, toda tabela de tenant precisa detenant_id, foreign keys, índices por tenant e políticas RLS. No PostgreSQL, donos de tabela e roles comBYPASSRLSpodem contornar RLS, então a role da aplicação não deve ser dona das tabelas protegidas. Para tabelas críticas, useFORCE ROW LEVEL SECURITY.
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Com connection pool, o contexto do tenant deve viver dentro da transação. Um valor de sessão pode sobrar em uma conexão reutilizada;set_config(..., true)fica local à transação.
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
A query omiteWHERE tenant_id = $1de propósito. Se RLS estiver ativo, PostgreSQL filtra as linhas invisíveis. Em produção você pode adicionar filtros por performance, mas eles não substituem RLS.
Alinhe autenticação, cobrança e jobs
Autenticação não é apenas “o usuário está logado”, e sim “o que ele pode fazer neste tenant”. Quando consultores, agências ou suporte pertencem a vários tenants, buscar apenas poruser_idé perigoso. PassetenantId, userId, roleerequestIdentre serviços.
Cobrança usa a mesma fronteira. Stripe documentausage-based billing, mas sua aplicação primeiro precisa contar o uso do tenant correto. Limites de plano devem bloquear no servidor antes de gravações ou execuções de IA.
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
Jobs em segundo plano são uma fonte comum de vazamento. Não coloque sóprojectIdna fila para o worker buscar globalmente. Inclua otenantIdverificado no payload e entre emwithTenantno worker.
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
Registre o suficiente, não tudo
Logs podem virar uma segunda base de dados com controles mais fracos. OLogging Cheat Sheet da OWASP enfatiza sanitização e exclusão de dados sensíveis. Em SaaS multi-tenant, normalmente você precisa detenantId, requestId, nome do evento, actor ID e target ID. Não precisa de cookies, Authorization, API keys, prompts completos, request body completo ou endereço de cobrança.
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
Separe logs de auditoria dos logs operacionais. Auditoria deve responder quem fez o quê, em qual tenant, contra qual alvo e quando. Não copie todos os valores alterados sem necessidade clara.
Escreva testes que tentam vazar dados
Testes felizes não bastam. Teste Tenant A usando ID de Tenant B, worker com payload inconsistente, ausência deapp.tenant_id, limite de plano excedido e logger recebendo campos sensíveis.
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Dê critérios adversariais ao Claude Code.
Adicione testes de vazamento multi-tenant:
1. Sessão Tenant A usando projectId do Tenant B retorna 403 ou 404.
2. Query sem app.tenant_id retorna zero linhas ou fail closed.
3. Job sem tenantId é rejeitado.
4. Excesso de plano retorna 402 antes da escrita.
5. Logs nunca incluem authorization, cookie, prompt, token ou apiKey.
Explique qual implementação errada cada teste pegaria e mostre comandos/resultados após corrigir.
Evite armadilhas de migração
Ao adaptar um SaaS existente, o erro comum é adicionartenant_idnullable, publicar e prometer limpar depois. NULL temporário pode existir durante backfill, mas o release só termina comNOT NULL, foreign keys, unique constraints por tenant, índices, RLS e testes.
Cinco armadilhas aparecem muito. Primeiro, misturar dados globais e dados de tenant: códigos de país podem ser globais, configurações de cliente não. Segundo, storage key sem tenant. Terceiro, índice de busca semtenant_idem Algolia, Meilisearch ou OpenSearch. Quarto, desligar RLS no admin; suporte deve usar impersonation explícita e auditada. Quinto, não testar restore de um único tenant.
Crie um plano de migração para converter tabelas existentes para multi-tenancy.
Phase 1: adicionar colunas tenant_id e mapear registros existentes.
Phase 2: escrever backfill SQL e validação de NULL restante.
Phase 3: adicionar NOT NULL, foreign keys, unique constraints por tenant e índices.
Phase 4: habilitar RLS e sugerir FORCE ROW LEVEL SECURITY.
Phase 5: testar APIs, jobs, logs e índices de busca contra vazamentos cross-tenant.
Inclua critérios de rollback e SQL de verificação em cada fase.
Use prompts seguros para Claude Code
O prompt também precisa conter a fronteira do tenant. Evite “torne multi-tenant rápido” ou “admins veem tudo”. Um prompt seguro inclui proibições, testes, fontes e escopo.
Você implementa comportamento SaaS multi-tenant seguro.
Escopo permitido: src/app, src/lib, db/migrations e tests.
Obrigatório:
- Resolver tenant_id no servidor; nunca confiar em tenant IDs do cliente.
- Usar PostgreSQL RLS em toda tabela tenant-owned.
- Em connection pool, chamar set_config('app.tenant_id', value, true) dentro de transação.
- Propagar tenantId por auth, billing, jobs, logs e search indexing.
- Adicionar testes que tentam leituras/escritas cross-tenant.
Proibido:
- Desabilitar RLS.
- Confiar em x-tenant-id.
- Logar Authorization, Cookie, API keys ou prompts completos.
- Encerrar sem testes.
O relatório final deve incluir arquivos alterados, comandos executados, falhas encontradas e risco residual.
A documentaçãoClaude Code Security da Anthropic cobre permissões, prompt injection e responsabilidade de revisão. Em repositórios SaaS sensíveis, versionar comandos permitidos, conexões MCP e regras de projeto ajuda a revisão.
CTA e entrega prática
Multi-tenancy não é a feature mais chamativa, mas vende confiança em B2B SaaS. Explicar isolamento de dados, auditoria, limites de plano, acesso de suporte e restore por tenant facilita questionários de segurança.
Para equipes adotando Claude Code, coloque as regras de tenant emCLAUDE.mde fixe com testes. Em cada review pergunte: tenant_idpropagou, RLS bloqueia erro, jobs estão scoped, logs evitam segredos? Equipes podem começar comtreinamento e consultoria Claude Code. Desenvolvedores individuais podem usar acheatsheet gratuita para transformar a lista em prompts repetíveis.
Resultado do teste prático
Quando Masa testou esse padrão em um CRM pequeno, o primeiro vazamento não estava na API route, mas no worker de resumo diário. A UI passavatenantId, mas o payload da fila tinha sóprojectId, então o worker podia buscar fora do tenant esperado. Ao forçarwithTenanttambém no worker e adicionar um teste SQL semapp.tenant_id, o bug ficou reproduzível e foi corrigido. A melhor instrução para Claude Code não foi “adicione testes felizes”, e sim “primeiro escreva um teste em que Tenant A tenta ler Tenant B”.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.