Skip to content

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 último Administrador activo 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 rol

El 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:

ÁmbitoclassidobjidScope
Managers de plataforma749200global (un solo lock para toda la plataforma)
Admins de tenant74921hashtext(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: SERIALIZABLE obliga a un wrapper de reintento o a exponer un 409 que el cliente deba reintentar. El advisory lock no agrega ninguna superficie de error: el segundo request o pasa, o recibe el mismo 400 de 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. SERIALIZABLE puede 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 400 que recibía en el camino secuencial. Sin cambios en camaroneras_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étodos ensureNotLastManager/ensureNotLastAdmin/ hasOtherActiveAdmin ahora reciben el tx de 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.all y asertan una 200, una 400 y que queda un admin activo): test/platform.e2e-spec.ts (plataforma) y test/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 de hashtext entre 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.