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ério | Row-level | Schema-per-company |
|---|---|---|
| Isolamento | Lógico. Depende de disciplina em toda query, ou de RLS bem configurada | Físico dentro da instância. Cruzar tenants exige acesso a outro schema, não um WHERE esquecido |
| Custo | Menor. Um conjunto de tabelas, índices e conexões compartilhadas | Cresce com o número de schemas: mais objetos, mais metadata, backups e migrações mais longos |
| Migração de schema | Uma migration atinge todos de uma vez — rápida, sem rollout gradual | N migrations, uma por schema — mais lenta, mas permite migrar cliente a cliente |
| Blast radius | Um filtro esquecido expõe dados de outro cliente; uma migration ruim afeta todos | Erros ficam contidos no schema; vazamento cross-tenant exige falha de permissão, não de disciplina |
| Compliance | Provar isolamento significa demonstrar disciplina de código e testes | Mais 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:
| Produto | Mecanismo | Fronteira |
|---|---|---|
| Database (MySQL 8 / PostgreSQL 16) | Row-level ou schema-per-company: cada cliente vira um schema catcher_c_<id> no seu Cloud SQL | No 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 tenant | A 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 tenant | Isolamento por IAM: a credencial de um bucket não abre outro |
| Cache (Redis) | Prefixo de chave t:<id>:* + usuário Redis ACL opcional | A ACL restringe o usuário ao próprio prefixo; comando fora dele é negado pelo Redis |
| Wiki (RAG) | Espaço por projeto/tenant | Busca, perguntas e páginas não cruzam espaços — o retrieval só enxerga o espaço consultado |
| MCP | Escopos por token + RBAC por tool | Um 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.