Apariencia
0053. Hardening del invariante "último administrador" con advisory locks
Estado
Aceptada
Contexto
Dos invariantes de seguridad protegen al sistema de quedarse sin nadie que pueda administrarlo:
- Plataforma (
PlatformUsersService, ADR-0048): no se puede desactivar ni cambiarle el rol al último usuario con capacidad de crear usuarios de plataforma (usuarios-plataforma:crear). - Tenant (
UsersService): no se puede desactivar ni degradar al últimoAdministradoractivo de una organización.
Ambos estaban implementados como check-then-act: primero un findMany que cuenta los otros admins/managers activos, y —en un paso separado— la escritura que desactiva o cambia el rol. El conteo se leía fuera de la transacción de escritura.
Esa separación abre una ventana de carrera. Dos requests concurrentes que desactivan a los dos últimos admins:
A: LEE → ve a B activo → pasa el chequeo ✓
B: LEE → ve a A activo → pasa el chequeo ✓
A: ESCRIBE → desactiva A
B: ESCRIBE → desactiva B
Resultado: 0 admins. Sistema sin administrador.Impacto práctico bajo (requiere dos escrituras en la misma fracción de segundo), pero la consecuencia —plataforma u organización sin administrador— es grave y no se auto-corrige. Encontrado en el code-review de platform-rbac-backend; el mismo patrón preexistía en el equivalente de tenant. Se endurecen los dos en la misma tarea (backend-hardening-locks, 2026-07-23).
Decisión
Hacer atómico el check+write: mover el conteo y la escritura a una misma transacción, y serializar el acceso con un advisory lock de transacción de Postgres (pg_advisory_xact_lock), adquirido antes de leer.
Orden invariante en los 4 caminos afectados (PlatformUsersService.deactivate/changeRole, UsersService.deactivate/changeRole):
$transaction:
1. lock → pg_advisory_xact_lock(...) (el 2º request espera aquí)
2. read → contar otros admins/managers activos (datos frescos)
3. write → desactivar / cambiar rolEl lock se libera solo al terminar la transacción (commit o rollback, automático). El segundo request espera a que el primero confirme, relee con el estado ya actualizado (ve al primer admin ya desactivado) y correctamente lanza el 400 de negocio de siempre.
Esquema de claves (src/common/utils/advisory-lock.ts)
pg_advisory_xact_lock(classid int4, objid int4) combina ambos enteros en una clave de 64 bits. Se usa un classid distinto por dominio para que plataforma y tenant nunca compartan clave:
| Ámbito | classid | objid | Scope |
|---|---|---|---|
| Managers de plataforma | 74920 | 0 | global (un solo lock para toda la plataforma) |
| Admins de tenant | 74921 | hashtext(organizationId) | por organización (dos tenants no se bloquean entre sí) |
Por qué advisory lock y no SERIALIZABLE
Se evaluó envolver check+write en una transacción SERIALIZABLE (Postgres abortaría una de las dos con 40001 serialization_failure). Se descartó:
- Sin modo de error nuevo:
SERIALIZABLEobliga a un wrapper de reintento o a exponer un409que el cliente deba reintentar. El advisory lock no agrega ninguna superficie de error: el segundo request o pasa, o recibe el mismo400de negocio que ya existía. - Frecuencia bajísima: no justifica el costo de reintentos + su testeo.
- Scope-aware y blast radius mínimo: el lock es explícito y acotado a estos 4 caminos; tenant se serializa por-org, sin contención cross-tenant.
SERIALIZABLEpuede abortar por solapamientos más amplios que este caso puntual.
Consecuencias
- Comportamiento del cliente sin cambios: mismos endpoints, mismos códigos. El segundo request concurrente recibe el mismo
400que recibía en el camino secuencial. Sin cambios encamaroneras_admin/camaroneras_platform/camaroneras_mobile; sin cambios de contrato (no toca la API Reference). - Helper nuevo
src/common/utils/advisory-lock.ts(acquirePlatformManagerLock,acquireTenantAdminLock). Los métodosensureNotLastManager/ensureNotLastAdmin/hasOtherActiveAdminahora reciben eltxde la transacción y leen a través de él. - Tests e2e de regresión de la carrera (disparan dos desactivaciones simultáneas de los dos últimos admins con
Promise.ally asertan una 200, una 400 y que queda un admin activo):test/platform.e2e-spec.ts(plataforma) ytest/platform-tenant-admin.e2e-spec.ts(tenant, vía el passthrough cross-tenant). Sin el lock, ambas pasarían el chequeo → dos 200 y cero admins. - Limitación conocida: el advisory lock es opt-in por código — solo protege si todo camino que desactiva/degrada un admin pasa por estos métodos y toma el lock. Mitigado centralizando el chequeo en
ensure*. Una colisión dehashtextentre dos orgs solo provoca una espera de más, nunca una incorrección. No hay riesgo de deadlock: cada transacción toma un único lock.
Referencias
- Tarea
backend-hardening-locks(2026-07-23). - ADR-0048 — origen del invariante de plataforma.
camaroneras_backend/src/common/utils/advisory-lock.ts,src/modules/platform/platform-users.service.ts,src/modules/users/users.service.ts.camaroneras_backend/test/platform.e2e-spec.ts,test/platform-tenant-admin.e2e-spec.ts.