Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/1/202612 min read

Um ERP, Várias Empresas: Modelar Multi-Entidade em PostgreSQL Sem Fugas de Dados

Um ERP, Várias Empresas: Modelar Multi-Entidade em PostgreSQL Sem Fugas de Dados

# Um ERP, Várias Empresas: Modelar Multi-Entidade em PostgreSQL Sem Fugas de Dados

TL;DR: Base separada, esquema separado ou coluna company_id com Row Level Security — cada abordagem tem um custo operacional real e diferente; e um teste de isolamento que só verifica "não apareceu nada da empresa B" pode passar mesmo quando o isolamento nunca funcionou.

O cenário: um grupo, várias empresas, um ERP

Um grupo com participação em vários blocos, ou um EPC com subsidiárias a operar em contratos diferentes, chega quase sempre ao mesmo ponto de decisão: várias entidades legais, um único ERP. A holding em Luanda quer relatório consolidado do grupo. Cada empresa-operadora ou parceira de JV precisa da sua própria contabilidade, do seu próprio SAF-T e do seu próprio ficheiro fiscal — sem que um utilizador da empresa A consiga, por engano ou por curiosidade, ver uma fatura, um contrato de fornecedor ou uma folha de produção da empresa B.

Isto não é um requisito abstrato de "segurança". É uma exigência legal concreta em Angola: a partir de 1 de janeiro de 2026, o Decreto Presidencial n.º 71/25 torna obrigatória a faturação eletrónica para grandes contribuintes e fornecedores do Estado, com submissão do SAF-T (AO) contabilístico até 10 de abril de cada ano, sempre a partir de software certificado ou validado pela AGT (EY Angola). Se o mesmo ERP serve três empresas do grupo, os três SAF-T têm de sair limpos, cada um só com os dados da sua própria entidade fiscal. Um SAF-T da empresa A com uma linha da empresa B não é um bug cosmético — é um problema com a AGT.

No sector petrolífero há ainda uma segunda camada: entidades que prestam serviços às operadoras e à concessionária nacional precisam de registo e certificação junto da ANPG, ao abrigo do regime de conteúdo local estabelecido pelo Decreto Presidencial n.º 271/20, de 20 de outubro de 2020 (CMS Law). Cada entidade certificada tem o seu próprio plano de conteúdo local reportado à ANPG — mais uma razão para que os dados de cada empresa jurídica se mantenham distintos dentro do mesmo sistema.

A pergunta de engenharia é sempre a mesma: como modelamos isto em PostgreSQL sem duplicar a aplicação inteira por empresa, e sem correr o risco de uma fuga de dados entre entidades?

Três abordagens reais — e o custo honesto de cada uma

1. Base de dados separada por empresa

Cada empresa do grupo tem a sua própria base de dados PostgreSQL. É o isolamento mais forte que existe a nível de dados: não há tabela partilhada, não há política a esquecer, não há WHERE a falhar.

O custo aparece na operação, não no código. Cada nova empresa é uma nova base para migrar, monitorizar, fazer backup e restaurar. Um ALTER TABLE na aplicação torna-se um script que corre N vezes, uma por base — e se falhar a meio numa delas, o esquema do grupo diverge. Relatórios consolidados ao nível do grupo (a holding a somar receita das três operadoras) deixam de ser uma query e passam a ser dblink, postgres_fdw ou um pipeline de ETL à parte. O pool de ligações também sofre: cada base de dados normalmente quer o seu próprio conjunto de ligações, e isso soma depressa em memória e em ficheiros abertos no servidor Postgres à medida que o grupo cresce. Um artigo de engenharia da Crunchy Data sobre desenho de multi-tenência em Postgres resume bem: manter versões diferentes de esquema entre bases separadas "torna-se doloroso" e a abordagem só compensa até um número relativamente baixo de clientes/entidades — a Crunchy Data escreve que com cerca de 10 provavelmente está bem, mas que a partir de 50 ou mais convém afastar-se deste padrão (Crunchy Data).

Para um grupo com três a oito empresas, isto é perfeitamente gerível. Para um grupo que planeia continuar a criar SPVs por projeto, começa a ficar caro em engenharia de operações.

2. Esquema separado por empresa, base única

Um único cluster PostgreSQL, uma base de dados, mas cada empresa tem o seu próprio schema (empresa_a.faturas, empresa_b.faturas). É um meio-termo genuíno: ainda há uma fronteira física real entre entidades — um esquema não vê o outro a menos que se peça explicitamente — mas partilha-se o mesmo motor, a mesma memória, o mesmo pg_stat_statements.

O custo aqui é de migração e de "ruído entre vizinhos" (noisy neighbour). Uma alteração de esquema ainda tem de correr N vezes, uma por schema, embora dentro da mesma ligação — o que é mais barato do que N bases, mas continua a ser N execuções, N pontos de falha. Uma empresa com um relatório pesado pode consumir memória de trabalho e I/O que atrasa as outras, porque todas partilham o mesmo shared_buffers e o mesmo work_mem global. O relatório consolidado do grupo volta a ser possível dentro da mesma base — UNION ALL entre schemas é uma query normal — mas só se o número de schemas for gerível; com dezenas de empresas, gerir migrações e permissões por schema começa a pesar. A Crunchy Data nota também que ferramentas recentes como Citus 12 combinado com PgBouncer tornaram este modelo mais viável ao aliviar o problema histórico de pooling de ligações por schema (Crunchy Data).

Esta é normalmente a escolha certa quando há uma dezena de empresas do grupo, cada uma com volume de dados significativo, e a equipa quer isolamento físico real sem multiplicar clusters.

3. Coluna discriminadora (`company_id`) com Row Level Security

Uma tabela faturas só, partilhada por todas as empresas, com uma coluna company_id. O isolamento não é físico — é imposto por uma política de segurança que o motor de bases de dados aplica a cada leitura e escrita.

É a abordagem mais barata de operar: uma migração, uma base, um schema, relatórios de grupo são apenas uma query sem filtro. É também a que a documentação oficial do PostgreSQL apoia diretamente através de Row Security Policies, disponível desde a versão 9.5. Mas é a única das três em que o isolamento vive inteiramente na lógica da aplicação e da base de dados — não existe fronteira física a proteger o erro humano.

RLS é poderoso, mas não é automático

A documentação oficial do PostgreSQL é explícita sobre os limites do Row Level Security (PostgreSQL Docs — Row Security Policies):

  • ALTER TABLE ... ENABLE ROW LEVEL SECURITY só começa a filtrar linhas depois de existir pelo menos uma política com CREATE POLICY. Sem política, a tabela fica em "default-deny" — ninguém vê nada, o que também é um erro fácil de introduzir e mascarar.
  • Superusers e roles com o atributo `BYPASSRLS` ignoram sempre a política, em qualquer tabela. Se a ligação da aplicação ao Postgres usa uma role com privilégios de administração — coisa comum em scripts de manutenção, seeds, ou até a própria role usada por engano em produção — o RLS simplesmente não corre, sem aviso nenhum.
  • O dono da tabela também bypassa RLS por omissão. É preciso ALTER TABLE ... FORCE ROW LEVEL SECURITY para obrigar mesmo o owner a respeitar as políticas — passo que é fácil esquecer porque a maioria das ligações administrativas usa exatamente essa role.
  • Uma política precisa de existir em cada tabela que guarda dados sensíveis por empresa. Esquecer uma tabela nova — uma tabela de auditoria, uma tabela de anexos, uma tabela de log — deixa essa tabela sem qualquer isolamento, mesmo que todas as outras estejam corretas.

Na prática, a política típica de isolamento por empresa combina a coluna discriminadora com uma variável de sessão, seguindo o padrão documentado pela Crunchy Data no seu blog de engenharia sobre RLS para tenants: a aplicação define SET app.current_company_id = '...' no início de cada pedido, e a política lê esse valor com current_setting():

ALTER TABLE faturas ENABLE ROW LEVEL SECURITY;
ALTER TABLE faturas FORCE ROW LEVEL SECURITY;

CREATE POLICY isolamento_empresa ON faturas
  USING (company_id = current_setting('app.current_company_id', true)::uuid);

(Crunchy Data — Row Level Security for Tenants in Postgres). Isto funciona bem com pooling de ligações (PgBouncer em modo transação, por exemplo) precisamente porque não exige uma role de base de dados por empresa — mas exige disciplina: se algum caminho de código (um job em background, uma migração, um endpoint de admin) esquecer de definir a variável de sessão antes de consultar, ou usar uma role com BYPASSRLS, os dados de todas as empresas ficam visíveis nesse caminho, sem que nada rebente visivelmente.

O ponto central: um teste que procura "nada" pode passar sem testar nada

É aqui que entra o erro mais comum — e mais silencioso — quando equipas implementam isolamento multi-empresa. Escreve-se um teste assim:

teste: "utilizador da empresa A não vê faturas da empresa B"
  autenticar como utilizador da empresa A
  consultar faturas
  afirmar: nenhuma fatura pertence à empresa B

Este teste passa. E não prova nada.

Uma afirmação de ausência é satisfeita por qualquer razão que produza zero resultados — não apenas pelo isolamento a funcionar. Se os dados de teste da empresa B nunca chegaram a ser inseridos, o teste passa. Se houver um erro de tipeagem no filtro (company_id = 'empres_a' em vez de 'empresa_a') que não corresponde a nenhuma linha, o teste passa. Se o JOIN estiver mal escrito e simplesmente não devolver linhas nenhumas, o teste passa. Se a política RLS nunca sequer foi aplicada porque a ligação de teste usa uma role com BYPASSRLS e a query, por acaso, não devolve nada porque a tabela está vazia no ambiente de teste, o teste passa — sem que o RLS tenha sido exercitado uma única vez.

Isto não é um caso raro. É o resultado natural de escrever apenas a metade "negativa" do teste. Uma asserção de ausência não distingue entre "o isolamento funcionou" e "não havia nada para encontrar de qualquer forma".

Como escrever um teste que prova que olhou para algo real

Um teste de isolamento que vale alguma coisa tem de cumprir quatro condições:

1. Semear dados reais de pelo menos duas empresas. Não uma linha vazia — uma fatura concreta da empresa A e uma fatura concreta da empresa B, com identificadores distintos e conhecidos.

2. Afirmar presença, não só ausência. O teste tem de confirmar que a fatura da empresa A aparece (com o ID certo, os valores certos) e só depois confirmar que a da empresa B não aparece. Se a asserção positiva falhar, sabemos imediatamente que o problema é a query ou os dados de teste, não o isolamento.

3. Correr pelo mesmo caminho de código e com a mesma role que um pedido real usaria. Nunca através de uma ligação de superuser ou de uma role de administração/seed que tenha BYPASSRLS — isso testa a query, não a política.

4. Quebrar a política de propósito, uma vez, e confirmar que o teste falha. Comentar temporariamente a cláusula USING, ou trocar a role de teste para uma com BYPASSRLS, e correr o mesmo teste. Se continuar a passar depois disso, o teste nunca esteve a testar o isolamento — estava a testar outra coisa e a mascarar-se de teste de segurança. Só depois de o ver falhar quando o isolamento é quebrado é que se pode confiar que ele falha quando o isolamento se quebrar em produção.

Este último passo — o "teste de mutação" manual — é o único que distingue com confiança um teste de isolamento real de um teste que só parece um.

O que isto significa para um ERP a servir várias empresas em Angola

A Wise Hustlers constrói e opera o seu próprio ERP para operações de petróleo e gás, de produção e contratos a finanças e conformidade, e a mesma decisão reaparece em várias camadas do produto: onde a fronteira entre empresas do grupo é legal e fiscal (SAF-T, faturação certificada pela AGT), tende a compensar isolamento mais forte — schema separado ou mesmo base separada para a contabilidade — mesmo que o resto da aplicação (catálogo de fornecedores, dados de ativos, planos de manutenção) viva perfeitamente bem numa tabela partilhada com RLS. Não é uma escolha única para o sistema inteiro; é uma escolha por domínio, feita com os custos reais de cada opção em cima da mesa, não por preferência estética.

Quando o requisito é modelar isto de forma sólida — com testes de isolamento que provam alguma coisa, e não apenas passam — este tipo de trabalho de arquitetura de dados é o que fazemos em projetos de desenvolvimento de software à medida.

Perguntas frequentes

RLS é suficiente sozinho, sem nenhum outro controlo?

Não deveria ser a única camada. RLS protege contra queries mal filtradas na camada de aplicação, mas continua a depender de a ligação da aplicação nunca ter BYPASSRLS e de FORCE ROW LEVEL SECURITY estar ativo em todas as tabelas relevantes. Auditoria de acessos e revisão de roles continuam a ser necessárias.

Podemos misturar as três abordagens no mesmo ERP?

Sim, e é frequentemente a decisão mais sensata: dados fiscais e contabilísticos com isolamento físico mais forte (schema ou base separada), dados operacionais partilhados (ativos, fornecedores, manutenção) com RLS.

RLS tem impacto de performance?

A própria documentação do PostgreSQL nota que o caso mais simples e com melhor desempenho é quando a política avalia apenas valores da própria linha (como company_id = current_setting(...)), sem sub-queries complexas — o que é exatamente o padrão discriminador. Políticas com sub-selects a outras tabelas custam mais.

Como validamos que o SAF-T de cada empresa sai limpo com dados partilhados?

O teste tem de gerar o ficheiro através do mesmo caminho de código de produção, com dados de pelo menos duas empresas semeados, e comparar o conteúdo linha a linha — não apenas confirmar que o ficheiro foi gerado sem erro.

Fontes