Num banco multi-tenant, esta tabela é referencialmente válida:
user_org_unit(user_id, org_unit_id)As duas foreign keys garantem que o usuário existe e que a unidade existe. Nenhuma garante que eles pertencem ao mesmo tenant. Um bug no handler, um script de migração ou um import em lote, e pronto: um usuário do cliente A passa a integrar uma unidade do cliente B. O banco aceita, porque ninguém disse a ele que isso é impossível.
Invariantes tenant-aware
Toda relação sensível deveria preservar igualdade de tenant:
tenant(user) = tenant(org_unit)
tenant(resource) = tenant(org_unit)
tenant(grantee) = tenant(resource) = tenant(grantor)
tenant(user) = tenant(role) -- quando a role é do tenantA forma mais limpa de garantir isso é com chave composta. Cada tabela expõe o par (id, tenant_id) como único, e as tabelas de junção referenciam o par:
ALTER TABLE app_user ADD UNIQUE (id, tenant_id);
ALTER TABLE org_unit ADD UNIQUE (id, tenant_id);
CREATE TABLE user_org_unit (
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
org_unit_id uuid NOT NULL,
PRIMARY KEY (user_id, org_unit_id),
FOREIGN KEY (user_id, tenant_id)
REFERENCES app_user (id, tenant_id),
FOREIGN KEY (org_unit_id, tenant_id)
REFERENCES org_unit (id, tenant_id)
);Com a mesma coluna tenant_id participando das duas FKs, um vínculo cross-tenant deixa de ser um estado possível. Quando migrar tudo para chaves compostas de uma vez é caro demais, um constraint trigger serve como etapa intermediária. O que não serve é “já validamos no handler”.
O banco deve ser capaz de recusar estados cross-tenant impossíveis.
Identidade não é e-mail
Na mesma linha: relação viva aponta para identificador estável. E-mail é canal de contato e muitas vezes login, mas muda. Domínio corporativo muda, grafia é corrigida, gente troca de nome. Uma user_role com e-mail como chave é uma migração dolorosa agendada para o futuro. Use UUID imutável nas relações e, se quiser, guarde um snapshot textual nos eventos de auditoria, para que a trilha continue legível mesmo depois que o cadastro mudar.
Testes que tentam quebrar
O teste certo aqui não verifica que o handler rejeita. Ele tenta construir o estado proibido direto no banco e passa quando o banco recusa:
- associar um usuário do tenant A a uma unidade do tenant B;
- compartilhar um objeto do tenant A com alguém do tenant B;
- atribuir uma role de outro tenant;
- ligar um recurso a uma unidade incompatível.
Se algum desses inserts passar, o isolamento depende da disciplina de cada endpoint. E disciplina de endpoint é exatamente o que falha no dia em que alguém escreve um script às pressas.