03 - SaaS Multi-tenant (Isolamento de dados)
video: https://youtu.be/... no frontmatter da nota.SaaS Multi-tenant: Isolamento de Dados
Este caso acompanha uma plataforma B2B de gestão financeira com 4.800 tenants ativos, de escritórios com 3 usuários a varejistas com 12 mil, em que os 2% maiores respondem por 60% da receita. Os objetivos arquiteturais são três: manter o custo de infraestrutura proporcional ao plano vendido, oferecer isolamento de dados verificável em auditoria e preservar um caminho de crescimento por tenant sem replataforma. O desafio central é escolher entre banco por tenant, schema por tenant e linha com tenant_id, sabendo que os três modelos precisam coexistir conforme o cliente cresce. O desenho adota o modelo por linha com Row Level Security como padrão e sobe a escalada de isolamento conforme o contrato exige. O resultado é uma única plataforma que serve do freelancer ao enterprise sem bifurcar o código.
Requisitos e pressupostos
A base de clientes define a tensão do problema. A distribuição por plano separa três perfis com exigências incomensuráveis:
- Freemium: 60% dos tenants, menos de 50 usuários cada, tolerância a performance compartilhada, sensibilidade extrema a custo.
- Business: 35% dos tenants, até 2 mil usuários, SLA de 99,5%, backup com retenção de 30 dias.
- Enterprise: 2% dos tenants, até 12 mil usuários, SLA de 99,9%, exigência contratual de isolamento físico e chave de criptografia própria.
O volume corrobora a assimetria. A plataforma recebe 90 milhões de requisições por dia, e o 1% de tenants com maior uso responde por 55% delas, com pico de 6.000 requisições por segundo no fechamento mensal dos maiores clientes. Esse desequilíbrio é a razão econômica dos três modelos: cobrar infraestrutura dedicada de quem tem 3 usuários seria inviável, e colocar o enterprise em competição aberta pelos recursos seria invendável.
Os requisitos de conformidade atravessam os três perfis: LGPD com direito ao esquecimento operável por tenant, SOC 2 Type II com evidência de isolamento, e pentest anual cujo roteiro inclui tentativa explícita de vazamento entre tenants. O throughput é heterogêneo a ponto de o tenant campeão gerar 400 vezes a carga da mediana, o que torna noisy neighbor um risco diário, e não teórico. A migração entre modelos precisa ser online, porque vendas fecham contratos com data de go-live, e nenhum cliente aceita janela de indisponibilidade para mudar de plano.
Os pressupostos simplificam o desenho onde é possível. O padrão de acesso é sempre por tenant, e quase nenhuma consulta operacional cruza tenants; relatórios globais rodam na plataforma de dados, alimentada por CDC, e não no banco transacional. Clientes enterprise aceitam pagar mais pelo isolamento, o que financia a coexistência dos modelos.
Arquitetura proposta
Três modelos de isolamento coexistem sob um roteador único. O modelo por linha com tenant_id e RLS é o padrão e atende a esmagadora maioria; o schema por tenant serve a camada intermediária que precisa de isolamento lógico mais forte sem custo de banco dedicado; o banco por tenant fica reservado ao enterprise com contrato que justifica. Um registro central guarda o descriptor de cada tenant, com plano, localização dos dados, classe de pool e referência à chave de criptografia. A tabela compara os três modelos nos critérios que decidem a escolha.
| Critério | Banco por tenant | Schema por tenant | Linha com tenant_id + RLS |
|---|---|---|---|
| Custo de infraestrutura | Alto, piso por banco | Médio, compartilha instância | Baixo, um cluster para milhares |
| Isolamento de dados | Físico, o mais forte | Lógico, forte por namespace | Lógico, dependente de política |
| Operação de migração | Migrações vezes N bancos | Migrações vezes N schemas | Migração única |
| Escala de tenants | Centenas | Milhares | Centenas de milhares |
| Noisy neighbor | Nenhum | Compartilha CPU e IO | Compartilha tudo |
| Restauração pontual | Por tenant | Por schema, custosa | Global apenas |
| Quando usar | Enterprise com contrato | Camada intermediária | Padrão para a maioria |
| Conformidade e evidência | Evidência simples de auditoria | Evidência por namespace | Evidência por política testada |
Os componentes são compartilhados com pontos de variação por tenant. Gateway, autenticação, registro de descriptors e plataforma de dados são únicos; pools de conexão, localização de dados e chaves variam por tenant e por classe. O código do aplicativo é um só: as diferenças vivem na configuração carregada do descriptor, e não em forks de código por modelo de isolamento.
A criptografia segue envelope com chaves separadas por tenant: uma KEK por região no KMS e uma DEK por tenant, com cache de DEK em TTL curto para não pagar latência de KMS a cada requisição. A rotação anual é automatizada, e o offboarding revoga a DEK e registra a eliminação dos dados, fechando o ciclo do direito ao esquecimento.
# Descriptor de tenant no registro central, cacheado com TTL de 60 s
tenant: "8f3a2b1c-77d4-4e19-9c2b-a51e0f6d3b44"
plano: enterprise
modelo: banco-dedicado # linha | schema | banco-dedicado
host: "acme.finapp.com.br"
pool:
classe: enterprise # define pool dedicado e quotas
size: 20
dados:
cluster: "pg-enterprise-01" # onde os dados vivem hoje
database: "acme"
chaves:
dek_arn: "arn:aws:kms:sa-east-1:...:key/abc123"
Fluxo principal: da requisição à linha isolada
O roteamento começa no subdomínio. A requisição chega em acme.finapp.com.br, o middleware resolve o tenant pelo host com o descriptor cacheado em Redis por 60 segundos, e carrega plano, modelo de isolamento e DEK. Com o contexto em mão, abre conexão no pool da classe correspondente e define o tenant na sessão do banco antes de qualquer query. O aplicativo nunca repete o filtro manualmente: a política de RLS é a única porta de entrada, e a ausência de contexto bloqueia tudo por padrão.
-- Tabela compartilhada com RLS: a política é a única porta de entrada
CREATE TABLE lancamentos (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
descricao TEXT NOT NULL,
valor NUMERIC(14,2) NOT NULL,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE lancamentos ENABLE ROW LEVEL SECURITY;
ALTER TABLE lancamentos FORCE ROW LEVEL SECURITY; -- vale também para o dono da tabela
CREATE POLICY isolamento_tenant ON lancamentos
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Sessão da requisição: o pool define o contexto antes da query
-- SET app.tenant_id = '8f3a2b1c-77d4-4e19-9c2b-a51e0f6d3b44';
-- Sem a variável, a política retorna zero linhas e a query falha fechada
O middleware concentra a resolução de contexto em um único ponto, e o fluxo a seguir é o caminho de toda requisição autenticada.
def resolve_contexto(request):
# 1. Tenant pelo host, com fallback para o claim do JWT
tenant = registro.buscar_por_host(request.host) or registro.buscar_por_jwt(request.token)
if tenant is None:
return Recusar(404) # sem tenant, não há resposta útil
descriptor = cache.get(f"descriptor:{tenant.id}") or registro.carregar(tenant.id)
# 2. Chave de criptografia: DEK cacheada, KMS apenas no cache miss
dek = cache_deks.obter(tenant.dek_arn)
# 3. Pool da classe e contexto definido na sessão do banco
with pools[descriptor.pool_classe].conexao() as conn:
conn.set("app.tenant_id", tenant.id)
return conn # a partir daqui, a RLS filtra cada query
O pooling é a peça que sustenta o desenho. O PgBouncer opera em modo transaction com pools separados por classe de plano, e a variável de sessão é definida dentro de cada transação emprestada, evitando contaminação entre requisições. A falha fechada é testada no pentest anual: remover o header de autenticação ou o claim de tenant deve resultar em resposta vazia, e não em dados de outro cliente.
Decisões e trade-offs
| Decisão | Alternativa descartada | Trade-off aceito |
|---|---|---|
| RLS como padrão de isolamento | Filtro manual em cada query | Dependência da política; elimina a classe inteira de bug de vazamento |
| Descriptor em cache Redis de 60 s | Consulta ao registro por requisição | Defasagem de 60 s em mudanças de plano; latência estável |
| DEK por tenant com cache curto | Chave única global | Custo de KMS e de cache; revogação por tenant fica viável |
| Migração por CDC com cutover no roteador | Dump e restore com janela | Complexidade de leitura dupla; migração sem downtime |
| Pool por classe de plano | Pool único global | Capacidade fixada por classe; garante piso ao enterprise |
O equilíbrio dessas decisões mudou com o tempo, e o desenho registrou as revisões. No início, o pool único parecia suficiente, até o primeiro fechamento mensal concentrar 6.000 requisições por segundo em uma dúzia de tenants. A cota por plano e o pool por classe nasceram desse incidente, e a lição registrada é que capacidade em SaaS multi-tenant se negocia por contrato, e não se espera que a sorte distribua.
A migração entre modelos segue um caminho padrão replicado por tenant. A origem habilita publicação lógica, o destino recebe o snapshot inicial e sincroniza por CDC, e o roteador troca o descriptor do tenant para o novo modelo quando o lag chega a zero. O cutover é reversível em segundos, porque basta apontar o descriptor de volta, e o período de leitura dupla dura horas, e não semanas. Cada migração individual move entre 20 GB e 800 GB, com duração observada de 40 minutos no menor a 6 horas no maior, sempre sem janela de indisponibilidade.
Escalabilidade e falhas
O noisy neighbor é tratado em três camadas. No gateway, token bucket por tenant com cota por plano, em que o estouro devolve 429 com cabeçalho de retry, e não atrasa os outros. No banco, statement_timeout por classe impede que uma consulta pesada do freemium ocupe conexão por minutos, e o enterprise carrega pool dedicado que o isola fisicamente dos menores. Na observação, painéis por tenant com custo computado aproximado identificam abuso antes que ele vire incidente.
| Plano | Requisições por minuto | Pool de conexões | Statement timeout |
|---|---|---|---|
| Freemium | 600 | Compartilhado | 5 s |
| Business | 3.000 | Compartilhado com piso | 15 s |
| Enterprise | 15.000 | Dedicado | 60 s |
A observabilidade também é por tenant. Cada painel de serviço tem a decomposição por tenant uma camada abaixo, e o custo computado aproximado por tenant, somando CPU, IO e requisições, alimenta o relatório mensal de margem. Esse número virou ferramenta de venda: o plano é precificado com base no custo real observado, e não em intuição.
A escala do modelo compartilhado usa sharding por hash de tenant em 8 shards lógicos, e o descriptor guarda o shard de cada tenant, o que permite redistribuição por migração individual sem re-shard global. As falhas têm raio previsto: problema no banco dedicado derruba um único cliente; problema em um shard do modelo compartilhado derruba o sétimo dos tenants, e a restauração pontual, limitação honesta do modelo por linha, é resolvida por PITR global com perda máxima de 5 minutos. Para o enterprise que exige restauração individual, o contrato já nasce no banco dedicado.
O drill de isolamento roda trimestralmente junto com o pentest. Um tenant sintético é criado em cada modelo, a tentativa de leitura cruzada é executada em três variantes, sem contexto, com contexto de outro tenant e com contexto adulterado no JWT, e as três devem falhar fechadas. O resultado entra no relatório de SOC 2 como evidência de isolamento, e não fica preso em relatório interno.
Lições aplicáveis
O caso confirma que multi-tenant é um espectro, e não uma escolha binária. As lições abaixo vêm dos erros cometidos antes de chegar ao desenho atual:
- Comece pelo modelo por linha e suba a escalada de isolamento por demanda: a coexistência dos três modelos custou menos que escolher cedo e replataformar depois.
- A política de RLS precisa de teste adversário contínuo: o pentest anual com tentativa explícita de vazamento virou requisito de venda, e não só de conformidade.
- Pool por classe resolve noisy neighbor melhor que throttle interno: quem paga mais tem piso garantido, e quem paga pouco recebe degradação visível e aceitável.
- Descriptor de tenant centralizado simplifica tudo: plano, modelo, shard e chave em um lugar transformam migração em mudança de ponteiro.
- Migração online é obrigatória em SaaS: dump com janela parecia suficiente até a primeira venda enterprise com data de go-live contratual.
- Criptografia por tenant vale o custo: revogação no offboarding e chave própria por contrato deixaram de ser exceção tratada à mão.
- Cobrar isolamento é viável: o enterprise paga o custo real do banco dedicado, e essa receita financia a complexidade dos três modelos.
- Restauração pontual é o critério esquecido: o modelo por linha brilha em custo, mas restringe restauração individual, e essa limitação precisa estar no contrato antes do incidente.