ADR-024: USUA_PERM_ROOT no se concede desde la API — el administrador de entidad no es el techo de su entidad¶
Estado: Aceptado Fecha: 2026-07-31 Autores: Auditoría de seguridad + fastapi-developer
Contexto¶
Una auditoría de seguridad sobre services/auth-service encontró un hallazgo Alto:
un administrador de entidad (USUA_PERM_ADMIN, el rol operativo más alto de un tenant)
podía auto-escalarse a USUA_PERM_ROOT (bypass total, ver has_permission en
app/services/rbac_service.py) por dos vías:
POST /auth/usersconis_root: true—RBACService.create_userinsertaba la fila sin comprobar quién hacía la petición.PUT /auth/groups/{gid}/permissions/{pid}concediendoUSUA_PERM_ROOTa un grupo — incluyendo un grupo del que el propio actor es miembro.USUA_PERM_ROOTes una fila normal deauth_permissions(seedmigrations/tenant/004_permissions_seed.sql), yGET /auth/permissionsla devolvía sin filtrar, así que aparecía en el desplegable del panel de administración como cualquier otro permiso concedible.
Además, esa segunda vía es una escalada de RF-SEG-08
por otro eje: PUT /auth/groups/{gid}/clearance no comprobaba que el max_level pedido
no excediera el clearance efectivo de quien lo pide — un admin con clearance RESERVADA
podía subirse a sí mismo (vía el grupo del que es miembro) a CLASIFICADA.
Al diseñar el cierre apareció una restricción de arranque que no es opcional: verificado
contra el código, no existe ninguna vía de crear el primer ROOT de un tenant fuera de
la API. scripts/init_tenant.py (tenant-service) no crea usuario alguno, y
rbac_repository.upsert_user_by_keycloak_sub es explícito: "Never flips is_root — admin
must set it manually". Cerrar las dos vías sin más deja un punto muerto: ningún tenant
podría tener jamás su primer ROOT.
Decisión¶
USUA_PERM_ROOT deja de ser concedible desde la API bajo cualquier circunstancia,
incluso por otro ROOT vía las dos vías anteriores salvo que el llamante YA sea ROOT.
La única forma de que un tenant obtenga su primer (o siguiente) ROOT es un script de
operación (app/ops/grant_root.py) ejecutado por quien tiene acceso directo a la base de
datos — nunca por HTTP, nunca expuesto por el api-gateway.
Concretamente:
RBACService.create_user: rechazais_root=truecon 403 salvo que elcallerya sea ROOT.RBACService.set_group_permission: rechaza conceder (crud > 0)USUA_PERM_ROOTa un grupo con 403 salvo que elcallerya sea ROOT. Revocar (crud = 0) sigue permitido sin restricción — solo se bloquea la concesión.GET /auth/permissionsomiteUSUA_PERM_ROOTdel catálogo para un llamante no-ROOT (no ofrecer en un selector de UI un permiso que el propio backend va a rechazar).PUT /auth/groups/{gid}/clearance: no-write-up — rechaza con 403 unmax_levelpor encima del clearance efectivo delcaller, salvo que sea ROOT. Mismo patrón que el no-write-up de clasificación dearchive_service.create_expediente/document_service.reclassify(RF-SEG-08): 403 explícito, chequeo antes de mutar.app/ops/grant_root.py(nuevo): concedeis_root=truea un usuario existente de UN tenant explícito (nunca "todos"), directamente contra la BD. Idempotente, audita enpublic.audit_log(rbac.root_otorgado_fuera_de_banda, actorsystem:ops-grant-root), y aborta si la auditoría no está disponible. Sigue el mismo patrón queapp/ops/purge_revoked_signing_keys.py/app/ops/rewrap_signing_keys.py— el precedente ya establecido de operaciones sensibles fuera de banda.
El administrador de entidad ya no es el techo de su propia entidad. Un
USUA_PERM_ADMIN sigue pudiendo administrar todo lo demás (usuarios no-ROOT, grupos,
permisos no-ROOT, clearance hasta su propio nivel), pero ni él ni ningún otro
USUA_PERM_ADMIN puede crear un par de sí mismo con más privilegio que él, y no puede
elevar el clearance de un grupo por encima del suyo. Solo ROOT concede ROOT; solo un
clearance igual o superior concede ese clearance.
Justificación¶
- Mínimo privilegio real, no nominal:
USUA_PERM_ADMINdocumentaba ser el rol administrativo del tenant, pero al poder auto-concederseUSUA_PERM_ROOT(bypass total de TODO gate RBAC del tenant, incluyendo los de otros servicios que confían enX-User-Id/permisos resueltos aquí) era, de facto, indistinguible de ROOT. El hallazgo no es solo "un admin puede hacer cosas de más" — es que la distinción ROOT/ADMIN no existía en la práctica. - Consistencia con RF-SEG-08: el proyecto ya aplica no-write-up en clasificación de radicados/expedientes (nadie origina contenido por encima de su propia habilitación). Permitir que un admin subiera el clearance de un grupo por encima del suyo era la misma vulnerabilidad en el eje de permisos administrativos en vez del eje documental.
- Separación de canales: un privilegio que, si se abusa, compromete TODO el tenant
(ROOT) no debe ser alcanzable por el mismo canal (HTTP, tras el gateway) que un admin ya
controla. Requerir acceso directo a la base de datos para conceder ROOT impone una
barrera operacional distinta — el mismo principio que ya rige la purga/rotación de
material de firma (
app/ops/*). - Fail-closed sobre fail-open en el catálogo de permisos: excluir
USUA_PERM_ROOTdeGET /auth/permissionspara no-ROOT no es solo estética — ofrecer en un desplegable una opción que el backend va a rechazar es prometer una operación que falla, y una API que promete cosas que no cumple invita a que el cliente (o un atacante) intente rodear el gate por otra vía no auditada.
Consecuencias¶
Positivas - Cierra el hallazgo Alto: auto-escalada a ROOT y a clearance superior al propio ya no son posibles desde ningún endpoint HTTP. - El primer ROOT de un tenant queda con una vía de arranque clara, auditada e idempotente. - El patrón de "operación sensible = script de ops fuera de banda, nunca endpoint" queda reforzado con un tercer precedente (junto a purga y rotación de claves de firma).
Negativas / mitigaciones
- Fricción operativa: aprovisionar el primer ROOT de un tenant nuevo exige acceso a la
base de datos (o a quien lo tenga), no autoservicio desde el panel de administración.
Aceptado deliberadamente — es la contrapartida directa de cerrar la auto-escalada; un
ROOT autoservicio y una auto-escalada cerrada son mutuamente excluyentes.
- El frontend ofrece hoy una casilla "Otorgar ROOT" en el formulario de creación de
usuario que, para un admin no-ROOT, ahora siempre falla con 403. Pendiente: el frontend
debe ocultar esa casilla (y la opción USUA_PERM_ROOT del selector de permisos de grupo)
cuando el usuario en sesión no sea ROOT — GET /auth/permissions ya no la lista para
ese caso, así que el selector de permisos se resuelve solo; la casilla de is_root en
el formulario de usuario necesita un chequeo explícito de is_root del propio caller
(expuesto en GET /auth/me o EffectivePermissions).
- app/ops/grant_root.py no tiene test de integración automatizado (igual que sus dos
scripts hermanos) — se valida contra Postgres real de forma manual antes de cada cambio,
siguiendo la práctica ya establecida del proyecto para scripts de ops.
Alternativas descartadas¶
- Permitir que ROOT se auto-conceda vía la API pero bloquear solo a no-ROOT (en vez de bloquear la concesión de ROOT incondicionalmente salvo llamante-ROOT): descartado por simplicidad de razonamiento — la regla "solo ROOT concede ROOT" es más fácil de auditar que una excepción condicional, y de todas formas un ROOT existente puede usar el script de ops si prefiere no dejar rastro en la sesión HTTP.
- Voltear
is_rootcon una migración.sqlde datos para el primer ROOT: descartado porque una migración numerada es un artefacto versionado y compartido — no debe llevar datos específicos de un tenant/entorno (usuario, contraseña, tenant real). El script de ops mantiene esa separación: código versionado, dato de entorno como argumento.