En una base de datos multi-tenant, esta tabla es referencialmente válida:
user_org_unit(user_id, org_unit_id)Las dos foreign keys garantizan que el usuario existe y que la unidad existe. Ninguna garantiza que pertenezcan al mismo tenant. Un bug en el handler, un script de migración o una importación masiva, y listo: un usuario del cliente A pasa a formar parte de una unidad del cliente B. La base de datos lo acepta, porque nadie le dijo que eso es imposible.
Invariantes tenant-aware
Toda relación sensible debería preservar la igualdad de tenant:
tenant(user) = tenant(org_unit)
tenant(resource) = tenant(org_unit)
tenant(grantee) = tenant(resource) = tenant(grantor)
tenant(user) = tenant(role) -- cuando el role es del tenantLa forma más limpia de garantizarlo es con una clave compuesta. Cada tabla expone el par (id, tenant_id) como único, y las tablas de unión referencian el 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)
);Con la misma columna tenant_id participando en las dos FKs, un vínculo cross-tenant deja de ser un estado posible. Cuando migrar todo a claves compuestas de una vez es demasiado caro, un constraint trigger sirve como paso intermedio. Lo que no sirve es “ya lo validamos en el handler”.
La base de datos debe ser capaz de rechazar estados cross-tenant imposibles.
Identidad no es e-mail
En la misma línea: una relación viva apunta a un identificador estable. El e-mail es un canal de contacto y muchas veces el login, pero cambia. El dominio corporativo cambia, la grafía se corrige, la gente se cambia el nombre. Una user_role con el e-mail como clave es una migración dolorosa programada para el futuro. Usa un UUID inmutable en las relaciones y, si quieres, guarda un snapshot textual en los eventos de auditoría, para que el rastro siga siendo legible incluso después de que cambien los datos del usuario.
Tests que intentan romper
El test correcto aquí no verifica que el handler rechace. Intenta construir el estado prohibido directamente en la base de datos y pasa cuando la base de datos lo rechaza:
- asociar un usuario del tenant A a una unidad del tenant B;
- compartir un objeto del tenant A con alguien del tenant B;
- asignar un role de otro tenant;
- vincular un recurso a una unidad incompatible.
Si alguno de esos inserts pasa, el aislamiento depende de la disciplina de cada endpoint. Y la disciplina de endpoint es exactamente lo que falla el día en que alguien escribe un script con prisas.