diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c091554..c6f0a89 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: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v4
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ce19205..944b966 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: 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/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..e62e0f5 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 = {
@@ -38,8 +38,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 +62,6 @@ void setUp() {
@AfterEach
void tearDown() {
- TraceContext.setCurrentTraceData(null);
resetAllRequests();
}
@@ -141,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")));
@@ -162,16 +160,16 @@ 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));
- 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));
@@ -194,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))
@@ -224,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()
@@ -246,16 +244,16 @@ 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));
- // 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));
@@ -376,7 +374,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/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..bfa09d9
--- /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) {
+ }
+}
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")));
+ }
+}
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.