Lab 38 — Multi-tenant: linha, schema ou conta
O problema, e a empresa que o tem
A Rotafácil é uma plataforma de rastreio e gestão de entregas usada por 340 lojas de e-commerce, entre elas a própria Cadência, do L03. Como a maioria dos SaaS B2B no primeiro ano, todos os clientes dividem a mesma infraestrutura: um banco, uma tabela de pedidos, uma coluna tenant_id que identifica a loja dona de cada linha.
O incidente que abre este laboratório aconteceu num recurso novo: um painel de métricas agregadas que soma pedidos por categoria de produto. A consulta tem `JOIN` correto e filtro de data correto — só não tem `WHERE tenant_id`. Uma operadora da Sapataria Nordeste abriu o painel e viu, no total geral, um número grande demais para o próprio negócio. Era a soma dos 340 tenants, incluindo o faturamento da Cadência.
O contrato enterprise que a Rotafácil está negociando com uma rede maior exige, por cláusula, "dados em infraestrutura de conta própria". Isso não é exagero jurídico: é a mesma pergunta que o incidente acabou de responder na prática — no modelo atual, o vazamento entre clientes não é impossível, é só improvável até faltar uma linha de código. Um cliente que audita o fornecedor antes de assinar faz exatamente essa pergunta.
O que este laboratório NÃO é
Não é criptografia. KMS protege contra quem rouba o disco ou o backup, não contra um tenant autenticado lendo linha de outro — os dois são atacantes diferentes, e um SaaS precisa se proteger dos dois. Também não é isolamento de rede: colocar cada serviço numa VPC correta impede acesso de FORA da aplicação, mas o vazamento deste módulo acontece DENTRO da própria aplicação autenticada, numa consulta que o firewall nunca vê.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando ou um teste na seção de implantação, não com a sensação de ter entendido.
- Provar, com teste automatizado, que uma consulta sem filtro de tenant vaza dado no modelo por linha sem RLS.
- Implementar RLS obrigatória no Postgres e demonstrar que a MESMA consulta, sem nenhuma mudança no C#, deixa de vazar.
- Propagar a identidade do tenant do login até a transação SQL sem que o cliente possa forjá-la.
- Explicar por que `SET` de sessão é perigoso com pool de conexão, e usar `set_config` transacional em seu lugar.
- Provisionar um schema por tenant e medir o custo operacional real de migrar N schemas em vez de um.
- Criar uma conta AWS dedicada a um tenant via Organizations, com papel cross-account restrito por ExternalId.
- Rodar um canário em produção que tenta vazar dado entre tenants a cada poucos minutos e aciona alarme.
- Escolher, para um catálogo de clientes real, qual modelo cabe a cada faixa — e justificar por requisito, não por preferência pessoal.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Modelos de isolamento (silo, pool, bridge) | SAP-C02 | os três modelos implementados lado a lado, com o mesmo dado | por que "mais isolamento" nem sempre é a resposta certa |
| Row-Level Security | SAP-C02, DVA-C02 | `FORCE ROW LEVEL SECURITY` e a política com `USING`/`WITH CHECK` | que RLS habilitada sem `FORCE` não protege o dono da tabela nem o papel de migration |
| Raio de um incidente (blast radius) | SAP-C02 | a fórmula que compara o alcance de um mesmo erro nos três modelos | que o raio muda com a fronteira escolhida, não com a quantidade de camadas de código |
| Ruído entre inquilinos (noisy neighbor) | SAP-C02 | o tenant de 40% do volume competindo por I/O com os pequenos | quando isolamento físico (schema ou conta) resolve o que RLS sozinha não resolve |
| Guardrails de conta (SCP) | SAP-C02 | Organizations + SCP como teto do modelo por conta | SCP não concede permissão, só limita o que o IAM local poderia permitir |
| Confused deputy em papel cross-account | SCS-C02, SAP-C02 | o papel que o time da plataforma assume na conta do tenant | por que a ausência de `ExternalId` é a vulnerabilidade, não a política em si |
| Controle de acesso por atributo (ABAC) no DynamoDB | SAP-C02, SCS-C02 | `dynamodb:LeadingKeys` como equivalente de RLS quando o banco é DynamoDB | que a condição amarra a chave de partição à identidade do chamador, não ao código da aplicação |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve RLS habilitada e pergunta por que um relatório rodado por um job de manutenção ainda devolve todos os tenants. A resposta não é "a política está errada" — é que o job roda com o papel DONO da tabela (o mesmo usado para migration), e sem `FORCE ROW LEVEL SECURITY` a política nunca se aplica ao dono. `ENABLE` liga a política; `FORCE` é quem a torna inescapável para quem tem privilégio de dono.
Requisitos, e como cada um muda o desenho
Requisito não funcional que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Vazamento entre tenants | zero, mesmo com bug de aplicação | obriga RLS com `FORCE` como piso — a proteção não pode depender só do código correto |
| Contrato enterprise exige conta própria | pelo menos 1 tenant grande | obriga o modelo por conta para esse segmento — é o que torna a arquitetura um bridge, não um pool puro |
| Custo operacional por tenant pequeno | não pode crescer linearmente com o número de clientes | descarta silo total; mantém pool com RLS como padrão para os 330+ tenants pequenos |
| Tempo de detecção de vazamento | minutos, não meses | obriga canário automatizado publicando métrica, não auditoria manual periódica |
| Migration sem indisponibilidade perceptível | sim | no modelo por linha é um comando; no por schema é um laço sobre N schemas; no por conta é N migrations coordenadas |
| Auditoria de acesso cross-account | obrigatória para o modelo por conta | `ExternalId` no papel assumido, sessão de no máximo 1 hora, CloudTrail habilitado na conta do tenant |
| Volume do maior tenant | 40% do total | motiva isolamento físico (schema ou conta) para esse tenant não competir por I/O com os pequenos |
| Encerramento de contrato de um tenant | apagar 100% dos dados dele, sem tocar nos outros | no modelo por linha é `DELETE WHERE tenant_id`; no por schema é `DROP SCHEMA`; no por conta é fechar a conta — a granularidade cresce na mesma ordem |
Arquitetura mínima: por linha, sem RLS — e o defeito é provado
Este é o desenho que a Rotafácil tem hoje, e ele é um ponto de partida legítimo: atende 340 lojas com uma tabela e uma instância. O laboratório começa por PROVAR o defeito, não descrevê-lo — porque "esqueça o WHERE" soa a detalhe até virar um número real na tela de um cliente.
- → HTTPS 443, token do tenant A
- → encaminha a requisição
- → SQL — o filtro de tenant é o que o desenvolvedor escreveu
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
Este desenho publica e atende os 340 lojistas com uma tabela só — é barato e é o que a maioria dos SaaS B2B tem no primeiro ano. O defeito não está em nenhuma configuração errada: está em uma coluna que existe mas que nada além do bom senso do time obriga a filtrar. Percorra os passos até o ponto em que o filtro falta, e note que o Postgres nunca saberia dizer que algo deu errado.
- O token diz quem é o tenant; a rota decide se pergunta. O JWT do operador carrega a identidade da loja, mas nada nesta arquitetura obriga a aplicação a usá-la. A informação existe; a aplicação de fato usá-la em CADA consulta é um hábito, não uma garantia estrutural.
- A maioria das consultas filtra — por hábito, não por garantia. As rotas mais antigas têm `WHERE tenant_id = @id` porque quem as escreveu lembrou. Isso funciona há dois anos, e é exatamente o que esconde o problema: funcionar por acaso repetido parece funcionar por desenho.
- Um endpoint novo — o painel de métricas agregadas — esqueceu o filtro. O relatório junta `pedidos` com `produtos` por categoria para mostrar tendência de venda. A cláusula `JOIN` tem condição; o `WHERE` de tenant, não. O SQL é válido, compila, os testes de unidade passam — porque os testes usam um único tenant fake.
- Para o motor do banco, não existe "tenant" — existe uma tabela. O RDS Postgres não tem nenhum conceito de fronteira de cliente. Ele executa exatamente a consulta que recebeu, e uma consulta sem filtro é, do ponto de vista do banco, uma consulta perfeitamente válida que devolve todas as linhas pedidas.
- O vazamento aparece na resposta, não em nenhum log de erro. Não há exceção, não há linha 500, não há alarme. O operador do tenant A abre o painel e vê, entre os números, um total que não bate — porque ele inclui pedidos do tenant B. O incidente é descoberto por quem foi vazado, não por quem vazou.
- Por que alguém escreve assim. Porque o `WHERE tenant_id` é uma linha a mais em cada consulta nova, e nada no compilador, no linter ou no code review estrutural obriga a escrevê-la. A pressão de prazo aposta que quem escrever vai lembrar — e na maioria das vezes lembra, o que é justamente o que torna o dia em que não lembra invisível até o cliente reclamar.
A medição abaixo é a versão manual da prova: a mesma rota que vazou em produção, chamada de verdade contra a base de exemplo deste laboratório.
# prova-vazamento.sh — o vazamento medido, antes de qualquer teste automatizado
# Rodado contra a base de EXEMPLO deste laboratorio (340 tenants semeados).
$ psql "$DATABASE_URL" -c \
"SELECT tenant_id, count(*) FROM pedidos GROUP BY tenant_id ORDER BY 2 DESC LIMIT 3"
tenant_id | count
---------------------------------------+-------
3f29a7c1-...-cadencia | 1842
7b41f0aa-...-sapataria-nordeste | 931
9c02e5bb-...-armarinho-tres-marias | 604
# Autenticado como operador da Sapataria Nordeste, chamando a rota nova:
$ curl -s -H "Authorization: Bearer $TOKEN_SAPATARIA" \
"$HOST/painel/metricas-agregadas" | jq '.totalGeral, .tenantsNaResposta'
274193.50
["cadencia", "sapataria-nordeste", "armarinho-tres-marias", "... 337 outros"]
# O numero e a soma de TODOS os 340 tenants. Uma operadora que so deveria
# enxergar a propria loja recebeu o faturamento de 339 concorrentes junto.
E a versão automatizada — a que fica no repositório e roda em todo commit, em vez de depender de alguém lembrar de testar manualmente:
// VazamentoEntreTenantsTests.cs — a prova do defeito, nao a descricao dele
//
// Roda contra um Postgres real via Testcontainers (nao mock: RLS e uma
// garantia do MOTOR, e so o motor de verdade prova ou desmente ela). Semeia
// dois tenants na mesma tabela e chama a consulta exata do endpoint que
// vazou em producao -- sem NENHUMA linha de contexto de tenant por baixo,
// porque o desenho minimo nao tem nenhuma.
public class VazamentoEntreTenantsTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _pg =
new PostgreSqlBuilder().WithImage("postgres:16").Build();
public async Task InitializeAsync() => await _pg.StartAsync();
public async Task DisposeAsync() => await _pg.DisposeAsync();
[Fact(DisplayName = "Consulta sem WHERE tenant_id devolve mais de um tenant")]
public async Task PainelDeMetricas_NaoFiltraTenant()
{
await using var ds = NpgsqlDataSource.Create(_pg.GetConnectionString());
await CriarTabelaSemRlsAsync(ds); // so a tabela; nenhuma politica
await SemearAsync(ds, tenantId: TenantCadencia, quantidadeDePedidos: 1842);
await SemearAsync(ds, tenantId: TenantSapatariaNordeste, quantidadeDePedidos: 931);
// A MESMA consulta do endpoint /painel/metricas-agregadas que vazou em
// producao: agrega pedidos por tenant, sem clausula de tenant nenhuma.
await using var cmd = ds.CreateCommand(
"SELECT tenant_id, SUM(total) FROM pedidos GROUP BY tenant_id");
await using var reader = await cmd.ExecuteReaderAsync();
var tenantsNaResposta = new HashSet<Guid>();
while (await reader.ReadAsync())
tenantsNaResposta.Add(reader.GetGuid(0));
// FALHA DE PROPOSITO no desenho minimo -- e a prova gravada do
// defeito, nao uma descricao dele. Uma unica sessao autenticada como
// Sapataria Nordeste nao deveria enxergar o tenant Cadencia junto.
Assert.True(tenantsNaResposta.Count == 1,
$"VAZAMENTO CONFIRMADO: {tenantsNaResposta.Count} tenants distintos " +
"apareceram na resposta de uma unica sessao autenticada.");
}
}
Isto não é um bug raro — é o pior incidente que um SaaS B2B pode ter
Vazamento de dado entre clientes quebra a premissa básica de contratar um SaaS multi-tenant: que os dados de uma empresa não aparecem para a concorrente que usa o mesmo sistema. Diferente de um 500 ou de uma lentidão, este incidente não se explica com "instabilidade temporária" — ele é motivo de rescisão de contrato, de notificação regulatória quando há dado pessoal envolvido, e de meses reconstruindo a confiança de quem foi vazado. O custo de engenharia para evitá-lo (as próximas seções) é ordens de grandeza menor que o custo de explicá-lo depois.
Arquitetura para produção: por linha, com RLS obrigatória
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. A diferença estrutural em relação ao desenho anterior não é "mais uma caixa": é que a fronteira de tenant deixa de morar no código da aplicação e passa a morar no motor do banco.
- → login com e-mail e senha
- → invoca antes de emitir o token
- → devolve a claim tenant_id assinada
- → access token com a claim tenant_id
- → Authorization: Bearer <token>
- → encaminha a requisição
- → set_config(app.tenant_id) e só então consulta
- → busca a credencial de conexão
- → aciona a cada 5 minutos
- → tenta ler linha de outro tenant
- → publica métrica de vazamento detectado
- Fora da AWS
- Segurança e identidade
- Compute
- Rede e entrega
- Banco de dados
- Integração de apps
- Gestão e governança
A fronteira sai do código da aplicação e desce para o Postgres: com `FORCE ROW LEVEL SECURITY`, nenhuma consulta — nem a de um desenvolvedor que esqueceu o filtro — lê linha de outro tenant, porque o motor recusa antes de devolver qualquer coisa. A claim de tenant chega assinada desde a Cognito, e um vigia próprio tenta vazar dado a cada poucos minutos para provar, em produção, que a proteção continua de pé.
- A claim de tenant é emitida, não declarada pelo cliente. A versão 2 do gatilho de pré-geração de token roda DENTRO do fluxo de autenticação e escreve a claim no access token — o mesmo token que a API vai validar. Um atributo customizado comum some no access token; só a claim explícita do gatilho sobrevive até a API.
- O cliente recebe um token que ele não pode forjar. A claim vem assinada pela Cognito. O operador não pode editar o próprio token para se passar por outro tenant — só pode enviar o que recebeu.
- A aplicação lê o tenant do token; nunca decide sozinha. O middleware valida a assinatura do JWT e extrai a claim. Não há parâmetro de URL, corpo de requisição nem cabeçalho customizado que sirva de fonte alternativa — reduz a superfície de onde o tenant poderia vir errado.
- Cada transação declara o tenant antes de perguntar qualquer coisa. `SELECT set_config('app.tenant_id', @id, true)` roda como primeira instrução da transação. O terceiro argumento `true` é o equivalente parametrizável de `SET LOCAL`: o valor esquece sozinho ao fim da transação, então uma conexão reaproveitada pelo pool nunca herda o tenant da requisição anterior.
- O banco aplica o filtro mesmo se o SQL não pedir. Com `FORCE ROW LEVEL SECURITY`, a política vale até para o dono da tabela. Um `SELECT *` sem `WHERE` — o mesmo erro do desenho anterior — devolve só as linhas do tenant declarado, porque o Postgres reescreve a consulta antes de executar, e a aplicação não tem como contornar isso por engano.
- Um vigia próprio prova a proteção em produção, sem esperar reclamação. O canário se autentica como um tenant de teste e tenta, de propósito, ler uma linha marcada como pertencente a outro. Se conseguir, o alarme dispara em minutos — antes de qualquer cliente notar, e sem depender de ninguém reportar.
Repare no que NÃO mudou: a consulta em `PedidosRepositorio.ListarAsync` continua sem `WHERE tenant_id`, exatamente como no desenho mínimo. O que muda é que agora existe uma política de RLS entre o SQL escrito e o dado devolvido — e é essa camada, não a disciplina de quem escreve a query, que garante o isolamento.
O critério de aceite, numa frase
O mesmo teste que falhava no desenho mínimo (`VazamentoEntreTenantsTests`) passa a rodar contra este desenho e passa — sem NENHUMA linha de `WHERE tenant_id` escrita à mão. Se o teste só passar depois de um desenvolvedor adicionar filtro manual em algum lugar, a arquitetura de produção não está pronta: a garantia teria voltado a depender de memória humana.
O caminho de uma consulta, ponta a ponta
A claim de tenant não nasce num formulário nem num cabeçalho customizado: ela nasce num gatilho da Cognito, disparado antes de o token ser emitido, e chega à API já assinada.
// access token decodificado (payload) -- o que a API realmente recebe
{
"sub": "3f29a7c1-...",
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXX",
"client_id": "5g8h...",
"token_use": "access",
"scope": "rotafacil-api/pedidos.leitura",
// Esta claim NAO existe por padrao no access token. Ela so aparece porque
// o gatilho de pre-geracao de token, versao 2, foi configurado para
// escreve-la em accessTokenGeneration.claimsToAddOrOverride -- a versao 1
// do gatilho so alcanca o ID token, que a API nunca ve.
"custom:tenant_id": "7b41f0aa-2222-4444-8888-sapataria-nordeste",
"exp": 1723046400,
"iat": 1723042800
}
A armadilha do atributo customizado comum
Um atributo customizado do perfil do usuário (`custom:tenant_id` cadastrado direto no pool) aparece no ID TOKEN por padrão — e não no access token, que é o que a maioria das APIs valida em cada requisição. Confiar nisso sem testar produz uma claim que existe no login e desaparece na primeira chamada de API. É por isso que este módulo usa o gatilho de pré-geração de token na versão 2: ela escreve explicitamente em `accessTokenGeneration.claimsToAddOrOverride`, o único caminho documentado para uma claim de aplicação chegar ao access token.
As decisões, e o que se perde em cada uma
📋 Uma plataforma B2B com 340 lojas-cliente, a maioria pequena e algumas grandes (a maior responde por 40% do volume), sem orçamento para operar infraestrutura dedicada por cliente, mas com um incidente de vazamento real já registrado e um contrato enterprise em negociação que exige isolamento "em conta própria".
Nenhum dos três modelos sozinho serve aos dois extremos do catálogo de clientes. Silo por conta para 340 lojas pequenas multiplicaria o custo operacional por 340 sem benefício proporcional — a maioria delas não gera tráfego nem risco que justifique uma conta inteira. Pool por linha sem exceção deixaria o tenant grande (40% do volume) competindo por capacidade com os pequenos e sujeito ao MESMO raio de incidente que eles, apesar de ter exigência contratual maior. O modelo bridge aplica RLS como piso universal (nenhum tenant fica sem proteção de banco) e reserva conta dedicada para onde o contrato ou o volume justificam o custo — que é exatamente o que o whitepaper da AWS chama de estratégia mista.
Alt: Silo total: uma conta por tenant, desde o primeiro cliente — Isolamento máximo, mas o custo de on-boarding, patch, monitoramento e suporte multiplica por 340 desde o primeiro dia — inviável para tenants que pagam uma fração do que uma conta dedicada custa para operar.
Alt: Pool total: uma tabela com RLS, sem exceção para ninguém — Mais barato e mais simples de operar, mas o tenant grande compete por I/O e CPU com os pequenos (ruído de vizinhança), e nenhuma cláusula contratual de "dados em conta própria" pode ser cumprida sem migrar depois — sob pressão, com o cliente já dentro.
Alt: Schema por tenant para todos — Isolamento melhor que linha, pior que conta, mas migração de schema deixa de ser UM comando e passa a ser um laço sobre 340 conexões — o tempo de deploy cresce com o número de tenants, não com o tamanho da mudança.
Alt: Adiar a decisão até o incidente acontecer — É a situação real que abre este laboratório: o vazamento já ocorreu com pool sem RLS. Corrigir depois do incidente custa a confiança do cliente vazado, não só a engenharia.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Fronteira padrão para a maioria dos tenants | linha, com RLS obrigatória | linha sem RLS; schema para todos | zero custo adicional de infraestrutura por tenant pequeno | continua sendo um banco físico só — falha de instância afeta todos os tenants pool ao mesmo tempo |
| Como declarar o tenant na sessão | `set_config(..., true)` por transação | `SET LOCAL` direto; `SET` de sessão; variável de aplicação em memória | `set_config` aceita parâmetro ligado; `SET` não aceita e convida a montar SQL por concatenação | uma instrução extra no início de toda transação que toca a tabela |
| Claim de tenant no token | gatilho de pré-geração de token, versão 2 | atributo customizado simples; claim no ID token; header customizado enviado pelo cliente | é o único caminho que coloca a claim no ACCESS token, assinado e não forjável pelo cliente | uma função Lambda a mais para manter e versionar |
| Detecção de vazamento | canário ativo, autenticado, tentando vazar de propósito | auditoria de log manual; revisão de código; alarme só em erro de aplicação | prova a proteção em produção de forma contínua, não só no ambiente de teste | uma tabela de dado sintético e uma Lambda agendada a mais para operar |
| Isolamento para o tenant contratual grande | conta AWS dedicada via Organizations | schema dedicado com cota de recurso; réplica de leitura exclusiva | é a única fronteira que cumpre "dados em conta própria" de forma literal e auditável | on-boarding, patch e monitoramento passam a existir por tenant, não só por plataforma |
A dívida que o modelo por linha cria, e que este módulo não esconde
Mesmo com RLS, todos os tenants pool continuam numa única instância RDS: manutenção, upgrade de versão e uma falha de AZ afetam os 330+ tenants ao mesmo tempo. Isso é aceitável para a maioria — é o trade-off que o requisito de custo por tenant pequeno pede — mas não é isolamento de infraestrutura, é isolamento de DADO. Quem precisa das duas coisas está no segmento que vai para o modelo por conta.
Construir: RLS no Postgres, e por que FORCE não é opcional
A trava central deste laboratório mora aqui. Sem `FORCE ROW LEVEL SECURITY`, a política vale para a maioria das conexões e não vale para exatamente as que mais importam: o papel dono da tabela, tipicamente o mesmo usado para rodar migration.
-- rls.sql — a fronteira desce do codigo da aplicacao para o motor do banco
-- O papel que a aplicacao usa NUNCA pode ter BYPASSRLS nem ser dono da tabela.
-- Sao os dois jeitos silenciosos de a protecao abaixo nao valer para ninguem.
CREATE ROLE app_rotafacil LOGIN PASSWORD '(via Secrets Manager, nunca aqui)'
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
GRANT SELECT, INSERT, UPDATE, DELETE ON pedidos TO app_rotafacil;
GRANT USAGE ON SCHEMA public TO app_rotafacil;
-- Habilita a politica de linha na tabela.
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
-- FORCE fecha a brecha mais comum: sem ele, o DONO da tabela (tipicamente o
-- papel que roda migration) continua ignorando a politica. Isso pega quem usa
-- o mesmo papel para aplicacao e para migration -- erro comum de day one.
ALTER TABLE pedidos FORCE ROW LEVEL SECURITY;
-- A politica. USING filtra LEITURA; WITH CHECK filtra ESCRITA -- sem o
-- segundo, um INSERT ou UPDATE poderia gravar tenant_id de outro cliente,
-- mesmo que a leitura subsequente ja estivesse protegida.
CREATE POLICY isolamento_por_tenant ON pedidos
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- O segundo argumento `true` de current_setting evita excecao quando a
-- variavel de sessao nao foi definida (conexao sem contexto de tenant): a
-- politica compara tenant_id com NULL, que nunca e verdadeiro, e a consulta
-- devolve ZERO linhas em vez de estourar erro. Fail-closed, nao fail-open --
-- uma conexao que "esqueceu" de declarar o tenant nao ve nada, nunca ve tudo.
-- Indice composto: toda politica de RLS vira um predicado extra em CADA
-- consulta, entao tenant_id precisa estar na esquerda de todo indice usado
-- por rota quente, ou o ganho de RLS custa um scan sequencial por chamada.
CREATE INDEX idx_pedidos_tenant_criado ON pedidos (tenant_id, criado_em DESC);
O papel de aplicação com BYPASSRLS anula tudo isto em silêncio
Um papel com o atributo `BYPASSRLS` — ou um superusuário — ignora toda política de RLS, `FORCE` ou não. Não há erro, não há aviso: as consultas simplesmente voltam a devolver todos os tenants, exatamente como no desenho mínimo. A causa mais comum é conveniência de operação: alguém cria o papel de aplicação copiando o de administração "para não ter que lidar com permissão depois". Audite com `SELECT rolname, rolbypassrls FROM pg_roles` e trate qualquer `t` fora dos papéis de operação como incidente.
Construir: o contexto de tenant por transação, em C#
A claim chega assinada; falta levá-la, sem intermediário que o cliente controle, até a consulta. O middleware extrai a claim do token já validado; o repositório declara o tenant como primeira instrução de CADA transação que toca uma tabela com RLS.
// TenantContext.cs — a claim assinada vira contexto de sessao, por transacao
public sealed class TenantContextMiddleware
{
private readonly RequestDelegate _proximo;
public TenantContextMiddleware(RequestDelegate proximo) => _proximo = proximo;
public async Task InvokeAsync(HttpContext ctx)
{
// A claim so existe porque o gatilho de pre-geracao de token (v2) a
// escreveu no access token. Se ela nao estiver aqui, a requisicao nao
// tem tenant -- e e rejeitada, nao assumida como "publica".
var raw = ctx.User.FindFirst("custom:tenant_id")?.Value;
if (!Guid.TryParse(raw, out var tenantId))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
ctx.Items["TenantId"] = tenantId;
await _proximo(ctx);
}
}
// Repositorio base: TODA consulta passa por aqui, e a declaracao do tenant e
// a PRIMEIRA instrucao da transacao -- antes de qualquer SELECT do caso de uso.
public abstract class RepositorioComTenant
{
private readonly NpgsqlDataSource _dataSource;
private readonly IHttpContextAccessor _http;
protected RepositorioComTenant(NpgsqlDataSource dataSource, IHttpContextAccessor http)
{
_dataSource = dataSource;
_http = http;
}
protected async Task<NpgsqlConnection> AbrirComTenantAsync(NpgsqlTransaction tx)
{
var tenantId = (Guid)_http.HttpContext!.Items["TenantId"]!;
// `SET LOCAL app.tenant_id = @id` NAO existe como comando parametrizavel
// -- o protocolo SET nao aceita bind parameter. `set_config(..., true)`
// e a forma segura e parametrizavel: o terceiro argumento `true` e
// exatamente o que `SET LOCAL` faz -- o valor vale so ate o COMMIT ou
// ROLLBACK da transacao atual.
//
// Isto e o que torna seguro reusar conexao de um pool: a proxima
// transacao na MESMA conexao fisica nao herda o tenant_id da anterior,
// porque ele nunca sobreviveu ao fim da transacao passada.
await using var cmd = new NpgsqlCommand(
"SELECT set_config('app.tenant_id', $1, true)", tx.Connection, tx);
cmd.Parameters.AddWithValue(tenantId.ToString());
await cmd.ExecuteNonQueryAsync();
return tx.Connection!;
}
}
public sealed class PedidosRepositorio : RepositorioComTenant
{
public PedidosRepositorio(NpgsqlDataSource ds, IHttpContextAccessor http) : base(ds, http) { }
public async Task<IReadOnlyList<Pedido>> ListarAsync(NpgsqlTransaction tx)
{
var conn = await AbrirComTenantAsync(tx);
// Sem WHERE tenant_id. De proposito: a linha abaixo e IDENTICA ao
// endpoint que vazou no desenho minimo. A diferenca inteira do
// resultado vem da politica de RLS que o banco aplica por baixo.
await using var cmd = new NpgsqlCommand("SELECT id, tenant_id, total FROM pedidos", conn, tx);
await using var reader = await cmd.ExecuteReaderAsync();
var pedidos = new List<Pedido>();
while (await reader.ReadAsync())
pedidos.Add(new Pedido(reader.GetGuid(0), reader.GetGuid(1), reader.GetDecimal(2)));
return pedidos;
}
}
O pool de conexão é onde este desenho quebra se alguém usar `SET` puro
Com `Npgsql` (como com qualquer driver que faz pooling), uma conexão física é reaproveitada entre requisições de tenants DIFERENTES. `SET app.tenant_id = ...` de sessão sobrevive ao fim da transação — a próxima requisição a pegar aquela conexão do pool herdaria o tenant da anterior até declarar o seu, e uma requisição que só faz leitura rápida pode nem chegar a declarar nada antes de consultar. `set_config(..., true)` evita isso porque o valor expira automaticamente no `COMMIT` ou `ROLLBACK`, não no fim da conexão.
Construir: o modelo por schema, e o preço que ele cobra depois
Isolamento melhor que por linha: um erro de aplicação que ignora o `search_path` alcança, no pior caso, o schema conectado — não a base inteira. O preço aparece na operação, não no desenho: uma migration que era um comando vira um laço.
-- provisionar_schema.sql — o preco do isolamento por schema: on-boarding vira DDL
-- Chamado pela funcao de on-boarding quando um tenant novo assina o plano
-- "schema dedicado". O nome do schema NUNCA vem direto do input do usuario --
-- vai por um identificador interno gerado pela aplicacao, para impedir
-- injecao via nome de schema (identifiers nao aceitam parametro ligado).
CREATE OR REPLACE FUNCTION provisionar_schema_tenant(p_tenant_slug text)
RETURNS void AS $$
DECLARE
v_schema text := 'tenant_' || p_tenant_slug;
BEGIN
EXECUTE format('CREATE SCHEMA %I', v_schema);
-- Clona a estrutura de um schema-molde (`_template`) mantido em migration.
-- Isto e o efeito colateral que o modelo por schema cobra: uma migration
-- que no modelo por linha e UM comando aqui vira um loop sobre N schemas.
EXECUTE format(
'CREATE TABLE %I.pedidos (LIKE _template.pedidos INCLUDING ALL)', v_schema);
EXECUTE format(
'CREATE TABLE %I.itens_pedido (LIKE _template.itens_pedido INCLUDING ALL)', v_schema);
-- O papel de aplicacao ganha USAGE apenas no schema deste tenant -- ele
-- continua sem enxergar os outros 339 por falta de GRANT, nao por politica.
EXECUTE format('GRANT USAGE ON SCHEMA %I TO app_rotafacil', v_schema);
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO app_rotafacil', v_schema);
END;
$$ LANGUAGE plpgsql;
#!/usr/bin/env bash
# migrar_todos_os_schemas.sh — a migration que era um comando virou um laco
set -euo pipefail
# No modelo por linha, aplicar uma migration e UM `dotnet ef database update`.
# Aqui, e este laco -- e ele cresce linearmente com o numero de tenants no
# plano "schema dedicado", nao com o tamanho da mudanca.
SCHEMAS=$(psql "$DATABASE_URL" -tAc \
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant_%'")
falhas=0
for schema in $SCHEMAS; do
echo "migrando ${schema}..."
# search_path troca o alvo do runner de migration SEM duplicar a conexao.
if ! psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-c "SET search_path TO ${schema}, public;" \
-f migrations/2026_08_07_add_coluna_prioridade.sql; then
echo "FALHOU em ${schema} -- os demais continuam, mas este tenant fica na versao antiga" >&2
falhas=$((falhas+1))
fi
done
# Uma migration parcialmente aplicada -- alguns schemas na v42, outros na v41
# -- e o modo de falha caracteristico deste modelo, e ele nao existe no
# modelo por linha (onde ha uma tabela so, uma transacao so).
echo "concluido: ${falhas} schema(s) com falha"
[ "$falhas" -eq 0 ]
Migration parcial é o modo de falha que só existe neste modelo
No modelo por linha há uma tabela e uma transação de migration. Aqui, se o laço falha no schema 214 de 340 — por lock, por espaço em disco, por timeout — os primeiros 213 já estão na versão nova e os demais não. O sistema fica com tenants em versões de schema DIFERENTES ao mesmo tempo, e o código da aplicação precisa tolerar as duas por um período, ou o script precisa ser idempotente e recomeçável pelo ponto da falha.
Construir: o modelo por conta, para quem exige a fronteira mais forte
Não existe rede, IAM nem credencial que alcance, por padrão, a conta vizinha. É a única fronteira deste laboratório que continua de pé mesmo se o código da aplicação tiver um bug de isolamento — porque o bug simplesmente não tem como atravessar a fronteira de conta.
# conta.tf — o modelo por conta: a fronteira e a propria conta AWS
# Uma conta de membro por tenant grande. O nome e o e-mail raiz sao gerados
# pela plataforma, nunca digitados a mao -- isso e o que permite este
# recurso ser criado por automacao de on-boarding, nao por ticket manual.
resource "aws_organizations_account" "tenant" {
for_each = var.tenants_enterprise # ex.: { "cadencia" = {...}, "outraloja" = {...} }
name = "rotafacil-tenant-${each.key}"
email = "aws-tenant-${each.key}@rotafacil.example.com"
parent_id = aws_organizations_organizational_unit.tenants_enterprise.id
# SCPs anexados a esta OU sao o TETO do que a conta pode fazer, mesmo que
# o IAM local permita mais -- e a fronteira de governanca que nenhum
# `Resource: "*"` de dentro da conta consegue furar.
role_name = "OrganizationAccountAccessRole"
lifecycle {
prevent_destroy = true # fechar conta por engano perde os dados do tenant
}
}
# O time da plataforma acessa a conta do tenant assumindo um papel -- nunca
# com credencial de longa duracao criada dentro da conta do cliente.
data "aws_iam_policy_document" "confianca_cross_account" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = [var.conta_plataforma_arn]
}
# ExternalId evita o problema do "deputado confuso": sem ele, qualquer
# entidade que descubra o ARN do papel na conta do tenant poderia
# assumi-lo. Com ele, so quem tambem conhece o segredo compartilhado passa.
condition {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.external_id_por_tenant[each.key]]
}
}
}
resource "aws_iam_role" "acesso_plataforma" {
provider = aws.tenant_account
for_each = var.tenants_enterprise
name = "PlataformaRotafacilAcesso"
assume_role_policy = data.aws_iam_policy_document.confianca_cross_account.json
max_session_duration = 3600 # credencial de sessao curta, nunca de longa duracao
}
ExternalId ausente transforma o papel cross-account num convite
Sem a condição `sts:ExternalId`, qualquer entidade que descubra o ARN do papel `PlataformaRotafacilAcesso` na conta do tenant — e ARN de papel não é segredo, aparece em log e em erro de permissão — pode tentar assumi-lo. Com o `ExternalId`, só quem também conhece o valor compartilhado entre as duas contas passa. Esta é a defesa contra o "problema do deputado confuso" que a certificação de segurança cobra por nome, e a defesa inteira é uma condição de uma linha que é fácil esquecer porque, sem ela, o `assume-role` também funciona — só que para qualquer um.
Para o tenant que ainda está no plano DynamoDB em vez de RDS, o equivalente funcional de RLS não é um recurso do banco — é uma condição de política de IAM que amarra a chave de partição à identidade de quem chama:
{
"// comentario": "policy de exemplo -- JSON real nao aceita comentario",
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/pedidos",
"Condition": {
"ForAllValues:StringLike": {
"dynamodb:LeadingKeys": ["${aws:PrincipalTag/tenant_id}*"]
}
}
}]
}
`dynamodb:LeadingKeys` exige que a chave de partição da linha acessada comece pelo valor da tag de sessão `tenant_id` do papel assumido — geralmente obtida de `sts:AssumeRole` com tags de sessão (ABAC), uma por login. Sem essa condição, uma credencial do DynamoDB tem acesso a QUALQUER partição da tabela, e o isolamento volta a depender só do código.
Construir: o canário que tenta vazar dado em produção, de propósito
Todos os testes até aqui rodam antes do deploy. Este roda DEPOIS, contra o ambiente real, a cada poucos minutos — porque a única prova que importa de verdade é a de que a proteção continua de pé hoje, não a de que ela estava de pé quando o código foi revisado.
# canario.py — o vigia que tenta vazar dado a cada 5 minutos
import os
import boto3
import psycopg
TENANT_TESTE = os.environ["TENANT_TESTE_ID"] # tenant so para o canario
TENANT_ALVO = os.environ["TENANT_ALVO_ID"] # tenant "vizinho" no teste
cloudwatch = boto3.client("cloudwatch")
def handler(event, context):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
# Declara o tenant de teste -- exatamente como a API declararia.
cur.execute("SELECT set_config('app.tenant_id', %s, true)", (TENANT_TESTE,))
# Tenta, de proposito, ler uma linha marcada como pertencente ao
# tenant "vizinho". Numa fronteira que funciona, zero linhas.
cur.execute(
"SELECT count(*) FROM pedidos WHERE tenant_id = %s", (TENANT_ALVO,)
)
vazou = cur.fetchone()[0]
conn.rollback() # o set_config e transacional; isto tambem o descarta
cloudwatch.put_metric_data(
Namespace="Rotafacil/IsolamentoTenant",
MetricData=[{
"MetricName": "LinhasVazadasNoCanario",
"Value": float(vazou),
"Unit": "Count",
}],
)
if vazou > 0:
# Nao levanta excecao silenciosa -- o alarme do CloudWatch e quem
# acorda alguem, mas o log entra na trilha de qualquer forma.
print(f"ALERTA: canario leu {vazou} linha(s) do tenant vizinho")
return {"linhasVazadas": vazou}
Por que o canário usa `rollback`, não `commit`
O canário lê de propósito, nunca escreve dado real, e desfaz a própria transação ao final. Isso o torna seguro de rodar em produção continuamente: na pior das hipóteses ele confirma um vazamento (e isso é exatamente o que se quer detectar cedo) — ele nunca cria, nunca altera, nunca cobra espaço em disco além dos poucos milissegundos da própria consulta.
Implantar, e provar com número — não com sensação
Quatro provas. Nenhuma conclusão vem de "parece que funcionou".
// VazamentoEntreTenantsTests.cs — o MESMO ataque, contra o desenho de producao
//
// O teste da secao anterior falhava (vermelho) porque nao havia fronteira
// nenhuma abaixo da aplicacao. Este arquivo reusa o mesmo ataque -- a mesma
// consulta sem WHERE -- mas agora contra a infraestrutura real deste modulo:
// tabela com FORCE ROW LEVEL SECURITY e o repositorio que declara o tenant
// via set_config antes de consultar.
public class VazamentoEntreTenantsTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _pg =
new PostgreSqlBuilder().WithImage("postgres:16").Build();
public async Task InitializeAsync() => await _pg.StartAsync();
public async Task DisposeAsync() => await _pg.DisposeAsync();
[Fact(DisplayName = "Com RLS obrigatoria, a mesma consulta sem WHERE nunca vaza")]
public async Task PainelDeMetricas_NuncaExpoeOutroTenant()
{
await using var ds = NpgsqlDataSource.Create(_pg.GetConnectionString());
await AplicarRlsSqlAsync(ds); // executa rls.sql sobre o container real
await SemearAsync(ds, tenantId: TenantCadencia, quantidadeDePedidos: 1842);
await SemearAsync(ds, tenantId: TenantSapatariaNordeste, quantidadeDePedidos: 931);
var http = new HttpContextAccessorFalso(tenantId: TenantSapatariaNordeste);
var repo = new PedidosRepositorio(ds, http);
await using var conn = await ds.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync();
// O MESMO SQL sem WHERE do teste anterior -- o repositorio nunca
// filtra por tenant no C#. Quem filtra e a politica no Postgres.
var pedidos = await repo.ListarAsync(tx);
Assert.All(pedidos, p => Assert.Equal(TenantSapatariaNordeste, p.TenantId));
// Verde: nenhuma linha da Cadencia atravessou, mesmo com a consulta
// exatamente igual a que vazou no desenho minimo.
}
}
| # | Prova | Comando | Resultado que aprova | O que a reprovação significa |
|---|---|---|---|---|
| 1 | O mesmo teste que falhava agora passa | dotnet test --filter VazamentoEntreTenantsTests | 1 de 1 aprovado, sem nenhum WHERE tenant_id novo no C# | se falhar, a política de RLS não está ativa ou o papel usado no teste tem BYPASSRLS |
| 2 | A política existe e está com FORCE ligado | psql -c "SELECT relforcerowsecurity FROM pg_class WHERE relname='pedidos'" | t (true) | f significa que o dono da tabela — e qualquer job que rode com esse papel — continua vendo tudo |
| 3 | O canário não encontra vazamento na conta compartilhada | aws lambda invoke --function-name canario-isolamento saida.json && cat saida.json | {"linhasVazadas": 0} | qualquer valor acima de zero é um incidente ativo, não um teste que falhou |
| 4 | O papel cross-account recusa assumir sem o ExternalId certo | aws sts assume-role --role-arn arn:...:role/PlataformaRotafacilAcesso --role-session-name teste | AccessDenied (sem --external-id) e sucesso só com o valor correto | sucesso sem ExternalId é a vulnerabilidade de deputado confuso, aberta |
A prova que mais importa é a primeira, não a última
É tentador correr para o canário e para a conta cross-account, que parecem mais sofisticados. Mas a prova 1 é a que sustenta 330+ dos 340 tenants: se ela falhar, nenhuma das outras três compensa, porque a maioria dos clientes está no modelo por linha, não no por conta.
Uma tabela tem `ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY` e a política de isolamento criada, mas um job de manutenção que roda com o papel dono da tabela continua lendo todos os tenants. Qual é a causa mais provável?
Quebrar de propósito: três falhas e o diagnóstico
As três falhas abaixo foram provocadas de propósito nesta base de exemplo, para que o sintoma fique reconhecível antes de acontecer em produção.
| Falha provocada | Sintoma | Onde olhar | Correção |
|---|---|---|---|
| Papel de aplicação recriado a partir do papel de migration (herdou dono da tabela) | RLS parece configurada certa, mas consultas seguem devolvendo todos os tenants | `SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'app_rotafacil'` | criar papel de aplicação isolado do papel de migration, sem `BYPASSRLS` nem posse da tabela |
| Middleware usa `SET app.tenant_id` (sessão) em vez de `set_config(..., true)` | vazamento intermitente — só aparece sob carga, quando o pool reaproveita conexão entre tenants | log de auditoria do Postgres mostrando `app.tenant_id` de um tenant numa transação de outro | trocar por `set_config` com o terceiro argumento `true`, transacional |
| Papel cross-account sem `sts:ExternalId` ou com o mesmo valor para todos os tenants | `assume-role` funciona vindo de qualquer entidade que tenha o ARN do papel | a política de confiança (`assume_role_policy`) do papel na conta do tenant | ExternalId único por tenant, gerado pela plataforma, nunca reaproveitado |
A segunda falha é a mais perigosa das três, e a mais silenciosa
Ela só se manifesta sob concorrência real — os testes de integração comuns, que rodam um request de cada vez, não a pegam. Ela chega a produção passando por toda a suíte de testes, e o primeiro sinal costuma ser um cliente reportando dado que não é dele, exatamente como no incidente que abriu este módulo. É também o motivo pelo qual o canário da seção anterior roda continuamente: ele é o único mecanismo deste desenho capaz de pegar esta falha específica antes de um cliente.
Segurança: onde cada modelo pode falhar, e como isso é detectado
O risco central deste laboratório — vazamento entre tenants — muda de forma conforme o modelo, mas nunca chega a zero sozinho. Cada linha abaixo é um jeito real de a fronteira falhar, não um risco genérico de checklist.
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Consulta sem filtro de tenant (modelo por linha, sem RLS) | Alta | Crítico — vazamento de dado de negócio entre clientes | RLS com FORCE como padrão de todo repositório novo | canário contínuo + teste automatizado no CI | revogar acesso do endpoint, notificar tenants afetados, corrigir a política |
| Papel de aplicação com BYPASSRLS ou dono da tabela | Média | Crítico — anula a proteção inteira sem sintoma visível | papel de aplicação separado do papel de migration, nunca superusuário | auditoria periódica de `pg_roles`; alarme se um papel novo ganhar o atributo | remover o atributo, forçar rotação de credencial, revisar quem criou o papel |
| `SET` de sessão em vez de `set_config` transacional | Média | Alto — vazamento intermitente sob carga, difícil de reproduzir | lint de código que proíbe `SET` fora de `set_config(..., true)` em código de acesso a dado | log de auditoria do Postgres cruzado com o tenant esperado por requisição | trocar a chamada, e auditar quantas requisições passaram pela conexão contaminada |
| Papel cross-account sem `ExternalId` único por tenant | Baixa | Crítico — qualquer entidade com o ARN pode assumir o papel | ExternalId gerado pela plataforma, armazenado no Secrets Manager, nunca reaproveitado | CloudTrail na conta do tenant, alarme em `AssumeRole` fora do papel esperado | rotacionar o ExternalId, revisar sessões assumidas no período |
| Tenant grande consumindo I/O do banco compartilhado | Alta em pico | Médio — degradação para os tenants pequenos, não vazamento de dado | migrar o tenant de maior volume para schema ou conta dedicados | métrica de latência por tenant via `pg_stat_statements` etiquetada | mover o tenant identificado para isolamento físico |
RLS não substitui o menor privilégio em IAM — soma-se a ele
Um papel de aplicação sem `BYPASSRLS` ainda pode ter permissão de IAM excessiva na conexão com o banco (por exemplo, acesso administrativo ao RDS via IAM que permita trocar o próprio papel de conexão). RLS protege a CONSULTA; a política de IAM da credencial que a task usa para se conectar protege o CAMINHO até a consulta. As duas fronteiras são independentes e nenhuma cobre a ausência da outra.
Observabilidade: as perguntas que o painel tem de responder
- O canário encontrou alguma linha vazada nos últimos 5 minutos?
- Algum papel de conexão ganhou BYPASSRLS desde a última auditoria?
- Qual tenant está consumindo I/O ou tempo de CPU desproporcional ao próprio volume?
- Alguma sessão assumiu o papel cross-account fora da janela e do ExternalId esperados?
- Quantos schemas estão em versão de migration diferente da mais recente, e há quanto tempo?
| Alarme | Métrica | Limiar inicial | Por que este limiar |
|---|---|---|---|
| Canário de vazamento | LinhasVazadasNoCanario (custom, CloudWatch) | > 0 em qualquer avaliação | não existe valor aceitável acima de zero — é vazamento confirmado, não tendência |
| Papel com BYPASSRLS fora do esperado | contagem via job agendado sobre pg_roles | > 0 papéis fora da lista de exceção | a lista de exceção (superusuário de operação) é pequena e conhecida; qualquer novo é desvio |
| Latência de consulta por tenant | p99 de `pg_stat_statements` etiquetado por tenant_id | 3× a mediana dos tenants pool | sinal de ruído de vizinhança antes de virar reclamação |
| AssumeRole fora do papel esperado | CloudTrail Insights na conta do tenant | qualquer AssumeRole de identidade não cadastrada | a lista de quem pode assumir o papel é pequena e não deveria crescer sem revisão |
| Schemas divergentes de versão | contagem de schemas != versão da migration mais recente | > 0 por mais de 1 hora | divergência curta é esperada durante o rollout; persistente é migration travada |
A métrica que mais economiza tempo de diagnóstico
O canário é o único sinal desta lista que responde "vazou?" diretamente, sem inferência. As outras métricas apontam CONDIÇÕES que aumentam o risco (um papel com privilégio a mais, uma sessão fora do esperado); o canário mede o EVENTO em si, executando o próprio ataque que se quer prevenir.
Escala: 10, 10 mil, 1 milhão de pedidos — e falha de AZ
| Ordem de grandeza | Modelo por linha (RLS) | Modelo por schema | Modelo por conta | Falha de AZ |
|---|---|---|---|---|
| 10 tenants, tráfego baixo | sobra folga na mesma instância pequena | sem ganho perceptível; overhead de N schemas não se paga ainda | inviável — custo fixo por conta não se justifica para 10 clientes pequenos | RDS Multi-AZ cobre todos os tenants pool com um único failover |
| 10 mil pedidos/dia agregados | RLS acrescenta um predicado por consulta; índice composto (tenant_id, ...) mantém o plano de execução saudável | schema começa a compensar para os tenants no top 10% de volume | ainda restrito aos tenants com exigência contratual, não por volume | cada schema falha e recupera junto — mesma instância |
| 1 milhão de pedidos/dia, tenant maior com 40% | o tenant grande compete por I/O com os pequenos mesmo protegido por RLS — RLS isola DADO, não CAPACIDADE | schema dedicado para o tenant grande reduz o ruído, mas ainda compete por CPU/memória da instância | conta dedicada dá ao tenant grande sua própria capacidade, sem competir com ninguém | o tenant em conta própria escolhe sua própria estratégia de Multi-AZ, independente dos demais |
| Falha de AZ na instância pool | todos os 300+ tenants pool sofrem o mesmo failover ao mesmo tempo | idem — schema não isola infraestrutura, só namespace lógico | o tenant em conta própria tem seu próprio raio de falha, isolado dos demais | — |
O efeito que só aparece quando você procura
RLS resolve o isolamento de DADO em qualquer escala — a política não fica mais fraca com mais tenants. O que não escala junto é a CAPACIDADE compartilhada: adicionar RLS não muda o fato de que 340 tenants competem pelo mesmo CPU e pela mesma memória de uma instância RDS. Confundir as duas coisas é o erro mais comum de quem acabou de implementar RLS e assume que "resolveu multi-tenant" — resolveu a metade que era sobre dado.
Custo: o que cada modelo acrescenta à fatura
Nenhum valor absoluto abaixo: os modelos diferem em QUANTAS unidades de infraestrutura existem, e essa é a dimensão que decide o custo — calcule os números da sua conta no AWS Pricing Calculator.
| Cenário | Modelo por linha (pool + RLS) | Modelo por schema (bridge) | Modelo por conta (silo) |
|---|---|---|---|
| Protótipo — poucos tenants pequenos | 1 instância RDS para todos; custo cresce com uso agregado, não com contagem de tenant | não se justifica ainda — overhead de gestão de schema sem volume que o compense | inviável — custo fixo mínimo por conta (suporte, guardrails, monitoramento) não se paga |
| Produção pequena — 340 tenants, a maioria pequena | uma instância dimensionada para o agregado; é o cenário deste laboratório | reservado para o top 5–10% por volume — algumas dezenas de schemas, gerência ainda simples | reservado para os poucos tenants com exigência contratual — cada conta soma custo fixo de governança |
| Alta escala — 10 mil+ tenants | uma instância só não aguenta; passa a exigir múltiplas instâncias por faixa de tenant (sharding), o que reintroduz parte da complexidade do schema | centenas de schemas — o laço de migration da seção 11 vira o gargalo operacional dominante | dezenas a centenas de contas — custo de CloudTrail, Config e Security Hub multiplicando por conta se torna a linha visível |
| O que dominam os cenários | Onde o custo se esconde |
|---|---|
| Modelo por linha | parece "grátis" por tenant, mas o tenant grande sozinho pode forçar upgrade de instância para todos os outros — o custo dele é socializado |
| Modelo por schema | a fatura de infraestrutura cresce pouco; o custo real é hora de engenharia operando N migrations, não visível na fatura da AWS |
| Modelo por conta | CloudTrail, Config, GuardDuty e Security Hub cobram POR CONTA — 50 contas de tenant multiplicam essas linhas por 50, mesmo com tráfego baixo em cada uma |
O antipadrão que transforma custo previsível em imprevisível
Colocar um tenant de alto volume no pool "porque ainda não deu problema" adia o custo, não o elimina: ele reaparece como upgrade de instância inteira — pago por TODOS os 340 tenants — no dia em que o volume do maior finalmente satura a capacidade compartilhada. Migrar esse tenant para schema ou conta ANTES do limite é o que mantém o custo dos pequenos estável.
Well-Architected nos seis pilares
| Pilar | Situação | Risco | Melhoria | Prioridade |
|---|---|---|---|---|
| Segurança | RLS obrigatória no modelo padrão; conta dedicada para o segmento contratual | papel com BYPASSRLS criado por engano; ExternalId ausente no cross-account | auditoria automatizada de pg_roles e de políticas de confiança cross-account | Alta |
| Confiabilidade | canário contínuo detecta vazamento em minutos | instância RDS única é ponto único de falha para 300+ tenants pool | Multi-AZ na instância pool; plano de migração para os tenants que crescerem além da capacidade | Alta |
| Eficiência de performance | índice composto (tenant_id, ...) mantém o plano de RLS eficiente | tenant de 40% do volume degrada p99 dos pequenos sob pico | monitorar latência por tenant e migrar o outlier para schema/conta antes do limite | Média |
| Otimização de custo | pool evita custo fixo por tenant pequeno | CloudTrail/Config/GuardDuty multiplicando por conta no modelo silo | consolidar auditoria de contas de tenant via Organizations, não por conta isolada | Média |
| Excelência operacional | migration é 1 comando no pool; vira laço com risco parcial no schema | schema divergente de versão sem detecção automática | alarme de contagem de schemas fora da versão mais recente, com corte de tempo | Média |
| Sustentabilidade | pool concentra utilização, reduzindo capacidade ociosa por tenant | silo total para todos os tenants desperdiçaria capacidade reservada e não usada | manter o bridge como padrão evita superprovisionamento por tenant pequeno | Baixa |
Evolução em níveis: de uma tabela comum à plataforma com IA isolada por tenant
Não é uma escada de "qual modelo é melhor" — é o que muda de risco e de custo conforme o catálogo de clientes cresce e se diversifica.
Uma tabela sem coluna de tenant nenhuma, porque só existe um cliente.Coluna tenant_id na tabela, filtro escrito à mão em cada consulta.RLS obrigatória com FORCE, claim de tenant assinada pela Cognito, canário contínuo.A maioria dos tenants no pool com RLS; o tenant de maior volume migrado para schema dedicado para não competir por I/O com os pequenos.Organizations provisiona conta dedicada para os tenants com exigência contratual, com papel cross-account, ExternalId único e SCP como teto de governança.Um copiloto interno ou uma base de conhecimento (Bedrock Knowledge Bases) responde perguntas sobre o histórico de pedidos — e a mesma fronteira de tenant precisa valer na recuperação de contexto, não só na tabela relacional.A ordem não é negociável, e o motivo é concreto
Não faz sentido levar isolamento até a camada de IA (nível 6) enquanto a camada de dado relacional (níveis 2–3) ainda depende de um WHERE escrito à mão. A fronteira mais fraca do sistema é a que decide o raio real de um incidente, e ela quase sempre está embaixo, não na parte mais nova.
Onde IA entra nesta arquitetura, e onde não entra
Isolamento de tenant é um problema de fronteira de dado e de infraestrutura — não um problema que um modelo de linguagem resolve melhor que uma política de RLS ou uma conta AWS separada. Forçar IA aqui seria vender complexidade para um problema que já tem solução determinística e auditável.
Onde IA entra de verdade é um problema DIFERENTE, adjacente a este: quando a aplicação usa Bedrock Knowledge Bases ou um agente para responder perguntas sobre o histórico de um cliente, a recuperação de contexto pode citar documento de outro tenant se a base vetorial for compartilhada sem filtro de metadado — o mesmo incidente deste módulo, um andar acima na pilha.
| Cenário | IA resolve? | Por quê |
|---|---|---|
| Detectar automaticamente se um WHERE tenant_id está faltando no código | Não, hoje | é um problema de análise estática determinística (linter, revisão de política de RLS) — mais confiável e mais barato que inferência de modelo |
| Decidir qual tenant vai para schema ou conta dedicada | Parcialmente | um modelo pode SUGERIR candidatos a partir de métricas de volume e ruído, mas a decisão final é de negócio (contrato, orçamento) — não delegável |
| Impedir um documento do tenant A aparecer numa resposta gerada para o tenant B | Sim, é o problema do L90 | metadado de tenant no índice vetorial e um filtro obrigatório na recuperação — a mesma lógica de RLS, aplicada à camada de IA |
O uso de IA que parece atraente e é armadilha aqui
"Treinar um modelo para detectar consultas suspeitas de vazamento em tempo real" soa sofisticado e resolve pior o que RLS resolve de graça e sem inferência nenhuma: FORCE ROW LEVEL SECURITY barra a linha ANTES de ela sair do banco, com garantia matemática, não com um classificador que pode errar. Gaste o orçamento de IA no problema que só ela resolve — o L90 — não neste.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz isso | Sintoma em produção | Forma correta |
|---|---|---|---|
| Confiar só no WHERE tenant_id escrito à mão | é a forma mais rápida de entregar a primeira versão, e funciona nos testes que só usam um tenant | vazamento no primeiro endpoint que alguém escrever sob pressão de prazo | RLS com FORCE como piso obrigatório, não como opção de quem lembrar |
| Papel de aplicação reaproveitado do papel de migration | evita gerenciar duas credenciais e duas políticas de permissão | RLS parece configurada e não protege nada, porque o papel é dono da tabela | papel de aplicação separado, sem posse da tabela e sem BYPASSRLS |
| Atributo customizado da Cognito sem o gatilho de pré-geração de token | parece funcionar em teste manual, porque o desenvolvedor está olhando o ID token no login | a claim simplesmente não existe no access token que a API recebe em produção | gatilho de pré-geração de token, versão 2, escrevendo em accessTokenGeneration |
| `SET` de sessão em vez de `set_config(..., true)` | a sintaxe do SET é a primeira que aparece em qualquer tutorial de RLS | vazamento intermitente sob carga, quando o pool reaproveita conexão entre tenants | set_config transacional, que expira sozinho no fim da transação |
| Colocar todos os tenants no modelo silo "para garantir isolamento máximo" | parece a decisão mais segura, e evita o desconforto de explicar por que um cliente pequeno está no pool | custo operacional multiplicado por centenas de contas, a maioria com tráfego irrisório | bridge: RLS como padrão, conta dedicada só onde volume ou contrato exigem |
| ExternalId genérico ou reaproveitado entre tenants no papel cross-account | parece simplificar o on-boarding de conta nova | qualquer entidade que descubra UM ExternalId pode tentar assumir o papel de QUALQUER tenant | ExternalId único por tenant, gerado pela plataforma e nunca reaproveitado |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Consulta sem WHERE devolve todos os tenants mesmo com RLS habilitada | FORCE ROW LEVEL SECURITY não foi aplicado, ou o papel de conexão é dono da tabela ou tem BYPASSRLS | SELECT relforcerowsecurity FROM pg_class; SELECT rolbypassrls FROM pg_roles | rls.sql e o papel usado na connection string da aplicação | aplicar FORCE e trocar o papel de conexão por um sem BYPASSRLS e sem posse da tabela |
| Vazamento aparece só sob carga, nunca em teste manual isolado | uso de SET de sessão em vez de set_config transacional, com pool de conexão reaproveitando entre requisições | log de auditoria do Postgres cruzando app.tenant_id da sessão com o tenant esperado da requisição | middleware/repositório que declara o contexto de tenant | trocar por set_config(..., true) dentro da transação |
| Requisição autenticada recebe 403 mesmo com login bem-sucedido | a claim tenant_id não chegou ao access token — atributo customizado sem o gatilho de pré-geração v2 | decodificar o access token (não o ID token) e conferir a claim | configuração do gatilho de pré-geração de token no user pool | configurar claimsToAddOrOverride no accessTokenGeneration do gatilho v2 |
| assume-role na conta do tenant funciona mesmo sem passar --external-id | a política de confiança do papel não exige a condição sts:ExternalId | ler assume_role_policy do papel PlataformaRotafacilAcesso na conta do tenant | conta.tf, bloco de confiança cross-account | adicionar a condição StringEquals sts:ExternalId com valor único por tenant |
| Migration aplicada parcialmente deixa alguns tenants com schema desatualizado | o laço de migração por schema falhou no meio e não é idempotente | contar schemas com versão diferente da migration mais recente | migrar_todos_os_schemas.sh e o log de execução | tornar o script idempotente e recomeçável do ponto da falha, e alarmar sobre divergência |
A pergunta que resolve metade destes casos
"Que papel do Postgres a conexão está usando, e ele tem BYPASSRLS ou posse da tabela?" — a resposta explica a maioria dos casos em que RLS parece configurada certa e não protege nada.
Limpeza: o que o destroy não leva
A ordem importa mais aqui do que em outros laboratórios, porque parte da infraestrutura deste módulo — schemas e contas de tenant — não é toda gerenciada pelo mesmo estado do Terraform.
#!/usr/bin/env bash
set -euo pipefail
# 1. Remove o canario agendado ANTES de remover o banco, senao ele passa a
# falhar em loop tentando alcancar uma instancia que ja nao existe.
aws events remove-targets --rule canario-isolamento --ids canario
aws events delete-rule --rule-name canario-isolamento
# 2. Schemas de tenant NAO fazem parte do estado do Terraform -- foram
# criados por funcao SQL (provisionar_schema_tenant), entao sao removidos
# por SQL tambem. Confirme que nao ha uso ativo antes de derrubar.
psql "$DATABASE_URL" -c "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant_%'"
# DROP SCHEMA tenant_xxx CASCADE; -- um por vez, de proposito, nunca em lote
# 3. Contas de tenant (Organizations) NAO fecham com terraform destroy por
# padrao -- aws_organizations_account so remove a conta da ORGANIZACAO,
# nao fecha a conta AWS. Fechar de fato exige a API de fechamento de conta
# ou o console, e tem janela de 90 dias antes de poder reingressar numa
# organizacao. Planeje isso ANTES de prometer "encerramento imediato" a um
# tenant que sai.
terraform destroy -auto-approve
| Recurso | O `destroy` remove? | Observação |
|---|---|---|
| Instância RDS pool | Sim | inclui todos os schemas de tenant CRIADOS via Terraform; os criados via função SQL de on-boarding, não |
| Política de RLS e papéis do Postgres | Sim, se gerenciados por Terraform; não, se aplicados via script SQL manual | confira se rls.sql está em recurso de Terraform ou em script separado |
| Schema de tenant (modelo por schema) | Não | foi criado por função SQL de on-boarding, fora do estado do Terraform — remoção é manual, um schema por vez |
| Conta AWS do tenant (Organizations) | Não fecha a conta | aws_organizations_account remove da organização; fechar de fato exige API/console e tem janela de 90 dias |
| Segredo no Secrets Manager | Não, por padrão | fica em janela de recuperação; force_delete_without_recovery remove de imediato e sem volta |
| Regra do EventBridge e função Lambda do canário | Sim | remova a regra antes do banco, para não gerar erro em loop contra alvo inexistente |
Fechar a conta errada de tenant é irreversível dentro da janela de 90 dias
Uma conta AWS fechada não pode reingressar numa Organization por 90 dias. Antes de fechar a conta de um tenant que encerrou contrato, confirme que não há obrigação de retenção de dado pendente (contratual ou regulatória) que exija acesso à conta durante esse período.
Resumo: problema, peça e motivo
| Problema | Peça | Motivo |
|---|---|---|
| Consulta sem WHERE tenant_id vaza a base inteira | RLS com FORCE ROW LEVEL SECURITY | o banco filtra mesmo que o SQL não peça |
| Papel dono da tabela ignora a política | papel de aplicação sem posse e sem BYPASSRLS | FORCE só vale para quem não tem privilégio de dono |
| Cliente poderia forjar o próprio tenant | claim assinada pela Cognito, via gatilho de pré-geração v2 | o access token chega já com a identidade, verificável e não editável pelo cliente |
| Pool de conexão reaproveitado entre tenants | set_config(..., true), transacional | o valor expira sozinho no fim da transação, nunca sobrevive para a próxima requisição |
| Migration de N schemas pode falhar no meio | script idempotente e recomeçável, com alarme de divergência | reduz o tempo em que tenants ficam em versões diferentes sem ninguém saber |
| Cláusula contratual de "conta própria" | conta AWS dedicada via Organizations, com ExternalId único | é a única fronteira que não depende de nenhuma linha de código da aplicação |
| Vazamento pode acontecer sem ninguém notar por meses | canário ativo, autenticado, tentando vazar de propósito | prova a proteção em produção continuamente, não só no ambiente de teste |
| Falha | Proteção |
|---|---|
| WHERE esquecido numa consulta nova | RLS obrigatória barra a linha antes de sair do banco |
| Papel de conexão com privilégio excessivo | auditoria periódica de pg_roles e separação de papéis |
| ExternalId ausente no papel cross-account | condição sts:ExternalId única por tenant |
| Migration parcial entre schemas | script idempotente + alarme de divergência de versão |
- Login gera o access token, com a claim tenant_id escrita pelo gatilho de pré-geração v2.
- ALB encaminha a requisição sem inspecionar claim nenhuma.
- Middleware valida a assinatura do JWT e extrai o tenant já verificado.
- Repositório abre a transação e declara o tenant com set_config(..., true).
- Postgres reescreve a consulta pela política de RLS antes de executar.
- Resultado filtrado volta à aplicação — sem nenhum WHERE tenant_id escrito à mão.
- Canário tenta vazar dado de outro tenant a cada 5 minutos, publicando métrica.
- Alarme dispara em minutos se qualquer linha vazar, antes de um cliente notar.
Perguntas frequentes
❓ RLS sozinha já resolve isolamento multi-tenant, sem mais nada?
❓ Por que não usar SET LOCAL diretamente em vez de set_config?
❓ FORCE ROW LEVEL SECURITY protege contra um superusuário do Postgres?
❓ Qual a diferença prática entre isolamento por schema e por conta AWS?
❓ Custom attribute da Cognito não aparece automaticamente no access token?
❓ Por que o papel cross-account precisa de ExternalId, se já exige o ARN da conta confiável?
❓ Um canário que tenta vazar dado de propósito não é ele mesmo um risco de segurança?
❓ Todo SaaS B2B precisa dos três modelos implementados ao mesmo tempo?
Fixando
Sob carga em produção, um cliente ocasionalmente recebe dado de outro tenant, mas o mesmo cenário nunca se reproduz em teste de integração com uma requisição por vez. O middleware declara o tenant com `SET app.tenant_id = @id` (sem `LOCAL`) no início de cada requisição. Qual é a explicação mais provável?
Uma empresa negocia um contrato enterprise que exige "dados em infraestrutura de conta própria" para um único cliente grande, enquanto os outros 339 tenants são pequenos e sensíveis a custo. Qual desenho atende ao requisito sem desperdiçar orçamento?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L12 (claim de identidade via Cognito, validação de JWT) e L31 (fronteira de serviço bem definida, para saber onde a fronteira de tenant se encaixa) |
| Conhecimentos adquiridos | os três modelos de isolamento e o trade-off real de cada um; FORCE ROW LEVEL SECURITY e a exceção de superusuário/BYPASSRLS; set_config transacional contra o risco de pool de conexão; ExternalId contra deputado confuso em cross-account; canário como prova contínua em produção |
| Limitação que fica | o modelo por linha continua numa única instância física — RLS isola dado, não capacidade nem raio de falha de infraestrutura; e o modelo por schema herda o risco de migration parcial |
| Próximo exemplo recomendado | L43 — multi-conta com SCP e Control Tower. Aprofunda exatamente a fronteira de conta que este laboratório introduziu de forma mínima |
| Também habilitado por este módulo | L98 (plataforma de IA multi-time com cota e chargeback) reusa a fronteira de conta deste laboratório para isolar orçamento e cota por time; L90 (vazamento entre inquilinos via IA) estende o mesmo raciocínio de isolamento até a camada de recuperação de contexto |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: AWS Whitepaper — SaaS Tenant Isolation Strategies — os modelos silo, pool e bridge, e o vocabulário de raio de incidente usado neste módulo; AWS Whitepaper — SaaS Storage Strategies — particionamento de dado e multitenancy em RDS; PostgreSQL — Row Security Policies — o comportamento de ENABLE, FORCE e a exceção documentada de superusuário e BYPASSRLS; e a documentação da Cognito sobre o gatilho de pré-geração de token, versão 2, para claims no access token. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e por modelo de contratação.
O que não foi verificado, e você deve conferir na sua conta
Os números de volume (340 tenants, 40% do maior, 1842/931/604 pedidos) são da base de exemplo deste laboratório, não uma medição universal — use-os como ordem de grandeza para montar o SEU cenário de teste, não como referência de dimensionamento. O comportamento de FORCE ROW LEVEL SECURITY e da exceção de BYPASSRLS foi conferido na documentação oficial do PostgreSQL; teste sempre `SELECT rolbypassrls FROM pg_roles` no papel real usado pela sua aplicação antes de assumir que a proteção está ativa. O comportamento do gatilho de pré-geração de token versão 2 deve ser reconferido no console da Cognito da sua conta, porque comportamento de gatilho evolui entre versões do serviço.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…