Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

03 - SaaS Multi-tenant (Isolamento de dados)

#isolamento#multi-tenant#noisy-neighbor#rls#saas
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione 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.