From f35032e91b536afef3585828e02cf995652d2a54 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 12 Aug 2026 18:26:54 +0700 Subject: [PATCH 1/5] migrate on sp4 and dont use woody-http-bridge --- .github/workflows/build.yml | 2 +- .github/workflows/deploy.yml | 2 +- agents.md | 48 ------- pom.xml | 53 +++++--- .../vality/wachter/WachterApplication.java | 2 +- .../wachter/client/ProxyHeadersExtractor.java | 16 +-- .../vality/wachter/client/WachterClient.java | 9 +- .../wachter/config/RestClientConfig.java | 26 ++-- .../security/JwtTokenDetailsExtractor.java | 50 ++++++++ .../wachter/service/WachterService.java | 4 +- .../tracing/TraceHeaderNormalizer.java | 117 ++++++++++++++++++ .../vality/wachter/tracing/TraceHeaders.java | 43 +++++++ src/main/resources/application.yml | 17 +-- .../client/WachterClientOperationsTest.java | 66 +--------- ...bstractKeycloakOpenIdAsWiremockConfig.java | 9 +- .../controller/WachterControllerTest.java | 2 +- .../integration/WachterIntegrationTest.java | 25 ++-- wachter_context.md | 72 ----------- 18 files changed, 287 insertions(+), 276 deletions(-) delete mode 100644 agents.md create mode 100644 src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java create mode 100644 src/main/java/dev/vality/wachter/tracing/TraceHeaderNormalizer.java create mode 100644 src/main/java/dev/vality/wachter/tracing/TraceHeaders.java delete mode 100644 wachter_context.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c091554..f694553 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,4 +7,4 @@ on: jobs: build: - uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v3 + uses: paydev-tech/java-workflow/.github/workflows/maven-service-build.yml@v2 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce19205..b119b38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ env: jobs: deploy: - uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v3 + uses: paydev-tech/java-workflow/.github/workflows/maven-service-deploy.yml@v2 secrets: github-token: ${{ secrets.GITHUB_TOKEN }} mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }} diff --git a/agents.md b/agents.md deleted file mode 100644 index d3fe008..0000000 --- a/agents.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agents Guide - -This document helps automation agents work effectively on the **Wachter** service. - -## Project Snapshot -- **Purpose:** authorize requests from Control Center and transparently proxy them to domain services. -- **Stack:** Java 21, Spring Boot 3, RestClient (JDK HTTP), OpenTelemetry (OTLP/HTTP), Woody tracing library, JWT via Spring Security. -- **Tracing Flow:** `WoodyTracingFilter` normalizes Woody headers, hydrates Woody trace context, starts an OpenTelemetry span, and stores normalized headers in the request. -- **Proxy Flow:** `WachterService` performs access checks, `WachterClient` (using `RestClient`) forwards requests, and `WachterController` returns upstream status/headers/body unchanged. - -## Key Components -- `dev.vality.wachter.config` – Spring configuration; `ApplicationConfig`, `WebConfig`, `OtelConfig`. -- `dev.vality.wachter.config.tracing` – tracing helpers (`WoodyHeadersNormalizer`, `WoodyTraceContextApplier`, `WoodyTelemetrySupport`, `WoodyTracingFilter`). -- `dev.vality.wachter.client` – outbound proxy pieces (`WachterClient`, `WachterRequestFactory`, `WachterClientResponse`). -- `dev.vality.wachter.security` – access control (`AccessService`, `RoleAccessService`, `JwtTokenDetailsExtractor`). -- `dev.vality.wachter.service` – application services (`WachterService`, `MethodNameReaderService`). -- `dev.vality.wachter.controller` – REST controllers (`WachterController`, error handling tests). -- `dev.vality.wachter.constants` – header/attribute constants. - -## Tests & Verification -- **Unit tests:** `mvn test` (runs JUnit + WireMock integration suite). -- **Key suites:** `WachterClient*Test`, `WebConfigTest`, controller tests, `WachterIntegrationTest` (WireMock-based end-to-end proxy verification). -- Ensure new features include coverage across: header normalization, trace context propagation, authorization checks, proxy behaviour. - -## Conventions & Practices -- Maintain dual Woody headers (`woody.*` + `x-woody-*`). -- Preserve upstream responses exactly (status, headers, body). -- Use `JwtTokenDetailsExtractor` for JWT-derived values; avoid duplicating claim parsing. -- When touching tracing, ensure OpenTelemetry span attributes (`HTTP_*`, `traceparent`) remain intact. -- Follow existing Checkstyle conventions: `final` for immutable locals, concise logging via SLF4J, minimal inline comments. -- Update documentation only when requested. - -## Common Tasks -1. **Modify tracing behaviour:** inspect `config/tracing` classes; update tests in `WebConfigTest` and `WachterIntegrationTest`. -2. **Adjust outbound proxying:** change `WachterRequestFactory`/`WachterClient`; add/update tests in `client` package and integration suite. -3. **Authorization changes:** update `AccessService`, `RoleAccessService`; extend security tests accordingly. - -## Useful Commands -- Run full suite: `mvn test` -- Format/imports: rely on IDE (no automatic formatter configured). -- Generate coverage (optional): `mvn test -Pcoverage` (if profile exists; verify before use). - -## Gotchas -- `WachterController` enforces deadline checks before proxying; always keep `DeadlineUtil` behaviour in mind. -- `WoodyHeadersNormalizer` merges JWT metadata and deadlines with priority rules—changing behaviour requires revisiting corresponding tests. -- Integration tests spin up WireMock; avoid port conflicts by keeping default configuration. - -Stay aligned with the README and trace plans when planning new work. diff --git a/pom.xml b/pom.xml index b90d1ab..6a27770 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ dev.vality service-parent-pom - 3.1.9 + 4.0.0 wachter @@ -16,7 +16,7 @@ UTF-8 UTF-8 - 21 + 25 8022 8023 ${server.port} ${management.port} @@ -24,10 +24,6 @@ - - dev.vality.woody - woody-api - dev.vality.woody woody-thrift @@ -36,10 +32,6 @@ dev.vality.geck serializer - - dev.vality - woody-http-bridge - @@ -56,7 +48,11 @@ org.springframework.boot - spring-boot-starter-oauth2-resource-server + spring-boot-starter-security-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-restclient org.springframework.boot @@ -68,12 +64,20 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-webmvc org.hibernate hibernate-validator + + org.hibernate.validator + hibernate-validator + + + org.hibernate.orm + hibernate-* + @@ -90,15 +94,12 @@ org.projectlombok lombok + provided jakarta.servlet jakarta.servlet-api - - jakarta.annotation - jakarta.annotation-api - jakarta.validation jakarta.validation-api @@ -115,6 +116,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-webmvc-test + test + io.jsonwebtoken jjwt-api @@ -136,7 +142,7 @@ org.wiremock.integrations wiremock-spring-boot - 3.10.0 + 4.0.9 test @@ -162,6 +168,7 @@ true Dockerfile + opentelemetry-javaagent.jar @@ -200,6 +207,18 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + diff --git a/src/main/java/dev/vality/wachter/WachterApplication.java b/src/main/java/dev/vality/wachter/WachterApplication.java index dc41a12..44e678f 100644 --- a/src/main/java/dev/vality/wachter/WachterApplication.java +++ b/src/main/java/dev/vality/wachter/WachterApplication.java @@ -2,7 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.boot.web.server.servlet.context.ServletComponentScan; @ServletComponentScan @SpringBootApplication diff --git a/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java index 5135111..087f24e 100644 --- a/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java +++ b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java @@ -1,6 +1,6 @@ package dev.vality.wachter.client; -import dev.vality.woody.http.bridge.tracing.TraceHeadersConstants; +import dev.vality.wachter.tracing.TraceHeaders; import jakarta.servlet.http.HttpServletRequest; import lombok.experimental.UtilityClass; import org.springframework.http.HttpHeaders; @@ -43,19 +43,19 @@ public class ProxyHeadersExtractor { "dnt", "priority", "service", - TraceHeadersConstants.OTEL_TRACE_PARENT, - TraceHeadersConstants.OTEL_TRACE_STATE, - TraceHeadersConstants.ExternalHeaders.X_REQUEST_ID, - TraceHeadersConstants.ExternalHeaders.X_REQUEST_DEADLINE, - TraceHeadersConstants.ExternalHeaders.X_INVOICE_ID + TraceHeaders.OTEL_TRACE_PARENT, + TraceHeaders.OTEL_TRACE_STATE, + TraceHeaders.ExternalHeaders.X_REQUEST_ID, + TraceHeaders.ExternalHeaders.X_REQUEST_DEADLINE, + TraceHeaders.ExternalHeaders.X_INVOICE_ID ).map(header -> header.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); private static final List EXCLUDED_PREFIXES = List.of( "cf-", "cdn-", "sec-", - TraceHeadersConstants.WOODY_PREFIX, - TraceHeadersConstants.ExternalHeaders.X_WOODY_PREFIX + TraceHeaders.WOODY_PREFIX, + TraceHeaders.ExternalHeaders.X_WOODY_PREFIX ); public HttpHeaders extractHeaders(HttpServletRequest request) { diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index 804bb37..26df8b4 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,7 +1,6 @@ package dev.vality.wachter.client; -import dev.vality.woody.http.bridge.tracing.TraceContextExtractor; -import dev.vality.woody.http.bridge.tracing.TraceContextHeadersNormalizer; +import dev.vality.wachter.tracing.TraceHeaderNormalizer; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,11 +26,11 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont var httpMethod = resolveMethod(servletRequest); var proxyHeaders = ProxyHeadersExtractor.extractHeaders(servletRequest); - var traceHeaders = TraceContextExtractor.extractHeaders(); + var traceHeaders = TraceHeaderNormalizer.normalizeRequest(servletRequest); var httpHeaders = new HttpHeaders(); proxyHeaders.forEach(httpHeaders::addAll); - traceHeaders.forEach(httpHeaders::set); + httpHeaders.addAll(traceHeaders); log.info("-> Send request to {} {} | headers: {}", httpMethod, url, httpHeaders); @@ -48,7 +47,7 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont log.info("<- Receive response from {} {} | status: {}, headers: {}", httpMethod, url, status, response.getHeaders()); var responseBody = Objects.requireNonNullElse(response.bodyTo(byte[].class), EMPTY_BODY); - var responseHeaders = TraceContextHeadersNormalizer.normalizeResponseHeaders(response.getHeaders()); + var responseHeaders = TraceHeaderNormalizer.normalizeResponse(response.getHeaders()); return new WachterClientResponse(status, responseHeaders, responseBody); }); } diff --git a/src/main/java/dev/vality/wachter/config/RestClientConfig.java b/src/main/java/dev/vality/wachter/config/RestClientConfig.java index ad3650e..49a94a6 100644 --- a/src/main/java/dev/vality/wachter/config/RestClientConfig.java +++ b/src/main/java/dev/vality/wachter/config/RestClientConfig.java @@ -1,6 +1,6 @@ package dev.vality.wachter.config; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import dev.vality.wachter.config.properties.HttpProperties; import lombok.RequiredArgsConstructor; import org.apache.hc.client5.http.classic.HttpClient; @@ -21,13 +21,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.web.client.RestClient; import javax.net.ssl.SSLContext; -import java.time.Duration; -import java.util.List; @Configuration @RequiredArgsConstructor @@ -85,25 +82,16 @@ public CloseableHttpClient httpClient( @Bean public HttpComponentsClientHttpRequestFactory requestFactory(HttpClient httpClient) { - HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient); - factory.setConnectTimeout(Duration.ofMillis(httpProperties.getConnectionTimeout())); - factory.setConnectionRequestTimeout(Duration.ofMillis(httpProperties.getPoolTimeout())); - factory.setReadTimeout(Duration.ofMillis(httpProperties.getRequestTimeout())); - return factory; + return new HttpComponentsClientHttpRequestFactory(httpClient); } @Bean - public RestClient restClient(ClientHttpRequestFactory requestFactory, ObjectMapper objectMapper) { + public RestClient restClient(ClientHttpRequestFactory requestFactory, JsonMapper jsonMapper) { return RestClient.builder() .requestFactory(requestFactory) - .messageConverters(converters -> updateObjectMapper(converters, objectMapper)) + .configureMessageConverters(converters -> converters + .registerDefaults() + .withJsonConverter(new JacksonJsonHttpMessageConverter(jsonMapper))) .build(); } - - private void updateObjectMapper(List> converters, ObjectMapper objectMapper) { - converters.stream() - .filter(MappingJackson2HttpMessageConverter.class::isInstance) - .map(MappingJackson2HttpMessageConverter.class::cast) - .forEach(converter -> converter.setObjectMapper(objectMapper)); - } } diff --git a/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java new file mode 100644 index 0000000..cb3db38 --- /dev/null +++ b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java @@ -0,0 +1,50 @@ +package dev.vality.wachter.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.List; +import java.util.Optional; + +public final class JwtTokenDetailsExtractor { + + private JwtTokenDetailsExtractor() { + } + + public static Optional extract(Authentication authentication) { + if (!(authentication instanceof JwtAuthenticationToken jwtAuthentication)) { + return Optional.empty(); + } + var token = jwtAuthentication.getToken(); + return Optional.of(new JwtTokenDetails( + token.getSubject(), + token.getClaimAsString("preferred_username"), + token.getClaimAsString("email"), + extractRealm(token), + jwtAuthentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList())); + } + + private static String extractRealm(Jwt token) { + var issuer = token.getClaimAsString(JwtClaimNames.ISS); + if (issuer == null || issuer.isBlank()) { + return null; + } + var normalized = issuer.replaceAll("/+$", ""); + var lastSlash = normalized.lastIndexOf('/'); + var realm = lastSlash >= 0 ? normalized.substring(lastSlash + 1) : normalized; + return realm.isBlank() ? null : realm; + } + + public record JwtTokenDetails( + String subject, + String preferredUsername, + String email, + String realm, + List roles) { + } +} diff --git a/src/main/java/dev/vality/wachter/service/WachterService.java b/src/main/java/dev/vality/wachter/service/WachterService.java index af0d40e..79b5748 100644 --- a/src/main/java/dev/vality/wachter/service/WachterService.java +++ b/src/main/java/dev/vality/wachter/service/WachterService.java @@ -4,7 +4,7 @@ import dev.vality.wachter.mapper.ServiceMapper; import dev.vality.wachter.security.AccessData; import dev.vality.wachter.security.AccessService; -import dev.vality.woody.http.bridge.util.JwtTokenDetailsExtractor; +import dev.vality.wachter.security.JwtTokenDetailsExtractor; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; @@ -30,7 +30,7 @@ public WachterClientResponse process(HttpServletRequest request) { var contentData = getContentData(request); var methodName = methodNameReaderService.getMethodName(contentData); var authentication = SecurityContextHolder.getContext().getAuthentication(); - var tokenDetails = JwtTokenDetailsExtractor.extractFromContext(authentication) + var tokenDetails = JwtTokenDetailsExtractor.extract(authentication) .orElseThrow(() -> new IllegalStateException("JWT authentication is required")); var service = serviceMapper.getService(request); accessService.checkUserAccess(AccessData.builder() diff --git a/src/main/java/dev/vality/wachter/tracing/TraceHeaderNormalizer.java b/src/main/java/dev/vality/wachter/tracing/TraceHeaderNormalizer.java new file mode 100644 index 0000000..37ae4c2 --- /dev/null +++ b/src/main/java/dev/vality/wachter/tracing/TraceHeaderNormalizer.java @@ -0,0 +1,117 @@ +package dev.vality.wachter.tracing; + +import dev.vality.wachter.security.JwtTokenDetailsExtractor; +import jakarta.servlet.http.HttpServletRequest; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; + +import static dev.vality.wachter.tracing.TraceHeaders.*; + +@UtilityClass +public class TraceHeaderNormalizer { + + public HttpHeaders normalizeRequest(HttpServletRequest request) { + var normalized = new HashMap(); + var names = request.getHeaderNames(); + if (names != null) { + Collections.list(names).forEach(name -> normalizeHeader(request, normalized, name)); + } + copy(normalized, WOODY_META_REQUEST_ID, request.getHeader(ExternalHeaders.X_REQUEST_ID)); + copy(normalized, WOODY_META_REQUEST_INVOICE_ID, request.getHeader(ExternalHeaders.X_INVOICE_ID)); + normalizeDeadline(request, normalized); + mergeJwt(normalized); + var headers = new HttpHeaders(); + normalized.forEach(headers::set); + return headers; + } + + public HttpHeaders normalizeResponse(HttpHeaders responseHeaders) { + var normalized = new HttpHeaders(); + responseHeaders.forEach((name, values) -> { + var lowerName = name.toLowerCase(Locale.ROOT); + if (lowerName.startsWith(WOODY_PREFIX)) { + normalized.addAll(toExternalName(lowerName), values); + } + }); + return normalized; + } + + private void normalizeHeader(HttpServletRequest request, HashMap headers, String name) { + var lowerName = name.toLowerCase(Locale.ROOT); + if (lowerName.startsWith(ExternalHeaders.X_WOODY_META_PREFIX)) { + var suffix = lowerName.substring(ExternalHeaders.X_WOODY_META_PREFIX.length()); + var metadata = suffix.startsWith("user-identity-") + ? "user-identity." + suffix.substring("user-identity-".length()) + : suffix; + copy(headers, WOODY_META_PREFIX + metadata, request.getHeader(name)); + } else if (lowerName.startsWith(ExternalHeaders.X_WOODY_PREFIX)) { + copy(headers, WOODY_PREFIX + lowerName.substring(ExternalHeaders.X_WOODY_PREFIX.length()), + request.getHeader(name)); + } else if (lowerName.startsWith(WOODY_PREFIX)) { + copy(headers, lowerName, request.getHeader(name)); + } + } + + private void normalizeDeadline(HttpServletRequest request, HashMap headers) { + var deadline = request.getHeader(ExternalHeaders.X_REQUEST_DEADLINE); + if (deadline == null || deadline.isBlank()) { + return; + } + var normalized = parseDeadline(deadline).toString(); + headers.putIfAbsent(WOODY_DEADLINE, normalized); + headers.put(WOODY_META_REQUEST_DEADLINE, normalized); + } + + private Instant parseDeadline(String value) { + if (value.matches("\\d+(ms|s|m)")) { + var unit = value.endsWith("ms") ? "ms" : value.substring(value.length() - 1); + var number = Long.parseLong(value.substring(0, value.length() - unit.length())); + var milliseconds = switch (unit) { + case "m" -> number * 60_000; + case "s" -> number * 1_000; + default -> number; + }; + return Instant.now().plusMillis(milliseconds); + } + return Instant.parse(value); + } + + private void mergeJwt(HashMap headers) { + JwtTokenDetailsExtractor.extract(SecurityContextHolder.getContext().getAuthentication()) + .ifPresent(details -> { + copy(headers, WOODY_META_ID, details.subject()); + copy(headers, WOODY_META_USERNAME, details.preferredUsername()); + copy(headers, WOODY_META_EMAIL, details.email()); + copy(headers, WOODY_META_REALM, details.realm()); + }); + } + + private String toExternalName(String woodyName) { + if (!woodyName.startsWith(WOODY_META_PREFIX)) { + return ExternalHeaders.X_WOODY_PREFIX + woodyName.substring(WOODY_PREFIX.length()); + } + var metadata = woodyName.substring(WOODY_META_PREFIX.length()); + if (metadata.equals("user-identity.x-request-id")) { + return ExternalHeaders.X_REQUEST_ID; + } + if (metadata.equals("user-identity.x-request-deadline")) { + return ExternalHeaders.X_REQUEST_DEADLINE; + } + if (metadata.equals("user-identity.x-invoice-id")) { + return ExternalHeaders.X_INVOICE_ID; + } + return ExternalHeaders.X_WOODY_META_PREFIX + metadata.replace("user-identity.", "user-identity-"); + } + + private void copy(HashMap headers, String name, String value) { + if (value != null && !value.isBlank()) { + headers.put(name, value); + } + } +} diff --git a/src/main/java/dev/vality/wachter/tracing/TraceHeaders.java b/src/main/java/dev/vality/wachter/tracing/TraceHeaders.java new file mode 100644 index 0000000..08bf1ce --- /dev/null +++ b/src/main/java/dev/vality/wachter/tracing/TraceHeaders.java @@ -0,0 +1,43 @@ +package dev.vality.wachter.tracing; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class TraceHeaders { + + public static final String WOODY_PREFIX = "woody."; + public static final String WOODY_TRACE_ID = "woody.trace-id"; + public static final String WOODY_SPAN_ID = "woody.span-id"; + public static final String WOODY_PARENT_ID = "woody.parent-id"; + public static final String WOODY_DEADLINE = "woody.deadline"; + public static final String WOODY_META_PREFIX = "woody.meta."; + public static final String WOODY_META_ID = WOODY_META_PREFIX + "user-identity.id"; + public static final String WOODY_META_USERNAME = WOODY_META_PREFIX + "user-identity.username"; + public static final String WOODY_META_EMAIL = WOODY_META_PREFIX + "user-identity.email"; + public static final String WOODY_META_REALM = WOODY_META_PREFIX + "user-identity.realm"; + public static final String WOODY_META_REQUEST_ID = WOODY_META_PREFIX + "user-identity.X-Request-ID"; + public static final String WOODY_META_REQUEST_DEADLINE = + WOODY_META_PREFIX + "user-identity.X-Request-Deadline"; + public static final String WOODY_META_REQUEST_INVOICE_ID = + WOODY_META_PREFIX + "user-identity.X-Invoice-ID"; + public static final String OTEL_TRACE_PARENT = "traceparent"; + public static final String OTEL_TRACE_STATE = "tracestate"; + + @UtilityClass + public static class ExternalHeaders { + + public static final String X_REQUEST_ID = "X-Request-ID"; + public static final String X_REQUEST_DEADLINE = "X-Request-Deadline"; + public static final String X_INVOICE_ID = "X-Invoice-ID"; + public static final String X_WOODY_PREFIX = "x-woody-"; + public static final String X_WOODY_TRACE_ID = X_WOODY_PREFIX + "trace-id"; + public static final String X_WOODY_SPAN_ID = X_WOODY_PREFIX + "span-id"; + public static final String X_WOODY_PARENT_ID = X_WOODY_PREFIX + "parent-id"; + public static final String X_WOODY_DEADLINE = X_WOODY_PREFIX + "deadline"; + public static final String X_WOODY_META_PREFIX = X_WOODY_PREFIX + "meta-"; + public static final String X_WOODY_META_ID = X_WOODY_META_PREFIX + "user-identity-id"; + public static final String X_WOODY_META_USERNAME = X_WOODY_META_PREFIX + "user-identity-username"; + public static final String X_WOODY_META_EMAIL = X_WOODY_META_PREFIX + "user-identity-email"; + public static final String X_WOODY_META_REALM = X_WOODY_META_PREFIX + "user-identity-realm"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index de40fc7..369ae78 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,9 +12,9 @@ management: health: show-details: always metrics: - enabled: true + access: read-only prometheus: - enabled: true + access: read-only endpoints: web: exposure: @@ -127,19 +127,6 @@ wachter: auth: enabled: true -woody-http-bridge: - tracing: - endpoints: - - path: /wachter - port: ${server.port} - request-header-mode: WOODY_OR_X_WOODY - response-header-mode: OFF - -otel: - enabled: true - resource: http://localhost:4318/v1/traces - timeout: 60000 - http: requestTimeout: 60000 poolTimeout: 10000 diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index 0c856e3..07f7619 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -1,16 +1,5 @@ package dev.vality.wachter.client; -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.SpanKind; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.Context; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -20,7 +9,7 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; -import static dev.vality.woody.http.bridge.tracing.TraceHeadersConstants.ExternalHeaders.X_WOODY_TRACE_ID; +import static dev.vality.wachter.tracing.TraceHeaders.ExternalHeaders.X_WOODY_TRACE_ID; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; @@ -29,40 +18,12 @@ class WachterClientOperationsTest { - private SdkTracerProvider tracerProvider; - - @BeforeEach - void setUp() { - GlobalOpenTelemetry.resetForTest(); - tracerProvider = SdkTracerProvider.builder().build(); - final var openTelemetry = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - GlobalOpenTelemetry.set(openTelemetry); - } - - @AfterEach - void tearDown() { - TraceContext.setCurrentTraceData(null); - GlobalOpenTelemetry.resetForTest(); - if (tracerProvider != null) { - tracerProvider.close(); - } - } - @Test void shouldSendRequestWithTracingHeaders() { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = prepareTraceData("test-span"); - - final var serviceSpan = traceData.getServiceSpan().getSpan(); - serviceSpan.setTraceId("test-trace-id"); - serviceSpan.setId("test-span-id"); - final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); @@ -87,7 +48,6 @@ void shouldSendRequestWithTracingHeaders() { assertEquals(HttpStatus.OK, actualResponse.statusCode()); assertArrayEquals(expectedResponse, actualResponse.body()); server.verify(); - traceData.finishOtelSpan(); } @Test @@ -96,10 +56,6 @@ void shouldFilterDisallowedHeaders() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = prepareTraceData("test-span"); - traceData.getServiceSpan().getSpan().setTraceId("filter-trace-id"); - traceData.getServiceSpan().getSpan().setId("filter-span-id"); - final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); servletRequest.addHeader(HttpHeaders.AUTHORIZATION, "Bearer secret"); @@ -120,7 +76,6 @@ void shouldFilterDisallowedHeaders() { client.send(servletRequest, null, "http://upstream/disallowed"); server.verify(); - traceData.finishOtelSpan(); } @Test @@ -129,11 +84,6 @@ void shouldHandleGetRequestWithoutBody() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = prepareTraceData("test-span"); - - final var serviceSpan = traceData.getServiceSpan().getSpan(); - serviceSpan.setTraceId("get-trace-id"); - final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("GET"); @@ -148,7 +98,6 @@ void shouldHandleGetRequestWithoutBody() { assertEquals(HttpStatus.OK, response.statusCode()); assertArrayEquals("{}".getBytes(), response.body()); server.verify(); - traceData.finishOtelSpan(); } @Test @@ -157,11 +106,6 @@ void shouldReturnErrorResponseWithoutThrowing() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = prepareTraceData("test-span"); - - final var serviceSpan = traceData.getServiceSpan().getSpan(); - serviceSpan.setTraceId("error-trace-id"); - final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); final var payload = "payload".getBytes(); @@ -179,14 +123,6 @@ void shouldReturnErrorResponseWithoutThrowing() { assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode()); assertArrayEquals("bad-gateway".getBytes(), response.body()); server.verify(); - traceData.finishOtelSpan(); } - private TraceData prepareTraceData(String spanName) { - final var traceData = new TraceData(); - traceData.startNewOtelSpan(spanName, SpanKind.SERVER, Context.current()); - traceData.openOtelScope(); - TraceContext.setCurrentTraceData(traceData); - return traceData; - } } diff --git a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java index 1e2970c..c55b1d5 100644 --- a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java +++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java @@ -3,12 +3,10 @@ import dev.vality.wachter.WachterApplication; import dev.vality.wachter.auth.utils.KeycloakOpenIdStub; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.junit.jupiter.SpringExtension; import org.wiremock.spring.EnableWireMock; import java.security.PrivateKey; @@ -22,14 +20,9 @@ "spring.security.oauth2.resourceserver.url=${wiremock.server.baseUrl}", "spring.security.oauth2.resourceserver.jwt.issuer-uri=${wiremock.server.baseUrl}/auth/realms/" + "${spring.security.oauth2.resourceserver.jwt.realm}", - "woody-http-bridge.tracing.endpoints[0].path=/wachter", - "woody-http-bridge.tracing.endpoints[0].port=8083", - "woody-http-bridge.tracing.endpoints[0].request-header-mode: WOODY_OR_X_WOODY", - "woody-http-bridge.tracing.endpoints[0].response-header-mode: OFF", }) @AutoConfigureMockMvc @EnableWireMock -@ExtendWith(SpringExtension.class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public abstract class AbstractKeycloakOpenIdAsWiremockConfig { diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java index 07ae6da..4807def 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java @@ -19,7 +19,7 @@ import java.time.temporal.ChronoUnit; import static dev.vality.wachter.client.WachterClient.WachterClientResponse; -import static dev.vality.woody.http.bridge.tracing.TraceHeadersConstants.*; +import static dev.vality.wachter.tracing.TraceHeaders.*; import static java.util.UUID.randomUUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 0e12a58..9c5eff2 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -1,12 +1,12 @@ package dev.vality.wachter.integration; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.verification.LoggedRequest; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; -import dev.vality.woody.api.trace.context.TraceContext; import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -28,7 +28,7 @@ import java.util.UUID; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static dev.vality.woody.http.bridge.tracing.TraceHeadersConstants.*; +import static dev.vality.wachter.tracing.TraceHeaders.*; import static org.junit.jupiter.api.Assertions.*; @TestPropertySource(properties = { @@ -39,7 +39,7 @@ class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { private static final String TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = new JsonMapper(); @Value("${server.port}") private int port; @@ -63,7 +63,6 @@ void setUp() { @AfterEach void tearDown() { - TraceContext.setCurrentTraceData(null); resetAllRequests(); } @@ -162,11 +161,11 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { upstreamRequest.getHeader(HttpHeaders.USER_AGENT)); var jwtClaims = decodeJwtPayload(jwt); - assertEquals(jwtClaims.get("sub").asText(), + assertEquals(jwtClaims.get("sub").asString(), upstreamRequest.getHeader(WOODY_META_ID)); - assertEquals(jwtClaims.get("preferred_username").asText(), + assertEquals(jwtClaims.get("preferred_username").asString(), upstreamRequest.getHeader(WOODY_META_USERNAME)); - assertEquals(jwtClaims.get("email").asText(), + assertEquals(jwtClaims.get("email").asString(), upstreamRequest.getHeader(WOODY_META_EMAIL)); assertEquals(extractRealm(jwtClaims), upstreamRequest.getHeader(WOODY_META_REALM)); @@ -246,10 +245,10 @@ void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { assertNotNull(upstreamRequest.getHeader(WOODY_DEADLINE)); // User identity metadata should be sourced from JWT when present - assertEquals(jwtClaims.get("sub").asText(), upstreamRequest.getHeader(WOODY_META_ID)); - assertEquals(jwtClaims.get("preferred_username").asText(), + assertEquals(jwtClaims.get("sub").asString(), upstreamRequest.getHeader(WOODY_META_ID)); + assertEquals(jwtClaims.get("preferred_username").asString(), upstreamRequest.getHeader(WOODY_META_USERNAME)); - assertEquals(jwtClaims.get("email").asText(), + assertEquals(jwtClaims.get("email").asString(), upstreamRequest.getHeader(WOODY_META_EMAIL)); assertEquals(extractRealm(jwtClaims), upstreamRequest.getHeader(WOODY_META_REALM)); @@ -376,7 +375,7 @@ private static String extractRealm(JsonNode jwtClaims) { if (issuerNode == null || issuerNode.isNull()) { return null; } - var issuer = issuerNode.asText(); + var issuer = issuerNode.asString(); if (issuer == null) { return null; } diff --git a/wachter_context.md b/wachter_context.md deleted file mode 100644 index b1e8cac..0000000 --- a/wachter_context.md +++ /dev/null @@ -1,72 +0,0 @@ -# Wachter – Reference Context - -## Project Overview -- **Role:** Security gateway for Control Center — authenticates users, enforces role-based access to domain APIs, then transparently proxies HTTP/Thrift payloads. -- **Stack:** Java 21, Spring Boot 3, RestClient (JDK `HttpClient` backend), Spring Security (Keycloak JWT), Woody tracing library, OpenTelemetry (SDK + OTLP/HTTP exporter), WireMock for integration tests. -- **Key Traits:** Dual-format Woody headers (`woody.*` + legacy `x-woody-*`), full upstream response passthrough, guaranteed W3C `traceparent` propagation. - -## Runtime Flow -1. **Ingress filter (`WoodyTracingFilter`):** normalizes incoming Woody headers, restores Woody `TraceContext`, and starts an OpenTelemetry SERVER span that injects `traceparent`, records response status, and captures exceptions. -2. **Controller (`WachterController`):** validates `X-Request-Deadline`, delegates to the service layer, and returns upstream responses (status, headers, body) unchanged. -3. **Service layer (`WachterService`):** extracts thrift method name, retrieves JWT from Spring Security, runs role-based checks via `AccessService`/`RoleAccessService`, resolves target service URL, and forwards the call. -4. **Outbound proxy (`WachterClient`/`WachterRequestFactory`):** merges servlet headers, normalized Woody headers, trace-context data, and JWT fallbacks; mirrors both Woody header families; executes the request via Spring `RestClient` and wraps the upstream response. - -## Configuration Highlights -- `ApplicationConfig`: builds a `RestClient` using `JdkClientHttpRequestFactory` backed by `HttpClient` with configured timeouts; exposes `WachterClient` bean. -- `WebConfig`: registers `WoodyTracingFilter` and exposes helper beans for tests (`normalizeWoodyHeaders`, `applyWoodyHeadersToTraceContext`). -- `OtelConfig`: conditionally initializes OpenTelemetry (OTLP HTTP exporter, always-on sampler, W3C propagators) and registers it globally. -- `application.yml`: defines service mappings, client timeout properties, authorization flags, and OpenTelemetry endpoint. - -## Security & Access Control -- JWT parsed via Spring Security; `JwtTokenDetailsExtractor` centralizes claim extraction (subject, username, email, realm, roles). -- `AccessService` assembles `AccessData` and defers permission checks to `RoleAccessService` (service-level or method-level access). -- Keycloak/OpenID behavior is stubbed for tests through `AbstractKeycloakOpenIdAsWiremockConfig` and `KeycloakOpenIdStub`. - -## Tracing & Header Strategy -- Normalized Woody headers stored under request attribute `wachter.normalizedWoodyHeaders`. -- `WoodyRequestFactory` ensures both `woody.*` and `x-woody-*` headers are emitted, including `meta.user-identity.*` suffixes derived via `WoodySuffixes.userIdentitySuffix`. -- `WoodyHeadersNormalizer` merges JWT metadata, resolves relative deadlines from `X-Request-Deadline`, and respects existing `woody.deadline` values. -- OpenTelemetry spans carry HTTP semantic attributes (`HTTP_METHOD`, `HTTP_TARGET`, `HTTP_STATUS_CODE`), set status to ERROR for 5xx, and record exceptions. - -## Package Map -- `dev.vality.wachter.config` – Spring configuration (application, web, OTEL, security). -- `dev.vality.wachter.config.tracing` – tracing utilities linking Woody and OpenTelemetry. -- `dev.vality.wachter.client` – outbound proxy logic (`WachterClient`, `WachterRequestFactory`, `WachterClientResponse`). -- `dev.vality.wachter.service` – business logic (`WachterService`, `MethodNameReaderService`). -- `dev.vality.wachter.security` – access control helpers and JWT utilities. -- `dev.vality.wachter.controller` – REST endpoints and error handling. -- `dev.vality.wachter.constants` – shared header and request attribute constants. -- `dev.vality.wachter.utils` – deadline and thrift utilities. - -## Testing -- **Unit suites:** `WebConfigTest`, `WachterClient*Test`, controller/security tests validate header propagation, trace context hydration, and authorization logic. -- **Integration:** `WachterIntegrationTest` (WireMock) verifies end-to-end behavior: headers mirrored, trace context captured, response passthrough. -- **Command:** run all tests with `mvn test`. - -## Operational Notes -- Maintain deadline consistency between `DeadlineUtil` checks and header normalization. -- OpenTelemetry exporter controlled via `otel.enabled` and `otel.resource` properties; ensure environment supplies OTLP endpoint. -- Follow Checkstyle expectations (`final` for immutable locals, minimal comments) to keep build green. -- Avoid modifying documentation without explicit request; `README.md` already synchronized with current architecture. - -## Quick Snippets -- **Normalized headers access:** - ```java - @SuppressWarnings("unchecked") - Map headers = (Map) - request.getAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS); - ``` -- **Run full suite:** - ```bash - mvn test - ``` -- **Create RestClient with custom timeout:** - ```java - RestClient client = builder - .requestFactory(new JdkClientHttpRequestFactory(HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(2)) - .build())) - .build(); - ``` - -Keep this context handy when planning automation or reviewing Wachter changes. From e122585a26c50b0439672b10f0f9bcb7cbadab97 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 12 Aug 2026 18:35:05 +0700 Subject: [PATCH 2/5] migrate on sp4 and dont use woody-http-bridge --- .github/workflows/build.yml | 2 +- .github/workflows/deploy.yml | 2 +- .../tracing/TraceHeaderNormalizerTest.java | 102 ++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/test/java/dev/vality/wachter/tracing/TraceHeaderNormalizerTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f694553..c6f0a89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,4 +7,4 @@ on: jobs: build: - uses: paydev-tech/java-workflow/.github/workflows/maven-service-build.yml@v2 + uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b119b38..944b966 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ env: jobs: deploy: - uses: paydev-tech/java-workflow/.github/workflows/maven-service-deploy.yml@v2 + uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v4 secrets: github-token: ${{ secrets.GITHUB_TOKEN }} mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceHeaderNormalizerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceHeaderNormalizerTest.java new file mode 100644 index 0000000..d71951d --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/TraceHeaderNormalizerTest.java @@ -0,0 +1,102 @@ +package dev.vality.wachter.tracing; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.DateTimeException; + +import static dev.vality.wachter.tracing.TraceHeaders.*; +import static org.junit.jupiter.api.Assertions.*; + +class TraceHeaderNormalizerTest { + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldNormalizeWoodyAndRequestHeadersWithoutManualOtelPropagation() { + var request = new MockHttpServletRequest(); + request.addHeader(ExternalHeaders.X_WOODY_TRACE_ID, "trace-id"); + request.addHeader(ExternalHeaders.X_WOODY_META_PREFIX + "user-identity-id", "header-user"); + request.addHeader(ExternalHeaders.X_REQUEST_ID, "request-id"); + request.addHeader(ExternalHeaders.X_INVOICE_ID, "invoice-id"); + request.addHeader("traceparent", "00-11111111111111111111111111111111-2222222222222222-01"); + + var headers = TraceHeaderNormalizer.normalizeRequest(request); + + assertAll( + () -> assertEquals("trace-id", headers.getFirst(WOODY_TRACE_ID)), + () -> assertEquals("header-user", headers.getFirst(WOODY_META_ID)), + () -> assertEquals("request-id", headers.getFirst(WOODY_META_REQUEST_ID)), + () -> assertEquals("invoice-id", headers.getFirst(WOODY_META_REQUEST_INVOICE_ID)), + () -> assertFalse(headers.containsHeader("traceparent"))); + } + + @Test + void shouldNormalizeAbsoluteDeadline() { + var request = new MockHttpServletRequest(); + request.addHeader(ExternalHeaders.X_REQUEST_DEADLINE, "2030-01-02T03:04:05Z"); + + var headers = TraceHeaderNormalizer.normalizeRequest(request); + + assertAll( + () -> assertEquals("2030-01-02T03:04:05Z", headers.getFirst(WOODY_DEADLINE)), + () -> assertEquals("2030-01-02T03:04:05Z", headers.getFirst(WOODY_META_REQUEST_DEADLINE))); + } + + @Test + void shouldFailFastForInvalidDeadline() { + var request = new MockHttpServletRequest(); + request.addHeader(ExternalHeaders.X_REQUEST_DEADLINE, "tomorrow"); + + assertThrows(DateTimeException.class, () -> TraceHeaderNormalizer.normalizeRequest(request)); + } + + @Test + void shouldMergeJwtIdentityOverIncomingMetadata() { + var request = new MockHttpServletRequest(); + request.addHeader(ExternalHeaders.X_WOODY_META_ID, "header-user"); + var jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("jwt-user") + .claim("preferred_username", "john") + .claim("email", "john@example.com") + .issuer("https://auth.example.com/realms/merchant") + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + var headers = TraceHeaderNormalizer.normalizeRequest(request); + + assertAll( + () -> assertEquals("jwt-user", headers.getFirst(WOODY_META_ID)), + () -> assertEquals("john", headers.getFirst(WOODY_META_USERNAME)), + () -> assertEquals("john@example.com", headers.getFirst(WOODY_META_EMAIL)), + () -> assertEquals("merchant", headers.getFirst(WOODY_META_REALM))); + } + + @Test + void shouldNormalizeWoodyResponseHeadersOnly() { + var response = new HttpHeaders(); + response.add(WOODY_TRACE_ID, "trace-id"); + response.add(WOODY_META_REQUEST_ID, "request-id"); + response.add(WOODY_META_EMAIL, "john@example.com"); + response.add("traceparent", "00-11111111111111111111111111111111-2222222222222222-01"); + response.add("content-type", "application/json"); + + var headers = TraceHeaderNormalizer.normalizeResponse(response); + + assertAll( + () -> assertEquals("trace-id", headers.getFirst(ExternalHeaders.X_WOODY_TRACE_ID)), + () -> assertEquals("request-id", headers.getFirst(ExternalHeaders.X_REQUEST_ID)), + () -> assertEquals("john@example.com", headers.getFirst(ExternalHeaders.X_WOODY_META_EMAIL)), + () -> assertFalse(headers.containsHeader("traceparent")), + () -> assertFalse(headers.containsHeader("content-type"))); + } +} From 99d880afa8495dd96e5bcf520b778acf12e6a7e8 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 12 Aug 2026 18:43:33 +0700 Subject: [PATCH 3/5] migrate on sp4 and dont use woody-http-bridge --- .../integration/WachterIntegrationTest.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 9c5eff2..e62e0f5 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -38,7 +38,6 @@ }) class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { - private static final String TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]"; private static final ObjectMapper OBJECT_MAPPER = new JsonMapper(); @Value("${server.port}") @@ -140,7 +139,7 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { .toEntity(byte[].class); assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(upstreamTraceparent, response.getHeaders().getFirst(OTEL_TRACE_PARENT)); + assertFalse(response.getHeaders().containsHeader(OTEL_TRACE_PARENT)); assertArrayEquals(responseBody, response.getBody()); List requests = findAll(postRequestedFor(urlEqualTo("/deanonimus"))); @@ -170,7 +169,7 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { assertEquals(extractRealm(jwtClaims), upstreamRequest.getHeader(WOODY_META_REALM)); - assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).matches(TRACEPARENT_PATTERN)); + assertFalse(upstreamRequest.containsHeader(OTEL_TRACE_PARENT)); assertEquals(requestId, upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); assertEquals(deadline.toString(), upstreamRequest.getHeader(WOODY_META_REQUEST_DEADLINE)); @@ -193,7 +192,7 @@ void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { final var responseBody = "test-response".getBytes(); final var jwt = generateSimpleJwtWithRoles(); final var jwtClaims = decodeJwtPayload(jwt); - var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; + var traceparent = "00-3d8202ad198e4d37771c995246e1b356-9cfa814ae977266e-01"; stubFor(post(urlEqualTo("/magista")) .withRequestBody(binaryEqualTo(payload)) @@ -223,8 +222,8 @@ void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { headers.set(ExternalHeaders.X_REQUEST_ID, "mixed-request-id"); headers.set(ExternalHeaders.X_REQUEST_DEADLINE, deadline.toString()); - // Traceparent - headers.set(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); + // The application must leave trace context propagation to the Java agent. + headers.set(OTEL_TRACE_PARENT, traceparent); }) .body(payload) .retrieve() @@ -253,8 +252,8 @@ void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { assertEquals(extractRealm(jwtClaims), upstreamRequest.getHeader(WOODY_META_REALM)); - // Traceparent should be preserved - assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).contains(otelTraceId)); + // Maven tests run without the Java agent, so the application must not proxy trace context itself. + assertFalse(upstreamRequest.containsHeader(OTEL_TRACE_PARENT)); // Request metadata should be preserved assertEquals("mixed-request-id", upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); From ce19d03e5271e53372dbbb687bbf59122599d0e0 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 12 Aug 2026 18:48:37 +0700 Subject: [PATCH 4/5] migrate on sp4 and dont use woody-http-bridge --- .../tracing/OpenTelemetryAgentTest.java | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java diff --git a/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java b/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java new file mode 100644 index 0000000..e0e138a --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java @@ -0,0 +1,168 @@ +package dev.vality.wachter.tracing; + +import com.github.tomakehurst.wiremock.WireMockServer; +import dev.vality.wachter.auth.utils.JwtTokenBuilder; +import dev.vality.wachter.auth.utils.KeycloakOpenIdStub; +import dev.vality.wachter.testutil.TMessageUtil; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static dev.vality.wachter.tracing.TraceHeaders.OTEL_TRACE_PARENT; +import static org.junit.jupiter.api.Assertions.*; + +class OpenTelemetryAgentTest { + + private static final String TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}"; + private static final String TRACE_ID = "11111111111111111111111111111111"; + private static final String SPAN_ID = "2222222222222222"; + + @Test + void shouldContinueIncomingTraceAndCreateTraceWhenMissing() throws Exception { + var wireMock = new WireMockServer(0); + wireMock.start(); + var application = startApplication(wireMock); + try { + var jwt = configureAuthentication(wireMock); + wireMock.stubFor(post(urlEqualTo("/upstream")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + awaitApplication(application.port(), application.process()); + + var incomingTraceparent = "00-" + TRACE_ID + "-" + SPAN_ID + "-01"; + sendRequest(application.port(), jwt, incomingTraceparent); + var continued = singleUpstreamTraceparent(wireMock); + + assertAll( + () -> assertTrue(continued.matches(TRACEPARENT_PATTERN), continued), + () -> assertEquals(TRACE_ID, traceId(continued)), + () -> assertNotEquals(SPAN_ID, spanId(continued))); + + wireMock.resetRequests(); + sendRequest(application.port(), jwt, null); + var created = singleUpstreamTraceparent(wireMock); + + assertAll( + () -> assertTrue(created.matches(TRACEPARENT_PATTERN), created), + () -> assertNotEquals("00000000000000000000000000000000", traceId(created)), + () -> assertNotEquals("0000000000000000", spanId(created))); + } finally { + stop(application.process()); + wireMock.stop(); + } + } + + private AgentApplication startApplication(WireMockServer wireMock) throws Exception { + var port = availablePort(); + var managementPort = availablePort(); + var java = Path.of(System.getProperty("java.home"), "bin", "java"); + var agent = Path.of("target/maven-shared-archive-resources/opentelemetry-javaagent.jar") + .toAbsolutePath(); + assertTrue(Files.isRegularFile(agent), "Packaged OpenTelemetry agent is missing"); + var process = new ProcessBuilder( + java.toString(), + "-javaagent:" + agent, + "-cp", System.getProperty("java.class.path"), + "dev.vality.wachter.WachterApplication", + "--server.port=" + port, + "--management.server.port=" + managementPort, + "--spring.security.oauth2.resourceserver.url=" + wireMock.baseUrl(), + "--spring.security.oauth2.resourceserver.jwt.issuer-uri=" + + wireMock.baseUrl() + "/auth/realms/internal", + "--wachter.services.Domain.url=" + wireMock.baseUrl() + "/upstream") + .redirectErrorStream(true) + .redirectOutput(Path.of("target/opentelemetry-agent-test.log").toFile()); + var environment = process.environment(); + environment.put("OTEL_TRACES_EXPORTER", "none"); + environment.put("OTEL_METRICS_EXPORTER", "none"); + environment.put("OTEL_LOGS_EXPORTER", "none"); + environment.put("OTEL_SERVICE_NAME", "wachter-agent-test"); + return new AgentApplication(process.start(), port); + } + + private String configureAuthentication(WireMockServer wireMock) throws Exception { + configureFor("localhost", wireMock.port()); + var generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + var tokenBuilder = new JwtTokenBuilder(generator.generateKeyPair()); + var keycloak = new KeycloakOpenIdStub(wireMock.baseUrl() + "/auth", "internal", tokenBuilder); + keycloak.givenStub(); + return keycloak.generateJwt("Domain", "messages:methodName"); + } + + private void sendRequest(int port, String jwt, String traceparent) throws Exception { + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/wachter")) + .timeout(Duration.ofSeconds(10)) + .header("Authorization", "Bearer " + jwt) + .header("Service", "Domain") + .header("X-Request-ID", "agent-test") + .header("X-Request-Deadline", Instant.now().plusSeconds(30).toString()) + .POST(HttpRequest.BodyPublishers.ofByteArray( + TMessageUtil.createTMessage(new TBinaryProtocol.Factory()))) + .build(); + if (traceparent != null) { + request = HttpRequest.newBuilder(request, (name, value) -> true) + .header(OTEL_TRACE_PARENT, traceparent) + .build(); + } + var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, response.statusCode()); + } + + private String singleUpstreamTraceparent(WireMockServer wireMock) { + var requests = wireMock.findAll(postRequestedFor(urlEqualTo("/upstream"))); + assertEquals(1, requests.size()); + return requests.getFirst().getHeader(OTEL_TRACE_PARENT); + } + + private void awaitApplication(int port, Process process) throws Exception { + var deadline = Instant.now().plusSeconds(30); + while (Instant.now().isBefore(deadline)) { + if (!process.isAlive()) { + fail("Wachter agent process exited with code " + process.exitValue()); + } + try (var socket = new java.net.Socket("localhost", port)) { + return; + } catch (java.io.IOException ignored) { + Thread.sleep(100); + } + } + fail("Timed out waiting for Wachter agent process"); + } + + private void stop(Process process) throws InterruptedException { + process.destroy(); + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + + private int availablePort() throws Exception { + try (var socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private String traceId(String traceparent) { + return traceparent.split("-")[1]; + } + + private String spanId(String traceparent) { + return traceparent.split("-")[2]; + } + + private record AgentApplication(Process process, int port) { + } +} From a407ec3a85445bbafe30e2545bbe655e852a59b3 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 12 Aug 2026 18:55:08 +0700 Subject: [PATCH 5/5] migrate on sp4 and dont use woody-http-bridge --- .../dev/vality/wachter/tracing/OpenTelemetryAgentTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java b/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java index e0e138a..bfa09d9 100644 --- a/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java +++ b/src/test/java/dev/vality/wachter/tracing/OpenTelemetryAgentTest.java @@ -78,8 +78,8 @@ private AgentApplication startApplication(WireMockServer wireMock) throws Except "--server.port=" + port, "--management.server.port=" + managementPort, "--spring.security.oauth2.resourceserver.url=" + wireMock.baseUrl(), - "--spring.security.oauth2.resourceserver.jwt.issuer-uri=" - + wireMock.baseUrl() + "/auth/realms/internal", + "--spring.security.oauth2.resourceserver.jwt.issuer-uri=" + + wireMock.baseUrl() + "/auth/realms/internal", "--wachter.services.Domain.url=" + wireMock.baseUrl() + "/upstream") .redirectErrorStream(true) .redirectOutput(Path.of("target/opentelemetry-agent-test.log").toFile());