Engenharia · · 9 min de leitura · Time Catcher Data

Multi-tenancy sem dor:
row-level vs schema-per-company na prática

Todo SaaS B2B carrega o mesmo problema estrutural: dados de clientes diferentes vivem na mesma infraestrutura, e um vazamento entre eles é o tipo de bug que encerra contratos. Row-level e schema-per-company são as duas respostas clássicas, e a escolha errada cobra caro em migração, custo ou compliance. Este post compara os dois modelos sem torcida — e mostra como o Catcher Data trata tenant como entidade de primeira classe em todos os produtos.

O problema central de SaaS B2B

Em um SaaS B2B, o cliente do seu cliente não existe para o banco de dados — a menos que você o modele. Cada tabela, cada índice, cada query precisa responder à mesma pergunta: de quem é este dado? Errar essa resposta uma única vez — um WHERE esquecido, um JOIN sem filtro — significa mostrar dados da empresa A para a empresa B. Isso não é um bug de interface. É o fim da confiança que sustenta o contrato.

E multi-tenancy não é só leitura. É migração de schema que precisa rodar para todos os clientes sem quebrar nenhum. É remoção de cliente com garantia de apagamento. É a auditoria de segurança do cliente enterprise perguntando como você prova o isolamento. A escolha do modelo define o custo de tudo isso pelos próximos anos — e é cara de reverter.

O problema também deixou de ser exclusivo do banco relacional. O mesmo cliente que tem linhas em uma tabela tem embeddings no vector store, chaves no cache, arquivos no storage e conhecimento indexado num RAG. Cada uma dessas superfícies carrega dado de cliente — e vaza com a mesma gravidade. Um modelo de tenancy que só responde pelo SQL responde por uma fração do risco.

Os dois modelos, comparados de verdade

Row-level: todos os tenants nas mesmas tabelas, com uma coluna de tenant e filtro obrigatório em toda query — ou RLS, no PostgreSQL. Schema-per-company: cada cliente em um schema próprio dentro da instância, com a mesma estrutura de tabelas replicada.

CritérioRow-levelSchema-per-company
IsolamentoLógico. Depende de disciplina em toda query, ou de RLS bem configuradaFísico dentro da instância. Cruzar tenants exige acesso a outro schema, não um WHERE esquecido
CustoMenor. Um conjunto de tabelas, índices e conexões compartilhadasCresce com o número de schemas: mais objetos, mais metadata, backups e migrações mais longos
Migração de schemaUma migration atinge todos de uma vez — rápida, sem rollout gradualN migrations, uma por schema — mais lenta, mas permite migrar cliente a cliente
Blast radiusUm filtro esquecido expõe dados de outro cliente; uma migration ruim afeta todosErros ficam contidos no schema; vazamento cross-tenant exige falha de permissão, não de disciplina
ComplianceProvar isolamento significa demonstrar disciplina de código e testesMais simples de auditar: dados fisicamente separados, export e delete por schema

A leitura fria da tabela: row-level cobra disciplina contínua e devolve custo baixo; schema-per-company cobra operação mais pesada e devolve fronteira física. Nenhuma linha resolve a decisão sozinha — o peso de cada critério depende do seu contrato, do seu ticket médio e de quantas vezes "prove o isolamento" aparece nas suas negociações.

Quando usar cada um

  • Row-level quando você tem muitos tenants pequenos, produto self-serve, schema evoluindo rápido e time enxuto. O custo operacional baixo compensa a disciplina extra — especialmente se você usa RLS no PostgreSQL em vez de confiar apenas no código da aplicação.
  • Schema-per-company quando os tenants são menos numerosos e maiores, quando contratos enterprise exigem isolamento demonstrável, ou quando "apagar tudo do cliente X" precisa ser uma operação de banco, não uma caça a rows espalhadas.
  • Híbrido é o caminho natural de quem cresce: começa row-level e promove clientes grandes para schema próprio quando o contrato pede. O problema é que, se a sua camada de dados não suporta os dois modelos, essa promoção vira um projeto de migração de meses.

Em todos os casos, a pergunta decisiva não é "qual modelo é melhor" — é "quanto custa mudar de ideia". Se promover um cliente para schema próprio é uma operação suportada pela sua camada de dados, a escolha inicial deixa de ser aposta. Se é um projeto de migração, você está decidindo hoje o roadmap de daqui a dois anos.

Tenant como entidade nativa em todos os produtos

No Catcher Data, o end-customer do seu app é uma entidade de primeira classe da plataforma: o Tenant. Você não implementa isolamento por convenção em cada produto de dados — cria o tenant uma vez, via console ou API, e cada produto materializa o isolamento no mecanismo certo para ele. O ciclo de vida completo — criação, listagem, remoção, recuperação — está na documentação de tenants.

A consequência prática: o modelo de isolamento acompanha o tenant pela plataforma inteira. O mesmo cliente que tem schema isolado no banco tem role própria no vector store, bucket próprio no storage e namespace próprio no cache — sem você escrever a camada de tenancy seis vezes, uma por produto.

A matriz de isolamento nos 6 produtos

Cada produto materializa a fronteira no mecanismo nativo da tecnologia que o sustenta — não numa convenção de aplicação por cima. A tabela resume onde a fronteira de cada tenant é traçada:

ProdutoMecanismoFronteira
Database (MySQL 8 / PostgreSQL 16)Row-level ou schema-per-company: cada cliente vira um schema catcher_c_<id> no seu Cloud SQLNo modo schema, cruzar tenants exige privilégio em outro schema — não um WHERE esquecido
Vector (pgvector)Schema Postgres t_<id> + role tr_<id> por tenantA role de um tenant não tem privilégio no schema de outro; quem nega é o Postgres, não o app
Storage (S3-compatível)Bucket por tenantIsolamento por IAM: a credencial de um bucket não abre outro
Cache (Redis)Prefixo de chave t:<id>:* + usuário Redis ACL opcionalA ACL restringe o usuário ao próprio prefixo; comando fora dele é negado pelo Redis
Wiki (RAG)Espaço por projeto/tenantBusca, perguntas e páginas não cruzam espaços — o retrieval só enxerga o espaço consultado
MCPEscopos por token + RBAC por toolUm token só enxerga as tools e os dados do escopo concedido

Repare no padrão da terceira coluna: em nenhum produto a fronteira depende de disciplina do código da aplicação. Ela fica na camada mais baixa disponível — schema, role, IAM, ACL, escopo — onde um bug do seu app não alcança. É a diferença entre isolamento que o seu time promete e isolamento que a infraestrutura impõe. E onde há camada opcional — como a ACL do Cache —, a convenção de prefixo continua valendo sem ela; a ACL só transforma a convenção em imposição.

O dia em que você remove um tenant

Remoção de tenant é a operação mais perigosa da multi-tenancy, porque a versão ingênua dela é irreversível: dropar um schema leva um segundo, restaurá-lo é um projeto. E ela nunca chega em condições ideais — chega num script de offboarding, numa automação de billing, num clique de operador no fim do expediente.

Por isso, no Catcher Data, remover um tenant nunca dropa nada na hora. A remoção é um soft-delete com grace period de 7 dias. Durante a janela, o tenant sai da operação normal, mas o schema continua intacto e a remoção é reversível — restaurou, voltou tudo. Só depois da janela o reaper, um processo que roda diariamente, dropa o schema de fato e encerra o ciclo.

Dois detalhes do desenho valem registro. Primeiro: o reaper é estruturalmente restrito — só aceita schemas com o prefixo catcher_c_, e é incapaz de dropar qualquer outro schema, mesmo diante de um bug na fila de remoção. Segundo: a janela não é cortesia. Ela é a diferença entre "a automação de offboarding removeu três clientes errados" ser uma recuperação de cinco minutos ou um restore de backup com perda de dados. O clique errado deixa de ser catástrofe e vira evento reversível.

A regra geral por trás do desenho: destruição de dados nunca deve ser síncrona com a decisão de destruir. O tempo entre a ordem e a execução é o seu seguro — e 7 dias cobrem o fim de semana, as férias do responsável e o ciclo de cobrança em que o cliente "removido" volta atrás.

Como a gente prova o isolamento

Isolamento descrito em documentação é promessa. Prova é ataque. O Catcher Data mantém uma suíte de pentest que ataca a API de produção — a mesma data-api.catcher.one que o seu app usa, autenticada por X-API-Key — e valida o contrato de segurança de ponta a ponta: autenticação, RBAC, injection e, no núcleo, o isolamento cross-tenant.

O teste central funciona assim: a suíte cria uma empresa descartável — a empresa B —, emite a chave de API dela e tenta ler, com essa chave, os recursos da empresa A. A instância de banco. A coleção de vetores. O bucket de storage. O espaço de wiki. As entradas de cache. A expectativa é rígida: 404 em todas as tentativas. Não um 403 confirmando que o recurso existe mas está proibido — um 404, como se o recurso não existisse. Para a empresa B, os dados da empresa A não são um lugar trancado; são um lugar que não está no mapa.

O cross-tenant é o núcleo, mas a suíte cobre o resto do contrato: credencial ausente, forjada ou revogada recebe 401 em toda rota; a chave de um owner comum é negada nas rotas administrativas; ids forjados não resolvem; o guard do editor SQL bloqueia GRANT e DDL perigosos; caminhos de storage com ../ são rejeitados. Cada item é um ataque que alguém vai tentar em produção — a suíte só chega primeiro.

Qualquer resposta 2xx — um único vazamento — reprova a suíte inteira. A última execução completa passou 114/114, sem findings críticos ou altos. E a suíte roda de novo a cada mudança em autenticação, RBAC ou multi-tenancy, porque isolamento não é um estado que se conquista uma vez; é um estado que se mantém — e regressão de segurança é silenciosa até o dia em que deixa de ser.

Guard rails além do isolamento

Dois outros guard rails operam na mesma direção em produção:

  • SQL guard por padrão. GRANT e DDL perigosos são bloqueados por padrão no acesso SQL. Writes e DDL são opt-in, habilitados por toggle explícito. O caminho padrão é o caminho seguro — quem precisa de mais poder pede, conscientemente.
  • Provisionamento keyless. A infraestrutura é provisionada via IAM, sem senha de root trafegando entre serviços. Menos segredos em trânsito, menos superfície para vazar.

E quando algo falha, o erro conta a própria história: toda resposta de erro da API carrega um error_code estável e um trace_id que casa com o log do backend — inclusive os 404 de isolamento. Auditar um acesso negado é seguir um identificador, não caçar timestamps.

O resumo honesto

Não existe modelo vencedor. Row-level otimiza custo e velocidade de evolução; schema-per-company otimiza isolamento e auditabilidade. O erro não está em escolher um deles — está em construir sobre uma plataforma que só suporta um e descobrir a limitação no meio de uma negociação enterprise, ou de um incidente de vazamento. Tratar tenant como entidade nativa, com os dois modos disponíveis e guard rails de operação, remove da decisão a parte que costuma doer: a irreversibilidade.

Multi-tenancy pronta em todos os produtos

Crie um tenant no Catcher Data e escolha row-level ou schema-per-company — o isolamento acompanha banco, vetores, storage e cache, com guard rails de produção.