Decisiones de arquitectura

ADR-0009: La identidad es externa y el LIS es un resource server

Estado: Aceptado · Fecha: 2026-09-25

Contexto

El pom.xml traía spring-security-webauthn y la intención declarada era ofrecer passkeys, OTT, MFA y login con Google. Todos esos flujos son interactivos de navegador y con estado, y eso choca con dos decisiones ya tomadas:

Flujo Qué necesita Contra qué choca
Passkeys / WebAuthn PublicKeyCredentialCreationOptions en la HttpSession por defecto, y POST con CSRF STATELESS y CSRF desactivado (ADR-0005)
OTT / magic link Challenge persistido, POST con CSRF, página de submit ADR-0005 y D-03
OAuth2 Login AuthorizationRequest guardado entre redirecciones del navegador ADR-0005
OIDC Back-Channel Logout Exige que la cookie de sesión se llame JSESSIONID No hay sesión
MFA Redirige al usuario a la página del factor que falta No hay páginas (D-03)

La pregunta se resolvió preguntando a la documentación en lugar de eligiendo por gusto. El mecanismo de autenticación no aparece ni una vez en el marco conceptual ni en procesos y casos de uso: ni contraseña, ni MFA, ni passkey, ni SSO, ni federación. Lo que sí exigen es otra cosa:

El modelo distingue dos identidades y los procesos deben respetar esa separación: cuenta_usuario registra quién ejecuta la operación y profesional_laboratorio registra quién aprueba o firma. Una cuenta puede enlazarse con su profesional, pero no lo sustituye. Todas las acciones clínicas y administrativas guardan actor, fecha y contexto.

El requisito es atribución y auditoría, no fortaleza de credencial. P15 se define como «acceso controlado, evidencia y alertas» sobre cuenta_usuario, cuenta_rol, evento_auditoria y alerta.

Conviene nombrar la confusión de categorías que esto evita: la firma del informe no se resuelve con MFA. Se resuelve con informe_resultados.firmante apuntando a un profesional_laboratorio más el hash_sha256 del artefacto, que ya están en el esquema. Es un registro de dominio, no un mecanismo de sesión. Autenticar más fuerte no firma un informe.

Decisión

La identidad la emite un proveedor externo. El LIS valida tokens y no hospeda login. Cada petición a /api/** se autentica por sí misma con su token; ADR-0005 se conserva intacto y deja de tener una cláusula pendiente.

Passkeys, OTT, MFA y login con Google no se cancelan: se mudan. Pasan a ser configuración del proveedor de identidad, que los trae probados y mantenidos, en lugar de código de este servicio.

Salen del pom.xml:

  • spring-security-webauthn, porque este servicio ya no registra ni verifica credenciales.
  • thymeleaf-extras-springsecurity6, porque solo sirve para etiquetas sec: en páginas HTML.

Thymeleaf se queda, y su propósito queda escrito para que no vuelva a confundirse: rellenar plantillas de mensaje —cuerpos de correo, avisos— no servir páginas. Por eso el directorio se llama plantillas y tiene su propio README.

Evidencia en el modelo de datos

Cuatro hechos, en orden de contundencia:

  1. Ninguna de las 90 tablas almacena credenciales, passkeys, tokens ni sesiones. Spring Security WebAuthn exigiría añadir por migración las tablas de JdbcPublicKeyCredentialUserEntityRepository y JdbcUserCredentialRepository. Un modelo que cubre microbiología, inventario y cartera no se olvidó de las credenciales: decidió no tenerlas.
  2. La unicidad de la identidad externa es global; la del nombre es por organización. unique (proveedor_identidad, identificador_externo) frente a unique (organizacion_id, nombre_acceso). Lo primero es exactamente la semántica de issuer más subject de un token OIDC. Si el LIS emitiera la identidad, esas dos columnas serían redundantes y no necesitarían unicidad global.
  3. El comentario de la tabla: «referencia a una identidad externa; no almacena contraseñas ni secretos reversibles». Y los datos de demostración ya usan proveedor_identidad = 'oidc-demo'.
  4. D-03 lo refuerza. Dice que la UI está fuera del plan pero «la UI llegará», con las pantallas ya diseñadas. Si mañana llega una interfaz, es un cliente separado y el LIS sigue validando tokens. Las páginas por defecto de Spring Security serían HTML servido por este servicio, que es justo lo que D-03 excluye.

Consecuencias

  • ADR-0005 deja de tener una cláusula condicional: no habrá autenticación por cookie en esta API, así que CSRF queda desactivado de forma permanente y razonada, no provisional.
  • El servicio no custodia credenciales. Lo que no se almacena no se filtra.
  • Passkeys y MFA llegan antes y mejor hechos que si se implementaran aquí.
  • Una dependencia menos en el classpath, y un esquema que no necesita migraciones para tablas de infraestructura de autenticación. − Dependencia operativa de un proveedor de identidad: hay que desplegarlo, configurarlo y mantenerlo. − Desarrollar contra un IdP es más incómodo que contra un usuario en memoria. Se mitiga conservando el perfil dev con Basic hasta M8.

Qué cambia en el código, y cuándo

Cambio Cuándo
spring-security-webauthn y thymeleaf-extras-springsecurity6 fuera Ahora
spring-boot-starter-oauth2-resource-server dentro, validación de JWT, SecurityFilterChain propio M8
ResolverContexto busca por (proveedor_identidad, identificador_externo) = (iss, sub) del token, no por nombre_acceso M8
Autorización por rol y por recurso sobre cuenta_rol M8
Basic con usuario del perfil dev como andamiaje Hasta M8

La resolución actual por nombre_acceso (S1.01a) es provisional y está anotada como tal: funciona porque el usuario de desarrollo es un nombre, pero la clave natural de una identidad externa es la pareja proveedor más identificador, que es su restricción única global.

Lo que esta decisión NO resuelve

No elige el proveedor concreto ni cómo se despliega. Esa elección depende de una variable de producto que sigue abierta: si el LIS se vende como caja única en el laboratorio del cliente, exigir un IdP al lado sube el coste de instalación, y entonces habría que reconsiderar un servidor de autorización dentro del mismo despliegue. Si la instalación la opera el proveedor del software, el IdP externo gana sin discusión. Se decide antes de M8, no antes de M1.

No define el contrato de errores del borde de seguridad. Eso es de ADR-0008, todavía sin escribir, que heredará de aquí un supuesto firme: un 401 lleva WWW-Authenticate y cuerpo problem+json, sin redirecciones, porque no hay página de login a la que volver.

No toca SecurityConfig todavía: sigue siendo Basic contra un usuario en memoria, que es suficiente andamiaje para M1 y se sustituye en M8.

Alternativas descartadas

El LIS emite su identidad, con dos cadenas de filtros. Una con estado y CSRF para /auth/** y otra stateless para /api/**. Conserva un solo despliegue (D-05) y spring-security-webauthn, pero obliga a emitir tokens propios —la parte del sistema donde un error cuesta más caro y donde menos valor propio se aporta— y a añadir al esquema tablas de credenciales que el modelo no contempló. Queda como el camino a revisar solo si el despliegue en caja única lo exige.

Un servidor de autorización separado, en otro proceso. Resuelve lo mismo que el IdP externo pero hay que escribirlo y mantenerlo, y contradice D-05 (un despliegue, un equipo de una persona).