Voltar para os textos

Foreign key não garante tenant

Uma FK prova que as duas linhas existem. Não prova que elas são do mesmo cliente.

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 tenant

A 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.