Torna agli scritti

La foreign key non garantisce il tenant

Una FK dimostra che le due righe esistono. Non dimostra che appartengano allo stesso cliente.

In un database multi-tenant, questa tabella è referenzialmente valida:

user_org_unit(user_id, org_unit_id)

Le due foreign key garantiscono che l'utente esiste e che l'unità esiste. Nessuna garantisce che appartengano allo stesso tenant. Un bug nell'handler, uno script di migrazione o un import massivo, ed ecco fatto: un utente del cliente A entra a far parte di un'unità del cliente B. Il database accetta, perché nessuno gli ha detto che è impossibile.

Invarianti tenant-aware

Ogni relazione sensibile dovrebbe preservare l'uguaglianza di tenant:

tenant(user)     = tenant(org_unit)
tenant(resource) = tenant(org_unit)
tenant(grantee)  = tenant(resource) = tenant(grantor)
tenant(user)     = tenant(role)   -- quando il role è del tenant

Il modo più pulito di garantirlo è una chiave composta. Ogni tabella espone la coppia (id, tenant_id) come unica, e le tabelle di join referenziano la coppia:

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)
);

Con la stessa colonna tenant_id che partecipa a entrambe le FK, un legame cross-tenant smette di essere uno stato possibile. Quando migrare tutto a chiavi composte in un colpo solo costa troppo, un constraint trigger fa da passaggio intermedio. Quello che non va bene è “l'abbiamo già validato nell'handler”.

Il database deve essere in grado di rifiutare stati cross-tenant impossibili.

L'identità non è l'e-mail

Sulla stessa linea: una relazione viva punta a un identificatore stabile. L'e-mail è un canale di contatto e spesso il login, ma cambia. Il dominio aziendale cambia, la grafia viene corretta, le persone cambiano nome. Una user_role con l'e-mail come chiave è una migrazione dolorosa programmata per il futuro. Usa UUID immutabili nelle relazioni e, se vuoi, salva uno snapshot testuale negli eventi di audit, così la traccia resta leggibile anche dopo che l'anagrafica è cambiata.

Test che cercano di rompere

Il test giusto qui non verifica che l'handler rifiuti. Prova a costruire lo stato proibito direttamente nel database e passa quando il database rifiuta:

  • associare un utente del tenant A a un'unità del tenant B;
  • condividere un oggetto del tenant A con qualcuno del tenant B;
  • assegnare un role di un altro tenant;
  • collegare una risorsa a un'unità incompatibile.

Se uno di questi insert passa, l'isolamento dipende dalla disciplina di ogni endpoint. E la disciplina degli endpoint è esattamente ciò che cede il giorno in cui qualcuno scrive uno script di fretta.