This commit is contained in:
JOSE ANASTACIO ESQUIVEL SALAS 2026-10-30 02:55:58 -06:00
parent 6b38075492
commit dfc7633a1f
19 changed files with 378 additions and 20 deletions

View File

@ -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

View File

@ -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.

View File

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

View File

@ -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}

View File

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

View File

@ -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",

View File

@ -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;

View File

@ -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.

View File

@ -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.

73
specs/POC_AUTH.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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<String> 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();
}
}

View File

@ -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){

View File

@ -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<Jwt> 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 {

View File

@ -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}

View File

@ -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<String,Object> 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<String> 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());
}
}

View File

@ -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());
}
}

View File

@ -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"));
}
}