Use Cases (Atualizado: 02/06/2026)

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.

SaaS multi-tenant seguro com Claude Code: RLS, autenticação, cobrança e logs

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.

ModeloQuando usarForçaArmadilha
Banco compartilhado, schema compartilhadoSaaS inicial ou médio com muitos clientes pequenosOperação leve e analytics simplesUm filtro tenant ausente pode vazar dados
Banco compartilhado, schema por tenantPoucos clientes grandes com separação maiorPermissões e migrações parcialmente separadasMuitos schemas complicam deploy
Banco por tenantSetores regulados ou contratos rígidosBackup, chaves e falhas podem ser isoladosCusto 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”.

#Claude Code #multi-tenant #SaaS #PostgreSQL #segurança
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.