From dfc7633a1f98b6136f00f3b6ba7b108603de9743 Mon Sep 17 00:00:00 2001 From: jesquivels Date: Fri, 30 Oct 2026 02:55:58 -0600 Subject: [PATCH] mmmm --- .env.example | 5 + README.md | 3 +- compose.mock.yaml | 6 +- compose.yaml | 7 +- demo/providers-mock/README.md | 11 ++- .../Orquestador-NMP.postman_collection.json | 2 +- examples/postman/README.md | 19 ++-- specs/DECISIONS.md | 12 +++ specs/IMPLEMENTATION_PLAN.md | 2 + specs/POC_AUTH.md | 73 ++++++++++++++ specs/PROGRESS.md | 9 ++ specs/SECURITY.md | 4 + .../security/DemoTokenDecoder.java | 39 ++++++++ .../security/JwtActorMapper.java | 2 +- .../security/SecurityConfiguration.java | 12 +++ .../resources/application-demo-token.yaml | 16 ++++ .../demosecuritytest/DemoTokenHttpTest.java | 95 +++++++++++++++++++ .../orchestrator/DemoTokenIT.java | 50 ++++++++++ .../orchestrator/DemoTokenSecurityTest.java | 31 ++++++ 19 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 specs/POC_AUTH.md create mode 100644 src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/DemoTokenDecoder.java create mode 100644 src/main/resources/application-demo-token.yaml create mode 100644 src/test/java/com/organizacion/demosecuritytest/DemoTokenHttpTest.java create mode 100644 src/test/java/com/organizacion/orchestrator/DemoTokenIT.java create mode 100644 src/test/java/com/organizacion/orchestrator/DemoTokenSecurityTest.java diff --git a/.env.example b/.env.example index bf0d769..19ee4ba 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ SPRING_PROFILES_ACTIVE=postgres96 JWT_ISSUER_URI=http://keycloakic.ddns.net/auth/realms/controlms JWT_JWK_SET_URI=http://keycloakic.ddns.net/auth/realms/controlms/protocol/openid-connect/certs JWT_AUDIENCE=orquestador-nmp +# POC sin Keycloak: compose.mock.yaml activa demo-token y reutiliza MOCK_ADMIN_TOKEN. +# Opcional: token independiente de 32..256 caracteres base64url (sin espacios). +# ORCHESTRATOR_DEMO_TOKEN= +# ORCHESTRATOR_DEMO_SUBJECT=poc-user +# ORCHESTRATOR_DEMO_SCOPE=demo APP_BIND_ADDRESS=127.0.0.1 APP_PORT=8080 diff --git a/README.md b/README.md index 0a976ff..389d4e1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Incluye la estructura Maven, configuración JWT/PostgreSQL, Docker y salud verif Para configurar respuestas de proveedores manualmente desde Postman, consultar [Proveedores mock](demo/providers-mock/README.md). El archivo opcional `compose.mock.yaml` añade el servicio HTTP de demo en `microsipNet`; el Compose -base conserva el servicio conectado a PostgreSQL externo. +base conserva el servicio conectado a PostgreSQL externo. La POC puede usar +[token fijo sin Keycloak](specs/POC_AUTH.md) mediante el perfil demo-token. - Docker con contenedores Linux y Compose v2. La imagen compila con JDK 21 y Maven Wrapper; no requiere Java/Maven en el anfitrión. - PostgreSQL externo con base y usuario existentes, accesible desde Docker. El entorno actual usa PostgreSQL 9.6.2 en server02:30432 mediante el perfil postgres96; ver ADR-006. diff --git a/compose.mock.yaml b/compose.mock.yaml index c21eb3a..33101e7 100644 --- a/compose.mock.yaml +++ b/compose.mock.yaml @@ -26,7 +26,11 @@ services: providers-mock: condition: service_healthy environment: - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-postgres96},demo-mock + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-postgres96},demo-mock,demo-token + ORCHESTRATOR_DEMO_TOKEN: ${ORCHESTRATOR_DEMO_TOKEN:-${MOCK_ADMIN_TOKEN:?Configura MOCK_ADMIN_TOKEN}} + ORCHESTRATOR_DEMO_SUBJECT: ${ORCHESTRATOR_DEMO_SUBJECT:-poc-user} + ORCHESTRATOR_DEMO_SCOPE: ${ORCHESTRATOR_DEMO_SCOPE:-demo} + ADMIN_BUSINESS_SCOPES: ${ADMIN_BUSINESS_SCOPES:-${ORCHESTRATOR_DEMO_SCOPE:-demo}} FLOW_CATALOG_PATH: /app/mock-config/publication-catalog.json INTEGRATION_ROUTES_PATH: /app/mock-config/integration-routes.json volumes: diff --git a/compose.yaml b/compose.yaml index aa23317..3e68b21 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,9 +11,10 @@ services: DB_PASSWORD: ${DB_PASSWORD:?Configura DB_PASSWORD en .env} DB_MIGRATION_USERNAME: ${DB_MIGRATION_USERNAME:-${DB_USERNAME}} DB_MIGRATION_PASSWORD: ${DB_MIGRATION_PASSWORD:-${DB_PASSWORD}} - JWT_ISSUER_URI: ${JWT_ISSUER_URI:?Configura JWT_ISSUER_URI en .env} - JWT_JWK_SET_URI: ${JWT_JWK_SET_URI:?Configura JWT_JWK_SET_URI en .env} - JWT_AUDIENCE: ${JWT_AUDIENCE:?Configura JWT_AUDIENCE en .env} + # Required by the application in normal mode; optional for the demo-token override. + JWT_ISSUER_URI: ${JWT_ISSUER_URI:-} + JWT_JWK_SET_URI: ${JWT_JWK_SET_URI:-} + JWT_AUDIENCE: ${JWT_AUDIENCE:-} JWT_BUSINESS_SCOPE_CLAIM: ${JWT_BUSINESS_SCOPE_CLAIM:-business_scope} FLOW_ADMIN_ENABLED: ${FLOW_ADMIN_ENABLED:-false} REQUEST_API_ENABLED: ${REQUEST_API_ENABLED:-false} diff --git a/demo/providers-mock/README.md b/demo/providers-mock/README.md index 14c2eba..9909570 100644 --- a/demo/providers-mock/README.md +++ b/demo/providers-mock/README.md @@ -14,7 +14,8 @@ MOCK_BIND_ADDRESS=0.0.0.0 MOCK_PORT=8788 ``` -El token es exclusivo del mock, no es un JWT de Keycloak. La publicación del +El token administra el mock y se reutiliza por defecto en el orquestador de POC +(ver POC_AUTH.md). No es un JWT de Keycloak. La publicación del puerto en 0.0.0.0 permite acceso desde Postman en tu red de demo. ```bash @@ -75,7 +76,7 @@ cuerpos de hasta 256 KiB. Omisión de cabeceras restablece status=200 y delay=0. El archivo base `compose.yaml` conserva un solo servicio. La combinación con `compose.mock.yaml` añade el mock a `microsipNet` y selecciona catálogo/rutas -sin sobrescribir `config/`. Activa el perfil `demo-mock` junto al perfil configurado +sin sobrescribir `config/`. Activa los perfiles `demo-mock` y `demo-token` junto al perfil configurado (por defecto postgres96). Ese perfil permite HTTP únicamente hacia el nombre `providers-mock` y puerto 8788. Sin ese perfil se mantiene el requisito HTTPS normal; local/test conserva su excepción previa para loopback. @@ -84,8 +85,10 @@ Antes de habilitar la demo, configurar en `.env` REQUEST_API_ENABLED=true, WORKER_ENABLED=true y, para administrar flujos, FLOW_ADMIN_ENABLED=true y ADMIN_BUSINESS_SCOPES. Se requieren el usuario DB de runtime restringido y las credenciales separadas de migración descritos en [SECURITY.md](../../specs/SECURITY.md). -Los JWT siguen procediendo de tu Keycloak. Este servicio no genera tokens JWT ni -crea tipos, flujos, solicitudes o documentos automáticamente. +El orquestador usa un token fijo en la POC, sin Keycloak. En Postman colocar +MOCK_ADMIN_TOKEN en accessToken, o el valor de ORCHESTRATOR_DEMO_TOKEN si lo +configuraste por separado. Ver [POC_AUTH.md](../../specs/POC_AUTH.md). No se crean +tipos, flujos, solicitudes o documentos automáticamente. Aplicar en el entorno de demo: diff --git a/examples/postman/Orquestador-NMP.postman_collection.json b/examples/postman/Orquestador-NMP.postman_collection.json index efbe7c0..733e696 100644 --- a/examples/postman/Orquestador-NMP.postman_collection.json +++ b/examples/postman/Orquestador-NMP.postman_collection.json @@ -2,7 +2,7 @@ "info": { "name": "Orquestador NMP — server02", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "description": "Ejecutar solicitudes manualmente por carpetas. No ejecutar toda la colección: publica y activa flujos, crea solicitudes y acepta eventos. Usar entorno de demo y datos sintéticos. Tokens vacíos: configurar en variables de colección. Los tokens específicos vacíos usan accessToken. Ver README.md adjunto. Las pruebas funcionales requieren flags, rol DB restringido, catálogo y proveedores configurados." + "description": "Ejecutar solicitudes manualmente por carpetas. No ejecutar toda la colección: publica y activa flujos, crea solicitudes y acepta eventos. Usar entorno de demo y datos sintéticos. Tokens vacíos: configurar en variables de colección. Los tokens específicos vacíos usan accessToken. Ver README.md adjunto. Las pruebas funcionales requieren flags, rol DB restringido, catálogo y proveedores configurados. POC sin Keycloak: con el perfil demo-token, accessToken es ORCHESTRATOR_DEMO_TOKEN (por defecto MOCK_ADMIN_TOKEN al usar compose.mock.yaml). Dejar los tokens específicos vacíos. Ver specs/POC_AUTH.md." }, "auth": { "type": "bearer", diff --git a/examples/postman/README.md b/examples/postman/README.md index e582352..64728f1 100644 --- a/examples/postman/README.md +++ b/examples/postman/README.md @@ -9,14 +9,15 @@ Publicar, activar, crear solicitudes y enviar eventos modifican datos. 1. Ejecutar la carpeta **01**: salud/readiness/liveness deben devolver 200 UP; la petición sin token debe devolver 401. -2. En Variables de la colección, colocar un access token de Keycloak vigente en - `accessToken`, sin el prefijo Bearer. Guardarlo solo localmente; no compartir - una exportación con tokens. Debe tener el issuer y audiencia configurados, - `sub`, expiración y el claim `business_scope` no vacío. -3. Para administración se pueden configurar `editorToken`, `publisherToken` y - `adminToken` por separado. Si están vacíos se usa `accessToken`. Roles: - FLOW_EDITOR para crear/simular; FLOW_PUBLISHER para publicar; FLOW_ADMIN para - activar. El business_scope debe estar en ADMIN_BUSINESS_SCOPES. +2. Para la POC con `compose.mock.yaml`, colocar en `accessToken` el token fijo + `ORCHESTRATOR_DEMO_TOKEN` o, si no lo definiste, el valor de `MOCK_ADMIN_TOKEN`. + No anteponer Bearer. No requiere Keycloak. Ver [POC_AUTH](../../specs/POC_AUTH.md). +3. Dejar vacíos `editorToken`, `publisherToken`, `adminToken` y `operatorToken` + para reutilizar accessToken. El servidor asigna usuario poc-user, ámbito demo + y roles de la POC. El ámbito debe estar en ADMIN_BUSINESS_SCOPES. +4. En modo normal sin demo-token, accessToken sí debe ser un JWT válido de Keycloak, + con issuer/audiencia/roles/business_scope configurados. Guardar cualquier token + solo localmente y no compartir una exportación con secretos. ## Simulación sin llamadas a proveedores @@ -34,7 +35,7 @@ Publicar, activar, crear solicitudes y enviar eventos modifican datos. ## Recorrido persistente Solo en entorno de demo con proveedores mock configurados. La colección no -levanta mocks ni configura Keycloak, roles DB, contratos o tipos de solicitud. +levanta mocks ni configura roles DB, contratos o tipos de solicitud. 1. Habilitar REQUEST_API_ENABLED y WORKER_ENABLED con el runtime DB restringido. Configurar rutas HTTP de proveedores de demo. El tipo CREDITO debe existir; diff --git a/specs/DECISIONS.md b/specs/DECISIONS.md index aa4958c..0bf5263 100644 --- a/specs/DECISIONS.md +++ b/specs/DECISIONS.md @@ -532,3 +532,15 @@ Límites: no proveedor real, almacenamiento documental ni antivirus; metadatos m El usuario solicita evitar certificados para la POC; sustituye la decisión TLS del mock en ADR-021. MockServer, healthcheck, rutas y Postman pasan a HTTP. Se eliminan generación de certificados, mounts de claves/truststore y JAVA_TOOL_OPTIONS del override; las respuestas persistidas se mantienen y no se borran volúmenes anteriores. El override activa el perfil explícito demo-mock junto al perfil DB. HttpRoutes permite HTTP hacia providers-mock:8788 únicamente con ese perfil; el requisito HTTPS normal y la excepción local/test para loopback se conservan. Se mantiene el token de administración del mock y la seguridad JWT del orquestador. No se habilita HTTP a otros hosts ni puertos por el perfil demo. La comunicación de la POC no está cifrada. No se modifica idempotencia ni versiones. Validación en E30; Docker sigue pendiente. + +## ADR-023 — Token fijo para autenticación de la POC — 2026-10-30 + +El usuario solicita quitar la dependencia de Keycloak en la POC y usar un token similar al mock. El perfil explícito demo-token selecciona un decoder de secreto opaco: compara hashes SHA-256 en tiempo constante y construye internamente la identidad que esperan los controladores. No analiza ni firma un JWT, no consulta issuer/JWKS y no acepta roles/identidad del cliente. Token requerido de 32..256 caracteres base64url; ausencia o formato inválido impide el arranque. + +La identidad configurada por el servidor es poc-user/demo por defecto, con ANALISTA, FLOW_EDITOR, FLOW_PUBLISHER, FLOW_ADMIN y EXECUTION_OPERATOR. Se conservan la cadena de autorización, ownership/scope, permisos DB, flags funcionales e idempotencia. Todas las llamadas con ese secreto comparten identidad. El secreto no se retiene en el principal, no se exporta en Postman y no se imprime. No tiene expiración automática; rotación mediante configuración y recreación. + +Alternativas descartadas: permitir todas las rutas elimina permisos y trazabilidad; JWT autofirmado mantiene infraestructura innecesaria para esta solicitud. Se reutiliza la representación Jwt interna exclusivamente como adaptación a los controladores existentes, documentada como identidad sintética, nunca como token JWT emitido. + +compose.mock.yaml activa demo-token junto a demo-mock y el perfil DB; ORCHESTRATOR_DEMO_TOKEN permite secreto separado y por defecto reutiliza MOCK_ADMIN_TOKEN. Subject/ámbito configurables; ADMIN_BUSINESS_SCOPES conserva valores explícitos o usa ámbito demo. Variables JWT dejan de ser obligatorias durante interpolación de Compose para permitir esta combinación; la aplicación normal exige issuer/JWKS/audiencia no vacíos. Sin demo-token se mantiene Nimbus RS256 y el token fijo no se acepta aunque esté configurado. + +Validación: pruebas HTTP aisladas sin DB/JWKS y pruebas de endpoints persistentes con PostgreSQL de pruebas; evidencia E31. Esta excepción de POC no cambia la configuración de producción ni cierra T16/T17. HTTP sin cifrado sigue siendo la modalidad de demo elegida por el usuario. diff --git a/specs/IMPLEMENTATION_PLAN.md b/specs/IMPLEMENTATION_PLAN.md index e748225..aeb6841 100644 --- a/specs/IMPLEMENTATION_PLAN.md +++ b/specs/IMPLEMENTATION_PLAN.md @@ -278,3 +278,5 @@ Seguimiento T16 (2026-10-28, E26): corregidas las entradas del build Docker para Extensión autorizada 2026-10-29: servicio de proveedores mock configurable manualmente (ADR-021), Compose opcional y colección Postman. Entregable relacionado con demos de T16/T17; no adelanta el cierre de esas tareas. Criterios: configuración HTTP de status/cuerpo/demora, persistencia tras reinicio, HTTPS y token administrativo verificados; despliegue Docker separado de la evidencia local. Ver E29 en PROGRESS. Actualización 2026-10-29 (ADR-022): por solicitud del usuario, el mock de la POC usa HTTP sin certificados. Perfil demo-mock limita la excepción a providers-mock:8788. Sustituye el criterio HTTPS de la extensión anterior; se conservan configuración, persistencia y token administrativo. T16/T17 mantienen su estado. + +Extensión autorizada 2026-10-30 (ADR-023): autenticación de POC con token fijo y perfil demo-token, sin Keycloak. Criterios: fail-closed sin token correcto, identidad/roles del servidor, ACL de propiedad/ámbito, persistencia/idempotencia y regresión JWT normal. No cierra T16/T17; ver POC_AUTH.md y evidencia E31. diff --git a/specs/POC_AUTH.md b/specs/POC_AUTH.md new file mode 100644 index 0000000..e09e23e --- /dev/null +++ b/specs/POC_AUTH.md @@ -0,0 +1,73 @@ +# Autenticación de la POC sin Keycloak + +El perfil explícito `demo-token` usa un token fijo en `Authorization: Bearer ...`. +No necesita un JWT, issuer, audiencia ni JWKS de Keycloak. El resto de las rutas +sigue protegido; sin token o con un valor incorrecto responde 401. + +El override `compose.mock.yaml` activa `postgres96,demo-mock,demo-token` por defecto. +Para usarlo basta el `MOCK_ADMIN_TOKEN` existente (al menos 32 caracteres base64url: +letras, números, guion o guion bajo). Compose lo reutiliza como token del +orquestador si no defines `ORCHESTRATOR_DEMO_TOKEN`. + +Opcionalmente, separar tokens en `.env`: + +```dotenv +# Sustituir por un token aleatorio de 32 a 256 caracteres base64url. +ORCHESTRATOR_DEMO_TOKEN= +ORCHESTRATOR_DEMO_SUBJECT=poc-user +ORCHESTRATOR_DEMO_SCOPE=demo +ADMIN_BUSINESS_SCOPES=demo +``` + +Un valor vacío de ORCHESTRATOR_DEMO_TOKEN en Compose usa MOCK_ADMIN_TOKEN. Al +arrancar directamente con Java y demo-token, ORCHESTRATOR_DEMO_TOKEN es obligatorio. +No guardar tokens reales en el repositorio ni en las colecciones exportadas. + +Aplicar la configuración y reconstruir el servicio: + +```bash +docker compose -f compose.yaml -f compose.mock.yaml up -d --build orquestador +docker compose -f compose.yaml -f compose.mock.yaml exec orquestador printenv SPRING_PROFILES_ACTIVE +``` + +En la colección Postman **Orquestador NMP**, establecer `accessToken` con ese +token fijo, sin escribir Bearer. Dejar vacíos `editorToken`, `publisherToken`, +`adminToken` y `operatorToken` para que reutilicen accessToken. Borrar valores +antiguos de esos campos y de variables de entorno de Postman que los sobrescriban. +La colección del mock mantiene `mockAdminToken=MOCK_ADMIN_TOKEN`. + +## Identidad y permisos + +El servidor asocia el token a `poc-user`, ámbito `demo`, y roles ANALISTA, +FLOW_EDITOR, FLOW_PUBLISHER, FLOW_ADMIN y EXECUTION_OPERATOR. Subject y ámbito +se pueden configurar con las variables indicadas; roles fijos para la demo. +El cliente no puede escoger identidad/roles mediante el cuerpo o cabeceras. + +Todas las personas que usen el mismo token son el mismo usuario de POC. Las +solicitudes creadas con otro subject o ámbito (incluido el usuario previo de +Keycloak) no se vuelven accesibles automáticamente. Cambiar el token conservando +subject y ámbito permite seguir trabajando con las solicitudes de la POC. + +Se conservan las comprobaciones de propiedad, ámbito, roles, If-Match, +idempotencia y el rol restringido de PostgreSQL. La administración exige que +ADMIN_BUSINESS_SCOPES incluya el ámbito configurado. Un valor previo en `.env` +no se reemplaza automáticamente por demo. + +FLOW_ADMIN_ENABLED, REQUEST_API_ENABLED y WORKER_ENABLED siguen siendo +interruptores independientes. Para el recorrido completo habilitarlos y usar +credenciales DB de runtime restringidas y migración separadas según SECURITY.md. +El token no crea tipos, flujos ni solicitudes y no evita esa comprobación DB. + +## Modo normal + +Sin `demo-token`, continúa la validación RS256, issuer, audiencia, expiración y +claims del JWT. JWT_ISSUER_URI, JWT_JWK_SET_URI y JWT_AUDIENCE son obligatorios +al arrancar la aplicación en modo normal, aunque Compose permita valores vacíos +para poder combinarlo con el override de POC. Configurar un token fijo sin +activar el perfil no habilita este modo. + +Internamente se adapta la identidad a la representación usada por los +controladores existentes. No se firma un JWT ni se confía en claims proporcionados +por el cliente; únicamente se compara el secreto configurado. No hay caducidad +automática del token fijo: rotarlo requiere recrear el servicio. Esta POC usa +HTTP sin cifrado conforme a la configuración de demo solicitada. diff --git a/specs/PROGRESS.md b/specs/PROGRESS.md index baa0efd..52183ee 100644 --- a/specs/PROGRESS.md +++ b/specs/PROGRESS.md @@ -469,3 +469,12 @@ Con la configuración del repositorio, readiness incluye PostgreSQL. Esta prueba - `mvnw.cmd --batch-mode --no-transfer-progress test -Dtest=HttpIntegrationTest`: BUILD SUCCESS, 24 pruebas, cero fallos/errores/omisiones, 40.400 s (17:56:56 -06:00). Incluye rechazo del host mock sin habilitación, aceptación explícita y rechazo de otros hosts/puertos. Log target/mock-http-maven.log. - `python demo/providers-mock/test_mock.py` con JDK 21: 4 tests OK en 64.015 s; HTTP real, healthcheck Java, configuración, persistencia, errores/demora y metadatos. Parseo de Compose, rutas y colección Postman aprobado; sin mounts de certificados ni JAVA_TOOL_OPTIONS en override. git diff --check aprobado. - No se ejecutó Docker ni despliegue en server02. T16 sigue BLOCKED, T17 PENDING. Aplicar `docker compose -f compose.yaml -f compose.mock.yaml up -d --build` y reimportar colección del mock; la reconstrucción del orquestador es necesaria para la nueva excepción explícita. + +## E31 — Autenticación de POC sin Keycloak — 2026-10-30 + +- Pedido explícito: token fijo similar al mock en lugar de integrar Keycloak. Perfil demo-token con DemoTokenDecoder, identidad/roles server-side y comparación de hashes en tiempo constante. Token incorrecto devuelve 401; configuración inválida falla al arrancar. Se mantienen ACL, flags, idempotencia y rol DB restringido. Sin perfil, Nimbus RS256 sigue siendo el decoder. ADR-023 y POC_AUTH.md. +- Compose opcional activa demo-token y reutiliza MOCK_ADMIN_TOKEN salvo ORCHESTRATOR_DEMO_TOKEN explícito. Subject poc-user y ámbito demo por defecto. Compose base permite interpolar JWT vacío para la POC, pero el arranque normal exige issuer/JWKS/audiencia. Colección Postman mantiene Bearer/accessToken, sin secretos guardados. +- Verificación dirigida: `mvnw.cmd --batch-mode --no-transfer-progress test -Dtest=DemoTokenSecurityTest,DemoTokenHttpTest,SecurityPolicyTest`: BUILD SUCCESS, 12 pruebas, cero fallos/errores/omisiones, 21.190 s. HTTP real sin DB/JWKS: autenticación, identidad no falsificable, propiedad/ámbito y rechazo del secreto en modo normal. El primer intento coincidió con reubicación del controlador de test; corregido al completar su aislamiento del escaneo productivo y repetir. +- Verificación final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=DemoTokenIT,SecurityHttpIT`, JDK 21, exclusivamente orquestador_test con schemas/roles temporales. BUILD SUCCESS, 1:40 min, finalizado 2026-10-30T02:52:49-06:00. 332 unitarias + 7 IT = 339 aprobadas, sin fallos/errores/omisiones. Regresión JWT normal (6 IT) y POC con JWKS detenido (1 IT): crear/publicar/activar flujo, crear/repetir solicitud, identidad persistida, aceptar/repetir evento e historial. No ejecuta worker el nuevo IT. +- Evidencia: target/demo-token-tests.log, target/demo-token-verify.log y reportes Surefire/Failsafe. JAR SHA256 7B0BA4D26106248D762F1C4462D416DC5E3BDEAC509DFDD45039D40D117F57F0. JAR incluye perfil y excluye clases de prueba. YAML, Postman sin secretos y git diff --check aprobados. +- No hubo despliegue ni cambios sobre orquestador/server02:8787. Docker/Compose real y suite integral Testcontainers siguen pendientes: T16 BLOCKED, T17 PENDING. Aplicar reconstrucción con ambos Compose y poner token fijo en accessToken, dejando tokens específicos vacíos. diff --git a/specs/SECURITY.md b/specs/SECURITY.md index 5e023e5..48b2a2b 100644 --- a/specs/SECURITY.md +++ b/specs/SECURITY.md @@ -1,5 +1,9 @@ # Seguridad T07 +Para la POC sin Keycloak, el perfil explícito `demo-token` permite un secreto fijo +según [POC_AUTH.md](POC_AUTH.md) y ADR-023. La validación JWT de esta guía describe +el modo normal, activo cuando no se selecciona ese perfil. + El servicio valida tokens RS256 mediante JWKS, emisor exacto, audiencia configurada, expiración obligatoria y sujeto no vacío. También valida nbf cuando está presente; el validador temporal de Spring admite 60 segundos de desfase. Las pruebas usan diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/DemoTokenDecoder.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/DemoTokenDecoder.java new file mode 100644 index 0000000..10154d4 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/DemoTokenDecoder.java @@ -0,0 +1,39 @@ +package com.organizacion.orchestrator.shared.infrastructure.security; + +import com.organizacion.orchestrator.shared.domain.Actor; +import org.springframework.security.oauth2.jwt.*; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.Instant; +import java.util.*; + +/** Explicit demo-token profile only. Adapts a fixed secret to the existing principal/ACL contract. + * No JWT is parsed, signed or trusted here; identity and roles come exclusively from server configuration. + */ +public final class DemoTokenDecoder implements JwtDecoder { + private static final Set ROLES=Set.of("ANALISTA","FLOW_EDITOR","FLOW_PUBLISHER","FLOW_ADMIN","EXECUTION_OPERATOR"); + private final byte[] expected; + private final Actor actor; + private final String scopeClaim; + public DemoTokenDecoder(String token,String subject,String scope,String scopeClaim){ + if(token==null||!token.matches("[A-Za-z0-9_-]{32,256}")) + throw new IllegalArgumentException("Demo token must contain 32..256 base64url characters"); + if(scopeClaim==null||scopeClaim.isBlank()||Set.of("sub","iat","exp","aud","iss","realm_access","resource_access").contains(scopeClaim)) + throw new IllegalArgumentException("Invalid demo scope claim"); + this.actor=new Actor(subject,scope,ROLES);this.scopeClaim=scopeClaim;this.expected=digest(token); + } + private static byte[] digest(String value){ + try{return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));} + catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);} + } + @Override public Jwt decode(String token)throws JwtException{ + if(token==null||token.length()>256||!MessageDigest.isEqual(expected,digest(token))) + throw new BadJwtException("Invalid demo token"); + Instant now=Instant.now(); + // Synthetic internal principal, not a JWT for clients. Never retain the shared secret as tokenValue. + return Jwt.withTokenValue("demo-authenticated").header("alg","DEMO") + .subject(actor.subject()).issuedAt(now).expiresAt(now.plusSeconds(60)) + .claim(scopeClaim,actor.businessScope()).claim("realm_access",Map.of("roles",List.copyOf(actor.roles()))) + .build(); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java index 171bbb3..bc511a9 100644 --- a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java @@ -5,7 +5,7 @@ import org.springframework.security.oauth2.core.*; import org.springframework.security.oauth2.jwt.Jwt; import java.util.*; -/** Converts only verified JWT claims. OAuth scopes and body fields cannot grant technical roles. */ +/** Maps authenticated claims: verified JWT or server-built demo identity. Body fields cannot grant roles. */ public final class JwtActorMapper { private final String businessScopeClaim,clientId; public JwtActorMapper(String businessScopeClaim,String clientId){ diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java index 2bb07eb..2943178 100644 --- a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java @@ -2,6 +2,7 @@ package com.organizacion.orchestrator.shared.infrastructure.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authorization.AuthorizationDecision; @@ -26,9 +27,12 @@ public class SecurityConfiguration { return new JwtActorMapper(scope,audience); } @Bean + @Profile("!demo-token") JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer, @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwks, @Value("${spring.security.oauth2.resourceserver.jwt.audiences}") String audience){ + if(issuer.isBlank()||jwks.isBlank()||audience.isBlank()) + throw new IllegalArgumentException("JWT issuer, JWKS and audience are required outside demo-token"); var decoder=NimbusJwtDecoder.withJwkSetUri(jwks).jwsAlgorithm(SignatureAlgorithm.RS256).build(); OAuth2TokenValidator required=jwt->{ if(jwt.getExpiresAt()==null||jwt.getSubject()==null||jwt.getSubject().isBlank() @@ -40,6 +44,14 @@ public class SecurityConfiguration { return decoder; } @Bean + @Profile("demo-token") + JwtDecoder demoTokenDecoder(@Value("${orchestrator.security.demo.token:}")String token, + @Value("${orchestrator.security.demo.subject:poc-user}")String subject, + @Value("${orchestrator.security.demo.scope:demo}")String scope, + @Value("${orchestrator.security.business-scope-claim:business_scope}")String scopeClaim){ + return new DemoTokenDecoder(token,subject,scope,scopeClaim); + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http,JwtActorMapper actors, @Value("${orchestrator.admin.enabled:false}") boolean enabled, @Value("${orchestrator.admin.business-scopes:}") String scopes) throws Exception { diff --git a/src/main/resources/application-demo-token.yaml b/src/main/resources/application-demo-token.yaml new file mode 100644 index 0000000..edea3f4 --- /dev/null +++ b/src/main/resources/application-demo-token.yaml @@ -0,0 +1,16 @@ +# POC only: no issuer discovery or JWKS requests. Normal profiles retain RS256 validation. +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: "" + jwk-set-uri: "" + audiences: orchestrator-demo +orchestrator: + security: + business-scope-claim: business_scope + demo: + token: ${ORCHESTRATOR_DEMO_TOKEN:} + subject: ${ORCHESTRATOR_DEMO_SUBJECT:poc-user} + scope: ${ORCHESTRATOR_DEMO_SCOPE:demo} diff --git a/src/test/java/com/organizacion/demosecuritytest/DemoTokenHttpTest.java b/src/test/java/com/organizacion/demosecuritytest/DemoTokenHttpTest.java new file mode 100644 index 0000000..ebb2998 --- /dev/null +++ b/src/test/java/com/organizacion/demosecuritytest/DemoTokenHttpTest.java @@ -0,0 +1,95 @@ +package com.organizacion.demosecuritytest; + +import com.organizacion.orchestrator.shared.infrastructure.security.*; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import org.junit.jupiter.api.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.web.bind.annotation.*; +import java.net.*; +import java.net.http.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Real HTTP authentication without a database, issuer, JWKS server or signed JWT. + * Test-only controller probes the same principal and ownership gate used by business endpoints. + */ +class DemoTokenHttpTest { + static final String TOKEN="synthetic-demo-token-0123456789abcdef"; + static ConfigurableApplicationContext app; + static String base; + static final HttpClient HTTP=HttpClient.newHttpClient(); + @Configuration(proxyBeanMethods=false) + @EnableAutoConfiguration(excludeName={"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", + "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration", + "org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration"}) + @Import({SecurityConfiguration.class,Probe.class}) + static class TestApp {} + @RestController + static class Probe { + final JwtActorMapper mapper; + Probe(JwtActorMapper mapper){this.mapper=mapper;} + @GetMapping("/probe") Map identity(@AuthenticationPrincipal Jwt principal){ + var actor=mapper.actor(principal); + return Map.of("subject",actor.subject(),"scope",actor.businessScope(),"roles",actor.roles()); + } + @PostMapping("/api/v1/admin/flows") String admin(){return "authorized";} + @GetMapping("/ownership/{subject}/{scope}") boolean ownership(@AuthenticationPrincipal Jwt principal,@PathVariable String subject,@PathVariable String scope){ + var owner=new ResourceOwner(new RequestId(UUID.randomUUID()),subject,scope); + var gate=new ResourceAccess((kind,id)->Optional.of(owner)); + try{gate.require(mapper.actor(principal),ResourceOwnershipStore.Kind.REQUEST,owner.requestId().value());return true;} + catch(DomainException denied){return false;} + } + } + static void start(boolean demo,String scopes){ + var args=new ArrayList<>(List.of("--server.address=127.0.0.1","--server.port=0","--logging.level.root=ERROR", + "--management.endpoint.health.validate-group-membership=false","--orchestrator.admin.enabled=true", + "--orchestrator.admin.business-scopes="+scopes,"--orchestrator.security.demo.token="+TOKEN)); + if(demo)args.add("--spring.profiles.active=demo-token"); + else args.addAll(List.of("--spring.profiles.active=default","--spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.invalid", + "--spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://issuer.invalid/jwks","--spring.security.oauth2.resourceserver.jwt.audiences=normal")); + app=new SpringApplication(TestApp.class).run(args.toArray(String[]::new)); + base="http://127.0.0.1:"+((WebServerApplicationContext)app).getWebServer().getPort(); + } + @AfterEach void close(){if(app!=null){app.close();app=null;}} + static HttpResponse call(String path,String token)throws Exception{ + var b=HttpRequest.newBuilder(URI.create(base+path)).timeout(java.time.Duration.ofSeconds(10)); + if(token!=null)b.header("Authorization","Bearer "+token); + if(path.startsWith("/api/"))b.POST(HttpRequest.BodyPublishers.ofString("{\"subject\":\"forged\",\"businessScope\":\"other\"}")); + // These headers must never choose the authenticated identity. + b.header("X-Subject","forged").header("X-Business-Scope","other"); + return HTTP.send(b.build(),HttpResponse.BodyHandlers.ofString()); + } + @Test void demoAuthenticatesOpaqueTokenAndKeepsOwnershipChecks()throws Exception{ + start(true,"demo"); + assertInstanceOf(DemoTokenDecoder.class,app.getBean(JwtDecoder.class)); + assertEquals(200,call("/actuator/health",null).statusCode()); + assertEquals(401,call("/probe",null).statusCode()); + assertEquals(401,call("/probe","wrong").statusCode()); + var response=call("/probe",TOKEN); + assertEquals(200,response.statusCode(),response.body()); + assertTrue(response.body().contains("poc-user"));assertTrue(response.body().contains("ANALISTA")); + assertFalse(response.body().contains("forged"));assertFalse(response.body().contains(TOKEN)); + assertEquals(200,call("/api/v1/admin/flows",TOKEN).statusCode()); + assertEquals("true",call("/ownership/poc-user/demo",TOKEN).body()); + assertEquals("false",call("/ownership/other/demo",TOKEN).body()); + assertEquals("false",call("/ownership/poc-user/other",TOKEN).body()); + } + @Test void adminScopeIsNotBypassedByDemoToken()throws Exception{ + start(true,"other"); + assertEquals(403,call("/api/v1/admin/flows",TOKEN).statusCode()); + } + @Test void normalModeRejectsFixedTokenEvenWhenSecretIsConfigured()throws Exception{ + start(false,"demo"); + assertInstanceOf(NimbusJwtDecoder.class,app.getBean(JwtDecoder.class)); + assertEquals(401,call("/probe",TOKEN).statusCode()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DemoTokenIT.java b/src/test/java/com/organizacion/orchestrator/DemoTokenIT.java new file mode 100644 index 0000000..065f9d5 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DemoTokenIT.java @@ -0,0 +1,50 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.shared.infrastructure.security.DemoTokenDecoder; +import org.junit.jupiter.api.*; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import java.net.URI; +import java.net.http.*; +import java.util.UUID; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Real controllers and isolated PostgreSQL, fixed POC token, JWKS deliberately stopped. */ +class DemoTokenIT { + static final String TOKEN=DemoTokenSecurityTest.TOKEN; + @BeforeAll static void setup()throws Exception{ + SecurityHttpIT.start(true,true,"--spring.profiles.active=postgres96,demo-token", + "--orchestrator.security.demo.token="+TOKEN,"--orchestrator.security.demo.scope=scope-a"); + assertInstanceOf(DemoTokenDecoder.class,app.getBean(JwtDecoder.class)); + keys.stop(0); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @Test void createPublishActivateAndAcceptEventWithFixedIdentityAndIdempotency()throws Exception{ + assertEquals(401,call("POST","/api/v1/admin/flows","wrong",draft(),null).statusCode()); + var created=call("POST","/api/v1/admin/flows",TOKEN,draft(),null); + assertEquals(201,created.statusCode(),created.body()); + String id=json.readTree(created.body()).get("id").asText(); + assertEquals(200,call("POST","/api/v1/admin/flows/"+id+"/publish",TOKEN,null,"\"0\"").statusCode()); + String type="POC_"+UUID.randomUUID().toString().replace("-",""); + admin.update("INSERT INTO "+schema+".request_type(type_key) VALUES (?)",type); + var active=call("PUT","/api/v1/admin/request-types/"+type+"/active-flow",TOKEN,"{\"flowId\":\""+id+"\"}","\"0\""); + assertEquals(200,active.statusCode(),active.body()); + String key=UUID.randomUUID().toString(); + var request=RequestCreationIT.post(type,key,"{}",TOKEN); + assertEquals(201,request.statusCode(),request.body()); + assertEquals(request.body(),RequestCreationIT.post(type,key,"{}",TOKEN).body()); + String requestId=json.readTree(request.body()).get("id").asText(); + var persisted=admin.queryForMap("SELECT owner_subject,business_scope FROM "+schema+".request_instance WHERE id=?",UUID.fromString(requestId)); + assertEquals("poc-user",persisted.get("owner_subject"));assertEquals("scope-a",persisted.get("business_scope")); + assertEquals(200,call("GET","/api/v1/requests/"+requestId,TOKEN,null,null).statusCode()); + var event=HttpRequest.newBuilder(URI.create(base+"/api/v1/requests/"+requestId+"/events")) + .timeout(java.time.Duration.ofSeconds(20)).header("Authorization","Bearer "+TOKEN) + .header("Idempotency-Key",UUID.randomUUID().toString()).header("If-Match","\"0\"") + .header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString("{\"event\":\"SUBMIT\",\"data\":{}}")); + var accepted=http.send(event.build(),HttpResponse.BodyHandlers.ofString()); + assertEquals(202,accepted.statusCode(),accepted.body()); + var replay=http.send(event.build(),HttpResponse.BodyHandlers.ofString()); + assertEquals(202,replay.statusCode());assertEquals(accepted.body(),replay.body()); + assertEquals(200,call("GET","/api/v1/requests/"+requestId+"/history",TOKEN,null,null).statusCode()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DemoTokenSecurityTest.java b/src/test/java/com/organizacion/orchestrator/DemoTokenSecurityTest.java new file mode 100644 index 0000000..d9d974b --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DemoTokenSecurityTest.java @@ -0,0 +1,31 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.shared.infrastructure.security.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.*; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; + +class DemoTokenSecurityTest { + static final String TOKEN="synthetic-demo-token-0123456789abcdef"; + @Test void fixedSecretProducesOnlyServerConfiguredIdentity(){ + var decoder=new DemoTokenDecoder(TOKEN,"poc-user","demo","business_scope"); + var jwt=decoder.decode(TOKEN); + var actor=new JwtActorMapper("business_scope","unused").actor(jwt); + assertEquals("poc-user",actor.subject());assertEquals("demo",actor.businessScope()); + assertEquals(Set.of("ANALISTA","FLOW_EDITOR","FLOW_PUBLISHER","FLOW_ADMIN","EXECUTION_OPERATOR"),actor.roles()); + assertNotEquals(TOKEN,jwt.getTokenValue()); + for(String wrong:new String[]{"",TOKEN+"x","eyJhbGciOiJub25lIn0.e30.","wrong"}) + assertThrows(BadJwtException.class,()->decoder.decode(wrong)); + assertThrows(BadJwtException.class,()->decoder.decode(null)); + } + @Test void missingOrInvalidConfigurationFailsClosedWithoutEchoingSecret(){ + for(String bad:new String[]{"","short"," contains spaces and should never be printed in errors "}){ + var error=assertThrows(IllegalArgumentException.class,()->new DemoTokenDecoder(bad,"poc-user","demo","business_scope")); + if(!bad.isEmpty())assertFalse(error.getMessage().contains(bad)); + } + assertThrows(IllegalArgumentException.class,()->new DemoTokenDecoder(TOKEN,"","demo","business_scope")); + assertThrows(IllegalArgumentException.class,()->new DemoTokenDecoder(TOKEN,"poc-user","","business_scope")); + assertThrows(IllegalArgumentException.class,()->new DemoTokenDecoder(TOKEN,"poc-user","demo","sub")); + } +}