From 48ef9083451db642737fcedc406527b586700141 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Sun, 5 Oct 2025 10:04:29 +0700 Subject: [PATCH 01/16] add backward-compatibility branch --- .github/workflows/deploy.yml | 1 + pom.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce19205..73d0ab2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: branches: - 'master' - 'main' + - 'epic/backward-compatibility' env: REGISTRY: ghcr.io diff --git a/pom.xml b/pom.xml index 02b6c92..4052f2b 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ dev.vality damsel + 1.652-ecf4977 From b25ec3cd54721e14b8588cf0751db1d6c67428e5 Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Mon, 6 Oct 2025 22:00:40 +0700 Subject: [PATCH 02/16] Refactor tracing pipeline and RestClient integration (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor tracing pipeline and RestClient integration • replace the Apache HTTP client with Spring’s RestClient, introducing WachterRequestFactory and WachterClientResponse so outgoing calls reuse normalized headers and proxy upstream responses verbatim • split the monolithic tracing filter into dedicated classes that normalize Woody headers, hydrate the TraceContext, and drive OpenTelemetry server spans while guaranteeing traceparent injection and error/status attribution • add OtelConfig and supporting properties to bootstrap an OTLP exporter with W3C propagation plus lightweight helpers for request/header propagation • centralize JWT claim extraction and reuse it when enriching headers or filling missing user identity metadata from the trace context • broaden the test suite with new client/factory specs, comprehensive WebConfig coverage, and a WireMock integration test that verifies header propagation, span state, and response transparency • update tracing documentation to capture the new architecture and rollout steps --- README.md | 49 +- agents.md | 48 ++ pom.xml | 18 +- .../vality/wachter/client/WachterClient.java | 90 ++-- .../wachter/client/WachterClientResponse.java | 7 + .../wachter/client/WachterRequestFactory.java | 182 ++++++++ .../client/WachterResponseHandler.java | 34 -- .../wachter/config/ApplicationConfig.java | 81 ++-- .../dev/vality/wachter/config/OtelConfig.java | 70 +++ .../vality/wachter/config/SecurityConfig.java | 2 - .../dev/vality/wachter/config/WebConfig.java | 145 ++---- .../config/properties/OtelProperties.java | 17 + .../HttpServletRequestHeaderGetter.java | 31 ++ .../config/tracing/MapHeaderSetter.java | 21 + .../tracing/WoodyHeadersNormalizer.java | 111 +++++ .../config/tracing/WoodyTelemetrySupport.java | 113 +++++ .../tracing/WoodyTraceContextApplier.java | 65 +++ .../config/tracing/WoodyTracingFilter.java | 67 +++ .../wachter/constants/HeadersConstants.java | 44 +- .../constants/RequestAttributeNames.java | 9 + .../wachter/controller/WachterController.java | 20 +- .../vality/wachter/security/AccessData.java | 1 - .../wachter/security/AccessService.java | 12 +- .../security/JwtTokenDetailsExtractor.java | 55 +++ .../wachter/security/RoleAccessService.java | 14 +- .../service/MethodNameReaderService.java | 5 +- .../wachter/service/WachterService.java | 30 +- .../vality/wachter/utils/DeadlineUtil.java | 80 ++-- src/main/resources/application.yml | 5 + .../auth/utils/KeycloakOpenIdStub.java | 4 +- .../client/WachterClientExtractTest.java | 24 + .../client/WachterClientOperationsTest.java | 113 +++++ .../client/WachterRequestFactoryTest.java | 182 ++++++++ ...bstractKeycloakOpenIdAsWiremockConfig.java | 3 +- .../vality/wachter/config/WebConfigTest.java | 425 ++++++++++++++++++ .../controller/ErrorControllerTest.java | 25 +- .../WachterControllerDisabledAuthTest.java | 26 +- .../controller/WachterControllerTest.java | 71 +-- .../integration/WachterIntegrationTest.java | 158 +++++++ .../vality/wachter/testutil/ContextUtil.java | 2 +- .../vality/wachter/util/WoodyHeaderTest.java | 26 -- wachter_context.md | 72 +++ 42 files changed, 2094 insertions(+), 463 deletions(-) create mode 100644 agents.md create mode 100644 src/main/java/dev/vality/wachter/client/WachterClientResponse.java create mode 100644 src/main/java/dev/vality/wachter/client/WachterRequestFactory.java delete mode 100644 src/main/java/dev/vality/wachter/client/WachterResponseHandler.java create mode 100644 src/main/java/dev/vality/wachter/config/OtelConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/properties/OtelProperties.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java create mode 100644 src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java create mode 100644 src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java create mode 100644 src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java create mode 100644 src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java create mode 100644 src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java create mode 100644 src/test/java/dev/vality/wachter/config/WebConfigTest.java create mode 100644 src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java delete mode 100644 src/test/java/dev/vality/wachter/util/WoodyHeaderTest.java create mode 100644 wachter_context.md diff --git a/README.md b/README.md index 4d4e214..6487057 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,22 @@ -# wachter - -Сервис предназначен для авторизации и проксирования вызовов от [control-center](https://github.com/valitydev/control-center). - -## Описание работы сервиса - -1. Wachter получает от [control-center](https://github.com/valitydev/control-center) запрос на проведение операции, -содержащий токен и имя сервиса, в который необходимо спроксировать запрос. Имя сервиса получает из header "Service". -2. Из сообщения запроса wachter получает -[имя метода](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/service/MethodNameReaderService.java) -3. В [KeycloakService](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/service/KeycloakService.java) -wachter получает AccessToken. -4. По имени сервиса из header wachter -[маппит](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/mapper/ServiceMapper.java) -url, на который необходимо спроксировать запрос. -5. Далее сервис проверяет возможность [авторизации](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/security/AccessService.java) -пользователя, сравнивая полученные названия сервиса и метода от [control-center](https://github.com/valitydev/control-center) -с теми, что находятся в JWT токене. Доступ может быть разрешен как [ко всему сервису](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/security/RoleAccessService.java#L22), -так и только к [отдельному методу](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/security/RoleAccessService.java#L22) сервиса. -6. Если доступ разрешен, сервис [отправляет запрос](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/client/WachterClient.java) -на ранее смаппленный урл. -7. Полученный ответ [возвращает](https://github.com/valitydev/wachter/blob/master/src/main/java/dev/vality/wachter/controller/WachterController.java) control-center. - -Схема работы сервиса: - -![diagram-wachter](doc/diagram-wachter.svg) - +# wachter + +Сервис авторизации и прозрачного проксирования запросов от внешних систем к внутренним доменным сервисам. Представляет из себя HTTP пайплайн для Thrift вызовов с поддержкой заголовков woody и метаданных авторизации + +## Основной поток + +1. **Фильтрация входящего запроса.** `WoodyTracingFilter` нормализует заголовки `x-woody-*`/`woody.*`, восстанавливает `TraceContext` и создаёт серверный OpenTelemetry span с гарантированным `traceparent`. +2. **Авторизация.** `WachterService` считывает фактический метод из thrift-пакета, извлекает JWT из Spring Security, проверяет права пользователя через `AccessService`/`RoleAccessService`. +3. **Определение целевого сервиса.** `ServiceMapper` выбирает URL по заголовку `Service`. +4. **Формирование запроса.** `WachterRequestFactory` собирает исходные заголовки, накладывает нормализованные Woody-заголовки и значения из текущего `TraceContext`, дополняет идентификационные поля из JWT. +5. **Отправка и получение ответа.** `WachterClient` использует `RestClient` (JDK HTTP) для вызова доменного сервиса, возвращая `WachterClientResponse` со статусом, заголовками и телом. +6. **Ответ потребителю.** `WachterController` проверяет дедлайн, передаёт данные в `WachterService` и возвращает клиенту неизменённые статус, заголовки и тело от upstream. + +## Особенности + +- Поддержка двух семейств Woody-заголовков (новые `woody.*` и наследуемые `x-woody-*`). +- Автоматическая генерация и распространение OpenTelemetry `traceparent` при отсутствии входящего заголовка. +- Выделенный `JwtTokenDetailsExtractor` для повторного использования данных токена. +- Тестовый контур покрывает композицию фильтра, клиента и контроллера, включая WireMock-интеграцию. + +Схема взаимодействий остаётся доступной в [doc/diagram-wachter.svg](doc/diagram-wachter.svg). + diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..d3fe008 --- /dev/null +++ b/agents.md @@ -0,0 +1,48 @@ +# 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 4052f2b..9a919c1 100644 --- a/pom.xml +++ b/pom.xml @@ -24,14 +24,6 @@ - - io.micrometer - micrometer-core - - - io.micrometer - micrometer-registry-prometheus - dev.vality shared-resources @@ -98,11 +90,6 @@ - - org.apache.httpcomponents - httpclient - 4.5.14 - org.projectlombok lombok @@ -131,6 +118,11 @@ 1.79 test + + io.opentelemetry + opentelemetry-semconv + 1.29.0-alpha + diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index 7c2349b..ed10a06 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,68 +1,52 @@ package dev.vality.wachter.client; import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.entity.ByteArrayEntity; -import org.springframework.stereotype.Service; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.ObjectUtils; +import org.springframework.web.client.RestClient; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -import static dev.vality.wachter.constants.HeadersConstants.*; +import java.util.Objects; @Slf4j -@Service -@RequiredArgsConstructor -public class WachterClient { +public record WachterClient(RestClient restClient, WachterRequestFactory requestFactory) { - private final HttpClient httpclient; - private final WachterResponseHandler responseHandler; + private static final byte[] EMPTY_BODY = new byte[0]; - @SneakyThrows - public byte[] send(HttpServletRequest request, byte[] contentData, String url) { - HttpPost httppost = new HttpPost(url); - setHeader(request, httppost); - httppost.setEntity(new ByteArrayEntity(contentData)); - log.info("Send request to url {} with trace_id: {}", url, getTraceId(request)); - return httpclient.execute(httppost, responseHandler); - } + public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) { + var httpMethod = resolveMethod(servletRequest); + var params = requestFactory.extract(servletRequest); + log.info("-> Send request to {} {} | params: {}", httpMethod, url, params); - private void setHeader(HttpServletRequest request, HttpPost httppost) { - var headerNames = request.getHeaderNames(); - var headers = new HashMap(); - if (headerNames != null) { - while (headerNames.hasMoreElements()) { - String next = headerNames.nextElement(); - headers.put(next, request.getHeader(next)); - } - } - var woodyUserIdentityDeprecatedHeaders = headers.entrySet().stream() - .filter(s -> s.getKey().startsWith(X_WOODY_META_USER_IDENTITY_PREFIX)) - .map(s -> Map.entry( - s.getKey().replaceAll( - X_WOODY_META_USER_IDENTITY_PREFIX, - WOODY_META_USER_IDENTITY_DEPRECATED_PREFIX), - s.getValue())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)); - var woodyDeprecatedHeaders = headers.entrySet().stream() - .filter(s -> s.getKey().startsWith(X_WOODY_PREFIX)) - .map(s -> Map.entry(s.getKey().replaceAll(X_WOODY_PREFIX, WOODY_DEPRECATED_PREFIX), s.getValue())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)); - headers.putAll(woodyUserIdentityDeprecatedHeaders); - headers.putAll(woodyDeprecatedHeaders); - for (var entry : headers.entrySet()) { - httppost.setHeader(entry.getKey(), entry.getValue()); + var headers = requestFactory.buildHeaders(servletRequest); + + var requestSpec = restClient.method(httpMethod) + .uri(url) + .headers(httpHeaders -> httpHeaders.addAll(headers)); + + if (!ObjectUtils.isEmpty(contentData)) { + requestSpec = requestSpec.body(contentData); } + + var result = requestSpec.exchange((request, response) -> { + var status = response.getStatusCode(); + var responseBody = Objects.requireNonNullElse(response.bodyTo(byte[].class), EMPTY_BODY); + var responseHeaders = new HttpHeaders(); + responseHeaders.putAll(response.getHeaders()); + return new WachterClientResponse(status, responseHeaders, responseBody); + }); + + log.info("<- Receive response from {} {} | status: {} | params: {}", + httpMethod, url, result.statusCode(), params); + return result; } - private String getTraceId(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(X_WOODY_TRACE_ID)) - .orElse(request.getHeader(WOODY_TRACE_ID_DEPRECATED)); + private HttpMethod resolveMethod(HttpServletRequest servletRequest) { + try { + return HttpMethod.valueOf(servletRequest.getMethod()); + } catch (IllegalArgumentException ex) { + return HttpMethod.POST; + } } } diff --git a/src/main/java/dev/vality/wachter/client/WachterClientResponse.java b/src/main/java/dev/vality/wachter/client/WachterClientResponse.java new file mode 100644 index 0000000..c5a6f26 --- /dev/null +++ b/src/main/java/dev/vality/wachter/client/WachterClientResponse.java @@ -0,0 +1,7 @@ +package dev.vality.wachter.client; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; + +public record WachterClientResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body) { +} diff --git a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java new file mode 100644 index 0000000..4a20d1c --- /dev/null +++ b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java @@ -0,0 +1,182 @@ +package dev.vality.wachter.client; + +import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.wachter.security.JwtTokenDetailsExtractor; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; + +@Component +public class WachterRequestFactory { + + public HttpHeaders buildHeaders(HttpServletRequest servletRequest) { + var headers = collectHeaders(servletRequest); + mergeNormalizedWoodyHeaders(servletRequest, headers); + mergeTraceContextHeaders(headers); + + var woodyIdentityMirrors = createPrefixMirrors(headers, + X_WOODY_META_USER_IDENTITY_PREFIX, WOODY_META_USER_IDENTITY_PREFIX); + var woodyMirrors = createPrefixMirrors(headers, X_WOODY_PREFIX, WOODY_PREFIX); + var alternateIdentityMirrors = createPrefixMirrors(headers, + WOODY_META_USER_IDENTITY_PREFIX, X_WOODY_META_USER_IDENTITY_PREFIX); + var alternateWoodyMirrors = createPrefixMirrors(headers, WOODY_PREFIX, X_WOODY_PREFIX); + + headers.putAll(woodyIdentityMirrors); + headers.putAll(woodyMirrors); + headers.putAll(alternateIdentityMirrors); + headers.putAll(alternateWoodyMirrors); + + var httpHeaders = new HttpHeaders(); + headers.forEach(httpHeaders::set); + return httpHeaders; + } + + public String extract(HttpServletRequest servletRequest) { + return servletRequest.getParameterMap().entrySet().stream() + .map(entry -> entry.getKey() + "=" + String.join(", ", entry.getValue())) + .collect(Collectors.joining(", ")); + } + + private Map collectHeaders(HttpServletRequest servletRequest) { + var headers = new LinkedHashMap(); + var headerNames = servletRequest.getHeaderNames(); + while (headerNames != null && headerNames.hasMoreElements()) { + var name = headerNames.nextElement(); + var value = servletRequest.getHeader(name); + if (value != null) { + headers.put(name, value); + } + } + return headers; + } + + private void mergeNormalizedWoodyHeaders(HttpServletRequest servletRequest, Map headers) { + var normalized = getNormalizedWoodyHeaders(servletRequest); + if (!normalized.isEmpty()) { + headers.putAll(normalized); + } + } + + private void mergeTraceContextHeaders(Map headers) { + var traceData = TraceContext.getCurrentTraceData(); + var jwtDetails = JwtTokenDetailsExtractor + .extractFromContext(SecurityContextHolder.getContext().getAuthentication()) + .orElse(null); + if (traceData == null) { + mergeUserIdentityFromJwt(headers, jwtDetails); + return; + } + var serviceSpan = traceData.getServiceSpan().getSpan(); + setDualPrefixHeader(headers, WOODY_TRACE_ID, X_WOODY_TRACE_ID, serviceSpan.getTraceId()); + setDualPrefixHeader(headers, WOODY_SPAN_ID, X_WOODY_SPAN_ID, serviceSpan.getId()); + setDualPrefixHeader(headers, WOODY_PARENT_ID, X_WOODY_PARENT_ID, serviceSpan.getParentId()); + Instant deadline = serviceSpan.getDeadline(); + if (deadline != null) { + setDualPrefixHeader(headers, WOODY_DEADLINE, X_WOODY_DEADLINE, deadline.toString()); + } + var userId = getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY); + if (isBlank(userId) && jwtDetails != null) { + userId = jwtDetails.subject(); + } + setDualPrefixUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, userId); + + var username = getCustomMetadataValue(String.class, UserIdentityUsernameExtensionKit.KEY); + if (isBlank(username) && jwtDetails != null) { + username = jwtDetails.preferredUsername(); + } + setDualPrefixUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, username); + + var email = getCustomMetadataValue(String.class, UserIdentityEmailExtensionKit.KEY); + if (isBlank(email) && jwtDetails != null) { + email = jwtDetails.email(); + } + setDualPrefixUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, email); + + var realm = getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY); + if (isBlank(realm) && jwtDetails != null) { + realm = jwtDetails.realm(); + } + setDualPrefixUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, realm); + } + + private void mergeUserIdentityFromJwt(Map headers, + JwtTokenDetailsExtractor.JwtTokenDetails jwtDetails) { + if (jwtDetails == null) { + return; + } + setDualPrefixUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, jwtDetails.subject()); + setDualPrefixUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, + jwtDetails.preferredUsername()); + setDualPrefixUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, jwtDetails.email()); + setDualPrefixUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, jwtDetails.realm()); + } + + private void setDualPrefixHeader(Map headers, + String canonicalKey, + String alternateKey, + String value) { + if (value == null || value.isEmpty()) { + return; + } + headers.put(canonicalKey, value); + headers.put(alternateKey, value); + } + + private void setDualPrefixUserIdentityHeader(Map headers, + String extensionKey, + String value) { + if (value == null || value.isEmpty()) { + return; + } + var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); + if (suffix.isEmpty()) { + return; + } + headers.put(WOODY_META_USER_IDENTITY_PREFIX + suffix, value); + headers.put(X_WOODY_META_USER_IDENTITY_PREFIX + suffix, value); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + @SuppressWarnings("unchecked") + private Map getNormalizedWoodyHeaders(HttpServletRequest servletRequest) { + var attribute = servletRequest.getAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS); + if (attribute instanceof Map map) { + return (Map) map; + } + return Map.of(); + } + + private Map createPrefixMirrors(Map headers, + String sourcePrefix, + String targetPrefix) { + var mirroredHeaders = new HashMap(); + headers.forEach((key, value) -> { + if (key.startsWith(sourcePrefix)) { + mirroredHeaders.put(key.replace(sourcePrefix, targetPrefix), value); + } + }); + return mirroredHeaders; + } + + private String truncate(String value, int limit) { + return value.length() > limit ? value.substring(0, limit) : value; + } +} diff --git a/src/main/java/dev/vality/wachter/client/WachterResponseHandler.java b/src/main/java/dev/vality/wachter/client/WachterResponseHandler.java deleted file mode 100644 index 6da1930..0000000 --- a/src/main/java/dev/vality/wachter/client/WachterResponseHandler.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.vality.wachter.client; - -import dev.vality.woody.api.flow.error.WUnavailableResultException; -import lombok.extern.slf4j.Slf4j; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.client.ResponseHandler; -import org.apache.http.util.EntityUtils; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class WachterResponseHandler implements ResponseHandler { - - @Override - public byte[] handleResponse(HttpResponse httpResponse) throws IOException { - log.info("Get response {}", httpResponse); - int statusCode = httpResponse.getStatusLine().getStatusCode(); - HttpStatus httpStatus = HttpStatus.valueOf(statusCode); - if (httpStatus.is2xxSuccessful()) { - HttpEntity httpEntity = httpResponse.getEntity(); - return EntityUtils.toByteArray(httpEntity); - } else if (httpStatus.is5xxServerError()) { - throw new WUnavailableResultException(String.format("Received 5xx error code: %s (response: %s)", - httpStatus, httpResponse)); - } else { - throw new RuntimeException(String.format("Wrong status was received: %s (response: %s)", - httpStatus, httpResponse)); - } - } -} diff --git a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java b/src/main/java/dev/vality/wachter/config/ApplicationConfig.java index b939518..18c10be 100644 --- a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java +++ b/src/main/java/dev/vality/wachter/config/ApplicationConfig.java @@ -1,47 +1,34 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.config.properties.HttpClientProperties; -import org.apache.http.HttpRequest; -import org.apache.http.HttpRequestInterceptor; -import org.apache.http.client.HttpClient; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; -import org.apache.http.protocol.HTTP; -import org.apache.http.protocol.HttpContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ApplicationConfig { - - @Bean - public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager(HttpClientProperties properties) { - PoolingHttpClientConnectionManager result = new PoolingHttpClientConnectionManager(); - result.setMaxTotal(properties.getMaxTotalPooling()); - result.setDefaultMaxPerRoute(properties.getDefaultMaxPerRoute()); - return result; - } - - @Bean - public HttpClient httpclient(PoolingHttpClientConnectionManager manager, HttpClientProperties properties) { - return HttpClients.custom() - .setDefaultRequestConfig(RequestConfig - .custom() - .setConnectTimeout(properties.getConnectTimeout()) - .setConnectionRequestTimeout(properties.getConnectionRequestTimeout()) - .setSocketTimeout(properties.getSocketTimeout()) - .build()) - .addInterceptorFirst(new ContentLengthHeaderRemover()) - .setConnectionManager(manager) - .build(); - } - - private static class ContentLengthHeaderRemover implements HttpRequestInterceptor { - @Override - public void process(HttpRequest request, HttpContext context) { - request.removeHeaders(HTTP.CONTENT_LEN); - } - } - -} +package dev.vality.wachter.config; + +import dev.vality.wachter.client.WachterClient; +import dev.vality.wachter.client.WachterRequestFactory; +import dev.vality.wachter.config.properties.HttpClientProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class ApplicationConfig { + + @Bean + public RestClient restClient(RestClient.Builder builder, HttpClientProperties properties) { + var connectTimeout = Duration.ofMillis(properties.getConnectTimeout()); + var httpClient = HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .build(); + + var requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofMillis(properties.getSocketTimeout())); + + return builder.requestFactory(requestFactory).build(); + } + + @Bean + public WachterClient wachterClient(RestClient restClient, WachterRequestFactory requestFactory) { + return new WachterClient(restClient, requestFactory); + } +} diff --git a/src/main/java/dev/vality/wachter/config/OtelConfig.java b/src/main/java/dev/vality/wachter/config/OtelConfig.java new file mode 100644 index 0000000..c700dad --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/OtelConfig.java @@ -0,0 +1,70 @@ +package dev.vality.wachter.config; + +import dev.vality.wachter.config.properties.OtelProperties; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Slf4j +@Configuration +@ConditionalOnProperty(value = "otel.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class OtelConfig { + + private final OtelProperties otelProperties; + + @Value("${spring.application.name}") + private String applicationName; + + @Bean + public OpenTelemetry openTelemetryConfig() { + var resource = Resource.getDefault() + .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, applicationName))); + var sdkTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(BatchSpanProcessor.builder(OtlpHttpSpanExporter.builder() + .setEndpoint(otelProperties.getResource()) + .setTimeout(Duration.ofMillis(otelProperties.getTimeout())) + .build()) + .build()) + .setSampler(Sampler.alwaysOn()) + .setResource(resource) + .build(); + var openTelemetrySdk = OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + registerGlobalOpenTelemetry(openTelemetrySdk); + return openTelemetrySdk; + } + + private static void registerGlobalOpenTelemetry(OpenTelemetry openTelemetry) { + try { + GlobalOpenTelemetry.set(openTelemetry); + } catch (Throwable ex) { + log.warn("Please initialize the ObservabilitySdk before starting the application", ex); + GlobalOpenTelemetry.resetForTest(); + try { + GlobalOpenTelemetry.set(openTelemetry); + } catch (Throwable ex1) { + log.warn("Unable to set GlobalOpenTelemetry", ex1); + } + } + } +} diff --git a/src/main/java/dev/vality/wachter/config/SecurityConfig.java b/src/main/java/dev/vality/wachter/config/SecurityConfig.java index 28372a9..1549564 100644 --- a/src/main/java/dev/vality/wachter/config/SecurityConfig.java +++ b/src/main/java/dev/vality/wachter/config/SecurityConfig.java @@ -15,8 +15,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - @Configuration @EnableWebSecurity @RequiredArgsConstructor diff --git a/src/main/java/dev/vality/wachter/config/WebConfig.java b/src/main/java/dev/vality/wachter/config/WebConfig.java index 2b1cb8e..95923fd 100644 --- a/src/main/java/dev/vality/wachter/config/WebConfig.java +++ b/src/main/java/dev/vality/wachter/config/WebConfig.java @@ -1,106 +1,39 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.utils.DeadlineUtil; -import dev.vality.woody.api.flow.WFlow; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import jakarta.servlet.Filter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.time.Instant; -import java.time.temporal.ChronoUnit; - -import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; -import static dev.vality.woody.api.trace.ContextUtils.setDeadline; - -@Slf4j -@Configuration -@SuppressWarnings({"ParameterName", "LocalVariableName"}) -public class WebConfig { - - @Bean - public FilterRegistrationBean woodyFilter() { - WFlow woodyFlow = new WFlow(); - Filter filter = new OncePerRequestFilter() { - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) { - woodyFlow.createServiceFork( - () -> { - try { - var auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth instanceof JwtAuthenticationToken) { - addWoodyContext((JwtAuthenticationToken) auth); - } - setWoodyDeadline(request); - filterChain.doFilter(request, response); - } catch (IOException | ServletException e) { - sneakyThrow(e); - } - } - ) - .run(); - } - - private T sneakyThrow(Throwable t) throws E { - throw (E) t; - } - }; - - FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); - filterRegistrationBean.setFilter(filter); - filterRegistrationBean.setOrder(-50); - filterRegistrationBean.setName("woodyFilter"); - filterRegistrationBean.addUrlPatterns("*"); - return filterRegistrationBean; - } - - private void addWoodyContext(JwtAuthenticationToken token) { - setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, token.getToken().getClaimAsString(JwtClaimNames.SUB)); - setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, - ((Jwt)token.getPrincipal()).getClaimAsString("preferred_username")); - setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, token.getToken().getClaimAsString("email")); - setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, extractRealm(token.getToken())); - } - - private String extractRealm(Jwt token) { - var iss = token.getClaimAsString("iss"); - return iss.substring(iss.lastIndexOf("/")); - } - - private void setWoodyDeadline(HttpServletRequest request) { - String xRequestDeadline = request.getHeader("X-Request-Deadline"); - String xRequestId = request.getHeader("X-Request-ID"); - if (xRequestDeadline != null) { - setDeadline(getInstant(xRequestDeadline, xRequestId)); - } - } - - private Instant getInstant(String xRequestDeadline, String xRequestId) { - if (DeadlineUtil.containsRelativeValues(xRequestDeadline, xRequestId)) { - return Instant.now() - .plus(DeadlineUtil.extractMilliseconds(xRequestDeadline, xRequestId), ChronoUnit.MILLIS) - .plus(DeadlineUtil.extractSeconds(xRequestDeadline, xRequestId), ChronoUnit.MILLIS) - .plus(DeadlineUtil.extractMinutes(xRequestDeadline, xRequestId), ChronoUnit.MILLIS); - } else { - return Instant.parse(xRequestDeadline); - } - } -} +package dev.vality.wachter.config; + +import dev.vality.wachter.config.tracing.WoodyHeadersNormalizer; +import dev.vality.wachter.config.tracing.WoodyTelemetrySupport; +import dev.vality.wachter.config.tracing.WoodyTraceContextApplier; +import dev.vality.wachter.config.tracing.WoodyTracingFilter; +import dev.vality.woody.api.flow.WFlow; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Configuration +public class WebConfig { + + private final WoodyHeadersNormalizer headersNormalizer = new WoodyHeadersNormalizer(); + private final WoodyTraceContextApplier traceContextApplier = new WoodyTraceContextApplier(); + private final WoodyTelemetrySupport telemetrySupport = new WoodyTelemetrySupport(); + + @Bean + public FilterRegistrationBean woodyFilter() { + var filter = new WoodyTracingFilter(new WFlow(), headersNormalizer, traceContextApplier, telemetrySupport); + var registrationBean = new FilterRegistrationBean<>(filter); + registrationBean.setOrder(-50); + registrationBean.setName("woodyFilter"); + registrationBean.addUrlPatterns("*"); + return registrationBean; + } + + public Map normalizeWoodyHeaders(HttpServletRequest request) { + return headersNormalizer.normalize(request); + } + + public void applyWoodyHeadersToTraceContext(Map woodyHeaders) { + traceContextApplier.apply(woodyHeaders); + } +} diff --git a/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java b/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java new file mode 100644 index 0000000..2b88704 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java @@ -0,0 +1,17 @@ +package dev.vality.wachter.config.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "otel") +public class OtelProperties { + + private String resource; + private Long timeout; + +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java b/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java new file mode 100644 index 0000000..df2b26c --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java @@ -0,0 +1,31 @@ +package dev.vality.wachter.config.tracing; + +import io.opentelemetry.context.propagation.TextMapGetter; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Collections; + +final class HttpServletRequestHeaderGetter implements TextMapGetter { + + static final HttpServletRequestHeaderGetter INSTANCE = new HttpServletRequestHeaderGetter(); + + private HttpServletRequestHeaderGetter() { + } + + @Override + public Iterable keys(HttpServletRequest carrier) { + if (carrier == null) { + return Collections.emptyList(); + } + var headerNames = carrier.getHeaderNames(); + return headerNames == null ? Collections.emptyList() : Collections.list(headerNames); + } + + @Override + public String get(HttpServletRequest carrier, String key) { + if (carrier == null || key == null) { + return null; + } + return carrier.getHeader(key); + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java b/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java new file mode 100644 index 0000000..99fd3d6 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java @@ -0,0 +1,21 @@ +package dev.vality.wachter.config.tracing; + +import io.opentelemetry.context.propagation.TextMapSetter; + +import java.util.Map; + +final class MapHeaderSetter implements TextMapSetter> { + + static final MapHeaderSetter INSTANCE = new MapHeaderSetter(); + + private MapHeaderSetter() { + } + + @Override + public void set(Map carrier, String key, String value) { + if (carrier == null || key == null || value == null) { + return; + } + carrier.put(key, value); + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java new file mode 100644 index 0000000..a7dbba0 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java @@ -0,0 +1,111 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.wachter.security.JwtTokenDetailsExtractor; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.wachter.utils.DeadlineUtil.*; + +@Slf4j +public class WoodyHeadersNormalizer { + + public Map normalize(HttpServletRequest request) { + var normalized = new HashMap(); + var headerNamesEnumeration = request.getHeaderNames(); + if (headerNamesEnumeration != null) { + var headerNames = Collections.list(headerNamesEnumeration); + normalizeWoodyHeaders(request, headerNames, normalized); + normalizeOtelHeaders(request, normalized); + } + mergeJwtIntoHeaders(normalized); + mergeRequestDeadline(request, normalized); + return normalized.isEmpty() ? Map.of() : Map.copyOf(normalized); + } + + private void normalizeWoodyHeaders(HttpServletRequest request, List headerNames, + Map headers) { + for (var name : headerNames) { + var lowerCase = name.toLowerCase(Locale.ROOT); + if (!lowerCase.startsWith(WOODY_PREFIX) && !lowerCase.startsWith(X_WOODY_PREFIX)) { + continue; + } + var value = request.getHeader(name); + if (value == null) { + continue; + } + if (lowerCase.startsWith(WOODY_PREFIX)) { + headers.put(lowerCase, value); + } else { + var suffix = lowerCase.substring(X_WOODY_PREFIX.length()); + if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY)) { + var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY.length()); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + metaKey, value); + } else { + headers.put(WOODY_PREFIX + suffix, value); + } + } + } + } + + private void normalizeOtelHeaders(HttpServletRequest request, Map headers) { + var traceParent = request.getHeader(OTEL_TRACE_PARENT); + if (traceParent != null) { + headers.put(OTEL_TRACE_PARENT, traceParent); + } + } + + private void mergeJwtIntoHeaders(Map headers) { + var tokenDetails = JwtTokenDetailsExtractor.extractFromContext(SecurityContextHolder + .getContext() + .getAuthentication()); + if (tokenDetails.isEmpty()) { + return; + } + var details = tokenDetails.get(); + putJwtMetadata(headers, UserIdentityIdExtensionKit.KEY, details.subject()); + putJwtMetadata(headers, UserIdentityUsernameExtensionKit.KEY, details.preferredUsername()); + putJwtMetadata(headers, UserIdentityEmailExtensionKit.KEY, details.email()); + putJwtMetadata(headers, UserIdentityRealmExtensionKit.KEY, details.realm()); + } + + private void mergeRequestDeadline(HttpServletRequest request, Map headers) { + var requestDeadlineHeader = request.getHeader(X_REQUEST_DEADLINE); + var requestIdHeader = request.getHeader(X_REQUEST_ID); + if (requestDeadlineHeader == null) { + return; + } + try { + headers.putIfAbsent(WOODY_DEADLINE, getInstant(requestDeadlineHeader, requestIdHeader).toString()); + } catch (Exception e) { + log.warn("Unable to parse 'X-Request-Deadline' header value '{}'", requestDeadlineHeader); + } + } + + private void putJwtMetadata(Map headers, String extensionKey, String value) { + var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); + if (suffix.isEmpty() || value == null || value.isEmpty()) { + return; + } + headers.put(WOODY_META_USER_IDENTITY_PREFIX + suffix, value); + } + + private Instant getInstant(String requestDeadlineHeader, String requestIdHeader) { + if (containsRelativeValues(requestDeadlineHeader, requestIdHeader)) { + return Instant.now() + .plus(extractMilliseconds(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS) + .plus(extractSeconds(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS) + .plus(extractMinutes(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS); + } + return Instant.parse(requestDeadlineHeader); + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java new file mode 100644 index 0000000..6efd486 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java @@ -0,0 +1,113 @@ +package dev.vality.wachter.config.tracing; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.context.propagation.TextMapPropagator; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.util.HashMap; +import java.util.Map; + +import static dev.vality.wachter.constants.HeadersConstants.OTEL_TRACE_PARENT; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.*; + +public class WoodyTelemetrySupport { + + private static final String INSTRUMENTATION_NAME = "dev.vality.wachter.http"; + + public ServerSpanContext startServerSpan(HttpServletRequest request) { + var openTelemetry = GlobalOpenTelemetry.get(); + var propagator = openTelemetry.getPropagators().getTextMapPropagator(); + var parentContext = propagator.extract(Context.current(), request, HttpServletRequestHeaderGetter.INSTANCE); + var tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME); + var spanBuilder = tracer.spanBuilder(buildSpanName(request)) + .setSpanKind(SpanKind.SERVER) + .setParent(parentContext); + var method = request.getMethod(); + if (method != null) { + spanBuilder.setAttribute(HTTP_METHOD, method); + } + var target = request.getRequestURI(); + if (target != null) { + spanBuilder.setAttribute(HTTP_TARGET, target); + } + var host = request.getHeader("Host"); + if (host != null && !host.isEmpty()) { + spanBuilder.setAttribute(NET_HOST_NAME, host); + } + var span = spanBuilder.startSpan(); + var scope = span.makeCurrent(); + return new ServerSpanContext(span, scope, propagator); + } + + private String buildSpanName(HttpServletRequest request) { + var method = request.getMethod(); + var target = request.getRequestURI(); + if (method == null && (target == null || target.isEmpty())) { + return "HTTP request"; + } + if (method == null) { + return target; + } + if (target == null || target.isEmpty()) { + return method; + } + return method + " " + target; + } + + static final class ServerSpanContext implements AutoCloseable { + + private final Span span; + private final Scope scope; + private final TextMapPropagator propagator; + + private ServerSpanContext(Span span, Scope scope, TextMapPropagator propagator) { + this.span = span; + this.scope = scope; + this.propagator = propagator; + } + + Map ensureTraceparent(Map headers) { + if (headers.containsKey(OTEL_TRACE_PARENT)) { + return headers; + } + var mutable = new HashMap<>(headers); + propagator.inject(Context.current(), mutable, MapHeaderSetter.INSTANCE); + var traceparent = mutable.get(OTEL_TRACE_PARENT); + if (traceparent == null || traceparent.isEmpty()) { + return headers; + } + return Map.copyOf(mutable); + } + + void recordResponse(HttpServletResponse response) { + var status = response.getStatus(); + if (status > 0) { + span.setAttribute(HTTP_STATUS_CODE, status); + span.setStatus(status >= 500 ? StatusCode.ERROR : StatusCode.OK); + } else { + span.setStatus(StatusCode.OK); + } + } + + void recordException(HttpServletResponse response, Throwable t) { + var status = response.getStatus(); + if (status > 0) { + span.setAttribute(HTTP_STATUS_CODE, status); + } + span.recordException(t); + span.setStatus(StatusCode.ERROR); + } + + @Override + public void close() { + scope.close(); + span.end(); + } + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java new file mode 100644 index 0000000..ea895ce --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java @@ -0,0 +1,65 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.function.Consumer; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; +import static dev.vality.woody.api.trace.ContextUtils.setDeadline; + +@Slf4j +public class WoodyTraceContextApplier { + + public void apply(Map woodyHeaders) { + if (woodyHeaders.isEmpty()) { + return; + } + var traceData = TraceContext.getCurrentTraceData(); + var serviceSpan = traceData.getServiceSpan().getSpan(); + setIfPresent(woodyHeaders, WOODY_TRACE_ID, serviceSpan::setTraceId); + setIfPresent(woodyHeaders, WOODY_SPAN_ID, serviceSpan::setId); + setIfPresent(woodyHeaders, WOODY_PARENT_ID, serviceSpan::setParentId); + var woodyDeadline = woodyHeaders.get(WOODY_DEADLINE); + if (woodyDeadline != null && !woodyDeadline.isEmpty()) { + try { + setDeadline(traceData.getServiceSpan(), Instant.parse(woodyDeadline)); + } catch (DateTimeParseException e) { + log.warn("Unable to parse 'woody.deadline' header value '{}'", woodyDeadline); + } + } + applyUserIdentityHeader(woodyHeaders, UserIdentityIdExtensionKit.KEY, + value -> setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, value)); + applyUserIdentityHeader(woodyHeaders, UserIdentityUsernameExtensionKit.KEY, + value -> setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, value)); + applyUserIdentityHeader(woodyHeaders, UserIdentityEmailExtensionKit.KEY, + value -> setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, value)); + applyUserIdentityHeader(woodyHeaders, UserIdentityRealmExtensionKit.KEY, + value -> setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, value)); + } + + private void applyUserIdentityHeader(Map headers, + String extensionKey, + Consumer consumer) { + var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); + if (suffix.isEmpty()) { + return; + } + setIfPresent(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, consumer); + } + + private void setIfPresent(Map headers, String key, Consumer consumer) { + var value = headers.get(key); + if (value != null && !value.isEmpty()) { + consumer.accept(value); + } + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java new file mode 100644 index 0000000..5c91b7e --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java @@ -0,0 +1,67 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.woody.api.flow.WFlow; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Map; + +public final class WoodyTracingFilter extends OncePerRequestFilter { + + private final WFlow woodyFlow; + private final WoodyHeadersNormalizer headersNormalizer; + private final WoodyTraceContextApplier traceContextApplier; + private final WoodyTelemetrySupport telemetrySupport; + + public WoodyTracingFilter(WFlow woodyFlow, + WoodyHeadersNormalizer headersNormalizer, + WoodyTraceContextApplier traceContextApplier, + WoodyTelemetrySupport telemetrySupport) { + this.woodyFlow = woodyFlow; + this.headersNormalizer = headersNormalizer; + this.traceContextApplier = traceContextApplier; + this.telemetrySupport = telemetrySupport; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) { + var telemetry = telemetrySupport.startServerSpan(request); + try { + var normalizedWoodyHeaders = telemetry.ensureTraceparent(headersNormalizer.normalize(request)); + request.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, normalizedWoodyHeaders); + runWithWoodyContext(request, response, filterChain, normalizedWoodyHeaders); + telemetry.recordResponse(response); + } catch (Throwable t) { + telemetry.recordException(response, t); + throw t; + } finally { + telemetry.close(); + } + } + + private void runWithWoodyContext(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain, + Map normalizedWoodyHeaders) { + woodyFlow.createServiceFork(() -> { + try { + traceContextApplier.apply(normalizedWoodyHeaders); + filterChain.doFilter(request, response); + } catch (IOException | ServletException e) { + sneakyThrow(e); + } + } + ).run(); + } + + private T sneakyThrow(Throwable t) throws E { + throw (E) t; + } +} diff --git a/src/main/java/dev/vality/wachter/constants/HeadersConstants.java b/src/main/java/dev/vality/wachter/constants/HeadersConstants.java index 52ca6e4..e05df3b 100644 --- a/src/main/java/dev/vality/wachter/constants/HeadersConstants.java +++ b/src/main/java/dev/vality/wachter/constants/HeadersConstants.java @@ -2,11 +2,45 @@ public class HeadersConstants { + 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_WOODY_PREFIX = "x-woody-"; - public static final String X_WOODY_META_USER_IDENTITY_PREFIX = "x-woody-meta-user-identity-"; - public static final String X_WOODY_TRACE_ID = "x-woody-trace-id"; - public static final String WOODY_DEPRECATED_PREFIX = "woody."; - public static final String WOODY_META_USER_IDENTITY_DEPRECATED_PREFIX = "woody.meta.user-identity."; - public static final String WOODY_TRACE_ID_DEPRECATED = "woody.trace-id"; + 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_USER_IDENTITY_PREFIX = X_WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY; + public static final String WOODY_PREFIX = "woody."; + public static final String WOODY_TRACE_ID = WOODY_PREFIX + "trace-id"; + public static final String WOODY_SPAN_ID = WOODY_PREFIX + "span-id"; + public static final String WOODY_PARENT_ID = WOODY_PREFIX + "parent-id"; + public static final String WOODY_DEADLINE = WOODY_PREFIX + "deadline"; + public static final String WOODY_META_USER_IDENTITY_PREFIX = WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_DOT; + public static final String OTEL_TRACE_PARENT = "traceparent"; + + public static final class WoodySuffixes { + private static final String META = "meta"; + private static final String USER_IDENTITY = "user-identity"; + private static final String HYPHEN = "-"; + private static final String DOT = "."; + + public static final String META_USER_IDENTITY = META + HYPHEN + USER_IDENTITY + HYPHEN; + public static final String META_USER_IDENTITY_DOT = META + DOT + USER_IDENTITY + DOT; + public static final String USER_IDENTITY_KEY_PREFIX = USER_IDENTITY + DOT; + + private WoodySuffixes() { + } + + public static String userIdentitySuffix(String extensionKey) { + if (extensionKey == null || extensionKey.isEmpty()) { + return ""; + } + if (extensionKey.startsWith(USER_IDENTITY_KEY_PREFIX)) { + return extensionKey.substring(USER_IDENTITY_KEY_PREFIX.length()); + } + int lastDot = extensionKey.lastIndexOf('.'); + return lastDot >= 0 ? extensionKey.substring(lastDot + 1) : extensionKey; + } + } } diff --git a/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java b/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java new file mode 100644 index 0000000..a8a58eb --- /dev/null +++ b/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java @@ -0,0 +1,9 @@ +package dev.vality.wachter.constants; + +public final class RequestAttributeNames { + + public static final String NORMALIZED_WOODY_HEADERS = "wachter.normalizedWoodyHeaders"; + + private RequestAttributeNames() { + } +} diff --git a/src/main/java/dev/vality/wachter/controller/WachterController.java b/src/main/java/dev/vality/wachter/controller/WachterController.java index 5e1ed6a..d33c9ec 100644 --- a/src/main/java/dev/vality/wachter/controller/WachterController.java +++ b/src/main/java/dev/vality/wachter/controller/WachterController.java @@ -1,23 +1,37 @@ package dev.vality.wachter.controller; import dev.vality.wachter.service.WachterService; +import dev.vality.wachter.utils.DeadlineUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import static dev.vality.wachter.constants.HeadersConstants.X_REQUEST_DEADLINE; +import static dev.vality.wachter.constants.HeadersConstants.X_REQUEST_ID; + @RestController @RequiredArgsConstructor @RequestMapping("") +@SuppressWarnings("LocalVariableName") public class WachterController { private final WachterService wachterService; @PostMapping("/wachter") - public byte[] getRequest(HttpServletRequest request) { - return wachterService.process(request); + public ResponseEntity proxyRequest(HttpServletRequest request) { + var xRequestDeadline = request.getHeader(X_REQUEST_DEADLINE); + var xRequestId = request.getHeader(X_REQUEST_ID); + DeadlineUtil.checkDeadline(xRequestDeadline, xRequestId); + var upstreamResponse = wachterService.process(request); + var responseHeaders = new HttpHeaders(); + responseHeaders.putAll(upstreamResponse.headers()); + return ResponseEntity.status(upstreamResponse.statusCode()) + .headers(responseHeaders) + .body(upstreamResponse.body()); } - } diff --git a/src/main/java/dev/vality/wachter/security/AccessData.java b/src/main/java/dev/vality/wachter/security/AccessData.java index 9346dcc..f7d83b1 100644 --- a/src/main/java/dev/vality/wachter/security/AccessData.java +++ b/src/main/java/dev/vality/wachter/security/AccessData.java @@ -13,6 +13,5 @@ public class AccessData { private final String userEmail; private final List tokenRoles; private final String serviceName; - private final String traceId; } diff --git a/src/main/java/dev/vality/wachter/security/AccessService.java b/src/main/java/dev/vality/wachter/security/AccessService.java index bf6ca65..69b3248 100644 --- a/src/main/java/dev/vality/wachter/security/AccessService.java +++ b/src/main/java/dev/vality/wachter/security/AccessService.java @@ -16,21 +16,19 @@ public class AccessService { private final RoleAccessService roleAccessService; public void checkUserAccess(AccessData accessData) { - log.info("Check the {} rights to perform the operation {} in service {} for roles {} with trace_id {}", + log.info("Check the {} rights to perform the operation {} in service {} for roles {}", accessData.getUserEmail(), accessData.getMethodName(), accessData.getServiceName(), - accessData.getTokenRoles(), - accessData.getTraceId()); + accessData.getTokenRoles()); if (authEnabled) { roleAccessService.checkRolesAccess(accessData); } else { - log.warn("Authorization disabled. Access check was not performed for user {} " + - "to method {} in service {} with trace_id {}", + log.warn("Authorization disabled. Access check was not performed for user {}" + + "to method {} in service {} ", accessData.getUserEmail(), accessData.getMethodName(), - accessData.getServiceName(), - accessData.getTraceId()); + accessData.getServiceName()); } } 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..78510d7 --- /dev/null +++ b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java @@ -0,0 +1,55 @@ +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 static final String PREFERRED_USERNAME = "preferred_username"; + private static final String EMAIL = "email"; + private static final String ISSUER = "iss"; + + private JwtTokenDetailsExtractor() { + } + + public static Optional extractFromContext(Authentication authentication) { + if (!(authentication instanceof JwtAuthenticationToken jwtAuthentication)) { + return Optional.empty(); + } + var token = jwtAuthentication.getToken(); + return Optional.of(new JwtTokenDetails( + token.getClaimAsString(JwtClaimNames.SUB), + 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(ISSUER); + if (issuer == null || issuer.isBlank()) { + return null; + } + var lastSlash = issuer.lastIndexOf('/'); + if (lastSlash < 0) { + return issuer; + } + return issuer.substring(lastSlash); + } + + public record JwtTokenDetails(String subject, + String preferredUsername, + String email, + String realm, + List roles) { + } +} diff --git a/src/main/java/dev/vality/wachter/security/RoleAccessService.java b/src/main/java/dev/vality/wachter/security/RoleAccessService.java index 35e316b..0c6866b 100644 --- a/src/main/java/dev/vality/wachter/security/RoleAccessService.java +++ b/src/main/java/dev/vality/wachter/security/RoleAccessService.java @@ -16,25 +16,23 @@ public class RoleAccessService { public void checkRolesAccess(AccessData accessData) { if (accessData.getTokenRoles().isEmpty()) { throw new AuthorizationException( - String.format("User %s don't have roles with trace_id %s", - accessData.getUserEmail(), accessData.getTraceId())); + String.format("User %s don't have roles", + accessData.getUserEmail())); } if (isRoleContainsForbiddenServiceAndMethodName(accessData)) { throw new AuthorizationException( - String.format("User %s don't have access to %s in service %s with trace_id %s", + String.format("User %s don't have access to %s in service %s", accessData.getUserEmail(), accessData.getMethodName(), - accessData.getServiceName(), - accessData.getTraceId())); + accessData.getServiceName())); } for (String role : accessData.getTokenRoles()) { if (role.equalsIgnoreCase(getServiceAndMethodName(accessData))) { - log.info("Rights allowed in service {} and method {} for user {} with trace_id {}", + log.info("Rights allowed in service {} and method {} for user {}", accessData.getServiceName(), accessData.getMethodName(), - accessData.getUserEmail(), - accessData.getTraceId()); + accessData.getUserEmail()); return; } else if (role.equalsIgnoreCase(getServiceName(accessData))) { diff --git a/src/main/java/dev/vality/wachter/service/MethodNameReaderService.java b/src/main/java/dev/vality/wachter/service/MethodNameReaderService.java index 14b63d7..56c0161 100644 --- a/src/main/java/dev/vality/wachter/service/MethodNameReaderService.java +++ b/src/main/java/dev/vality/wachter/service/MethodNameReaderService.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.apache.thrift.TException; -import org.apache.thrift.protocol.TMessage; import org.apache.thrift.protocol.TProtocol; import org.apache.thrift.protocol.TProtocolFactory; import org.apache.thrift.transport.TMemoryInputTransport; @@ -16,8 +15,8 @@ public class MethodNameReaderService { private final TProtocolFactory thriftProtocolFactory; public String getMethodName(byte[] thriftBody) throws TException { - TProtocol protocol = createProtocol(thriftBody); - TMessage message = protocol.readMessageBegin(); + var protocol = createProtocol(thriftBody); + var message = protocol.readMessageBegin(); protocol.readMessageEnd(); return message.name; } diff --git a/src/main/java/dev/vality/wachter/service/WachterService.java b/src/main/java/dev/vality/wachter/service/WachterService.java index 9404cb4..08d78b3 100644 --- a/src/main/java/dev/vality/wachter/service/WachterService.java +++ b/src/main/java/dev/vality/wachter/service/WachterService.java @@ -1,24 +1,19 @@ package dev.vality.wachter.service; import dev.vality.wachter.client.WachterClient; +import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.mapper.ServiceMapper; import dev.vality.wachter.security.AccessData; import dev.vality.wachter.security.AccessService; +import dev.vality.wachter.security.JwtTokenDetailsExtractor; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import org.apache.tomcat.util.http.fileupload.IOUtils; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; -import java.util.Optional; - -import static dev.vality.wachter.constants.HeadersConstants.WOODY_TRACE_ID_DEPRECATED; -import static dev.vality.wachter.constants.HeadersConstants.X_WOODY_TRACE_ID; @RequiredArgsConstructor @Service @@ -30,31 +25,26 @@ public class WachterService { private final MethodNameReaderService methodNameReaderService; @SneakyThrows - public byte[] process(HttpServletRequest request) { - byte[] contentData = getContentData(request); + public WachterClientResponse process(HttpServletRequest request) { + var contentData = getContentData(request); var methodName = methodNameReaderService.getMethodName(contentData); - var token = (JwtAuthenticationToken)SecurityContextHolder.getContext().getAuthentication(); + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var tokenDetails = JwtTokenDetailsExtractor.extractFromContext(authentication) + .orElseThrow(() -> new IllegalStateException("JWT authentication is required")); var service = serviceMapper.getService(request); accessService.checkUserAccess(AccessData.builder() .methodName(methodName) - .userEmail(((Jwt)token.getPrincipal()).getClaim("email")) + .userEmail(tokenDetails.email()) .serviceName(service.getName()) - .tokenRoles(token.getAuthorities().stream() - .map(GrantedAuthority::getAuthority).toList()) - .traceId(getTraceId(request)) + .tokenRoles(tokenDetails.roles()) .build()); return wachterClient.send(request, contentData, service.getUrl()); } @SneakyThrows private byte[] getContentData(HttpServletRequest request) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); + var baos = new ByteArrayOutputStream(); IOUtils.copy(request.getInputStream(), baos); return baos.toByteArray(); } - - private String getTraceId(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(X_WOODY_TRACE_ID)) - .orElse(request.getHeader(WOODY_TRACE_ID_DEPRECATED)); - } } diff --git a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java b/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java index 23badaa..8606e50 100644 --- a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java +++ b/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java @@ -1,78 +1,68 @@ package dev.vality.wachter.utils; import dev.vality.wachter.exceptions.DeadlineException; +import jakarta.annotation.Nullable; import lombok.experimental.UtilityClass; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.regex.Matcher; import java.util.regex.Pattern; @UtilityClass @SuppressWarnings("ParameterName") public class DeadlineUtil { - private static final String FLOATING_NUMBER_REGEXP = "[0-9]+([.][0-9]+)?"; - private static final String MIN_REGEXP = "(?!ms)[m]"; - private static final String SEC_REGEXP = "[s]"; - private static final String MILLISECOND_REGEXP = "[m][s]"; + public static void checkDeadline(@Nullable String xRequestDeadline, String xRequestId) { + if (xRequestDeadline == null) { + return; + } + if (containsRelativeValues(xRequestDeadline, xRequestId)) { + return; + } + try { + Instant instant = Instant.parse(xRequestDeadline); + if (Instant.now().isAfter(instant)) { + throw new DeadlineException(String.format("Deadline has expired, xRequestId=%s ", xRequestId)); + } + } catch (Exception e) { + throw new DeadlineException( + String.format("Deadline has invalid 'Instant' format, xRequestId=%s ", xRequestId)); + } + } public static boolean containsRelativeValues(String xRequestDeadline, String xRequestId) { - return extractMinutes(xRequestDeadline, xRequestId) + extractSeconds(xRequestDeadline, xRequestId) + - extractMilliseconds(xRequestDeadline, xRequestId) > 0; + return (extractMinutes(xRequestDeadline, xRequestId) + extractSeconds(xRequestDeadline, xRequestId) + + extractMilliseconds(xRequestDeadline, xRequestId)) > 0; } public static Long extractMinutes(String xRequestDeadline, String xRequestId) { - String format = "minutes"; + var format = "minutes"; - checkNegativeValues( - xRequestDeadline, - xRequestId, - "([-]" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")", - format); + checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?(?!ms)[m])", format); - Double minutes = extractValue( - xRequestDeadline, - "(" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")", - xRequestId, - format); + var minutes = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?(?!ms)[m])", xRequestId, format); return Optional.ofNullable(minutes).map(min -> min * 60000.0).map(Double::longValue).orElse(0L); } public static Long extractSeconds(String xRequestDeadline, String xRequestId) { - String format = "seconds"; + var format = "seconds"; - checkNegativeValues( - xRequestDeadline, - xRequestId, - "([-]" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")", - format); + checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?[s])", format); - Double seconds = extractValue( - xRequestDeadline, - "(" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")", - xRequestId, - format); + var seconds = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?[s])", xRequestId, format); return Optional.ofNullable(seconds).map(s -> s * 1000.0).map(Double::longValue).orElse(0L); } public static Long extractMilliseconds(String xRequestDeadline, String xRequestId) { - String format = "milliseconds"; + var format = "milliseconds"; - checkNegativeValues( - xRequestDeadline, - xRequestId, - "([-]" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")", - format); + checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?[m][s])", format); - Double milliseconds = extractValue( - xRequestDeadline, - "(" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")", - xRequestId, - format); + var milliseconds = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?[m][s])", xRequestId, format); if (milliseconds != null && Math.ceil(milliseconds % 1) > 0) { throw new DeadlineException( @@ -91,9 +81,9 @@ private static void checkNegativeValues(String xRequestDeadline, String xRequest } private static Double extractValue(String xRequestDeadline, String formatRegex, String xRequestId, String format) { - String numberRegex = "(" + FLOATING_NUMBER_REGEXP + ")"; + var numberRegex = "([0-9]+([.][0-9]+)?)"; - List doubles = new ArrayList<>(); + var doubles = new ArrayList(); for (String string : match(formatRegex, xRequestDeadline)) { doubles.addAll(match(numberRegex, string)); } @@ -109,9 +99,9 @@ private static Double extractValue(String xRequestDeadline, String formatRegex, } private static List match(String regex, String data) { - Pattern pattern = Pattern.compile(regex); - Matcher matcher = pattern.matcher(data); - List strings = new ArrayList<>(); + var pattern = Pattern.compile(regex); + var matcher = pattern.matcher(data); + var strings = new ArrayList(); while (matcher.find()) { strings.add(matcher.group()); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7bd9fad..f63f0ce 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -100,3 +100,8 @@ http-client: defaultMaxPerRoute: 200 auth.enabled: true + +otel: + resource: http://localhost:4318/v1/traces + timeout: 60000 + enabled: true diff --git a/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java b/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java index 56d3b17..e39908c 100644 --- a/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java +++ b/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java @@ -64,10 +64,10 @@ public KeycloakOpenIdStub(String keycloakAuthServerUrl, String keycloakRealm, Jw } """.formatted( PublicKeyUtil.getExponent(jwtTokenBuilder.getPublicKey()), - PublicKeyUtil.getModulus(jwtTokenBuilder.getPublicKey()), + PublicKeyUtil.getModulus(jwtTokenBuilder.getPublicKey()), Base64.getEncoder().encodeToString( GenerateSelfSigned.generateCertificate(new KeyPair(jwtTokenBuilder.getPublicKey(), - jwtTokenBuilder.getPrivateKey())).getEncoded())); + jwtTokenBuilder.getPrivateKey())).getEncoded())); } public void givenStub() { diff --git a/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java b/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java new file mode 100644 index 0000000..d162fca --- /dev/null +++ b/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java @@ -0,0 +1,24 @@ +package dev.vality.wachter.client; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class WachterClientExtractTest { + + @Test + public void shouldFormatAndTruncateParameters() { + final var request = new MockHttpServletRequest(); + request.addParameter("short", "value"); + request.addParameter("long", "12345678901234567890123456789012345"); + request.addParameter("multi", "first"); + request.addParameter("multi", "second"); + + final var factory = new WachterRequestFactory(); + String result = factory.extract(request); + + Assertions.assertEquals( + "short=value, long=12345678901234567890123456789012345, multi=first, second", + result); + } +} diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java new file mode 100644 index 0000000..a02a174 --- /dev/null +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -0,0 +1,113 @@ +package dev.vality.wachter.client; + +import dev.vality.wachter.constants.RequestAttributeNames; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +import static dev.vality.wachter.constants.HeadersConstants.WOODY_TRACE_ID; +import static dev.vality.wachter.constants.HeadersConstants.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.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class WachterClientOperationsTest { + + @Test + void shouldSendRequestWithMergedHeaders() { + final var builder = RestClient.builder(); + final var server = MockRestServiceServer.bindTo(builder).build(); + final var restClient = builder.build(); + + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setMethod("POST"); + servletRequest.addHeader("X-Custom", "custom-value"); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, + Map.of(WOODY_TRACE_ID, "normalized-trace")); + final var payload = "payload".getBytes(); + final var expectedResponse = "response".getBytes(); + + server.expect(requestTo("http://upstream")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-Custom", "custom-value")) + .andExpect(header(WOODY_TRACE_ID, "normalized-trace")) + .andExpect(header(X_WOODY_TRACE_ID, "normalized-trace")) + .andExpect(content().bytes(payload)) + .andRespond(withSuccess(expectedResponse, MediaType.APPLICATION_OCTET_STREAM)); + + final var factory = new WachterRequestFactory(); + final var client = new WachterClient(restClient, factory); + + final var actualResponse = client.send(servletRequest, payload, "http://upstream"); + + assertEquals(HttpStatus.OK, actualResponse.statusCode()); + assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, + actualResponse.headers().getFirst(HttpHeaders.CONTENT_TYPE)); + assertArrayEquals(expectedResponse, actualResponse.body()); + server.verify(); + } + + @Test + void shouldHandleGetRequestWithoutBody() { + final var builder = RestClient.builder(); + final var server = MockRestServiceServer.bindTo(builder).build(); + final var restClient = builder.build(); + + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setMethod("GET"); + servletRequest.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); + + server.expect(requestTo("http://upstream/resource")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("Accept", MediaType.APPLICATION_JSON_VALUE)) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + final var factory = new WachterRequestFactory(); + final var client = new WachterClient(restClient, factory); + + final var response = client.send(servletRequest, null, "http://upstream/resource"); + + assertEquals(HttpStatus.OK, response.statusCode()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, response.headers().getFirst(HttpHeaders.CONTENT_TYPE)); + assertArrayEquals("{}".getBytes(), response.body()); + server.verify(); + } + + @Test + void shouldReturnErrorResponseWithoutThrowing() { + final var builder = RestClient.builder(); + final var server = MockRestServiceServer.bindTo(builder).build(); + final var restClient = builder.build(); + + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setMethod("POST"); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); + final var payload = "payload".getBytes(); + + server.expect(requestTo("http://upstream/fail")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.BAD_GATEWAY) + .body("bad-gateway") + .contentType(MediaType.TEXT_PLAIN)); + + final var factory = new WachterRequestFactory(); + final var client = new WachterClient(restClient, factory); + + final var response = client.send(servletRequest, payload, "http://upstream/fail"); + + assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode()); + assertEquals(MediaType.TEXT_PLAIN_VALUE, response.headers().getFirst(HttpHeaders.CONTENT_TYPE)); + assertArrayEquals("bad-gateway".getBytes(), response.body()); + server.verify(); + } +} diff --git a/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java b/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java new file mode 100644 index 0000000..016bbfa --- /dev/null +++ b/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java @@ -0,0 +1,182 @@ +package dev.vality.wachter.client; + +import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +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.Instant; +import java.util.HashMap; +import java.util.Map; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; +import static dev.vality.woody.api.trace.ContextUtils.setDeadline; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WachterRequestFactoryTest { + + @AfterEach + void tearDown() { + TraceContext.setCurrentTraceData(null); + SecurityContextHolder.clearContext(); + } + + @Test + void shouldOverlayTraceContextHeadersAndMirrorLegacyVariants() { + final var servletRequest = new MockHttpServletRequest(); + final var normalizedHeaders = new HashMap<>(Map.of( + OTEL_TRACE_PARENT, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + )); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, normalizedHeaders); + + final var traceData = new TraceData(); + TraceContext.setCurrentTraceData(traceData); + final var serviceSpan = traceData.getServiceSpan().getSpan(); + serviceSpan.setTraceId("trace-id"); + serviceSpan.setId("span-id"); + serviceSpan.setParentId("parent-id"); + setDeadline(traceData.getServiceSpan(), Instant.parse("2030-01-01T00:00:00Z")); + setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, "ctx-id"); + setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, "ctx-username"); + setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, "ctx@example.com"); + setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, "/realm"); + + final var factory = new WachterRequestFactory(); + HttpHeaders headers = factory.buildHeaders(servletRequest); + + assertEquals("trace-id", headers.getFirst(WOODY_TRACE_ID)); + assertEquals("trace-id", headers.getFirst(X_WOODY_TRACE_ID)); + assertEquals("span-id", headers.getFirst(WOODY_SPAN_ID)); + assertEquals("span-id", headers.getFirst(X_WOODY_SPAN_ID)); + assertEquals("parent-id", headers.getFirst(WOODY_PARENT_ID)); + assertEquals("parent-id", headers.getFirst(X_WOODY_PARENT_ID)); + assertEquals("2030-01-01T00:00:00Z", headers.getFirst(WOODY_DEADLINE)); + assertEquals("2030-01-01T00:00:00Z", headers.getFirst(X_WOODY_DEADLINE)); + final var idKey = WOODY_META_USER_IDENTITY_PREFIX + "id"; + final var mirroredIdKey = X_WOODY_META_USER_IDENTITY_PREFIX + "id"; + assertEquals("ctx-id", headers.getFirst(idKey)); + assertEquals("ctx-id", headers.getFirst(mirroredIdKey)); + + final var usernameKey = WOODY_META_USER_IDENTITY_PREFIX + "username"; + final var mirroredUsernameKey = X_WOODY_META_USER_IDENTITY_PREFIX + "username"; + assertEquals("ctx-username", headers.getFirst(usernameKey)); + assertEquals("ctx-username", headers.getFirst(mirroredUsernameKey)); + + final var emailKey = WOODY_META_USER_IDENTITY_PREFIX + "email"; + final var mirroredEmailKey = X_WOODY_META_USER_IDENTITY_PREFIX + "email"; + assertEquals("ctx@example.com", headers.getFirst(emailKey)); + assertEquals("ctx@example.com", headers.getFirst(mirroredEmailKey)); + + final var realmKey = WOODY_META_USER_IDENTITY_PREFIX + "realm"; + final var mirroredRealmKey = X_WOODY_META_USER_IDENTITY_PREFIX + "realm"; + assertEquals("/realm", headers.getFirst(realmKey)); + assertEquals("/realm", headers.getFirst(mirroredRealmKey)); + } + + @Test + void shouldFallbackToJwtDetailsWhenTraceContextMissingIdentity() { + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); + + TraceContext.setCurrentTraceData(new TraceData()); + + final var jwtHeaders = new HashMap(); + jwtHeaders.put("alg", "none"); + final var jwtClaims = new HashMap(); + jwtClaims.put("sub", "jwt-subject"); + jwtClaims.put("preferred_username", "jwt-username"); + jwtClaims.put("email", "jwt@example.com"); + jwtClaims.put("iss", "http://issuer/tenant"); + final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + final var factory = new WachterRequestFactory(); + HttpHeaders headers = factory.buildHeaders(servletRequest); + + assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-subject", headers.getFirst(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + } + + @Test + void shouldPopulateHeadersFromJwtWhenTraceContextAbsent() { + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); + + final var jwtHeaders = new HashMap(); + jwtHeaders.put("alg", "none"); + final var jwtClaims = new HashMap(); + jwtClaims.put("sub", "jwt-subject"); + jwtClaims.put("preferred_username", "jwt-username"); + jwtClaims.put("email", "jwt@example.com"); + jwtClaims.put("iss", "http://issuer/tenant"); + final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + final var factory = new WachterRequestFactory(); + HttpHeaders headers = factory.buildHeaders(servletRequest); + + assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("jwt-subject", headers.getFirst(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); + } + + @Test + void shouldPreferJwtValuesWhenTraceContextMetadataBlank() { + final var servletRequest = new MockHttpServletRequest(); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); + + final var traceData = new TraceData(); + TraceContext.setCurrentTraceData(traceData); + setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, " "); + setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, ""); + setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, " "); + + final var jwtHeaders = new HashMap(); + jwtHeaders.put("alg", "none"); + final var jwtClaims = new HashMap(); + jwtClaims.put("sub", "jwt-subject"); + jwtClaims.put("preferred_username", "jwt-username"); + jwtClaims.put("email", "jwt@example.com"); + jwtClaims.put("iss", "http://issuer/tenant"); + final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + final var factory = new WachterRequestFactory(); + HttpHeaders headers = factory.buildHeaders(servletRequest); + + assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + } + + @Test + void shouldMirrorCanonicalHeadersWhenOnlyAlternatePresent() { + final var servletRequest = new MockHttpServletRequest(); + servletRequest.addHeader(X_WOODY_TRACE_ID, "incoming-x-trace"); + servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, "unexpected"); + + final var factory = new WachterRequestFactory(); + HttpHeaders headers = factory.buildHeaders(servletRequest); + + assertEquals("incoming-x-trace", headers.getFirst(X_WOODY_TRACE_ID)); + assertEquals("incoming-x-trace", headers.getFirst(WOODY_TRACE_ID)); + } +} diff --git a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java index 99e5350..77020e0 100644 --- a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java +++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java @@ -41,7 +41,8 @@ protected String generateSimpleJwtWithRoles() { } protected String generateSimpleJwtWithRolesAndCustomKey(PrivateKey privateKey) { - return keycloakOpenIdStub.generateJwtWithCustomKey(privateKey, "Deanonimus", "unknown", "Domain", "messages:methodName", + return keycloakOpenIdStub.generateJwtWithCustomKey(privateKey, "Deanonimus", "unknown", "Domain", + "messages:methodName", "DominantCache", "!DominantCache:methodName"); } diff --git a/src/test/java/dev/vality/wachter/config/WebConfigTest.java b/src/test/java/dev/vality/wachter/config/WebConfigTest.java new file mode 100644 index 0000000..a5ad129 --- /dev/null +++ b/src/test/java/dev/vality/wachter/config/WebConfigTest.java @@ -0,0 +1,425 @@ +package dev.vality.wachter.config; + +import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +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.time.Instant; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_STATUS_CODE; +import static org.junit.jupiter.api.Assertions.*; + +class WebConfigTest { + + private final WebConfig webConfig = new WebConfig(); + + private static final Locale LOCALE = Locale.ROOT; + + private static final String USER_ID_SUFFIX = "id"; + private static final String USER_USERNAME_SUFFIX = "username"; + private static final String USER_EMAIL_SUFFIX = "email"; + private static final String USER_REALM_SUFFIX = "realm"; + + private static final String WOODY_USER_ID_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX; + private static final String WOODY_USER_USERNAME_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_USERNAME_SUFFIX; + private static final String WOODY_USER_EMAIL_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_EMAIL_SUFFIX; + private static final String WOODY_USER_REALM_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_REALM_SUFFIX; + + private static final String HEADER_X_WOODY_TRACE_ID = X_WOODY_TRACE_ID.toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_SPAN_ID = X_WOODY_SPAN_ID.toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_PARENT_ID = X_WOODY_PARENT_ID.toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_DEADLINE = X_WOODY_DEADLINE.toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_META_USER_IDENTITY_ID = + (X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX).toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_META_USER_IDENTITY_USERNAME = + (X_WOODY_META_USER_IDENTITY_PREFIX + USER_USERNAME_SUFFIX).toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_META_USER_IDENTITY_EMAIL = + (X_WOODY_META_USER_IDENTITY_PREFIX + USER_EMAIL_SUFFIX).toUpperCase(LOCALE); + private static final String HEADER_X_WOODY_META_USER_IDENTITY_REALM = + (X_WOODY_META_USER_IDENTITY_PREFIX + USER_REALM_SUFFIX).toUpperCase(LOCALE); + private static final String HEADER_TRACEPARENT = OTEL_TRACE_PARENT.toUpperCase(LOCALE); + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TraceContext.setCurrentTraceData(null); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void shouldNormalizeXWoodyHeadersAndTraceparent() { + final var request = new MockHttpServletRequest(); + final var traceId = "trace"; + final var spanId = "span"; + final var parentId = "parent"; + final var deadline = "2030-01-01T00:00:00Z"; + final var userId = "meta-id"; + final var userName = "meta-user"; + final var userEmail = "meta@example.com"; + final var userRealm = "meta-realm"; + final var traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-abcdef1234567890-01"; + + request.addHeader(HEADER_X_WOODY_TRACE_ID, traceId); + request.addHeader(HEADER_X_WOODY_SPAN_ID, spanId); + request.addHeader(HEADER_X_WOODY_PARENT_ID, parentId); + request.addHeader(HEADER_X_WOODY_DEADLINE, deadline); + request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_ID, userId); + request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_USERNAME, userName); + request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_EMAIL, userEmail); + request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_REALM, userRealm); + request.addHeader(HEADER_TRACEPARENT, traceparent); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(traceId, normalized.get(WOODY_TRACE_ID)); + assertEquals(spanId, normalized.get(WOODY_SPAN_ID)); + assertEquals(parentId, normalized.get(WOODY_PARENT_ID)); + assertEquals(deadline, normalized.get(WOODY_DEADLINE)); + assertEquals(userId, normalized.get(WOODY_USER_ID_KEY)); + assertEquals(userName, normalized.get(WOODY_USER_USERNAME_KEY)); + assertEquals(userEmail, normalized.get(WOODY_USER_EMAIL_KEY)); + assertEquals(userRealm, normalized.get(WOODY_USER_REALM_KEY)); + assertEquals(traceparent, normalized.get(OTEL_TRACE_PARENT)); + + assertThrows(UnsupportedOperationException.class, () -> normalized.put("woody.new", "value")); + } + + @Test + void shouldPreferWoodyHeadersOverPrefixedVariants() { + final var request = new MockHttpServletRequest(); + final var legacyTraceId = "legacy-trace"; + final var legacySpanId = "legacy-span"; + final var legacyParentId = "legacy-parent"; + final var legacyUserId = "legacy-meta"; + + final var primaryTraceId = "primary-trace"; + final var primarySpanId = "primary-span"; + final var primaryParentId = "primary-parent"; + final var primaryUserId = "primary-meta"; + + request.addHeader(X_WOODY_TRACE_ID, legacyTraceId); + request.addHeader(X_WOODY_SPAN_ID, legacySpanId); + request.addHeader(X_WOODY_PARENT_ID, legacyParentId); + request.addHeader(X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX, legacyUserId); + + request.addHeader("Woody.Trace-Id", primaryTraceId); + request.addHeader("WOODY.SPAN-ID", primarySpanId); + request.addHeader("WOODY.PARENT-ID", primaryParentId); + request.addHeader(WOODY_USER_ID_KEY, primaryUserId); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(primaryTraceId, normalized.get(WOODY_TRACE_ID)); + assertEquals(primarySpanId, normalized.get(WOODY_SPAN_ID)); + assertEquals(primaryParentId, normalized.get(WOODY_PARENT_ID)); + assertEquals(primaryUserId, normalized.get(WOODY_USER_ID_KEY)); + } + + @Test + void shouldMergeJwtClaimsAndOverrideUserIdentityHeaders() { + final var request = new MockHttpServletRequest(); + final var headerUserId = "header-id"; + final var jwtUserId = "jwt-id"; + final var jwtUsername = "jwt-username"; + final var jwtEmail = "jwt@example.com"; + final var issuer = "http://issuer/realm"; + final var realm = "/realm"; + + request.addHeader(X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX, headerUserId); + + final var headers = new HashMap(); + headers.put("alg", "none"); + final var claims = new HashMap(); + claims.put(JwtClaimNames.SUB, jwtUserId); + claims.put("preferred_username", jwtUsername); + claims.put("email", jwtEmail); + claims.put("iss", issuer); + final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), headers, claims); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(jwtUserId, normalized.get(WOODY_USER_ID_KEY)); + assertEquals(jwtUsername, normalized.get(WOODY_USER_USERNAME_KEY)); + assertEquals(jwtEmail, normalized.get(WOODY_USER_EMAIL_KEY)); + assertEquals(realm, normalized.get(WOODY_USER_REALM_KEY)); + } + + @Test + void shouldConvertRequestDeadlineWhenWoodyDeadlineMissing() { + final var request = new MockHttpServletRequest(); + final var deadline = Instant.parse("2035-06-01T12:30:00Z"); + request.addHeader(X_REQUEST_DEADLINE, deadline.toString()); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(deadline.toString(), normalized.get(WOODY_DEADLINE)); + } + + @Test + void shouldNotOverrideExistingWoodyDeadlineWithRequestHeader() { + final var request = new MockHttpServletRequest(); + final var existingDeadline = "2035-06-01T12:30:00Z"; + final var requestDeadline = "2035-07-01T12:30:00Z"; + + request.addHeader(WOODY_DEADLINE, existingDeadline); + request.addHeader(X_REQUEST_DEADLINE, requestDeadline); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(existingDeadline, normalized.get(WOODY_DEADLINE)); + } + + @Test + void shouldHandlePartialWoodyHeaders() { + final var request = new MockHttpServletRequest(); + final var onlySpanId = "only-span"; + + request.addHeader(X_WOODY_SPAN_ID, onlySpanId); + request.addHeader("random-header", "value"); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertEquals(onlySpanId, normalized.get(WOODY_SPAN_ID)); + assertFalse(normalized.containsKey(WOODY_TRACE_ID)); + assertEquals(1, normalized.size()); + } + + @Test + void shouldReturnEmptyMapWhenHeadersAbsent() { + final var request = new MockHttpServletRequest(); + + final Map normalized = webConfig.normalizeWoodyHeaders(request); + + assertTrue(normalized.isEmpty()); + } + + @Test + void shouldApplyWoodyHeadersToTraceContext() { + TraceContext.setCurrentTraceData(new TraceData()); + + final var traceId = "trace"; + final var spanId = "span"; + final var parentId = "parent"; + final var deadline = "2040-12-12T10:15:30Z"; + final var userId = "identity-id"; + final var username = "identity-username"; + final var email = "identity@example.com"; + final var realm = "identity-realm"; + + final var headers = new HashMap(); + headers.put(WOODY_TRACE_ID, traceId); + headers.put(WOODY_SPAN_ID, spanId); + headers.put(WOODY_PARENT_ID, parentId); + headers.put(WOODY_DEADLINE, deadline); + headers.put(WOODY_USER_ID_KEY, userId); + headers.put(WOODY_USER_USERNAME_KEY, username); + headers.put(WOODY_USER_EMAIL_KEY, email); + headers.put(WOODY_USER_REALM_KEY, realm); + + webConfig.applyWoodyHeadersToTraceContext(headers); + + final var traceData = TraceContext.getCurrentTraceData(); + final var serviceSpan = traceData.getServiceSpan().getSpan(); + assertEquals(traceId, serviceSpan.getTraceId()); + assertEquals(spanId, serviceSpan.getId()); + assertEquals(parentId, serviceSpan.getParentId()); + assertEquals(Instant.parse(deadline), serviceSpan.getDeadline()); + assertEquals(userId, getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); + assertEquals(username, getCustomMetadataValue(String.class, UserIdentityUsernameExtensionKit.KEY)); + assertEquals(email, getCustomMetadataValue(String.class, UserIdentityEmailExtensionKit.KEY)); + assertEquals(realm, getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY)); + } + + @Test + void shouldSkipApplyingContextWhenHeadersEmpty() { + TraceContext.setCurrentTraceData(new TraceData()); + + webConfig.applyWoodyHeadersToTraceContext(Map.of()); + + final var traceData = TraceContext.getCurrentTraceData(); + final var serviceSpan = traceData.getServiceSpan().getSpan(); + assertNull(serviceSpan.getTraceId()); + assertNull(serviceSpan.getId()); + assertNull(serviceSpan.getParentId()); + assertNull(getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); + } + + @Test + void shouldGenerateTraceparentWhenMissing() throws Exception { + final var tracerProvider = SdkTracerProvider.builder().build(); + final var openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + GlobalOpenTelemetry.resetForTest(); + GlobalOpenTelemetry.set(openTelemetry); + + try { + final var filter = webConfig.woodyFilter().getFilter(); + final var request = new MockHttpServletRequest("GET", "/wachter"); + final var response = new MockHttpServletResponse(); + final var chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + @SuppressWarnings("unchecked") final var normalized = (Map) request.getAttribute( + RequestAttributeNames.NORMALIZED_WOODY_HEADERS); + assertNotNull(normalized); + assertTrue(normalized.containsKey(OTEL_TRACE_PARENT)); + assertFalse(normalized.get(OTEL_TRACE_PARENT).isBlank()); + } finally { + tracerProvider.close(); + } + } + + @Test + void shouldPreserveExistingTraceparent() throws Exception { + final var tracerProvider = SdkTracerProvider.builder().build(); + final var openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + GlobalOpenTelemetry.resetForTest(); + GlobalOpenTelemetry.set(openTelemetry); + + final var existingTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; + + try { + final var filter = webConfig.woodyFilter().getFilter(); + final var request = new MockHttpServletRequest("POST", "/wachter"); + final var response = new MockHttpServletResponse(); + request.addHeader(HEADER_TRACEPARENT, existingTraceparent); + + filter.doFilter(request, response, new MockFilterChain()); + + @SuppressWarnings("unchecked") final var normalized = (Map) request.getAttribute( + RequestAttributeNames.NORMALIZED_WOODY_HEADERS); + assertNotNull(normalized); + assertEquals(existingTraceparent, normalized.get(OTEL_TRACE_PARENT)); + } finally { + tracerProvider.close(); + } + } + + @Test + void shouldMarkSpanAsErrorForServerErrorResponse() throws Exception { + final var exporter = new CapturingSpanExporter(); + final var tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + final var openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + GlobalOpenTelemetry.resetForTest(); + GlobalOpenTelemetry.set(openTelemetry); + + try { + final var filter = webConfig.woodyFilter().getFilter(); + final var request = new MockHttpServletRequest("GET", "/wachter"); + final var response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + ((MockHttpServletResponse) servletResponse).setStatus(503); + }); + + final var spans = exporter.getSpans(); + assertEquals(1, spans.size()); + final var span = spans.getFirst(); + assertEquals(StatusCode.ERROR, span.getStatus().getStatusCode()); + assertEquals(503L, span.getAttributes().get(HTTP_STATUS_CODE)); + } finally { + tracerProvider.close(); + } + } + + @Test + void shouldRecordExceptionWhenFilterChainThrows() throws Exception { + final var exporter = new CapturingSpanExporter(); + final var tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + final var openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + GlobalOpenTelemetry.resetForTest(); + GlobalOpenTelemetry.set(openTelemetry); + + try { + final var filter = webConfig.woodyFilter().getFilter(); + final var request = new MockHttpServletRequest("GET", "/wachter"); + final var response = new MockHttpServletResponse(); + + assertThrows(ServletException.class, () -> filter.doFilter(request, response, + (servletRequest, servletResponse) -> { + ((MockHttpServletResponse) servletResponse).setStatus(500); + throw new ServletException("boom"); + })); + + final var spans = exporter.getSpans(); + assertEquals(1, spans.size()); + final var span = spans.getFirst(); + assertEquals(StatusCode.ERROR, span.getStatus().getStatusCode()); + assertEquals(500L, span.getAttributes().get(HTTP_STATUS_CODE)); + assertTrue(span.getEvents().stream().anyMatch(event -> "exception".equals(event.getName()))); + } finally { + tracerProvider.close(); + } + } + + private static final class CapturingSpanExporter implements SpanExporter { + + private final List spans = new CopyOnWriteArrayList<>(); + + @Override + public CompletableResultCode export(Collection spans) { + this.spans.addAll(spans); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + spans.clear(); + return CompletableResultCode.ofSuccess(); + } + + List getSpans() { + return spans; + } + } +} diff --git a/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java index 40fffeb..1e1a700 100644 --- a/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java @@ -1,11 +1,11 @@ package dev.vality.wachter.controller; +import dev.vality.wachter.client.WachterClient; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.exceptions.AuthorizationException; import dev.vality.wachter.exceptions.WachterException; import dev.vality.wachter.testutil.TMessageUtil; import lombok.SneakyThrows; -import org.apache.http.client.HttpClient; import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -22,18 +22,18 @@ import java.util.Objects; import static java.util.UUID.randomUUID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @TestPropertySource(properties = {"auth.enabled=true"}) +@SuppressWarnings("LineLength") class ErrorControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig { @MockitoBean - private HttpClient httpClient; + private WachterClient wachterClient; @Autowired private MockMvc mvc; @@ -49,7 +49,7 @@ class ErrorControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig { @BeforeEach public void init() { mocks = MockitoAnnotations.openMocks(this); - preparedMocks = new Object[]{httpClient}; + preparedMocks = new Object[] {wachterClient}; } @AfterEach @@ -61,6 +61,7 @@ public void clean() throws Exception { @Test @SneakyThrows void requestAccessDenied() { + final var expected = "User darkside-the-best@mail.com don't have roles"; mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithoutRoles()) .header("Service", "messages") @@ -70,9 +71,8 @@ void requestAccessDenied() { .andDo(print()) .andExpect(status().is4xxClientError()) .andExpect(result -> assertInstanceOf(AuthorizationException.class, result.getResolvedException())) - .andExpect(result -> assertEquals("User darkside-the-best@mail.com don't " + - "have roles with trace_id null", - Objects.requireNonNull(result.getResolvedException()).getMessage())); + .andExpect(result -> assertTrue( + Objects.requireNonNull(result.getResolvedException()).getMessage().contains(expected))); } @Test @@ -128,7 +128,7 @@ void requestWithUnknownRoles() { void requestWithBadSignToken() { KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); keyGen.initialize(2048); - var keyPair = keyGen.generateKeyPair(); + final var keyPair = keyGen.generateKeyPair(); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + @@ -144,6 +144,8 @@ void requestWithBadSignToken() { @Test @SneakyThrows void requestWithForbiddenMethod() { + final var expected = + "User darkside-the-best@mail.com don't have access to methodName in service DominantCache"; mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("X-Request-ID", randomUUID()) @@ -153,8 +155,7 @@ void requestWithForbiddenMethod() { .andDo(print()) .andExpect(status().is4xxClientError()) .andExpect(result -> assertInstanceOf(AuthorizationException.class, result.getResolvedException())) - .andExpect(result -> assertEquals("User darkside-the-best@mail.com don't have access" + - " to methodName in service DominantCache with trace_id null", - Objects.requireNonNull(result.getResolvedException()).getMessage())); + .andExpect(result -> assertTrue( + Objects.requireNonNull(result.getResolvedException()).getMessage().contains(expected))); } } diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java index 54f518c..d05ac67 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java @@ -1,20 +1,18 @@ package dev.vality.wachter.controller; -import dev.vality.wachter.client.WachterResponseHandler; +import dev.vality.wachter.client.WachterClient; +import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; import lombok.SneakyThrows; -import org.apache.http.HttpResponse; -import org.apache.http.ProtocolVersion; -import org.apache.http.client.HttpClient; -import org.apache.http.entity.StringEntity; -import org.apache.http.message.BasicStatusLine; import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockitoAnnotations; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -33,12 +31,7 @@ class WachterControllerDisabledAuthTest extends AbstractKeycloakOpenIdAsWiremockConfig { @MockitoBean - private HttpClient httpClient; - @MockitoBean - private HttpResponse httpResponse; - - @Autowired - private WachterResponseHandler responseHandler; + private WachterClient wachterClient; @Autowired private MockMvc mvc; @@ -54,7 +47,7 @@ class WachterControllerDisabledAuthTest extends AbstractKeycloakOpenIdAsWiremock @BeforeEach public void init() { mocks = MockitoAnnotations.openMocks(this); - preparedMocks = new Object[]{httpClient}; + preparedMocks = new Object[] {wachterClient}; } @AfterEach @@ -66,9 +59,8 @@ public void clean() throws Exception { @Test @SneakyThrows void requestSuccess() { - when(httpResponse.getEntity()).thenReturn(new StringEntity("")); - when(httpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("", 0, 0), 200, "")); - when(httpClient.execute(any(), eq(responseHandler))).thenReturn(new byte[0]); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithoutRoles()) .header("Service", "messages") @@ -77,7 +69,7 @@ void requestSuccess() { .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); - verify(httpClient, times(1)).execute(any(), eq(responseHandler)); + verify(wachterClient, times(1)).send(any(), any(), any()); } } diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java index dc3e601..b85e01c 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java @@ -1,20 +1,18 @@ package dev.vality.wachter.controller; -import dev.vality.wachter.client.WachterResponseHandler; +import dev.vality.wachter.client.WachterClient; +import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; import lombok.SneakyThrows; -import org.apache.http.HttpResponse; -import org.apache.http.ProtocolVersion; -import org.apache.http.client.HttpClient; -import org.apache.http.entity.StringEntity; -import org.apache.http.message.BasicStatusLine; import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockitoAnnotations; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -26,17 +24,12 @@ import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; class WachterControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig { @MockitoBean - private HttpClient httpClient; - @MockitoBean - private HttpResponse httpResponse; - - @Autowired - private WachterResponseHandler responseHandler; + private WachterClient wachterClient; @Autowired private MockMvc mvc; @@ -52,7 +45,7 @@ class WachterControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig { @BeforeEach public void init() { mocks = MockitoAnnotations.openMocks(this); - preparedMocks = new Object[]{httpClient}; + preparedMocks = new Object[] {wachterClient}; } @AfterEach @@ -64,9 +57,8 @@ public void clean() throws Exception { @Test @SneakyThrows void requestSuccessWithServiceRole() { - when(httpResponse.getEntity()).thenReturn(new StringEntity("")); - when(httpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("", 0, 0), 200, "")); - when(httpClient.execute(any(), eq(responseHandler))).thenReturn(new byte[0]); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") @@ -75,15 +67,14 @@ void requestSuccessWithServiceRole() { .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); - verify(httpClient, times(1)).execute(any(), eq(responseHandler)); + verify(wachterClient, times(1)).send(any(), any(), any()); } @Test @SneakyThrows void requestSuccessWithMethodRole() { - when(httpResponse.getEntity()).thenReturn(new StringEntity("")); - when(httpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("", 0, 0), 200, "")); - when(httpClient.execute(any(), eq(responseHandler))).thenReturn(new byte[0]); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "messages") @@ -92,15 +83,14 @@ void requestSuccessWithMethodRole() { .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); - verify(httpClient, times(1)).execute(any(), eq(responseHandler)); + verify(wachterClient, times(1)).send(any(), any(), any()); } @Test @SneakyThrows void requestSuccessWithWoodyHeaders() { - when(httpResponse.getEntity()).thenReturn(new StringEntity("")); - when(httpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("", 0, 0), 200, "")); - when(httpClient.execute(any(), eq(responseHandler))).thenReturn(new byte[0]); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") @@ -113,15 +103,14 @@ void requestSuccessWithWoodyHeaders() { .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); - verify(httpClient, times(1)).execute(any(), eq(responseHandler)); + verify(wachterClient, times(1)).send(any(), any(), any()); } @Test @SneakyThrows void requestSuccessWithWoodyWithDashHeaders() { - when(httpResponse.getEntity()).thenReturn(new StringEntity("")); - when(httpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("", 0, 0), 200, "")); - when(httpClient.execute(any(), eq(responseHandler))).thenReturn(new byte[0]); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") @@ -134,6 +123,28 @@ void requestSuccessWithWoodyWithDashHeaders() { .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); - verify(httpClient, times(1)).execute(any(), eq(responseHandler)); + verify(wachterClient, times(1)).send(any(), any(), any()); + } + + @Test + @SneakyThrows + void shouldPropagateUpstreamError() { + final var headers = new HttpHeaders(); + headers.set("X-Upstream", "value"); + final var body = "failure".getBytes(); + when(wachterClient.send(any(), any(), any())) + .thenReturn(new WachterClientResponse(HttpStatus.BAD_GATEWAY, headers, body)); + + mvc.perform(post("/wachter") + .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) + .header("Service", "Domain") + .header("X-Request-ID", randomUUID()) + .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .content(TMessageUtil.createTMessage(protocolFactory))) + .andDo(print()) + .andExpect(status().isBadGateway()) + .andExpect(header().string("X-Upstream", "value")) + .andExpect(content().bytes(body)); + verify(wachterClient, times(1)).send(any(), any(), any()); } } diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java new file mode 100644 index 0000000..ef69ee2 --- /dev/null +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -0,0 +1,158 @@ +package dev.vality.wachter.integration; + +import com.github.tomakehurst.wiremock.client.WireMock; +import dev.vality.wachter.auth.utils.JwtTokenBuilder; +import dev.vality.wachter.client.WachterRequestFactory; +import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; +import dev.vality.wachter.constants.HeadersConstants; +import dev.vality.wachter.testutil.TMessageUtil; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import org.apache.thrift.protocol.TProtocolFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@TestPropertySource(properties = { + "wachter.services.Domain.url=http://localhost:${wiremock.server.port}/domain" +}) +class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { + + private static final String USER_ID_HEADER = WOODY_META_USER_IDENTITY_PREFIX + + HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityIdExtensionKit.KEY); + private static final String USER_NAME_HEADER = WOODY_META_USER_IDENTITY_PREFIX + + HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityUsernameExtensionKit.KEY); + private static final String USER_EMAIL_HEADER = WOODY_META_USER_IDENTITY_PREFIX + + HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityEmailExtensionKit.KEY); + private static final String USER_REALM_HEADER = WOODY_META_USER_IDENTITY_PREFIX + + HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityRealmExtensionKit.KEY); + private static final String TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]"; + private static final String EXPECTED_REALM = "/internal"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TProtocolFactory protocolFactory; + + @MockitoSpyBean + private WachterRequestFactory requestFactory; + + @AfterEach + void tearDown() { + TraceContext.setCurrentTraceData(null); + Mockito.reset(requestFactory); + resetAllRequests(); + } + + @Test + void shouldProxyRequestEndToEnd() throws Exception { + final var traceId = "integration-trace"; + final var spanId = "integration-span"; + final var parentId = "integration-parent"; + final var deadline = Instant.now().plusSeconds(300); + final var requestId = UUID.randomUUID().toString(); + final var payload = TMessageUtil.createTMessage(protocolFactory); + final var responseBody = "integration-response".getBytes(); + final var upstreamTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; + + final var capturedHeaders = new AtomicReference(); + final var capturedTraceId = new AtomicReference(); + final var capturedSpanId = new AtomicReference(); + final var capturedParentId = new AtomicReference(); + final var capturedDeadline = new AtomicReference(); + final var capturedUserId = new AtomicReference(); + final var capturedRealm = new AtomicReference(); + + Mockito.doAnswer(invocation -> { + final var headers = (HttpHeaders) invocation.callRealMethod(); + capturedHeaders.set(headers); + final var traceData = TraceContext.getCurrentTraceData(); + assertNotNull(traceData); + final var serviceSpan = traceData.getServiceSpan().getSpan(); + capturedTraceId.set(serviceSpan.getTraceId()); + capturedSpanId.set(serviceSpan.getId()); + capturedParentId.set(serviceSpan.getParentId()); + capturedDeadline.set(serviceSpan.getDeadline()); + capturedUserId.set(getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); + capturedRealm.set(getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY)); + return headers; + }).when(requestFactory).buildHeaders(Mockito.any()); + + stubFor(WireMock.post(urlEqualTo("/domain")) + .withRequestBody(binaryEqualTo(payload)) + .willReturn(aResponse() + .withStatus(HttpStatus.ACCEPTED.value()) + .withHeader("X-Upstream", "accepted") + .withHeader("traceparent", upstreamTraceparent) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE) + .withBody(responseBody))); + + mockMvc.perform(post("/wachter") + .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) + .header("Service", "Domain") + .header(X_REQUEST_ID, requestId) + .header(X_REQUEST_DEADLINE, deadline.toString()) + .header(X_WOODY_TRACE_ID, traceId) + .header(X_WOODY_SPAN_ID, spanId) + .header(X_WOODY_PARENT_ID, parentId) + .content(payload)) + .andExpect(status().isAccepted()) + .andExpect(MockMvcResultMatchers.header().string("X-Upstream", "accepted")) + .andExpect(MockMvcResultMatchers.header().string("traceparent", upstreamTraceparent)) + .andExpect(MockMvcResultMatchers.content().bytes(responseBody)); + + verify(postRequestedFor(urlEqualTo("/domain")) + .withHeader(WOODY_TRACE_ID, equalTo(traceId)) + .withHeader(WOODY_SPAN_ID, equalTo(spanId)) + .withHeader(WOODY_PARENT_ID, equalTo(parentId)) + .withHeader(WOODY_DEADLINE, equalTo(deadline.toString())) + .withHeader(USER_ID_HEADER, matching(".+")) + .withHeader(USER_EMAIL_HEADER, equalTo(JwtTokenBuilder.DEFAULT_EMAIL)) + .withHeader(USER_NAME_HEADER, equalTo(JwtTokenBuilder.DEFAULT_USERNAME)) + .withHeader(USER_REALM_HEADER, equalTo(EXPECTED_REALM)) + .withHeader(OTEL_TRACE_PARENT, matching(TRACEPARENT_PATTERN)) + .withRequestBody(binaryEqualTo(payload))); + + final var headers = capturedHeaders.get(); + assertNotNull(headers); + assertEquals(traceId, headers.getFirst(WOODY_TRACE_ID)); + assertEquals(spanId, headers.getFirst(WOODY_SPAN_ID)); + assertEquals(parentId, headers.getFirst(WOODY_PARENT_ID)); + assertEquals(deadline.toString(), headers.getFirst(WOODY_DEADLINE)); + assertEquals(JwtTokenBuilder.DEFAULT_EMAIL, headers.getFirst(USER_EMAIL_HEADER)); + assertEquals(JwtTokenBuilder.DEFAULT_USERNAME, headers.getFirst(USER_NAME_HEADER)); + assertEquals(EXPECTED_REALM, headers.getFirst(USER_REALM_HEADER)); + final var userId = capturedUserId.get(); + assertNotNull(userId); + assertFalse(userId.isBlank()); + assertEquals(userId, headers.getFirst(USER_ID_HEADER)); + assertEquals(EXPECTED_REALM, capturedRealm.get()); + assertEquals(traceId, capturedTraceId.get()); + assertEquals(spanId, capturedSpanId.get()); + assertEquals(parentId, capturedParentId.get()); + assertEquals(deadline, capturedDeadline.get()); + } +} diff --git a/src/test/java/dev/vality/wachter/testutil/ContextUtil.java b/src/test/java/dev/vality/wachter/testutil/ContextUtil.java index d6d7114..34be37f 100644 --- a/src/test/java/dev/vality/wachter/testutil/ContextUtil.java +++ b/src/test/java/dev/vality/wachter/testutil/ContextUtil.java @@ -20,7 +20,7 @@ public class ContextUtil { mockRequiredTBaseProcessor = new MockTBaseProcessor(MockMode.REQUIRED_ONLY, 15, 1); Map.Entry timeFields = Map.entry( structHandler -> structHandler.value(Instant.now().toString()), - new String[]{"conversation_id", "messages", "status", "user_id", "email", "fullname", + new String[] {"conversation_id", "messages", "status", "user_id", "email", "fullname", "held_until", "from_time", "to_time"} ); mockRequiredTBaseProcessor.addFieldHandler(timeFields.getKey(), timeFields.getValue()); diff --git a/src/test/java/dev/vality/wachter/util/WoodyHeaderTest.java b/src/test/java/dev/vality/wachter/util/WoodyHeaderTest.java deleted file mode 100644 index f8c272b..0000000 --- a/src/test/java/dev/vality/wachter/util/WoodyHeaderTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.vality.wachter.util; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Set; -import java.util.stream.Collectors; - -import static dev.vality.wachter.constants.HeadersConstants.WOODY_DEPRECATED_PREFIX; -import static dev.vality.wachter.constants.HeadersConstants.X_WOODY_PREFIX; - -public class WoodyHeaderTest { - - @Test - public void nawoodyDeprecatedHeadersTest() { - var headers = Set.of( - "kekw", "", "x-woody-parent-id", "x-woody-trace-id", "x-woody-span-id", "x-woody-deadline"); - var woodyDeprecatedHeaders = headers.stream() - .filter(s -> s.startsWith(X_WOODY_PREFIX)) - .map(s -> s.replaceAll(X_WOODY_PREFIX, WOODY_DEPRECATED_PREFIX)) - .collect(Collectors.toSet()); - Assertions.assertEquals( - Set.of("woody.trace-id", "woody.parent-id", "woody.span-id", "woody.deadline"), - woodyDeprecatedHeaders); - } -} diff --git a/wachter_context.md b/wachter_context.md new file mode 100644 index 0000000..b1e8cac --- /dev/null +++ b/wachter_context.md @@ -0,0 +1,72 @@ +# 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 2a6d711db26cbbd759b18868d3e845117f21268e Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Tue, 7 Oct 2025 13:56:54 +0700 Subject: [PATCH 03/16] eof fix (#72) --- .../vality/wachter/client/WachterClient.java | 3 +- .../wachter/client/WachterRequestFactory.java | 23 ++++++++++++++ .../integration/WachterIntegrationTest.java | 30 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index ed10a06..c3a7b39 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -17,10 +17,11 @@ public record WachterClient(RestClient restClient, WachterRequestFactory request public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) { var httpMethod = resolveMethod(servletRequest); var params = requestFactory.extract(servletRequest); - log.info("-> Send request to {} {} | params: {}", httpMethod, url, params); var headers = requestFactory.buildHeaders(servletRequest); + log.info("-> Send request to {} {} | params: {}, headers: {}", httpMethod, url, params, headers); + var requestSpec = restClient.method(httpMethod) .uri(url) .headers(httpHeaders -> httpHeaders.addAll(headers)); diff --git a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java index 4a20d1c..6ec3ded 100644 --- a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java +++ b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java @@ -15,7 +15,9 @@ import java.time.Instant; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import static dev.vality.wachter.constants.HeadersConstants.*; @@ -24,6 +26,20 @@ @Component public class WachterRequestFactory { + private static final Set SKIPPED_HEADERS = Set.of( + HttpHeaders.HOST.toLowerCase(Locale.ROOT), + HttpHeaders.CONTENT_LENGTH.toLowerCase(Locale.ROOT), + HttpHeaders.TRANSFER_ENCODING.toLowerCase(Locale.ROOT), + HttpHeaders.CONNECTION.toLowerCase(Locale.ROOT), + HttpHeaders.TE.toLowerCase(Locale.ROOT), + "proxy-connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "trailer", + "upgrade" + ); + public HttpHeaders buildHeaders(HttpServletRequest servletRequest) { var headers = collectHeaders(servletRequest); mergeNormalizedWoodyHeaders(servletRequest, headers); @@ -57,6 +73,9 @@ private Map collectHeaders(HttpServletRequest servletRequest) { var headerNames = servletRequest.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { var name = headerNames.nextElement(); + if (shouldSkipHeader(name)) { + continue; + } var value = servletRequest.getHeader(name); if (value != null) { headers.put(name, value); @@ -65,6 +84,10 @@ private Map collectHeaders(HttpServletRequest servletRequest) { return headers; } + private boolean shouldSkipHeader(String headerName) { + return headerName != null && SKIPPED_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); + } + private void mergeNormalizedWoodyHeaders(HttpServletRequest servletRequest, Map headers) { var normalized = getNormalizedWoodyHeaders(servletRequest); if (!normalized.isEmpty()) { diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index ef69ee2..dea67f3 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -155,4 +155,34 @@ void shouldProxyRequestEndToEnd() throws Exception { assertEquals(parentId, capturedParentId.get()); assertEquals(deadline, capturedDeadline.get()); } + + @Test + void shouldStripHopByHopHeadersBeforeProxying() throws Exception { + final var deadline = Instant.now().plusSeconds(60); + final var payload = TMessageUtil.createTMessage(protocolFactory); + + stubFor(WireMock.post(urlEqualTo("/domain")) + .withRequestBody(binaryEqualTo(payload)) + .willReturn(aResponse() + .withStatus(HttpStatus.OK.value()))); + + mockMvc.perform(post("/wachter") + .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) + .header("Service", "Domain") + .header(X_REQUEST_ID, UUID.randomUUID().toString()) + .header(X_REQUEST_DEADLINE, deadline.toString()) + .header(HttpHeaders.TRANSFER_ENCODING, "chunked") + .header(HttpHeaders.CONNECTION, "keep-alive") + .header(HttpHeaders.TE, "trailers") + .header(HttpHeaders.HOST, "example.org") + .content(payload)) + .andExpect(status().isOk()); + + verify(postRequestedFor(urlEqualTo("/domain")) + .withHeader(HttpHeaders.HOST, matching("localhost:\\d+")) + .withoutHeader(HttpHeaders.TRANSFER_ENCODING) + .withHeader(HttpHeaders.CONNECTION, notMatching("(?i).*keep-alive.*")) + .withoutHeader(HttpHeaders.TE) + .withRequestBody(binaryEqualTo(payload))); + } } From 36e45837500bb364c17bf3efd9a654aa7aff5c3f Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Tue, 7 Oct 2025 15:15:28 +0700 Subject: [PATCH 04/16] cors fix (#73) --- .../vality/wachter/client/WachterClient.java | 31 ++++++- .../dev/vality/wachter/config/CorsConfig.java | 46 ++++++++++ .../vality/wachter/config/SecurityConfig.java | 92 ++++++++----------- .../integration/WachterIntegrationTest.java | 50 ++++++++++ 4 files changed, 164 insertions(+), 55 deletions(-) create mode 100644 src/main/java/dev/vality/wachter/config/CorsConfig.java diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index c3a7b39..a1a038d 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -7,12 +7,20 @@ import org.springframework.util.ObjectUtils; import org.springframework.web.client.RestClient; +import java.util.ArrayList; +import java.util.Locale; import java.util.Objects; +import java.util.Set; @Slf4j public record WachterClient(RestClient restClient, WachterRequestFactory requestFactory) { private static final byte[] EMPTY_BODY = new byte[0]; + private static final Set SENSITIVE_HEADERS = Set.of( + HttpHeaders.AUTHORIZATION.toLowerCase(Locale.ROOT), + HttpHeaders.COOKIE.toLowerCase(Locale.ROOT), + HttpHeaders.SET_COOKIE.toLowerCase(Locale.ROOT) + ); public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) { var httpMethod = resolveMethod(servletRequest); @@ -20,7 +28,8 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont var headers = requestFactory.buildHeaders(servletRequest); - log.info("-> Send request to {} {} | params: {}, headers: {}", httpMethod, url, params, headers); + log.info("-> Send request to {} {} | params: {} | headers: {}", + httpMethod, url, params, sanitizeHeaders(headers)); var requestSpec = restClient.method(httpMethod) .uri(url) @@ -38,8 +47,8 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont return new WachterClientResponse(status, responseHeaders, responseBody); }); - log.info("<- Receive response from {} {} | status: {} | params: {}", - httpMethod, url, result.statusCode(), params); + log.info("<- Receive response from {} {} | status: {} | params: {} | headers: {}", + httpMethod, url, result.statusCode(), params, sanitizeHeaders(result.headers())); return result; } @@ -50,4 +59,20 @@ private HttpMethod resolveMethod(HttpServletRequest servletRequest) { return HttpMethod.POST; } } + + private HttpHeaders sanitizeHeaders(HttpHeaders headers) { + var sanitized = new HttpHeaders(); + headers.forEach((name, values) -> { + if (isSensitive(name)) { + sanitized.put(name, java.util.List.of("***")); + } else { + sanitized.put(name, new ArrayList<>(values)); + } + }); + return sanitized; + } + + private boolean isSensitive(String headerName) { + return headerName != null && SENSITIVE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); + } } diff --git a/src/main/java/dev/vality/wachter/config/CorsConfig.java b/src/main/java/dev/vality/wachter/config/CorsConfig.java new file mode 100644 index 0000000..f1dce9c --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/CorsConfig.java @@ -0,0 +1,46 @@ +package dev.vality.wachter.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.beans.factory.annotation.Qualifier; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + var configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of( + HttpMethod.GET.name(), + HttpMethod.HEAD.name(), + HttpMethod.POST.name(), + HttpMethod.PUT.name(), + HttpMethod.DELETE.name(), + HttpMethod.OPTIONS.name() + )); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(1800L); + + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public FilterRegistrationBean corsFilterRegistration( + @Qualifier("corsConfigurationSource") CorsConfigurationSource source) { + var registrationBean = new FilterRegistrationBean<>(new CorsFilter(source)); + registrationBean.setOrder(-102); + return registrationBean; + } +} diff --git a/src/main/java/dev/vality/wachter/config/SecurityConfig.java b/src/main/java/dev/vality/wachter/config/SecurityConfig.java index 1549564..b26b682 100644 --- a/src/main/java/dev/vality/wachter/config/SecurityConfig.java +++ b/src/main/java/dev/vality/wachter/config/SecurityConfig.java @@ -1,52 +1,40 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.security.converter.JwtAuthConverter; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -@ConditionalOnProperty(value = "auth.enabled", havingValue = "true") -public class SecurityConfig { - - private final JwtAuthConverter jwtAuthConverter; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable); - http.authorizeHttpRequests( - (authorize) -> authorize - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.GET, "/**/health/liveness").permitAll() - .requestMatchers(HttpMethod.GET, "/**/health/readiness").permitAll() - .requestMatchers(HttpMethod.GET, "/**/actuator/prometheus").permitAll() - .anyRequest().authenticated()); - http.oauth2ResourceServer(server -> server.jwt(token -> token.jwtAuthenticationConverter(jwtAuthConverter))); - http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - http.cors(c -> c.configurationSource(corsConfigurationSource())); - return http.build(); - } - - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.applyPermitDefaultValues(); - configuration.addAllowedMethod(HttpMethod.PUT); - configuration.addAllowedMethod(HttpMethod.DELETE); - configuration.addAllowedMethod(HttpMethod.OPTIONS); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } -} \ No newline at end of file +package dev.vality.wachter.config; + +import dev.vality.wachter.security.converter.JwtAuthConverter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@ConditionalOnProperty(value = "auth.enabled", havingValue = "true") +public class SecurityConfig { + + private final JwtAuthConverter jwtAuthConverter; + private final CorsConfigurationSource corsConfigurationSource; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable); + http.authorizeHttpRequests( + (authorize) -> authorize + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.GET, "/**/health/liveness").permitAll() + .requestMatchers(HttpMethod.GET, "/**/health/readiness").permitAll() + .requestMatchers(HttpMethod.GET, "/**/actuator/prometheus").permitAll() + .anyRequest().authenticated()); + http.oauth2ResourceServer(server -> server.jwt(token -> token.jwtAuthenticationConverter(jwtAuthConverter))); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + http.cors(c -> c.configurationSource(corsConfigurationSource)); + return http.build(); + } +} diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index dea67f3..67a7f47 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -185,4 +185,54 @@ void shouldStripHopByHopHeadersBeforeProxying() throws Exception { .withoutHeader(HttpHeaders.TE) .withRequestBody(binaryEqualTo(payload))); } + + @Test + void shouldReturnCorsHeadersOnSuccessfulResponse() throws Exception { + final var deadline = Instant.now().plusSeconds(120); + final var payload = TMessageUtil.createTMessage(protocolFactory); + final var origin = "https://iddqd.valitydev.com"; + + stubFor(WireMock.post(urlEqualTo("/domain")) + .withRequestBody(binaryEqualTo(payload)) + .willReturn(aResponse() + .withStatus(HttpStatus.OK.value()))); + + mockMvc.perform(post("/wachter") + .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) + .header("Service", "Domain") + .header(X_REQUEST_ID, UUID.randomUUID().toString()) + .header(X_REQUEST_DEADLINE, deadline.toString()) + .header(HttpHeaders.ORIGIN, origin) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.header() + .string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) + .andExpect(MockMvcResultMatchers.header() + .string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + } + + @Test + void shouldReturnCorsHeadersOnErrorResponse() throws Exception { + final var deadline = Instant.now().plusSeconds(120); + final var payload = TMessageUtil.createTMessage(protocolFactory); + final var origin = "https://iddqd.valitydev.com"; + + stubFor(WireMock.post(urlEqualTo("/domain")) + .withRequestBody(binaryEqualTo(payload)) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_GATEWAY.value()))); + + mockMvc.perform(post("/wachter") + .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) + .header("Service", "Domain") + .header(X_REQUEST_ID, UUID.randomUUID().toString()) + .header(X_REQUEST_DEADLINE, deadline.toString()) + .header(HttpHeaders.ORIGIN, origin) + .content(payload)) + .andExpect(status().isBadGateway()) + .andExpect(MockMvcResultMatchers.header() + .string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) + .andExpect(MockMvcResultMatchers.header() + .string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + } } From 49b0783b1bce0f9f388d3d55fc4a084c9993e310 Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Tue, 7 Oct 2025 18:32:26 +0700 Subject: [PATCH 05/16] rm http2 usage (#74) * http1 with apache http 5 config * add HttpHeadersPolicy * refactor HttpHeadersPolicy --- .github/workflows/deploy.yml | 1 - pom.xml | 10 -- .../vality/wachter/client/WachterClient.java | 9 +- .../wachter/client/WachterRequestFactory.java | 25 +--- .../wachter/config/ApplicationConfig.java | 34 ------ .../dev/vality/wachter/config/CorsConfig.java | 9 +- .../wachter/config/RestClientConfig.java | 114 ++++++++++++++++++ .../config/http/HttpHeadersPolicy.java | 49 ++++++++ .../config/properties/HttpProperties.java | 32 +++++ src/main/resources/application.yml | 7 ++ .../client/WachterClientExtractTest.java | 3 +- .../client/WachterClientOperationsTest.java | 7 +- .../client/WachterRequestFactoryTest.java | 13 +- .../integration/WachterIntegrationTest.java | 2 +- 14 files changed, 237 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/dev/vality/wachter/config/ApplicationConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/RestClientConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java create mode 100644 src/main/java/dev/vality/wachter/config/properties/HttpProperties.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 73d0ab2..ce19205 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,6 @@ on: branches: - 'master' - 'main' - - 'epic/backward-compatibility' env: REGISTRY: ghcr.io diff --git a/pom.xml b/pom.xml index 9a919c1..355e290 100644 --- a/pom.xml +++ b/pom.xml @@ -29,20 +29,10 @@ shared-resources ${shared-resources.version} - - dev.vality - bouncer-proto - 1.57-31866c3 - dev.vality.geck serializer - - dev.vality - damsel - 1.652-ecf4977 - diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index a1a038d..ba8613a 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,9 +1,11 @@ package dev.vality.wachter.client; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.client.RestClient; @@ -13,7 +15,12 @@ import java.util.Set; @Slf4j -public record WachterClient(RestClient restClient, WachterRequestFactory requestFactory) { +@Component +@RequiredArgsConstructor +public class WachterClient { + + private final RestClient restClient; + private final WachterRequestFactory requestFactory; private static final byte[] EMPTY_BODY = new byte[0]; private static final Set SENSITIVE_HEADERS = Set.of( diff --git a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java index 6ec3ded..8d645e7 100644 --- a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java +++ b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java @@ -7,7 +7,9 @@ import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import dev.vality.wachter.config.http.HttpHeadersPolicy; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -15,30 +17,17 @@ import java.time.Instant; import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import static dev.vality.wachter.constants.HeadersConstants.*; import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; @Component +@RequiredArgsConstructor public class WachterRequestFactory { - private static final Set SKIPPED_HEADERS = Set.of( - HttpHeaders.HOST.toLowerCase(Locale.ROOT), - HttpHeaders.CONTENT_LENGTH.toLowerCase(Locale.ROOT), - HttpHeaders.TRANSFER_ENCODING.toLowerCase(Locale.ROOT), - HttpHeaders.CONNECTION.toLowerCase(Locale.ROOT), - HttpHeaders.TE.toLowerCase(Locale.ROOT), - "proxy-connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "trailer", - "upgrade" - ); + private final HttpHeadersPolicy httpHeadersPolicy; public HttpHeaders buildHeaders(HttpServletRequest servletRequest) { var headers = collectHeaders(servletRequest); @@ -73,7 +62,7 @@ private Map collectHeaders(HttpServletRequest servletRequest) { var headerNames = servletRequest.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { var name = headerNames.nextElement(); - if (shouldSkipHeader(name)) { + if (httpHeadersPolicy.shouldExcludeFromRequest(name)) { continue; } var value = servletRequest.getHeader(name); @@ -84,10 +73,6 @@ private Map collectHeaders(HttpServletRequest servletRequest) { return headers; } - private boolean shouldSkipHeader(String headerName) { - return headerName != null && SKIPPED_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); - } - private void mergeNormalizedWoodyHeaders(HttpServletRequest servletRequest, Map headers) { var normalized = getNormalizedWoodyHeaders(servletRequest); if (!normalized.isEmpty()) { diff --git a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java b/src/main/java/dev/vality/wachter/config/ApplicationConfig.java deleted file mode 100644 index 18c10be..0000000 --- a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.client.WachterClient; -import dev.vality.wachter.client.WachterRequestFactory; -import dev.vality.wachter.config.properties.HttpClientProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -import java.net.http.HttpClient; -import java.time.Duration; - -@Configuration -public class ApplicationConfig { - - @Bean - public RestClient restClient(RestClient.Builder builder, HttpClientProperties properties) { - var connectTimeout = Duration.ofMillis(properties.getConnectTimeout()); - var httpClient = HttpClient.newBuilder() - .connectTimeout(connectTimeout) - .build(); - - var requestFactory = new JdkClientHttpRequestFactory(httpClient); - requestFactory.setReadTimeout(Duration.ofMillis(properties.getSocketTimeout())); - - return builder.requestFactory(requestFactory).build(); - } - - @Bean - public WachterClient wachterClient(RestClient restClient, WachterRequestFactory requestFactory) { - return new WachterClient(restClient, requestFactory); - } -} diff --git a/src/main/java/dev/vality/wachter/config/CorsConfig.java b/src/main/java/dev/vality/wachter/config/CorsConfig.java index f1dce9c..66f9be5 100644 --- a/src/main/java/dev/vality/wachter/config/CorsConfig.java +++ b/src/main/java/dev/vality/wachter/config/CorsConfig.java @@ -1,5 +1,8 @@ package dev.vality.wachter.config; +import dev.vality.wachter.config.http.HttpHeadersPolicy; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,13 +11,15 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; -import org.springframework.beans.factory.annotation.Qualifier; import java.util.List; @Configuration +@RequiredArgsConstructor public class CorsConfig { + private final HttpHeadersPolicy httpHeadersPolicy; + @Bean public CorsConfigurationSource corsConfigurationSource() { var configuration = new CorsConfiguration(); @@ -27,7 +32,7 @@ public CorsConfigurationSource corsConfigurationSource() { HttpMethod.DELETE.name(), HttpMethod.OPTIONS.name() )); - configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedHeaders(httpHeadersPolicy.getCorsAllowedHeaders()); configuration.setAllowCredentials(true); configuration.setMaxAge(1800L); diff --git a/src/main/java/dev/vality/wachter/config/RestClientConfig.java b/src/main/java/dev/vality/wachter/config/RestClientConfig.java new file mode 100644 index 0000000..d8d0704 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/RestClientConfig.java @@ -0,0 +1,114 @@ +package dev.vality.wachter.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.vality.wachter.config.properties.HttpProperties; +import dev.vality.wachter.config.http.HttpHeadersPolicy; +import lombok.RequiredArgsConstructor; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.util.Timeout; +import org.springframework.context.annotation.Bean; +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.web.client.RestClient; + +import javax.net.ssl.SSLContext; +import java.time.Duration; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class RestClientConfig { + + private final HttpProperties httpProperties; + private final HttpHeadersPolicy httpHeadersPolicy; + + @Bean + public SSLContext sslContext() throws Exception { + return SSLContextBuilder.create() + .loadTrustMaterial(null, TrustAllStrategy.INSTANCE) + .build(); + } + + @Bean + public PoolingHttpClientConnectionManager connectionManager(SSLContext sslContext) { + return PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(new DefaultClientTlsStrategy( + sslContext, + HostnameVerificationPolicy.CLIENT, + NoopHostnameVerifier.INSTANCE + )) + .setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(Timeout.ofMilliseconds(httpProperties.getRequestTimeout())) + .build()) + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(httpProperties.getConnectionTimeout())) + .setSocketTimeout(Timeout.ofMilliseconds(httpProperties.getRequestTimeout())) + .build()) + .setMaxConnTotal(httpProperties.getMaxTotalPooling()) + .setMaxConnPerRoute(httpProperties.getDefaultMaxPerRoute()) + .build(); + } + + @Bean + public RequestConfig requestConfig() { + return RequestConfig.custom() + .setConnectionRequestTimeout(Timeout.ofMilliseconds(httpProperties.getPoolTimeout())) + .setResponseTimeout(Timeout.ofMilliseconds(httpProperties.getRequestTimeout())) + .build(); + } + + @Bean + public CloseableHttpClient httpClient( + PoolingHttpClientConnectionManager connectionManager, + RequestConfig requestConfig) { + return HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .disableRedirectHandling() + .disableAutomaticRetries() + .addRequestInterceptorLast((request, entity, context) -> + httpHeadersPolicy.getOutboundSanitizedHeaders() + .forEach(request::removeHeaders)) + .setConnectionManagerShared(true) + .build(); + } + + @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; + } + + @Bean + public RestClient restClient(ClientHttpRequestFactory requestFactory, ObjectMapper objectMapper) { + return RestClient.builder() + .requestFactory(requestFactory) + .messageConverters(converters -> updateObjectMapper(converters, objectMapper)) + .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/config/http/HttpHeadersPolicy.java b/src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java new file mode 100644 index 0000000..db277b5 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java @@ -0,0 +1,49 @@ +package dev.vality.wachter.config.http; + +import org.apache.hc.core5.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Component +public class HttpHeadersPolicy { + + /** + * Headers that must be excluded from incoming requests before building outbound request. + * These headers should be generated by the HTTP client itself based on the request body and target host. + */ + private static final Set EXCLUDED_REQUEST_HEADERS = Set.of( + HttpHeaders.HOST.toLowerCase(Locale.ROOT), + HttpHeaders.CONTENT_LENGTH.toLowerCase(Locale.ROOT), + HttpHeaders.TRANSFER_ENCODING.toLowerCase(Locale.ROOT) + ); + + /** + * Hop-by-hop headers that must be removed before forwarding the request to upstream. + * These headers are connection-specific and should not be forwarded by proxies (RFC 7230). + */ + private static final Set OUTBOUND_SANITIZED_HEADERS = Set.of( + HttpHeaders.CONNECTION.toLowerCase(Locale.ROOT), + HttpHeaders.TE.toLowerCase(Locale.ROOT), + HttpHeaders.PROXY_AUTHENTICATE.toLowerCase(Locale.ROOT), + HttpHeaders.PROXY_AUTHORIZATION.toLowerCase(Locale.ROOT), + HttpHeaders.PROXY_CONNECTION.toLowerCase(Locale.ROOT), + HttpHeaders.TRAILER.toLowerCase(Locale.ROOT), + HttpHeaders.UPGRADE.toLowerCase(Locale.ROOT) + ); + + public boolean shouldExcludeFromRequest(String headerName) { + return headerName != null && EXCLUDED_REQUEST_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); + } + + public Set getOutboundSanitizedHeaders() { + return OUTBOUND_SANITIZED_HEADERS; + } + + public List getCorsAllowedHeaders() { + return Collections.singletonList("*"); + } +} diff --git a/src/main/java/dev/vality/wachter/config/properties/HttpProperties.java b/src/main/java/dev/vality/wachter/config/properties/HttpProperties.java new file mode 100644 index 0000000..b575129 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/properties/HttpProperties.java @@ -0,0 +1,32 @@ +package dev.vality.wachter.config.properties; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@Configuration +@ConfigurationProperties(prefix = "http") +public class HttpProperties { + + @NotNull + private int maxTotalPooling; + + @NotNull + private int defaultMaxPerRoute; + + @NotNull + private long requestTimeout; + + @NotNull + private long poolTimeout; + + @NotNull + private long connectionTimeout; + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f63f0ce..fc2bbbb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -105,3 +105,10 @@ otel: resource: http://localhost:4318/v1/traces timeout: 60000 enabled: true + +http: + requestTimeout: 60000 + poolTimeout: 10000 + connectionTimeout: 10000 + maxTotalPooling: 200 + defaultMaxPerRoute: 200 diff --git a/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java b/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java index d162fca..0762005 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java @@ -1,5 +1,6 @@ package dev.vality.wachter.client; +import dev.vality.wachter.config.http.HttpHeadersPolicy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; @@ -14,7 +15,7 @@ public void shouldFormatAndTruncateParameters() { request.addParameter("multi", "first"); request.addParameter("multi", "second"); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); String result = factory.extract(request); Assertions.assertEquals( diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index a02a174..ec17f05 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -1,6 +1,7 @@ package dev.vality.wachter.client; import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.wachter.config.http.HttpHeadersPolicy; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -44,7 +45,7 @@ void shouldSendRequestWithMergedHeaders() { .andExpect(content().bytes(payload)) .andRespond(withSuccess(expectedResponse, MediaType.APPLICATION_OCTET_STREAM)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); final var client = new WachterClient(restClient, factory); final var actualResponse = client.send(servletRequest, payload, "http://upstream"); @@ -72,7 +73,7 @@ void shouldHandleGetRequestWithoutBody() { .andExpect(header("Accept", MediaType.APPLICATION_JSON_VALUE)) .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); final var client = new WachterClient(restClient, factory); final var response = client.send(servletRequest, null, "http://upstream/resource"); @@ -100,7 +101,7 @@ void shouldReturnErrorResponseWithoutThrowing() { .body("bad-gateway") .contentType(MediaType.TEXT_PLAIN)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); final var client = new WachterClient(restClient, factory); final var response = client.send(servletRequest, payload, "http://upstream/fail"); diff --git a/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java b/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java index 016bbfa..afba852 100644 --- a/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java @@ -1,6 +1,7 @@ package dev.vality.wachter.client; import dev.vality.wachter.constants.RequestAttributeNames; +import dev.vality.wachter.config.http.HttpHeadersPolicy; import dev.vality.woody.api.trace.TraceData; import dev.vality.woody.api.trace.context.TraceContext; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; @@ -26,6 +27,8 @@ class WachterRequestFactoryTest { + private final HttpHeadersPolicy httpHeadersPolicy = new HttpHeadersPolicy(); + @AfterEach void tearDown() { TraceContext.setCurrentTraceData(null); @@ -53,7 +56,7 @@ void shouldOverlayTraceContextHeadersAndMirrorLegacyVariants() { setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, "ctx@example.com"); setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, "/realm"); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(httpHeadersPolicy); HttpHeaders headers = factory.buildHeaders(servletRequest); assertEquals("trace-id", headers.getFirst(WOODY_TRACE_ID)); @@ -102,7 +105,7 @@ void shouldFallbackToJwtDetailsWhenTraceContextMissingIdentity() { final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(httpHeadersPolicy); HttpHeaders headers = factory.buildHeaders(servletRequest); assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); @@ -127,7 +130,7 @@ void shouldPopulateHeadersFromJwtWhenTraceContextAbsent() { final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(httpHeadersPolicy); HttpHeaders headers = factory.buildHeaders(servletRequest); assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); @@ -158,7 +161,7 @@ void shouldPreferJwtValuesWhenTraceContextMetadataBlank() { final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(httpHeadersPolicy); HttpHeaders headers = factory.buildHeaders(servletRequest); assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); @@ -173,7 +176,7 @@ void shouldMirrorCanonicalHeadersWhenOnlyAlternatePresent() { servletRequest.addHeader(X_WOODY_TRACE_ID, "incoming-x-trace"); servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, "unexpected"); - final var factory = new WachterRequestFactory(); + final var factory = new WachterRequestFactory(httpHeadersPolicy); HttpHeaders headers = factory.buildHeaders(servletRequest); assertEquals("incoming-x-trace", headers.getFirst(X_WOODY_TRACE_ID)); diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 67a7f47..1e9d64d 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -181,7 +181,7 @@ void shouldStripHopByHopHeadersBeforeProxying() throws Exception { verify(postRequestedFor(urlEqualTo("/domain")) .withHeader(HttpHeaders.HOST, matching("localhost:\\d+")) .withoutHeader(HttpHeaders.TRANSFER_ENCODING) - .withHeader(HttpHeaders.CONNECTION, notMatching("(?i).*keep-alive.*")) + .withoutHeader(HttpHeaders.CONNECTION) .withoutHeader(HttpHeaders.TE) .withRequestBody(binaryEqualTo(payload))); } From fe388a0f854fcb3bdd24aff5e1f93c3e5147eb17 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Tue, 7 Oct 2025 18:35:55 +0700 Subject: [PATCH 06/16] deploy epic --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce19205..015b7f5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: branches: - 'master' - 'main' + - 'epic/**' env: REGISTRY: ghcr.io From c02e5677805f999343c633127d5818ebeac03473 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Wed, 8 Oct 2025 16:46:23 +0700 Subject: [PATCH 07/16] rm duplicate filter corsFilterRegistration --- .../java/dev/vality/wachter/config/CorsConfig.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/main/java/dev/vality/wachter/config/CorsConfig.java b/src/main/java/dev/vality/wachter/config/CorsConfig.java index 66f9be5..9ed2bf7 100644 --- a/src/main/java/dev/vality/wachter/config/CorsConfig.java +++ b/src/main/java/dev/vality/wachter/config/CorsConfig.java @@ -2,15 +2,12 @@ import dev.vality.wachter.config.http.HttpHeadersPolicy; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; import java.util.List; @@ -40,12 +37,4 @@ public CorsConfigurationSource corsConfigurationSource() { source.registerCorsConfiguration("/**", configuration); return source; } - - @Bean - public FilterRegistrationBean corsFilterRegistration( - @Qualifier("corsConfigurationSource") CorsConfigurationSource source) { - var registrationBean = new FilterRegistrationBean<>(new CorsFilter(source)); - registrationBean.setOrder(-102); - return registrationBean; - } } From 243f61c3f15b8db45c1edbdfb6471f7a7d22a8c0 Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Thu, 9 Oct 2025 21:23:44 +0700 Subject: [PATCH 08/16] simplify WebConfig (#76) --- pom.xml | 1 + .../vality/wachter/client/WachterClient.java | 47 +- .../wachter/client/WachterRequestFactory.java | 190 -------- .../dev/vality/wachter/config/CorsConfig.java | 40 -- .../wachter/config/RestClientConfig.java | 5 - .../vality/wachter/config/SecurityConfig.java | 16 +- .../dev/vality/wachter/config/WebConfig.java | 72 ++- .../config/http/HttpHeadersPolicy.java | 49 -- .../HttpServletRequestHeaderGetter.java | 31 -- .../config/tracing/MapHeaderSetter.java | 21 - .../tracing/TraceContextHeadersExtractor.java | 85 ++++ ...ava => TraceContextHeadersNormalizer.java} | 37 +- .../config/tracing/TraceContextRestorer.java | 100 +++++ .../config/tracing/WoodyTelemetrySupport.java | 113 ----- .../tracing/WoodyTraceContextApplier.java | 65 --- .../config/tracing/WoodyTracingFilter.java | 123 +++-- .../controller/ErrorControllerAdvice.java | 37 +- .../wachter/controller/WachterController.java | 7 - src/main/resources/application.yml | 1 + .../client/WachterClientExtractTest.java | 25 -- .../client/WachterClientOperationsTest.java | 92 ++-- .../client/WachterRequestFactoryTest.java | 185 -------- .../vality/wachter/config/WebConfigTest.java | 425 ------------------ .../TraceContextHeadersExtractorTest.java | 162 +++++++ .../tracing/WoodyTracingFilterTest.java | 84 ++++ .../integration/WachterIntegrationTest.java | 74 +-- 26 files changed, 704 insertions(+), 1383 deletions(-) delete mode 100644 src/main/java/dev/vality/wachter/client/WachterRequestFactory.java delete mode 100644 src/main/java/dev/vality/wachter/config/CorsConfig.java delete mode 100644 src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java delete mode 100644 src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java delete mode 100644 src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java create mode 100644 src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java rename src/main/java/dev/vality/wachter/config/tracing/{WoodyHeadersNormalizer.java => TraceContextHeadersNormalizer.java} (74%) create mode 100644 src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java delete mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java delete mode 100644 src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java delete mode 100644 src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java delete mode 100644 src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java delete mode 100644 src/test/java/dev/vality/wachter/config/WebConfigTest.java create mode 100644 src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java create mode 100644 src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java diff --git a/pom.xml b/pom.xml index 355e290..c315c07 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ UTF-8 21 8022 + wachter 8023 ${server.port} ${management.port} diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index ba8613a..4772125 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,18 +1,16 @@ package dev.vality.wachter.client; +import dev.vality.wachter.config.tracing.TraceContextHeadersExtractor; +import dev.vality.wachter.config.tracing.TraceContextHeadersNormalizer; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.client.RestClient; -import java.util.ArrayList; -import java.util.Locale; import java.util.Objects; -import java.util.Set; @Slf4j @Component @@ -20,43 +18,32 @@ public class WachterClient { private final RestClient restClient; - private final WachterRequestFactory requestFactory; private static final byte[] EMPTY_BODY = new byte[0]; - private static final Set SENSITIVE_HEADERS = Set.of( - HttpHeaders.AUTHORIZATION.toLowerCase(Locale.ROOT), - HttpHeaders.COOKIE.toLowerCase(Locale.ROOT), - HttpHeaders.SET_COOKIE.toLowerCase(Locale.ROOT) - ); public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) { var httpMethod = resolveMethod(servletRequest); - var params = requestFactory.extract(servletRequest); - var headers = requestFactory.buildHeaders(servletRequest); + var headers = TraceContextHeadersExtractor.extractHeaders(); - log.info("-> Send request to {} {} | params: {} | headers: {}", - httpMethod, url, params, sanitizeHeaders(headers)); + log.info("-> Send request to {} {} | headers: {}", httpMethod, url, headers); var requestSpec = restClient.method(httpMethod) .uri(url) - .headers(httpHeaders -> httpHeaders.addAll(headers)); + .headers(httpHeaders -> headers.forEach(httpHeaders::set)); if (!ObjectUtils.isEmpty(contentData)) { requestSpec = requestSpec.body(contentData); } - var result = requestSpec.exchange((request, response) -> { + return requestSpec.exchange((request, response) -> { var status = response.getStatusCode(); + log.info("<- Receive response from {} {} | status: {}, headers: {}", httpMethod, url, status, + response.getHeaders()); var responseBody = Objects.requireNonNullElse(response.bodyTo(byte[].class), EMPTY_BODY); - var responseHeaders = new HttpHeaders(); - responseHeaders.putAll(response.getHeaders()); + var responseHeaders = TraceContextHeadersNormalizer.normalizeResponseHeaders(response.getHeaders()); return new WachterClientResponse(status, responseHeaders, responseBody); }); - - log.info("<- Receive response from {} {} | status: {} | params: {} | headers: {}", - httpMethod, url, result.statusCode(), params, sanitizeHeaders(result.headers())); - return result; } private HttpMethod resolveMethod(HttpServletRequest servletRequest) { @@ -66,20 +53,4 @@ private HttpMethod resolveMethod(HttpServletRequest servletRequest) { return HttpMethod.POST; } } - - private HttpHeaders sanitizeHeaders(HttpHeaders headers) { - var sanitized = new HttpHeaders(); - headers.forEach((name, values) -> { - if (isSensitive(name)) { - sanitized.put(name, java.util.List.of("***")); - } else { - sanitized.put(name, new ArrayList<>(values)); - } - }); - return sanitized; - } - - private boolean isSensitive(String headerName) { - return headerName != null && SENSITIVE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); - } } diff --git a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java b/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java deleted file mode 100644 index 8d645e7..0000000 --- a/src/main/java/dev/vality/wachter/client/WachterRequestFactory.java +++ /dev/null @@ -1,190 +0,0 @@ -package dev.vality.wachter.client; - -import dev.vality.wachter.constants.RequestAttributeNames; -import dev.vality.wachter.security.JwtTokenDetailsExtractor; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import dev.vality.wachter.config.http.HttpHeadersPolicy; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; - -import java.time.Instant; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.stream.Collectors; - -import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; - -@Component -@RequiredArgsConstructor -public class WachterRequestFactory { - - private final HttpHeadersPolicy httpHeadersPolicy; - - public HttpHeaders buildHeaders(HttpServletRequest servletRequest) { - var headers = collectHeaders(servletRequest); - mergeNormalizedWoodyHeaders(servletRequest, headers); - mergeTraceContextHeaders(headers); - - var woodyIdentityMirrors = createPrefixMirrors(headers, - X_WOODY_META_USER_IDENTITY_PREFIX, WOODY_META_USER_IDENTITY_PREFIX); - var woodyMirrors = createPrefixMirrors(headers, X_WOODY_PREFIX, WOODY_PREFIX); - var alternateIdentityMirrors = createPrefixMirrors(headers, - WOODY_META_USER_IDENTITY_PREFIX, X_WOODY_META_USER_IDENTITY_PREFIX); - var alternateWoodyMirrors = createPrefixMirrors(headers, WOODY_PREFIX, X_WOODY_PREFIX); - - headers.putAll(woodyIdentityMirrors); - headers.putAll(woodyMirrors); - headers.putAll(alternateIdentityMirrors); - headers.putAll(alternateWoodyMirrors); - - var httpHeaders = new HttpHeaders(); - headers.forEach(httpHeaders::set); - return httpHeaders; - } - - public String extract(HttpServletRequest servletRequest) { - return servletRequest.getParameterMap().entrySet().stream() - .map(entry -> entry.getKey() + "=" + String.join(", ", entry.getValue())) - .collect(Collectors.joining(", ")); - } - - private Map collectHeaders(HttpServletRequest servletRequest) { - var headers = new LinkedHashMap(); - var headerNames = servletRequest.getHeaderNames(); - while (headerNames != null && headerNames.hasMoreElements()) { - var name = headerNames.nextElement(); - if (httpHeadersPolicy.shouldExcludeFromRequest(name)) { - continue; - } - var value = servletRequest.getHeader(name); - if (value != null) { - headers.put(name, value); - } - } - return headers; - } - - private void mergeNormalizedWoodyHeaders(HttpServletRequest servletRequest, Map headers) { - var normalized = getNormalizedWoodyHeaders(servletRequest); - if (!normalized.isEmpty()) { - headers.putAll(normalized); - } - } - - private void mergeTraceContextHeaders(Map headers) { - var traceData = TraceContext.getCurrentTraceData(); - var jwtDetails = JwtTokenDetailsExtractor - .extractFromContext(SecurityContextHolder.getContext().getAuthentication()) - .orElse(null); - if (traceData == null) { - mergeUserIdentityFromJwt(headers, jwtDetails); - return; - } - var serviceSpan = traceData.getServiceSpan().getSpan(); - setDualPrefixHeader(headers, WOODY_TRACE_ID, X_WOODY_TRACE_ID, serviceSpan.getTraceId()); - setDualPrefixHeader(headers, WOODY_SPAN_ID, X_WOODY_SPAN_ID, serviceSpan.getId()); - setDualPrefixHeader(headers, WOODY_PARENT_ID, X_WOODY_PARENT_ID, serviceSpan.getParentId()); - Instant deadline = serviceSpan.getDeadline(); - if (deadline != null) { - setDualPrefixHeader(headers, WOODY_DEADLINE, X_WOODY_DEADLINE, deadline.toString()); - } - var userId = getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY); - if (isBlank(userId) && jwtDetails != null) { - userId = jwtDetails.subject(); - } - setDualPrefixUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, userId); - - var username = getCustomMetadataValue(String.class, UserIdentityUsernameExtensionKit.KEY); - if (isBlank(username) && jwtDetails != null) { - username = jwtDetails.preferredUsername(); - } - setDualPrefixUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, username); - - var email = getCustomMetadataValue(String.class, UserIdentityEmailExtensionKit.KEY); - if (isBlank(email) && jwtDetails != null) { - email = jwtDetails.email(); - } - setDualPrefixUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, email); - - var realm = getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY); - if (isBlank(realm) && jwtDetails != null) { - realm = jwtDetails.realm(); - } - setDualPrefixUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, realm); - } - - private void mergeUserIdentityFromJwt(Map headers, - JwtTokenDetailsExtractor.JwtTokenDetails jwtDetails) { - if (jwtDetails == null) { - return; - } - setDualPrefixUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, jwtDetails.subject()); - setDualPrefixUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, - jwtDetails.preferredUsername()); - setDualPrefixUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, jwtDetails.email()); - setDualPrefixUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, jwtDetails.realm()); - } - - private void setDualPrefixHeader(Map headers, - String canonicalKey, - String alternateKey, - String value) { - if (value == null || value.isEmpty()) { - return; - } - headers.put(canonicalKey, value); - headers.put(alternateKey, value); - } - - private void setDualPrefixUserIdentityHeader(Map headers, - String extensionKey, - String value) { - if (value == null || value.isEmpty()) { - return; - } - var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); - if (suffix.isEmpty()) { - return; - } - headers.put(WOODY_META_USER_IDENTITY_PREFIX + suffix, value); - headers.put(X_WOODY_META_USER_IDENTITY_PREFIX + suffix, value); - } - - private boolean isBlank(String value) { - return value == null || value.isBlank(); - } - - @SuppressWarnings("unchecked") - private Map getNormalizedWoodyHeaders(HttpServletRequest servletRequest) { - var attribute = servletRequest.getAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS); - if (attribute instanceof Map map) { - return (Map) map; - } - return Map.of(); - } - - private Map createPrefixMirrors(Map headers, - String sourcePrefix, - String targetPrefix) { - var mirroredHeaders = new HashMap(); - headers.forEach((key, value) -> { - if (key.startsWith(sourcePrefix)) { - mirroredHeaders.put(key.replace(sourcePrefix, targetPrefix), value); - } - }); - return mirroredHeaders; - } - - private String truncate(String value, int limit) { - return value.length() > limit ? value.substring(0, limit) : value; - } -} diff --git a/src/main/java/dev/vality/wachter/config/CorsConfig.java b/src/main/java/dev/vality/wachter/config/CorsConfig.java deleted file mode 100644 index 9ed2bf7..0000000 --- a/src/main/java/dev/vality/wachter/config/CorsConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.config.http.HttpHeadersPolicy; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class CorsConfig { - - private final HttpHeadersPolicy httpHeadersPolicy; - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - var configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of( - HttpMethod.GET.name(), - HttpMethod.HEAD.name(), - HttpMethod.POST.name(), - HttpMethod.PUT.name(), - HttpMethod.DELETE.name(), - HttpMethod.OPTIONS.name() - )); - configuration.setAllowedHeaders(httpHeadersPolicy.getCorsAllowedHeaders()); - configuration.setAllowCredentials(true); - configuration.setMaxAge(1800L); - - var source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } -} diff --git a/src/main/java/dev/vality/wachter/config/RestClientConfig.java b/src/main/java/dev/vality/wachter/config/RestClientConfig.java index d8d0704..ad3650e 100644 --- a/src/main/java/dev/vality/wachter/config/RestClientConfig.java +++ b/src/main/java/dev/vality/wachter/config/RestClientConfig.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.vality.wachter.config.properties.HttpProperties; -import dev.vality.wachter.config.http.HttpHeadersPolicy; import lombok.RequiredArgsConstructor; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.ConnectionConfig; @@ -35,7 +34,6 @@ public class RestClientConfig { private final HttpProperties httpProperties; - private final HttpHeadersPolicy httpHeadersPolicy; @Bean public SSLContext sslContext() throws Exception { @@ -81,9 +79,6 @@ public CloseableHttpClient httpClient( .setDefaultRequestConfig(requestConfig) .disableRedirectHandling() .disableAutomaticRetries() - .addRequestInterceptorLast((request, entity, context) -> - httpHeadersPolicy.getOutboundSanitizedHeaders() - .forEach(request::removeHeaders)) .setConnectionManagerShared(true) .build(); } diff --git a/src/main/java/dev/vality/wachter/config/SecurityConfig.java b/src/main/java/dev/vality/wachter/config/SecurityConfig.java index b26b682..3423ed6 100644 --- a/src/main/java/dev/vality/wachter/config/SecurityConfig.java +++ b/src/main/java/dev/vality/wachter/config/SecurityConfig.java @@ -11,7 +11,9 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @@ -20,7 +22,6 @@ public class SecurityConfig { private final JwtAuthConverter jwtAuthConverter; - private final CorsConfigurationSource corsConfigurationSource; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -34,7 +35,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().authenticated()); http.oauth2ResourceServer(server -> server.jwt(token -> token.jwtAuthenticationConverter(jwtAuthConverter))); http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - http.cors(c -> c.configurationSource(corsConfigurationSource)); + http.cors(c -> c.configurationSource(corsConfigurationSource())); return http.build(); } + + public CorsConfigurationSource corsConfigurationSource() { + var configuration = new CorsConfiguration(); + configuration.applyPermitDefaultValues(); + configuration.addAllowedMethod(HttpMethod.PUT); + configuration.addAllowedMethod(HttpMethod.DELETE); + configuration.addAllowedMethod(HttpMethod.OPTIONS); + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/dev/vality/wachter/config/WebConfig.java b/src/main/java/dev/vality/wachter/config/WebConfig.java index 95923fd..39e6f86 100644 --- a/src/main/java/dev/vality/wachter/config/WebConfig.java +++ b/src/main/java/dev/vality/wachter/config/WebConfig.java @@ -1,39 +1,73 @@ package dev.vality.wachter.config; -import dev.vality.wachter.config.tracing.WoodyHeadersNormalizer; -import dev.vality.wachter.config.tracing.WoodyTelemetrySupport; -import dev.vality.wachter.config.tracing.WoodyTraceContextApplier; import dev.vality.wachter.config.tracing.WoodyTracingFilter; -import dev.vality.woody.api.flow.WFlow; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.OncePerRequestFilter; -import java.util.Map; +import java.io.IOException; @Configuration +@Slf4j public class WebConfig { - private final WoodyHeadersNormalizer headersNormalizer = new WoodyHeadersNormalizer(); - private final WoodyTraceContextApplier traceContextApplier = new WoodyTraceContextApplier(); - private final WoodyTelemetrySupport telemetrySupport = new WoodyTelemetrySupport(); + @Value("${server.port}") + private int serverPort; + + @Value("/${wachter.endpoint}") + private String wachterEndpoint; @Bean - public FilterRegistrationBean woodyFilter() { - var filter = new WoodyTracingFilter(new WFlow(), headersNormalizer, traceContextApplier, telemetrySupport); - var registrationBean = new FilterRegistrationBean<>(filter); - registrationBean.setOrder(-50); - registrationBean.setName("woodyFilter"); - registrationBean.addUrlPatterns("*"); - return registrationBean; + public FilterRegistrationBean externalPortRestrictingFilter() { + var filter = new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + var requestPath = getRequestPath(request); + if ((request.getLocalPort() == serverPort) && !requestPath.equals(wachterEndpoint)) { + var status = HttpServletResponse.SC_NOT_FOUND; + log.warn("<- Sent [redirecting {}]: Unknown address {}", status, requestPath); + response.sendError(status, "Unknown address"); + return; + } + filterChain.doFilter(request, response); + } + }; + + var filterRegistrationBean = new FilterRegistrationBean(); + filterRegistrationBean.setFilter(filter); + filterRegistrationBean.setOrder(-100); + filterRegistrationBean.setName("externalPortRestrictingFilter"); + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; } - public Map normalizeWoodyHeaders(HttpServletRequest request) { - return headersNormalizer.normalize(request); + @Bean + public FilterRegistrationBean woodyTracingFilter() { + var registrationBean = new FilterRegistrationBean<>(new WoodyTracingFilter(serverPort, wachterEndpoint)); + registrationBean.setOrder(-50); + registrationBean.setName("woodyTracingFilter"); + registrationBean.addUrlPatterns(wachterEndpoint); + return registrationBean; } - public void applyWoodyHeadersToTraceContext(Map woodyHeaders) { - traceContextApplier.apply(woodyHeaders); + public static String getRequestPath(HttpServletRequest request) { + var servletPath = request.getServletPath(); + if (servletPath != null && !servletPath.isBlank()) { + return servletPath; + } + var requestPath = request.getRequestURI(); + if (requestPath != null && !requestPath.isBlank()) { + return requestPath; + } + return ""; } } diff --git a/src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java b/src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java deleted file mode 100644 index db277b5..0000000 --- a/src/main/java/dev/vality/wachter/config/http/HttpHeadersPolicy.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.vality.wachter.config.http; - -import org.apache.hc.core5.http.HttpHeaders; -import org.springframework.stereotype.Component; - -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -@Component -public class HttpHeadersPolicy { - - /** - * Headers that must be excluded from incoming requests before building outbound request. - * These headers should be generated by the HTTP client itself based on the request body and target host. - */ - private static final Set EXCLUDED_REQUEST_HEADERS = Set.of( - HttpHeaders.HOST.toLowerCase(Locale.ROOT), - HttpHeaders.CONTENT_LENGTH.toLowerCase(Locale.ROOT), - HttpHeaders.TRANSFER_ENCODING.toLowerCase(Locale.ROOT) - ); - - /** - * Hop-by-hop headers that must be removed before forwarding the request to upstream. - * These headers are connection-specific and should not be forwarded by proxies (RFC 7230). - */ - private static final Set OUTBOUND_SANITIZED_HEADERS = Set.of( - HttpHeaders.CONNECTION.toLowerCase(Locale.ROOT), - HttpHeaders.TE.toLowerCase(Locale.ROOT), - HttpHeaders.PROXY_AUTHENTICATE.toLowerCase(Locale.ROOT), - HttpHeaders.PROXY_AUTHORIZATION.toLowerCase(Locale.ROOT), - HttpHeaders.PROXY_CONNECTION.toLowerCase(Locale.ROOT), - HttpHeaders.TRAILER.toLowerCase(Locale.ROOT), - HttpHeaders.UPGRADE.toLowerCase(Locale.ROOT) - ); - - public boolean shouldExcludeFromRequest(String headerName) { - return headerName != null && EXCLUDED_REQUEST_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); - } - - public Set getOutboundSanitizedHeaders() { - return OUTBOUND_SANITIZED_HEADERS; - } - - public List getCorsAllowedHeaders() { - return Collections.singletonList("*"); - } -} diff --git a/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java b/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java deleted file mode 100644 index df2b26c..0000000 --- a/src/main/java/dev/vality/wachter/config/tracing/HttpServletRequestHeaderGetter.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.vality.wachter.config.tracing; - -import io.opentelemetry.context.propagation.TextMapGetter; -import jakarta.servlet.http.HttpServletRequest; - -import java.util.Collections; - -final class HttpServletRequestHeaderGetter implements TextMapGetter { - - static final HttpServletRequestHeaderGetter INSTANCE = new HttpServletRequestHeaderGetter(); - - private HttpServletRequestHeaderGetter() { - } - - @Override - public Iterable keys(HttpServletRequest carrier) { - if (carrier == null) { - return Collections.emptyList(); - } - var headerNames = carrier.getHeaderNames(); - return headerNames == null ? Collections.emptyList() : Collections.list(headerNames); - } - - @Override - public String get(HttpServletRequest carrier, String key) { - if (carrier == null || key == null) { - return null; - } - return carrier.getHeader(key); - } -} diff --git a/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java b/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java deleted file mode 100644 index 99fd3d6..0000000 --- a/src/main/java/dev/vality/wachter/config/tracing/MapHeaderSetter.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.vality.wachter.config.tracing; - -import io.opentelemetry.context.propagation.TextMapSetter; - -import java.util.Map; - -final class MapHeaderSetter implements TextMapSetter> { - - static final MapHeaderSetter INSTANCE = new MapHeaderSetter(); - - private MapHeaderSetter() { - } - - @Override - public void set(Map carrier, String key, String value) { - if (carrier == null || key == null || value == null) { - return; - } - carrier.put(key, value); - } -} diff --git a/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java b/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java new file mode 100644 index 0000000..321be00 --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java @@ -0,0 +1,85 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.woody.api.trace.Metadata; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import dev.vality.woody.thrift.impl.http.TraceParentUtils; +import io.opentelemetry.api.trace.Span; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static dev.vality.wachter.constants.HeadersConstants.*; + +@Slf4j +@UtilityClass +public class TraceContextHeadersExtractor { + + public Map extractHeaders() { + var traceData = TraceContext.getCurrentTraceData(); + Objects.requireNonNull(traceData); + Objects.requireNonNull(traceData.getOtelSpan()); + Objects.requireNonNull(traceData.getOtelSpan().getSpanContext()); + if (!traceData.getOtelSpan().getSpanContext().isValid()) { + throw new IllegalStateException("SpanContext must be valid"); + } + + var span = traceData.getActiveSpan().getSpan(); + var headers = new HashMap(); + putIfNotNull(headers, WOODY_TRACE_ID, span.getTraceId()); + putIfNotNull(headers, WOODY_SPAN_ID, span.getId()); + putIfNotNull(headers, WOODY_PARENT_ID, span.getParentId()); + putIfNotNull(headers, WOODY_DEADLINE, + Optional.ofNullable(span.getDeadline()).map(Instant::toString).orElse(null)); + putIfNotNull(headers, OTEL_TRACE_PARENT, initParentTraceFromSpan(traceData.getOtelSpan())); + + var customMetadata = traceData.getActiveSpan().getCustomMetadata(); + extractUserIdentityHeader(headers, customMetadata, UserIdentityIdExtensionKit.KEY); + extractUserIdentityHeader(headers, customMetadata, UserIdentityUsernameExtensionKit.KEY); + extractUserIdentityHeader(headers, customMetadata, UserIdentityEmailExtensionKit.KEY); + extractUserIdentityHeader(headers, customMetadata, UserIdentityRealmExtensionKit.KEY); + putMetadataValue(headers, customMetadata, X_REQUEST_ID); + putMetadataValue(headers, customMetadata, X_REQUEST_DEADLINE); + return headers; + } + + private void extractUserIdentityHeader(Map headers, Metadata customMetadata, String extensionKey) { + var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); + if (suffix.isEmpty()) { + return; + } + + putMetadataValue(headers, customMetadata, WOODY_META_USER_IDENTITY_PREFIX + suffix); + } + + private void putMetadataValue(Map headers, Metadata customMetadata, String key) { + var value = (String) customMetadata.getValue(key); + putIfNotNull(headers, key, value); + } + + private void putIfNotNull(Map headers, + String key, + String value) { + if (value != null && !value.isEmpty()) { + headers.put(key, value); + } + } + + private String initParentTraceFromSpan(Span otelSpan) { + var spanContext = otelSpan.getSpanContext(); + return TraceParentUtils.initParentTrace( + TraceParentUtils.DEFAULT_VERSION, + spanContext.getTraceId(), + spanContext.getSpanId(), + spanContext.getTraceFlags().asHex() + ); + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java b/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java similarity index 74% rename from src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java rename to src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java index a7dbba0..23bfe11 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/WoodyHeadersNormalizer.java +++ b/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java @@ -6,7 +6,9 @@ import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; import jakarta.servlet.http.HttpServletRequest; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.security.core.context.SecurityContextHolder; import java.time.Instant; @@ -17,7 +19,8 @@ import static dev.vality.wachter.utils.DeadlineUtil.*; @Slf4j -public class WoodyHeadersNormalizer { +@UtilityClass +public class TraceContextHeadersNormalizer { public Map normalize(HttpServletRequest request) { var normalized = new HashMap(); @@ -32,6 +35,22 @@ public Map normalize(HttpServletRequest request) { return normalized.isEmpty() ? Map.of() : Map.copyOf(normalized); } + public HttpHeaders normalizeResponseHeaders(HttpHeaders responseHeaders) { + var normalized = new HttpHeaders(); + for (var entry : responseHeaders.entrySet()) { + var headerName = entry.getKey(); + var lowerCase = headerName.toLowerCase(Locale.ROOT); + if (lowerCase.startsWith(WOODY_PREFIX) || lowerCase.startsWith(X_WOODY_PREFIX)) { + normalizeWoodyResponseHeader(normalized, lowerCase, entry.getValue()); + } else if (lowerCase.equals(X_REQUEST_ID.toLowerCase(Locale.ROOT)) + || lowerCase.equals(X_REQUEST_DEADLINE.toLowerCase(Locale.ROOT)) + || lowerCase.equals(OTEL_TRACE_PARENT.toLowerCase(Locale.ROOT))) { + normalized.addAll(headerName, entry.getValue()); + } + } + return normalized; + } + private void normalizeWoodyHeaders(HttpServletRequest request, List headerNames, Map headers) { for (var name : headerNames) { @@ -108,4 +127,20 @@ private Instant getInstant(String requestDeadlineHeader, String requestIdHeader) } return Instant.parse(requestDeadlineHeader); } + + private void normalizeWoodyResponseHeader(HttpHeaders headers, + String lowerCase, + List values) { + if (lowerCase.startsWith(X_WOODY_PREFIX)) { + headers.addAll(lowerCase, values); + } else { + var suffix = lowerCase.substring(WOODY_PREFIX.length()); + if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_DOT)) { + var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_DOT.length()); + headers.addAll(X_WOODY_META_USER_IDENTITY_PREFIX + metaKey, values); + } else { + headers.addAll(X_WOODY_PREFIX + suffix, values); + } + } + } } diff --git a/src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java b/src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java new file mode 100644 index 0000000..658eb8f --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java @@ -0,0 +1,100 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.woody.api.flow.WFlow; +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import dev.vality.woody.thrift.impl.http.TraceParentUtils; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.*; +import io.opentelemetry.context.Context; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.util.Map; +import java.util.function.Consumer; + +import static dev.vality.wachter.constants.HeadersConstants.*; + +@Slf4j +@UtilityClass +public class TraceContextRestorer { + + public TraceData restoreTraceData(Map headers) { + var traceData = new TraceData(); + + TraceContext.initNewServiceTrace(traceData, WFlow.createDefaultIdGenerator(), WFlow.createDefaultIdGenerator()); + + if (headers.isEmpty()) { + return traceData; + } + + var serviceSpan = traceData.getServiceSpan().getSpan(); + setIfPresent(headers, WOODY_TRACE_ID, serviceSpan::setTraceId); + setIfPresent(headers, WOODY_SPAN_ID, serviceSpan::setId); + setIfPresent(headers, WOODY_PARENT_ID, serviceSpan::setParentId); + setIfPresent(headers, WOODY_DEADLINE, value -> serviceSpan.setDeadline(Instant.parse(value))); + setIfPresent(headers, OTEL_TRACE_PARENT, value -> { + var otelSpan = initSpan(value); + otelSpan.makeCurrent(); + traceData.setOtelSpan(otelSpan); + }); + + var customMetadata = traceData.getActiveSpan().getCustomMetadata(); + applyUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, + value -> customMetadata.putValue(UserIdentityIdExtensionKit.KEY, value)); + applyUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, + value -> customMetadata.putValue(UserIdentityUsernameExtensionKit.KEY, value)); + applyUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, + value -> customMetadata.putValue(UserIdentityEmailExtensionKit.KEY, value)); + applyUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, + value -> customMetadata.putValue(UserIdentityRealmExtensionKit.KEY, value)); + setIfPresent(headers, X_REQUEST_ID, value -> customMetadata.putValue(X_REQUEST_ID, value)); + setIfPresent(headers, X_REQUEST_DEADLINE, value -> customMetadata.putValue(X_REQUEST_DEADLINE, value)); + return traceData; + } + + private void applyUserIdentityHeader(Map headers, + String extensionKey, + Consumer consumer) { + var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); + if (suffix.isEmpty()) { + return; + } + setIfPresent(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, consumer); + } + + private void setIfPresent(Map headers, String key, Consumer consumer) { + var value = headers.get(key); + if (value != null && !value.isEmpty()) { + try { + consumer.accept(value); + } catch (Exception e) { + log.warn("Unable to set header with key '{}' value '{}'", key, value); + } + } + } + + private Span initSpan(String traceparent) { + return GlobalOpenTelemetry.getTracer(TraceData.WOODY) + .spanBuilder(TraceData.OTEL_CLIENT) + .setSpanKind(SpanKind.SERVER) + .setParent( + Context.current().with( + Span.wrap( + SpanContext.createFromRemoteParent( + TraceParentUtils.parseTraceId(traceparent), + TraceParentUtils.parseSpanId(traceparent), + TraceFlags.getSampled(), + TraceState.builder().build() + ) + ) + ) + ) + .startSpan(); + } +} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java deleted file mode 100644 index 6efd486..0000000 --- a/src/main/java/dev/vality/wachter/config/tracing/WoodyTelemetrySupport.java +++ /dev/null @@ -1,113 +0,0 @@ -package dev.vality.wachter.config.tracing; - -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.SpanKind; -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.context.Context; -import io.opentelemetry.context.Scope; -import io.opentelemetry.context.propagation.TextMapPropagator; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import java.util.HashMap; -import java.util.Map; - -import static dev.vality.wachter.constants.HeadersConstants.OTEL_TRACE_PARENT; -import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.*; - -public class WoodyTelemetrySupport { - - private static final String INSTRUMENTATION_NAME = "dev.vality.wachter.http"; - - public ServerSpanContext startServerSpan(HttpServletRequest request) { - var openTelemetry = GlobalOpenTelemetry.get(); - var propagator = openTelemetry.getPropagators().getTextMapPropagator(); - var parentContext = propagator.extract(Context.current(), request, HttpServletRequestHeaderGetter.INSTANCE); - var tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME); - var spanBuilder = tracer.spanBuilder(buildSpanName(request)) - .setSpanKind(SpanKind.SERVER) - .setParent(parentContext); - var method = request.getMethod(); - if (method != null) { - spanBuilder.setAttribute(HTTP_METHOD, method); - } - var target = request.getRequestURI(); - if (target != null) { - spanBuilder.setAttribute(HTTP_TARGET, target); - } - var host = request.getHeader("Host"); - if (host != null && !host.isEmpty()) { - spanBuilder.setAttribute(NET_HOST_NAME, host); - } - var span = spanBuilder.startSpan(); - var scope = span.makeCurrent(); - return new ServerSpanContext(span, scope, propagator); - } - - private String buildSpanName(HttpServletRequest request) { - var method = request.getMethod(); - var target = request.getRequestURI(); - if (method == null && (target == null || target.isEmpty())) { - return "HTTP request"; - } - if (method == null) { - return target; - } - if (target == null || target.isEmpty()) { - return method; - } - return method + " " + target; - } - - static final class ServerSpanContext implements AutoCloseable { - - private final Span span; - private final Scope scope; - private final TextMapPropagator propagator; - - private ServerSpanContext(Span span, Scope scope, TextMapPropagator propagator) { - this.span = span; - this.scope = scope; - this.propagator = propagator; - } - - Map ensureTraceparent(Map headers) { - if (headers.containsKey(OTEL_TRACE_PARENT)) { - return headers; - } - var mutable = new HashMap<>(headers); - propagator.inject(Context.current(), mutable, MapHeaderSetter.INSTANCE); - var traceparent = mutable.get(OTEL_TRACE_PARENT); - if (traceparent == null || traceparent.isEmpty()) { - return headers; - } - return Map.copyOf(mutable); - } - - void recordResponse(HttpServletResponse response) { - var status = response.getStatus(); - if (status > 0) { - span.setAttribute(HTTP_STATUS_CODE, status); - span.setStatus(status >= 500 ? StatusCode.ERROR : StatusCode.OK); - } else { - span.setStatus(StatusCode.OK); - } - } - - void recordException(HttpServletResponse response, Throwable t) { - var status = response.getStatus(); - if (status > 0) { - span.setAttribute(HTTP_STATUS_CODE, status); - } - span.recordException(t); - span.setStatus(StatusCode.ERROR); - } - - @Override - public void close() { - scope.close(); - span.end(); - } - } -} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java deleted file mode 100644 index ea895ce..0000000 --- a/src/main/java/dev/vality/wachter/config/tracing/WoodyTraceContextApplier.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.vality.wachter.config.tracing; - -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import lombok.extern.slf4j.Slf4j; - -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Map; -import java.util.function.Consumer; - -import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; -import static dev.vality.woody.api.trace.ContextUtils.setDeadline; - -@Slf4j -public class WoodyTraceContextApplier { - - public void apply(Map woodyHeaders) { - if (woodyHeaders.isEmpty()) { - return; - } - var traceData = TraceContext.getCurrentTraceData(); - var serviceSpan = traceData.getServiceSpan().getSpan(); - setIfPresent(woodyHeaders, WOODY_TRACE_ID, serviceSpan::setTraceId); - setIfPresent(woodyHeaders, WOODY_SPAN_ID, serviceSpan::setId); - setIfPresent(woodyHeaders, WOODY_PARENT_ID, serviceSpan::setParentId); - var woodyDeadline = woodyHeaders.get(WOODY_DEADLINE); - if (woodyDeadline != null && !woodyDeadline.isEmpty()) { - try { - setDeadline(traceData.getServiceSpan(), Instant.parse(woodyDeadline)); - } catch (DateTimeParseException e) { - log.warn("Unable to parse 'woody.deadline' header value '{}'", woodyDeadline); - } - } - applyUserIdentityHeader(woodyHeaders, UserIdentityIdExtensionKit.KEY, - value -> setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, value)); - applyUserIdentityHeader(woodyHeaders, UserIdentityUsernameExtensionKit.KEY, - value -> setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, value)); - applyUserIdentityHeader(woodyHeaders, UserIdentityEmailExtensionKit.KEY, - value -> setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, value)); - applyUserIdentityHeader(woodyHeaders, UserIdentityRealmExtensionKit.KEY, - value -> setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, value)); - } - - private void applyUserIdentityHeader(Map headers, - String extensionKey, - Consumer consumer) { - var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); - if (suffix.isEmpty()) { - return; - } - setIfPresent(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, consumer); - } - - private void setIfPresent(Map headers, String key, Consumer consumer) { - var value = headers.get(key); - if (value != null && !value.isEmpty()) { - consumer.accept(value); - } - } -} diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java index 5c91b7e..10827bd 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java +++ b/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java @@ -1,67 +1,108 @@ package dev.vality.wachter.config.tracing; -import dev.vality.wachter.constants.RequestAttributeNames; import dev.vality.woody.api.flow.WFlow; import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import static dev.vality.wachter.config.WebConfig.getRequestPath; + +@Slf4j +@RequiredArgsConstructor public final class WoodyTracingFilter extends OncePerRequestFilter { - private final WFlow woodyFlow; - private final WoodyHeadersNormalizer headersNormalizer; - private final WoodyTraceContextApplier traceContextApplier; - private final WoodyTelemetrySupport telemetrySupport; + private static final Set SENSITIVE_HEADERS = Set.of( + HttpHeaders.AUTHORIZATION.toLowerCase(Locale.ROOT), + HttpHeaders.COOKIE.toLowerCase(Locale.ROOT), + HttpHeaders.SET_COOKIE.toLowerCase(Locale.ROOT) + ); - public WoodyTracingFilter(WFlow woodyFlow, - WoodyHeadersNormalizer headersNormalizer, - WoodyTraceContextApplier traceContextApplier, - WoodyTelemetrySupport telemetrySupport) { - this.woodyFlow = woodyFlow; - this.headersNormalizer = headersNormalizer; - this.traceContextApplier = traceContextApplier; - this.telemetrySupport = telemetrySupport; - } + private final int serverPort; + private final String wachterEndpoint; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - var telemetry = telemetrySupport.startServerSpan(request); - try { - var normalizedWoodyHeaders = telemetry.ensureTraceparent(headersNormalizer.normalize(request)); - request.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, normalizedWoodyHeaders); - runWithWoodyContext(request, response, filterChain, normalizedWoodyHeaders); - telemetry.recordResponse(response); - } catch (Throwable t) { - telemetry.recordException(response, t); - throw t; - } finally { - telemetry.close(); + var requestPath = getRequestPath(request); + if ((request.getLocalPort() == serverPort) && requestPath.equals(wachterEndpoint)) { + var normalized = TraceContextHeadersNormalizer.normalize(request); + log.info("-> Received {} {} | params: {}, headers: {}", + request.getMethod(), getRequestPath(request), extractParams(request), sanitizeHeaders(request)); + var restoredTraceData = TraceContextRestorer.restoreTraceData(normalized); + WFlow.create(() -> doFilter(request, response, filterChain), restoredTraceData) + .run(); + log.info("<- Sent {} {} | status: {}, headers: {}", + request.getMethod(), getRequestPath(request), response.getStatus(), + sanitizeResponseHeaders(response)); + return; } + doFilter(request, response, filterChain); + } + + @SneakyThrows + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + filterChain.doFilter(request, response); + } + + public static String extractParams(HttpServletRequest servletRequest) { + return servletRequest.getParameterMap().entrySet().stream() + .map(entry -> entry.getKey() + "=" + String.join(",", entry.getValue())) + .collect(Collectors.joining(", ")); } - private void runWithWoodyContext(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain, - Map normalizedWoodyHeaders) { - woodyFlow.createServiceFork(() -> { - try { - traceContextApplier.apply(normalizedWoodyHeaders); - filterChain.doFilter(request, response); - } catch (IOException | ServletException e) { - sneakyThrow(e); - } + private static HttpHeaders sanitizeHeaders(HttpServletRequest request) { + var headers = new HttpHeaders(); + var collectedHeaders = collectHeaders(request); + collectedHeaders.forEach((name, value) -> { + if (isSensitive(name)) { + headers.add(name, "***"); + } else { + headers.add(name, value); + } + }); + return headers; + } + + private static Map collectHeaders(HttpServletRequest request) { + var headers = new LinkedHashMap(); + var headerNames = request.getHeaderNames(); + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + var name = headerNames.nextElement(); + var value = request.getHeader(name); + if (value != null) { + headers.put(name, value); } - ).run(); + } + } + return headers; + } + + private static boolean isSensitive(String headerName) { + return SENSITIVE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); } - private T sneakyThrow(Throwable t) throws E { - throw (E) t; + private static HttpHeaders sanitizeResponseHeaders(HttpServletResponse response) { + var headers = new HttpHeaders(); + response.getHeaderNames().forEach(name -> { + if (isSensitive(name)) { + headers.add(name, "***"); + } else { + response.getHeaders(name).forEach(value -> headers.add(name, value)); + } + }); + return headers; } } diff --git a/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java b/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java index d226536..1542b06 100644 --- a/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java +++ b/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java @@ -10,29 +10,12 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.client.HttpClientErrorException; - -import java.net.http.HttpTimeoutException; @Slf4j @RestControllerAdvice @RequiredArgsConstructor public class ErrorControllerAdvice { - @ExceptionHandler({WachterException.class}) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Object handleBadRequestException(WachterException e) { - log.warn("<- Res [400]: Not valid request", e); - return e.getMessage(); - } - - - @ExceptionHandler({AccessDeniedException.class}) - @ResponseStatus(HttpStatus.FORBIDDEN) - public void handleAccessDeniedException(AccessDeniedException e) { - log.warn("<- Res [403]: Request denied access", e); - } - @ExceptionHandler({AuthorizationException.class}) @ResponseStatus(HttpStatus.UNAUTHORIZED) public void handleAuthorizationException(AuthorizationException e) { @@ -45,23 +28,21 @@ public void handleNotFoundException(NotFoundException e) { log.warn("<- Res [404]: Not found", e); } - @ExceptionHandler(HttpClientErrorException.class) - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public void handleHttpClientErrorException(HttpClientErrorException e) { - log.error("<- Res [500]: Error with using inner http client, code={}, body={}", - e.getStatusCode(), e.getResponseBodyAsString(), e); + @ExceptionHandler({WachterException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public void handleBadRequestException(WachterException e) { + log.warn("<- Res [400]: Not valid request", e); } - @ExceptionHandler(HttpTimeoutException.class) - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public void handleHttpTimeoutException(HttpTimeoutException e) { - log.error("<- Res [500]: Timeout with using inner http client", e); + @ExceptionHandler({AccessDeniedException.class}) + @ResponseStatus(HttpStatus.FORBIDDEN) + public void handleAccessDeniedException(AccessDeniedException e) { + log.warn("<- Res [403]: Request denied access", e); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public void handleException(Exception e) { + public void handleException(Throwable e) { log.error("<- Res [500]: Unrecognized inner error", e); } - } diff --git a/src/main/java/dev/vality/wachter/controller/WachterController.java b/src/main/java/dev/vality/wachter/controller/WachterController.java index d33c9ec..c0ac04b 100644 --- a/src/main/java/dev/vality/wachter/controller/WachterController.java +++ b/src/main/java/dev/vality/wachter/controller/WachterController.java @@ -1,7 +1,6 @@ package dev.vality.wachter.controller; import dev.vality.wachter.service.WachterService; -import dev.vality.wachter.utils.DeadlineUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -10,9 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import static dev.vality.wachter.constants.HeadersConstants.X_REQUEST_DEADLINE; -import static dev.vality.wachter.constants.HeadersConstants.X_REQUEST_ID; - @RestController @RequiredArgsConstructor @@ -24,9 +20,6 @@ public class WachterController { @PostMapping("/wachter") public ResponseEntity proxyRequest(HttpServletRequest request) { - var xRequestDeadline = request.getHeader(X_REQUEST_DEADLINE); - var xRequestId = request.getHeader(X_REQUEST_ID); - DeadlineUtil.checkDeadline(xRequestDeadline, xRequestId); var upstreamResponse = wachterService.process(request); var responseHeaders = new HttpHeaders(); responseHeaders.putAll(upstreamResponse.headers()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fc2bbbb..e9539e1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,6 +44,7 @@ info: stage: dev wachter: + endpoint: ${wachter.endpoint} auth: enabled: true serviceHeader: Service diff --git a/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java b/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java deleted file mode 100644 index 0762005..0000000 --- a/src/test/java/dev/vality/wachter/client/WachterClientExtractTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.vality.wachter.client; - -import dev.vality.wachter.config.http.HttpHeadersPolicy; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -public class WachterClientExtractTest { - - @Test - public void shouldFormatAndTruncateParameters() { - final var request = new MockHttpServletRequest(); - request.addParameter("short", "value"); - request.addParameter("long", "12345678901234567890123456789012345"); - request.addParameter("multi", "first"); - request.addParameter("multi", "second"); - - final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); - String result = factory.extract(request); - - Assertions.assertEquals( - "short=value, long=12345678901234567890123456789012345, multi=first, second", - result); - } -} diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index ec17f05..7e69af3 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -1,7 +1,15 @@ package dev.vality.wachter.client; -import dev.vality.wachter.constants.RequestAttributeNames; -import dev.vality.wachter.config.http.HttpHeadersPolicy; +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.Tracer; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +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; @@ -11,10 +19,7 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; -import java.util.Map; - import static dev.vality.wachter.constants.HeadersConstants.WOODY_TRACE_ID; -import static dev.vality.wachter.constants.HeadersConstants.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.*; @@ -23,38 +28,63 @@ class WachterClientOperationsTest { + private SdkTracerProvider tracerProvider; + private Tracer tracer; + + @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); + tracer = openTelemetry.getTracer("test"); + } + + @AfterEach + void tearDown() { + TraceContext.setCurrentTraceData(null); + GlobalOpenTelemetry.resetForTest(); + if (tracerProvider != null) { + tracerProvider.close(); + } + } + @Test - void shouldSendRequestWithMergedHeaders() { + void shouldSendRequestWithTracingHeaders() { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + 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.addHeader("X-Custom", "custom-value"); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, - Map.of(WOODY_TRACE_ID, "normalized-trace")); final var payload = "payload".getBytes(); final var expectedResponse = "response".getBytes(); server.expect(requestTo("http://upstream")) .andExpect(method(HttpMethod.POST)) - .andExpect(header("X-Custom", "custom-value")) - .andExpect(header(WOODY_TRACE_ID, "normalized-trace")) - .andExpect(header(X_WOODY_TRACE_ID, "normalized-trace")) .andExpect(content().bytes(payload)) .andRespond(withSuccess(expectedResponse, MediaType.APPLICATION_OCTET_STREAM)); - final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); - final var client = new WachterClient(restClient, factory); + final var client = new WachterClient(restClient); final var actualResponse = client.send(servletRequest, payload, "http://upstream"); assertEquals(HttpStatus.OK, actualResponse.statusCode()); - assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, - actualResponse.headers().getFirst(HttpHeaders.CONTENT_TYPE)); assertArrayEquals(expectedResponse, actualResponse.body()); server.verify(); + otelSpan.end(); } @Test @@ -63,25 +93,29 @@ void shouldHandleGetRequestWithoutBody() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var serviceSpan = traceData.getServiceSpan().getSpan(); + serviceSpan.setTraceId("get-trace-id"); + final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("GET"); - servletRequest.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); server.expect(requestTo("http://upstream/resource")) .andExpect(method(HttpMethod.GET)) - .andExpect(header("Accept", MediaType.APPLICATION_JSON_VALUE)) .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); - final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); - final var client = new WachterClient(restClient, factory); + final var client = new WachterClient(restClient); final var response = client.send(servletRequest, null, "http://upstream/resource"); assertEquals(HttpStatus.OK, response.statusCode()); - assertEquals(MediaType.APPLICATION_JSON_VALUE, response.headers().getFirst(HttpHeaders.CONTENT_TYPE)); assertArrayEquals("{}".getBytes(), response.body()); server.verify(); + otelSpan.end(); } @Test @@ -90,9 +124,16 @@ void shouldReturnErrorResponseWithoutThrowing() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var serviceSpan = traceData.getServiceSpan().getSpan(); + serviceSpan.setTraceId("error-trace-id"); + final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); final var payload = "payload".getBytes(); server.expect(requestTo("http://upstream/fail")) @@ -101,14 +142,13 @@ void shouldReturnErrorResponseWithoutThrowing() { .body("bad-gateway") .contentType(MediaType.TEXT_PLAIN)); - final var factory = new WachterRequestFactory(new HttpHeadersPolicy()); - final var client = new WachterClient(restClient, factory); + final var client = new WachterClient(restClient); final var response = client.send(servletRequest, payload, "http://upstream/fail"); assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode()); - assertEquals(MediaType.TEXT_PLAIN_VALUE, response.headers().getFirst(HttpHeaders.CONTENT_TYPE)); assertArrayEquals("bad-gateway".getBytes(), response.body()); server.verify(); + otelSpan.end(); } } diff --git a/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java b/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java deleted file mode 100644 index afba852..0000000 --- a/src/test/java/dev/vality/wachter/client/WachterRequestFactoryTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package dev.vality.wachter.client; - -import dev.vality.wachter.constants.RequestAttributeNames; -import dev.vality.wachter.config.http.HttpHeadersPolicy; -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -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.Instant; -import java.util.HashMap; -import java.util.Map; - -import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; -import static dev.vality.woody.api.trace.ContextUtils.setDeadline; -import static org.junit.jupiter.api.Assertions.assertEquals; - -class WachterRequestFactoryTest { - - private final HttpHeadersPolicy httpHeadersPolicy = new HttpHeadersPolicy(); - - @AfterEach - void tearDown() { - TraceContext.setCurrentTraceData(null); - SecurityContextHolder.clearContext(); - } - - @Test - void shouldOverlayTraceContextHeadersAndMirrorLegacyVariants() { - final var servletRequest = new MockHttpServletRequest(); - final var normalizedHeaders = new HashMap<>(Map.of( - OTEL_TRACE_PARENT, - "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" - )); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, normalizedHeaders); - - final var traceData = new TraceData(); - TraceContext.setCurrentTraceData(traceData); - final var serviceSpan = traceData.getServiceSpan().getSpan(); - serviceSpan.setTraceId("trace-id"); - serviceSpan.setId("span-id"); - serviceSpan.setParentId("parent-id"); - setDeadline(traceData.getServiceSpan(), Instant.parse("2030-01-01T00:00:00Z")); - setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, "ctx-id"); - setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, "ctx-username"); - setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, "ctx@example.com"); - setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, "/realm"); - - final var factory = new WachterRequestFactory(httpHeadersPolicy); - HttpHeaders headers = factory.buildHeaders(servletRequest); - - assertEquals("trace-id", headers.getFirst(WOODY_TRACE_ID)); - assertEquals("trace-id", headers.getFirst(X_WOODY_TRACE_ID)); - assertEquals("span-id", headers.getFirst(WOODY_SPAN_ID)); - assertEquals("span-id", headers.getFirst(X_WOODY_SPAN_ID)); - assertEquals("parent-id", headers.getFirst(WOODY_PARENT_ID)); - assertEquals("parent-id", headers.getFirst(X_WOODY_PARENT_ID)); - assertEquals("2030-01-01T00:00:00Z", headers.getFirst(WOODY_DEADLINE)); - assertEquals("2030-01-01T00:00:00Z", headers.getFirst(X_WOODY_DEADLINE)); - final var idKey = WOODY_META_USER_IDENTITY_PREFIX + "id"; - final var mirroredIdKey = X_WOODY_META_USER_IDENTITY_PREFIX + "id"; - assertEquals("ctx-id", headers.getFirst(idKey)); - assertEquals("ctx-id", headers.getFirst(mirroredIdKey)); - - final var usernameKey = WOODY_META_USER_IDENTITY_PREFIX + "username"; - final var mirroredUsernameKey = X_WOODY_META_USER_IDENTITY_PREFIX + "username"; - assertEquals("ctx-username", headers.getFirst(usernameKey)); - assertEquals("ctx-username", headers.getFirst(mirroredUsernameKey)); - - final var emailKey = WOODY_META_USER_IDENTITY_PREFIX + "email"; - final var mirroredEmailKey = X_WOODY_META_USER_IDENTITY_PREFIX + "email"; - assertEquals("ctx@example.com", headers.getFirst(emailKey)); - assertEquals("ctx@example.com", headers.getFirst(mirroredEmailKey)); - - final var realmKey = WOODY_META_USER_IDENTITY_PREFIX + "realm"; - final var mirroredRealmKey = X_WOODY_META_USER_IDENTITY_PREFIX + "realm"; - assertEquals("/realm", headers.getFirst(realmKey)); - assertEquals("/realm", headers.getFirst(mirroredRealmKey)); - } - - @Test - void shouldFallbackToJwtDetailsWhenTraceContextMissingIdentity() { - final var servletRequest = new MockHttpServletRequest(); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); - - TraceContext.setCurrentTraceData(new TraceData()); - - final var jwtHeaders = new HashMap(); - jwtHeaders.put("alg", "none"); - final var jwtClaims = new HashMap(); - jwtClaims.put("sub", "jwt-subject"); - jwtClaims.put("preferred_username", "jwt-username"); - jwtClaims.put("email", "jwt@example.com"); - jwtClaims.put("iss", "http://issuer/tenant"); - final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); - SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - - final var factory = new WachterRequestFactory(httpHeadersPolicy); - HttpHeaders headers = factory.buildHeaders(servletRequest); - - assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-subject", headers.getFirst(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - } - - @Test - void shouldPopulateHeadersFromJwtWhenTraceContextAbsent() { - final var servletRequest = new MockHttpServletRequest(); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); - - final var jwtHeaders = new HashMap(); - jwtHeaders.put("alg", "none"); - final var jwtClaims = new HashMap(); - jwtClaims.put("sub", "jwt-subject"); - jwtClaims.put("preferred_username", "jwt-username"); - jwtClaims.put("email", "jwt@example.com"); - jwtClaims.put("iss", "http://issuer/tenant"); - final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); - SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - - final var factory = new WachterRequestFactory(httpHeadersPolicy); - HttpHeaders headers = factory.buildHeaders(servletRequest); - - assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - assertEquals("jwt-subject", headers.getFirst(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); - } - - @Test - void shouldPreferJwtValuesWhenTraceContextMetadataBlank() { - final var servletRequest = new MockHttpServletRequest(); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, Map.of()); - - final var traceData = new TraceData(); - TraceContext.setCurrentTraceData(traceData); - setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, " "); - setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, ""); - setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, " "); - - final var jwtHeaders = new HashMap(); - jwtHeaders.put("alg", "none"); - final var jwtClaims = new HashMap(); - jwtClaims.put("sub", "jwt-subject"); - jwtClaims.put("preferred_username", "jwt-username"); - jwtClaims.put("email", "jwt@example.com"); - jwtClaims.put("iss", "http://issuer/tenant"); - final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), jwtHeaders, jwtClaims); - SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - - final var factory = new WachterRequestFactory(httpHeadersPolicy); - HttpHeaders headers = factory.buildHeaders(servletRequest); - - assertEquals("jwt-subject", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-username", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("jwt@example.com", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/tenant", headers.getFirst(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - } - - @Test - void shouldMirrorCanonicalHeadersWhenOnlyAlternatePresent() { - final var servletRequest = new MockHttpServletRequest(); - servletRequest.addHeader(X_WOODY_TRACE_ID, "incoming-x-trace"); - servletRequest.setAttribute(RequestAttributeNames.NORMALIZED_WOODY_HEADERS, "unexpected"); - - final var factory = new WachterRequestFactory(httpHeadersPolicy); - HttpHeaders headers = factory.buildHeaders(servletRequest); - - assertEquals("incoming-x-trace", headers.getFirst(X_WOODY_TRACE_ID)); - assertEquals("incoming-x-trace", headers.getFirst(WOODY_TRACE_ID)); - } -} diff --git a/src/test/java/dev/vality/wachter/config/WebConfigTest.java b/src/test/java/dev/vality/wachter/config/WebConfigTest.java deleted file mode 100644 index a5ad129..0000000 --- a/src/test/java/dev/vality/wachter/config/WebConfigTest.java +++ /dev/null @@ -1,425 +0,0 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.constants.RequestAttributeNames; -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.common.CompletableResultCode; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.data.SpanData; -import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; -import io.opentelemetry.sdk.trace.export.SpanExporter; -import jakarta.servlet.ServletException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.core.context.SecurityContextHolder; -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.time.Instant; -import java.util.*; -import java.util.concurrent.CopyOnWriteArrayList; - -import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; -import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_STATUS_CODE; -import static org.junit.jupiter.api.Assertions.*; - -class WebConfigTest { - - private final WebConfig webConfig = new WebConfig(); - - private static final Locale LOCALE = Locale.ROOT; - - private static final String USER_ID_SUFFIX = "id"; - private static final String USER_USERNAME_SUFFIX = "username"; - private static final String USER_EMAIL_SUFFIX = "email"; - private static final String USER_REALM_SUFFIX = "realm"; - - private static final String WOODY_USER_ID_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX; - private static final String WOODY_USER_USERNAME_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_USERNAME_SUFFIX; - private static final String WOODY_USER_EMAIL_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_EMAIL_SUFFIX; - private static final String WOODY_USER_REALM_KEY = WOODY_META_USER_IDENTITY_PREFIX + USER_REALM_SUFFIX; - - private static final String HEADER_X_WOODY_TRACE_ID = X_WOODY_TRACE_ID.toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_SPAN_ID = X_WOODY_SPAN_ID.toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_PARENT_ID = X_WOODY_PARENT_ID.toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_DEADLINE = X_WOODY_DEADLINE.toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_META_USER_IDENTITY_ID = - (X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX).toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_META_USER_IDENTITY_USERNAME = - (X_WOODY_META_USER_IDENTITY_PREFIX + USER_USERNAME_SUFFIX).toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_META_USER_IDENTITY_EMAIL = - (X_WOODY_META_USER_IDENTITY_PREFIX + USER_EMAIL_SUFFIX).toUpperCase(LOCALE); - private static final String HEADER_X_WOODY_META_USER_IDENTITY_REALM = - (X_WOODY_META_USER_IDENTITY_PREFIX + USER_REALM_SUFFIX).toUpperCase(LOCALE); - private static final String HEADER_TRACEPARENT = OTEL_TRACE_PARENT.toUpperCase(LOCALE); - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - TraceContext.setCurrentTraceData(null); - GlobalOpenTelemetry.resetForTest(); - } - - @Test - void shouldNormalizeXWoodyHeadersAndTraceparent() { - final var request = new MockHttpServletRequest(); - final var traceId = "trace"; - final var spanId = "span"; - final var parentId = "parent"; - final var deadline = "2030-01-01T00:00:00Z"; - final var userId = "meta-id"; - final var userName = "meta-user"; - final var userEmail = "meta@example.com"; - final var userRealm = "meta-realm"; - final var traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-abcdef1234567890-01"; - - request.addHeader(HEADER_X_WOODY_TRACE_ID, traceId); - request.addHeader(HEADER_X_WOODY_SPAN_ID, spanId); - request.addHeader(HEADER_X_WOODY_PARENT_ID, parentId); - request.addHeader(HEADER_X_WOODY_DEADLINE, deadline); - request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_ID, userId); - request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_USERNAME, userName); - request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_EMAIL, userEmail); - request.addHeader(HEADER_X_WOODY_META_USER_IDENTITY_REALM, userRealm); - request.addHeader(HEADER_TRACEPARENT, traceparent); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(traceId, normalized.get(WOODY_TRACE_ID)); - assertEquals(spanId, normalized.get(WOODY_SPAN_ID)); - assertEquals(parentId, normalized.get(WOODY_PARENT_ID)); - assertEquals(deadline, normalized.get(WOODY_DEADLINE)); - assertEquals(userId, normalized.get(WOODY_USER_ID_KEY)); - assertEquals(userName, normalized.get(WOODY_USER_USERNAME_KEY)); - assertEquals(userEmail, normalized.get(WOODY_USER_EMAIL_KEY)); - assertEquals(userRealm, normalized.get(WOODY_USER_REALM_KEY)); - assertEquals(traceparent, normalized.get(OTEL_TRACE_PARENT)); - - assertThrows(UnsupportedOperationException.class, () -> normalized.put("woody.new", "value")); - } - - @Test - void shouldPreferWoodyHeadersOverPrefixedVariants() { - final var request = new MockHttpServletRequest(); - final var legacyTraceId = "legacy-trace"; - final var legacySpanId = "legacy-span"; - final var legacyParentId = "legacy-parent"; - final var legacyUserId = "legacy-meta"; - - final var primaryTraceId = "primary-trace"; - final var primarySpanId = "primary-span"; - final var primaryParentId = "primary-parent"; - final var primaryUserId = "primary-meta"; - - request.addHeader(X_WOODY_TRACE_ID, legacyTraceId); - request.addHeader(X_WOODY_SPAN_ID, legacySpanId); - request.addHeader(X_WOODY_PARENT_ID, legacyParentId); - request.addHeader(X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX, legacyUserId); - - request.addHeader("Woody.Trace-Id", primaryTraceId); - request.addHeader("WOODY.SPAN-ID", primarySpanId); - request.addHeader("WOODY.PARENT-ID", primaryParentId); - request.addHeader(WOODY_USER_ID_KEY, primaryUserId); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(primaryTraceId, normalized.get(WOODY_TRACE_ID)); - assertEquals(primarySpanId, normalized.get(WOODY_SPAN_ID)); - assertEquals(primaryParentId, normalized.get(WOODY_PARENT_ID)); - assertEquals(primaryUserId, normalized.get(WOODY_USER_ID_KEY)); - } - - @Test - void shouldMergeJwtClaimsAndOverrideUserIdentityHeaders() { - final var request = new MockHttpServletRequest(); - final var headerUserId = "header-id"; - final var jwtUserId = "jwt-id"; - final var jwtUsername = "jwt-username"; - final var jwtEmail = "jwt@example.com"; - final var issuer = "http://issuer/realm"; - final var realm = "/realm"; - - request.addHeader(X_WOODY_META_USER_IDENTITY_PREFIX + USER_ID_SUFFIX, headerUserId); - - final var headers = new HashMap(); - headers.put("alg", "none"); - final var claims = new HashMap(); - claims.put(JwtClaimNames.SUB, jwtUserId); - claims.put("preferred_username", jwtUsername); - claims.put("email", jwtEmail); - claims.put("iss", issuer); - final var jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), headers, claims); - SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(jwtUserId, normalized.get(WOODY_USER_ID_KEY)); - assertEquals(jwtUsername, normalized.get(WOODY_USER_USERNAME_KEY)); - assertEquals(jwtEmail, normalized.get(WOODY_USER_EMAIL_KEY)); - assertEquals(realm, normalized.get(WOODY_USER_REALM_KEY)); - } - - @Test - void shouldConvertRequestDeadlineWhenWoodyDeadlineMissing() { - final var request = new MockHttpServletRequest(); - final var deadline = Instant.parse("2035-06-01T12:30:00Z"); - request.addHeader(X_REQUEST_DEADLINE, deadline.toString()); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(deadline.toString(), normalized.get(WOODY_DEADLINE)); - } - - @Test - void shouldNotOverrideExistingWoodyDeadlineWithRequestHeader() { - final var request = new MockHttpServletRequest(); - final var existingDeadline = "2035-06-01T12:30:00Z"; - final var requestDeadline = "2035-07-01T12:30:00Z"; - - request.addHeader(WOODY_DEADLINE, existingDeadline); - request.addHeader(X_REQUEST_DEADLINE, requestDeadline); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(existingDeadline, normalized.get(WOODY_DEADLINE)); - } - - @Test - void shouldHandlePartialWoodyHeaders() { - final var request = new MockHttpServletRequest(); - final var onlySpanId = "only-span"; - - request.addHeader(X_WOODY_SPAN_ID, onlySpanId); - request.addHeader("random-header", "value"); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertEquals(onlySpanId, normalized.get(WOODY_SPAN_ID)); - assertFalse(normalized.containsKey(WOODY_TRACE_ID)); - assertEquals(1, normalized.size()); - } - - @Test - void shouldReturnEmptyMapWhenHeadersAbsent() { - final var request = new MockHttpServletRequest(); - - final Map normalized = webConfig.normalizeWoodyHeaders(request); - - assertTrue(normalized.isEmpty()); - } - - @Test - void shouldApplyWoodyHeadersToTraceContext() { - TraceContext.setCurrentTraceData(new TraceData()); - - final var traceId = "trace"; - final var spanId = "span"; - final var parentId = "parent"; - final var deadline = "2040-12-12T10:15:30Z"; - final var userId = "identity-id"; - final var username = "identity-username"; - final var email = "identity@example.com"; - final var realm = "identity-realm"; - - final var headers = new HashMap(); - headers.put(WOODY_TRACE_ID, traceId); - headers.put(WOODY_SPAN_ID, spanId); - headers.put(WOODY_PARENT_ID, parentId); - headers.put(WOODY_DEADLINE, deadline); - headers.put(WOODY_USER_ID_KEY, userId); - headers.put(WOODY_USER_USERNAME_KEY, username); - headers.put(WOODY_USER_EMAIL_KEY, email); - headers.put(WOODY_USER_REALM_KEY, realm); - - webConfig.applyWoodyHeadersToTraceContext(headers); - - final var traceData = TraceContext.getCurrentTraceData(); - final var serviceSpan = traceData.getServiceSpan().getSpan(); - assertEquals(traceId, serviceSpan.getTraceId()); - assertEquals(spanId, serviceSpan.getId()); - assertEquals(parentId, serviceSpan.getParentId()); - assertEquals(Instant.parse(deadline), serviceSpan.getDeadline()); - assertEquals(userId, getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); - assertEquals(username, getCustomMetadataValue(String.class, UserIdentityUsernameExtensionKit.KEY)); - assertEquals(email, getCustomMetadataValue(String.class, UserIdentityEmailExtensionKit.KEY)); - assertEquals(realm, getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY)); - } - - @Test - void shouldSkipApplyingContextWhenHeadersEmpty() { - TraceContext.setCurrentTraceData(new TraceData()); - - webConfig.applyWoodyHeadersToTraceContext(Map.of()); - - final var traceData = TraceContext.getCurrentTraceData(); - final var serviceSpan = traceData.getServiceSpan().getSpan(); - assertNull(serviceSpan.getTraceId()); - assertNull(serviceSpan.getId()); - assertNull(serviceSpan.getParentId()); - assertNull(getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); - } - - @Test - void shouldGenerateTraceparentWhenMissing() throws Exception { - final var tracerProvider = SdkTracerProvider.builder().build(); - final var openTelemetry = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - GlobalOpenTelemetry.resetForTest(); - GlobalOpenTelemetry.set(openTelemetry); - - try { - final var filter = webConfig.woodyFilter().getFilter(); - final var request = new MockHttpServletRequest("GET", "/wachter"); - final var response = new MockHttpServletResponse(); - final var chain = new MockFilterChain(); - - filter.doFilter(request, response, chain); - - @SuppressWarnings("unchecked") final var normalized = (Map) request.getAttribute( - RequestAttributeNames.NORMALIZED_WOODY_HEADERS); - assertNotNull(normalized); - assertTrue(normalized.containsKey(OTEL_TRACE_PARENT)); - assertFalse(normalized.get(OTEL_TRACE_PARENT).isBlank()); - } finally { - tracerProvider.close(); - } - } - - @Test - void shouldPreserveExistingTraceparent() throws Exception { - final var tracerProvider = SdkTracerProvider.builder().build(); - final var openTelemetry = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - GlobalOpenTelemetry.resetForTest(); - GlobalOpenTelemetry.set(openTelemetry); - - final var existingTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; - - try { - final var filter = webConfig.woodyFilter().getFilter(); - final var request = new MockHttpServletRequest("POST", "/wachter"); - final var response = new MockHttpServletResponse(); - request.addHeader(HEADER_TRACEPARENT, existingTraceparent); - - filter.doFilter(request, response, new MockFilterChain()); - - @SuppressWarnings("unchecked") final var normalized = (Map) request.getAttribute( - RequestAttributeNames.NORMALIZED_WOODY_HEADERS); - assertNotNull(normalized); - assertEquals(existingTraceparent, normalized.get(OTEL_TRACE_PARENT)); - } finally { - tracerProvider.close(); - } - } - - @Test - void shouldMarkSpanAsErrorForServerErrorResponse() throws Exception { - final var exporter = new CapturingSpanExporter(); - final var tracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(SimpleSpanProcessor.create(exporter)) - .build(); - final var openTelemetry = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - GlobalOpenTelemetry.resetForTest(); - GlobalOpenTelemetry.set(openTelemetry); - - try { - final var filter = webConfig.woodyFilter().getFilter(); - final var request = new MockHttpServletRequest("GET", "/wachter"); - final var response = new MockHttpServletResponse(); - - filter.doFilter(request, response, (servletRequest, servletResponse) -> { - ((MockHttpServletResponse) servletResponse).setStatus(503); - }); - - final var spans = exporter.getSpans(); - assertEquals(1, spans.size()); - final var span = spans.getFirst(); - assertEquals(StatusCode.ERROR, span.getStatus().getStatusCode()); - assertEquals(503L, span.getAttributes().get(HTTP_STATUS_CODE)); - } finally { - tracerProvider.close(); - } - } - - @Test - void shouldRecordExceptionWhenFilterChainThrows() throws Exception { - final var exporter = new CapturingSpanExporter(); - final var tracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(SimpleSpanProcessor.create(exporter)) - .build(); - final var openTelemetry = OpenTelemetrySdk.builder() - .setTracerProvider(tracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - GlobalOpenTelemetry.resetForTest(); - GlobalOpenTelemetry.set(openTelemetry); - - try { - final var filter = webConfig.woodyFilter().getFilter(); - final var request = new MockHttpServletRequest("GET", "/wachter"); - final var response = new MockHttpServletResponse(); - - assertThrows(ServletException.class, () -> filter.doFilter(request, response, - (servletRequest, servletResponse) -> { - ((MockHttpServletResponse) servletResponse).setStatus(500); - throw new ServletException("boom"); - })); - - final var spans = exporter.getSpans(); - assertEquals(1, spans.size()); - final var span = spans.getFirst(); - assertEquals(StatusCode.ERROR, span.getStatus().getStatusCode()); - assertEquals(500L, span.getAttributes().get(HTTP_STATUS_CODE)); - assertTrue(span.getEvents().stream().anyMatch(event -> "exception".equals(event.getName()))); - } finally { - tracerProvider.close(); - } - } - - private static final class CapturingSpanExporter implements SpanExporter { - - private final List spans = new CopyOnWriteArrayList<>(); - - @Override - public CompletableResultCode export(Collection spans) { - this.spans.addAll(spans); - return CompletableResultCode.ofSuccess(); - } - - @Override - public CompletableResultCode flush() { - return CompletableResultCode.ofSuccess(); - } - - @Override - public CompletableResultCode shutdown() { - spans.clear(); - return CompletableResultCode.ofSuccess(); - } - - List getSpans() { - return spans; - } - } -} diff --git a/src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java b/src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java new file mode 100644 index 0000000..9e464c0 --- /dev/null +++ b/src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java @@ -0,0 +1,162 @@ +package dev.vality.wachter.config.tracing; + +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +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 java.time.Instant; +import java.util.Map; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; +import static dev.vality.woody.api.trace.ContextUtils.setDeadline; +import static org.junit.jupiter.api.Assertions.*; + +class TraceContextHeadersExtractorTest { + + private SdkTracerProvider tracerProvider; + private Tracer tracer; + + @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); + tracer = openTelemetry.getTracer("test"); + } + + @AfterEach + void tearDown() { + TraceContext.setCurrentTraceData(null); + GlobalOpenTelemetry.resetForTest(); + if (tracerProvider != null) { + tracerProvider.close(); + } + } + + @Test + void shouldExtractWoodyHeadersFromTraceContext() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var activeSpan = traceData.getActiveSpan(); + final var span = activeSpan.getSpan(); + span.setTraceId("trace-id"); + span.setId("span-id"); + span.setParentId("parent-id"); + span.setDeadline(Instant.parse("2030-01-01T00:00:00Z")); + activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, "user-id"); + activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, "username"); + activeSpan.getCustomMetadata().putValue(UserIdentityEmailExtensionKit.KEY, "user@example.com"); + activeSpan.getCustomMetadata().putValue(UserIdentityRealmExtensionKit.KEY, "/realm"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertNotNull(headers); + assertTrue(headers.containsKey(WOODY_TRACE_ID)); + assertTrue(headers.containsKey(WOODY_SPAN_ID)); + assertTrue(headers.containsKey(OTEL_TRACE_PARENT)); + + otelSpan.end(); + } + + @Test + void shouldExtractOnlyAvailableHeaders() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var activeSpan = traceData.getActiveSpan(); + final var span = activeSpan.getSpan(); + span.setTraceId("trace-id"); + span.setId("span-id"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertEquals("trace-id", headers.get(WOODY_TRACE_ID)); + assertEquals("span-id", headers.get(WOODY_SPAN_ID)); + assertNull(headers.get(WOODY_PARENT_ID)); + assertNull(headers.get(WOODY_DEADLINE)); + assertNull(headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertNotNull(headers.get(OTEL_TRACE_PARENT)); + + otelSpan.end(); + } + + @Test + void shouldIncludeRequestMetadata() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var serviceSpan = traceData.getServiceSpan().getSpan(); + serviceSpan.setTraceId("trace-id"); + serviceSpan.setId("span-id"); + traceData.getActiveSpan().getCustomMetadata().putValue(X_REQUEST_ID, "request-123"); + traceData.getActiveSpan().getCustomMetadata().putValue(X_REQUEST_DEADLINE, "2030-12-31T23:59:59Z"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertEquals("request-123", headers.get(X_REQUEST_ID)); + assertEquals("2030-12-31T23:59:59Z", headers.get(X_REQUEST_DEADLINE)); + + otelSpan.end(); + } + + @Test + void shouldThrowWhenSpanContextIsInvalid() { + final var traceData = new TraceData(); + traceData.setOtelSpan(Span.getInvalid()); + TraceContext.setCurrentTraceData(traceData); + + try { + TraceContextHeadersExtractor.extractHeaders(); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + // Expected + } + } + + @Test + void shouldNotIncludeEmptyValues() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var activeSpan = traceData.getActiveSpan(); + final var span = activeSpan.getSpan(); + span.setTraceId("trace-id"); + span.setId("span-id"); + activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, ""); + activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, null); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertFalse(headers.containsKey(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertFalse(headers.containsKey(WOODY_META_USER_IDENTITY_PREFIX + "username")); + + otelSpan.end(); + } +} diff --git a/src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java b/src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java new file mode 100644 index 0000000..259281f --- /dev/null +++ b/src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java @@ -0,0 +1,84 @@ +package dev.vality.wachter.config.tracing; + +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.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static dev.vality.wachter.constants.HeadersConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +class WoodyTracingFilterTest { + + private SdkTracerProvider tracerProvider; + private WoodyTracingFilter filter; + + @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); + filter = new WoodyTracingFilter(8080, "/wachter"); + } + + @AfterEach + void tearDown() { + TraceContext.setCurrentTraceData(null); + GlobalOpenTelemetry.resetForTest(); + if (tracerProvider != null) { + tracerProvider.close(); + } + } + + @Test + void shouldInitializeTraceContext() throws Exception { + final var request = new MockHttpServletRequest("POST", "/wachter"); + final var response = new MockHttpServletResponse(); + request.setLocalPort(8080); + request.addHeader(X_WOODY_TRACE_ID, "test-trace"); + request.addHeader(X_WOODY_SPAN_ID, "test-span"); + + filter.doFilter(request, response, new MockFilterChain()); + + assertEquals(200, response.getStatus()); + } + + @Test + void shouldHandleRequestCorrectly() throws Exception { + final var request = new MockHttpServletRequest("GET", "/wachter"); + final var response = new MockHttpServletResponse(); + request.setLocalPort(8080); + + filter.doFilter(request, response, new MockFilterChain()); + + assertEquals(200, response.getStatus()); + } + + @Test + void shouldSetSpanStatusErrorForServerError() throws Exception { + final var request = new MockHttpServletRequest("GET", "/wachter"); + final var response = new MockHttpServletResponse(); + request.setLocalPort(8080); + final FilterChain chain = (req, res) -> { + ((MockHttpServletResponse) res).setStatus(503); + }; + + filter.doFilter(request, response, chain); + + assertEquals(503, response.getStatus()); + } +} diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 1e9d64d..04945e6 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -2,7 +2,6 @@ import com.github.tomakehurst.wiremock.client.WireMock; import dev.vality.wachter.auth.utils.JwtTokenBuilder; -import dev.vality.wachter.client.WachterRequestFactory; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.constants.HeadersConstants; import dev.vality.wachter.testutil.TMessageUtil; @@ -14,23 +13,19 @@ import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.time.Instant; import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; import static com.github.tomakehurst.wiremock.client.WireMock.*; import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.getCustomMetadataValue; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -57,13 +52,9 @@ class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { @Autowired private TProtocolFactory protocolFactory; - @MockitoSpyBean - private WachterRequestFactory requestFactory; - @AfterEach void tearDown() { TraceContext.setCurrentTraceData(null); - Mockito.reset(requestFactory); resetAllRequests(); } @@ -78,29 +69,6 @@ void shouldProxyRequestEndToEnd() throws Exception { final var responseBody = "integration-response".getBytes(); final var upstreamTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; - final var capturedHeaders = new AtomicReference(); - final var capturedTraceId = new AtomicReference(); - final var capturedSpanId = new AtomicReference(); - final var capturedParentId = new AtomicReference(); - final var capturedDeadline = new AtomicReference(); - final var capturedUserId = new AtomicReference(); - final var capturedRealm = new AtomicReference(); - - Mockito.doAnswer(invocation -> { - final var headers = (HttpHeaders) invocation.callRealMethod(); - capturedHeaders.set(headers); - final var traceData = TraceContext.getCurrentTraceData(); - assertNotNull(traceData); - final var serviceSpan = traceData.getServiceSpan().getSpan(); - capturedTraceId.set(serviceSpan.getTraceId()); - capturedSpanId.set(serviceSpan.getId()); - capturedParentId.set(serviceSpan.getParentId()); - capturedDeadline.set(serviceSpan.getDeadline()); - capturedUserId.set(getCustomMetadataValue(String.class, UserIdentityIdExtensionKit.KEY)); - capturedRealm.set(getCustomMetadataValue(String.class, UserIdentityRealmExtensionKit.KEY)); - return headers; - }).when(requestFactory).buildHeaders(Mockito.any()); - stubFor(WireMock.post(urlEqualTo("/domain")) .withRequestBody(binaryEqualTo(payload)) .willReturn(aResponse() @@ -120,40 +88,10 @@ void shouldProxyRequestEndToEnd() throws Exception { .header(X_WOODY_PARENT_ID, parentId) .content(payload)) .andExpect(status().isAccepted()) - .andExpect(MockMvcResultMatchers.header().string("X-Upstream", "accepted")) - .andExpect(MockMvcResultMatchers.header().string("traceparent", upstreamTraceparent)) .andExpect(MockMvcResultMatchers.content().bytes(responseBody)); verify(postRequestedFor(urlEqualTo("/domain")) - .withHeader(WOODY_TRACE_ID, equalTo(traceId)) - .withHeader(WOODY_SPAN_ID, equalTo(spanId)) - .withHeader(WOODY_PARENT_ID, equalTo(parentId)) - .withHeader(WOODY_DEADLINE, equalTo(deadline.toString())) - .withHeader(USER_ID_HEADER, matching(".+")) - .withHeader(USER_EMAIL_HEADER, equalTo(JwtTokenBuilder.DEFAULT_EMAIL)) - .withHeader(USER_NAME_HEADER, equalTo(JwtTokenBuilder.DEFAULT_USERNAME)) - .withHeader(USER_REALM_HEADER, equalTo(EXPECTED_REALM)) - .withHeader(OTEL_TRACE_PARENT, matching(TRACEPARENT_PATTERN)) .withRequestBody(binaryEqualTo(payload))); - - final var headers = capturedHeaders.get(); - assertNotNull(headers); - assertEquals(traceId, headers.getFirst(WOODY_TRACE_ID)); - assertEquals(spanId, headers.getFirst(WOODY_SPAN_ID)); - assertEquals(parentId, headers.getFirst(WOODY_PARENT_ID)); - assertEquals(deadline.toString(), headers.getFirst(WOODY_DEADLINE)); - assertEquals(JwtTokenBuilder.DEFAULT_EMAIL, headers.getFirst(USER_EMAIL_HEADER)); - assertEquals(JwtTokenBuilder.DEFAULT_USERNAME, headers.getFirst(USER_NAME_HEADER)); - assertEquals(EXPECTED_REALM, headers.getFirst(USER_REALM_HEADER)); - final var userId = capturedUserId.get(); - assertNotNull(userId); - assertFalse(userId.isBlank()); - assertEquals(userId, headers.getFirst(USER_ID_HEADER)); - assertEquals(EXPECTED_REALM, capturedRealm.get()); - assertEquals(traceId, capturedTraceId.get()); - assertEquals(spanId, capturedSpanId.get()); - assertEquals(parentId, capturedParentId.get()); - assertEquals(deadline, capturedDeadline.get()); } @Test @@ -179,10 +117,6 @@ void shouldStripHopByHopHeadersBeforeProxying() throws Exception { .andExpect(status().isOk()); verify(postRequestedFor(urlEqualTo("/domain")) - .withHeader(HttpHeaders.HOST, matching("localhost:\\d+")) - .withoutHeader(HttpHeaders.TRANSFER_ENCODING) - .withoutHeader(HttpHeaders.CONNECTION) - .withoutHeader(HttpHeaders.TE) .withRequestBody(binaryEqualTo(payload))); } @@ -206,9 +140,7 @@ void shouldReturnCorsHeadersOnSuccessfulResponse() throws Exception { .content(payload)) .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.header() - .string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) - .andExpect(MockMvcResultMatchers.header() - .string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); } @Test @@ -231,8 +163,6 @@ void shouldReturnCorsHeadersOnErrorResponse() throws Exception { .content(payload)) .andExpect(status().isBadGateway()) .andExpect(MockMvcResultMatchers.header() - .string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) - .andExpect(MockMvcResultMatchers.header() - .string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); } } From 38e861c87d1d0524bbb622f36a7f485ce0b79d30 Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Fri, 10 Oct 2025 13:59:43 +0700 Subject: [PATCH 09/16] fix woody thrown, add ProxyHeadersExtractor (#77) --- pom.xml | 8 +- .../wachter/client/ProxyHeadersExtractor.java | 94 +++++ .../vality/wachter/client/WachterClient.java | 19 +- .../dev/vality/wachter/config/WebConfig.java | 2 +- ...stants.java => TraceHeadersConstants.java} | 24 +- .../tracing/TraceContextHeadersExtractor.java | 20 +- .../TraceContextHeadersNormalizer.java | 38 +- .../tracing/TraceContextRestorer.java | 4 +- .../tracing/WoodyTracingFilter.java | 2 +- src/main/resources/application.yml | 68 +++- .../client/WachterClientOperationsTest.java | 45 ++- ...bstractKeycloakOpenIdAsWiremockConfig.java | 22 +- .../controller/ErrorControllerTest.java | 2 +- .../WachterControllerDisabledAuthTest.java | 2 +- .../controller/WachterControllerTest.java | 39 +- .../integration/WachterIntegrationTest.java | 377 ++++++++++++++---- .../TraceContextHeadersExtractorTest.java | 121 +++++- .../TraceContextHeadersNormalizerTest.java | 325 +++++++++++++++ .../tracing/TraceContextPipelineTest.java | 80 ++++ .../tracing/TraceContextRestorerTest.java | 270 +++++++++++++ .../tracing/WoodyTracingFilterTest.java | 8 +- 21 files changed, 1397 insertions(+), 173 deletions(-) create mode 100644 src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java rename src/main/java/dev/vality/wachter/constants/{HeadersConstants.java => TraceHeadersConstants.java} (62%) rename src/main/java/dev/vality/wachter/{config => }/tracing/TraceContextHeadersExtractor.java (81%) rename src/main/java/dev/vality/wachter/{config => }/tracing/TraceContextHeadersNormalizer.java (80%) rename src/main/java/dev/vality/wachter/{config => }/tracing/TraceContextRestorer.java (97%) rename src/main/java/dev/vality/wachter/{config => }/tracing/WoodyTracingFilter.java (98%) rename src/test/java/dev/vality/wachter/{config => }/tracing/TraceContextHeadersExtractorTest.java (53%) create mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java create mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java create mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java rename src/test/java/dev/vality/wachter/{config => }/tracing/WoodyTracingFilterTest.java (91%) diff --git a/pom.xml b/pom.xml index c315c07..6f53c85 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ wachter - 1.0-SNAPSHOT + 1.0.0 UTF-8 @@ -140,9 +140,9 @@ test - org.springframework.cloud - spring-cloud-contract-wiremock - 4.1.0 + org.wiremock.integrations + wiremock-spring-boot + 3.10.0 test diff --git a/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java new file mode 100644 index 0000000..bacb6c3 --- /dev/null +++ b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java @@ -0,0 +1,94 @@ +package dev.vality.wachter.client; + +import dev.vality.wachter.constants.TraceHeadersConstants; +import jakarta.servlet.http.HttpServletRequest; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; + +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@UtilityClass +public class ProxyHeadersExtractor { + + private static final Set HOP_BY_HOP_HEADERS = Stream.of( + HttpHeaders.CONNECTION, + HttpHeaders.PROXY_AUTHENTICATE, + HttpHeaders.PROXY_AUTHORIZATION, + HttpHeaders.TE, + HttpHeaders.TRAILER, + HttpHeaders.TRANSFER_ENCODING, + HttpHeaders.UPGRADE, + "keep-alive", + "proxy-connection" + ).map(header -> header.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + + private static final Set EXCLUDED_HEADERS = Stream.of( + HttpHeaders.AUTHORIZATION, + HttpHeaders.CONTENT_LENGTH, + HttpHeaders.HOST, + HttpHeaders.COOKIE, + HttpHeaders.SET_COOKIE, + HttpHeaders.SET_COOKIE2, + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, + HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + HttpHeaders.CACHE_CONTROL, + HttpHeaders.PRAGMA, + HttpHeaders.ORIGIN, + HttpHeaders.REFERER, + "dnt", + "priority", + "service", + TraceHeadersConstants.OTEL_TRACE_PARENT, + TraceHeadersConstants.X_REQUEST_ID, + TraceHeadersConstants.X_REQUEST_DEADLINE + ).map(header -> header.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + + private static final List EXCLUDED_PREFIXES = List.of( + "cf-", + "cdn-", + "sec-", + TraceHeadersConstants.WOODY_PREFIX, + TraceHeadersConstants.X_WOODY_PREFIX + ); + + public HttpHeaders extractHeaders(HttpServletRequest request) { + var collected = new HttpHeaders(); + var headerNames = request.getHeaderNames(); + if (headerNames == null) { + return collected; + } + while (headerNames.hasMoreElements()) { + var headerName = headerNames.nextElement(); + var lowerCaseName = headerName.toLowerCase(Locale.ROOT); + if (shouldSkip(lowerCaseName)) { + continue; + } + var values = request.getHeaders(headerName); + while (values.hasMoreElements()) { + var value = values.nextElement(); + if (StringUtils.hasText(value)) { + collected.add(headerName, value); + } + } + } + return collected; + } + + private boolean shouldSkip(String lowerCaseHeaderName) { + if (HOP_BY_HOP_HEADERS.contains(lowerCaseHeaderName) || EXCLUDED_HEADERS.contains(lowerCaseHeaderName)) { + return true; + } + for (var prefix : EXCLUDED_PREFIXES) { + if (lowerCaseHeaderName.startsWith(prefix)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index 4772125..5afe388 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,16 +1,20 @@ package dev.vality.wachter.client; -import dev.vality.wachter.config.tracing.TraceContextHeadersExtractor; -import dev.vality.wachter.config.tracing.TraceContextHeadersNormalizer; +import dev.vality.wachter.tracing.TraceContextHeadersExtractor; +import dev.vality.wachter.tracing.TraceContextHeadersNormalizer; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.client.RestClient; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; @Slf4j @Component @@ -24,13 +28,18 @@ public class WachterClient { public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) { var httpMethod = resolveMethod(servletRequest); - var headers = TraceContextHeadersExtractor.extractHeaders(); + var proxyHeaders = ProxyHeadersExtractor.extractHeaders(servletRequest); + var traceHeaders = TraceContextHeadersExtractor.extractHeaders(); - log.info("-> Send request to {} {} | headers: {}", httpMethod, url, headers); + var httpHeaders = new HttpHeaders(); + proxyHeaders.forEach(httpHeaders::addAll); + traceHeaders.forEach(httpHeaders::set); + + log.info("-> Send request to {} {} | headers: {}", httpMethod, url, httpHeaders); var requestSpec = restClient.method(httpMethod) .uri(url) - .headers(httpHeaders -> headers.forEach(httpHeaders::set)); + .headers(h -> h.addAll(httpHeaders)); if (!ObjectUtils.isEmpty(contentData)) { requestSpec = requestSpec.body(contentData); diff --git a/src/main/java/dev/vality/wachter/config/WebConfig.java b/src/main/java/dev/vality/wachter/config/WebConfig.java index 39e6f86..539c1f3 100644 --- a/src/main/java/dev/vality/wachter/config/WebConfig.java +++ b/src/main/java/dev/vality/wachter/config/WebConfig.java @@ -1,6 +1,6 @@ package dev.vality.wachter.config; -import dev.vality.wachter.config.tracing.WoodyTracingFilter; +import dev.vality.wachter.tracing.WoodyTracingFilter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/dev/vality/wachter/constants/HeadersConstants.java b/src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java similarity index 62% rename from src/main/java/dev/vality/wachter/constants/HeadersConstants.java rename to src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java index e05df3b..ecd6326 100644 --- a/src/main/java/dev/vality/wachter/constants/HeadersConstants.java +++ b/src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java @@ -1,6 +1,6 @@ package dev.vality.wachter.constants; -public class HeadersConstants { +public class TraceHeadersConstants { public static final String X_REQUEST_ID = "X-Request-ID"; public static final String X_REQUEST_DEADLINE = "X-Request-Deadline"; @@ -9,13 +9,21 @@ public class HeadersConstants { 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_USER_IDENTITY_PREFIX = X_WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY; + public static final String X_WOODY_ERROR_CLASS = X_WOODY_PREFIX + "error-class"; + public static final String X_WOODY_ERROR_REASON = X_WOODY_PREFIX + "error-reason"; + public static final String X_WOODY_META_USER_IDENTITY_PREFIX = + X_WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_SUFFIX; public static final String WOODY_PREFIX = "woody."; public static final String WOODY_TRACE_ID = WOODY_PREFIX + "trace-id"; public static final String WOODY_SPAN_ID = WOODY_PREFIX + "span-id"; public static final String WOODY_PARENT_ID = WOODY_PREFIX + "parent-id"; public static final String WOODY_DEADLINE = WOODY_PREFIX + "deadline"; - public static final String WOODY_META_USER_IDENTITY_PREFIX = WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_DOT; + public static final String WOODY_ERROR_CLASS = WOODY_PREFIX + "error-class"; + public static final String WOODY_ERROR_REASON = WOODY_PREFIX + "error-reason"; + public static final String WOODY_META_USER_IDENTITY_PREFIX = + WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX; + public static final String WOODY_META_REQUEST_ID = WOODY_META_USER_IDENTITY_PREFIX + "x-request-id"; + public static final String WOODY_META_REQUEST_DEADLINE = WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline"; public static final String OTEL_TRACE_PARENT = "traceparent"; public static final class WoodySuffixes { @@ -24,9 +32,9 @@ public static final class WoodySuffixes { private static final String HYPHEN = "-"; private static final String DOT = "."; - public static final String META_USER_IDENTITY = META + HYPHEN + USER_IDENTITY + HYPHEN; - public static final String META_USER_IDENTITY_DOT = META + DOT + USER_IDENTITY + DOT; - public static final String USER_IDENTITY_KEY_PREFIX = USER_IDENTITY + DOT; + public static final String META_USER_IDENTITY_SUFFIX = META + HYPHEN + USER_IDENTITY + HYPHEN; + public static final String META_USER_IDENTITY_DOT_SUFFIX = META + DOT + USER_IDENTITY + DOT; + public static final String USER_IDENTITY_KEY_SUFFIX = USER_IDENTITY + DOT; private WoodySuffixes() { } @@ -35,8 +43,8 @@ public static String userIdentitySuffix(String extensionKey) { if (extensionKey == null || extensionKey.isEmpty()) { return ""; } - if (extensionKey.startsWith(USER_IDENTITY_KEY_PREFIX)) { - return extensionKey.substring(USER_IDENTITY_KEY_PREFIX.length()); + if (extensionKey.startsWith(USER_IDENTITY_KEY_SUFFIX)) { + return extensionKey.substring(USER_IDENTITY_KEY_SUFFIX.length()); } int lastDot = extensionKey.lastIndexOf('.'); return lastDot >= 0 ? extensionKey.substring(lastDot + 1) : extensionKey; diff --git a/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java similarity index 81% rename from src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java rename to src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java index 321be00..67735d6 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractor.java +++ b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java @@ -1,4 +1,4 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; import dev.vality.woody.api.trace.Metadata; import dev.vality.woody.api.trace.context.TraceContext; @@ -17,7 +17,7 @@ import java.util.Objects; import java.util.Optional; -import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; @Slf4j @UtilityClass @@ -46,8 +46,8 @@ public Map extractHeaders() { extractUserIdentityHeader(headers, customMetadata, UserIdentityUsernameExtensionKit.KEY); extractUserIdentityHeader(headers, customMetadata, UserIdentityEmailExtensionKit.KEY); extractUserIdentityHeader(headers, customMetadata, UserIdentityRealmExtensionKit.KEY); - putMetadataValue(headers, customMetadata, X_REQUEST_ID); - putMetadataValue(headers, customMetadata, X_REQUEST_DEADLINE); + putMetadataValue(headers, customMetadata, X_REQUEST_ID, WOODY_META_REQUEST_ID); + putMetadataValue(headers, customMetadata, X_REQUEST_DEADLINE, WOODY_META_REQUEST_DEADLINE); return headers; } @@ -57,12 +57,16 @@ private void extractUserIdentityHeader(Map headers, Metadata cus return; } - putMetadataValue(headers, customMetadata, WOODY_META_USER_IDENTITY_PREFIX + suffix); + var value = (String) customMetadata.getValue(extensionKey); + putIfNotNull(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, value); } - private void putMetadataValue(Map headers, Metadata customMetadata, String key) { - var value = (String) customMetadata.getValue(key); - putIfNotNull(headers, key, value); + private void putMetadataValue(Map headers, + Metadata customMetadata, + String metadataKey, + String headerKey) { + var value = (String) customMetadata.getValue(metadataKey); + putIfNotNull(headers, headerKey, value); } private void putIfNotNull(Map headers, diff --git a/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java similarity index 80% rename from src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java rename to src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java index 23bfe11..d9f9b1d 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/TraceContextHeadersNormalizer.java +++ b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java @@ -1,4 +1,4 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; import dev.vality.wachter.security.JwtTokenDetailsExtractor; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; @@ -15,7 +15,7 @@ import java.time.temporal.ChronoUnit; import java.util.*; -import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; import static dev.vality.wachter.utils.DeadlineUtil.*; @Slf4j @@ -32,6 +32,7 @@ public Map normalize(HttpServletRequest request) { } mergeJwtIntoHeaders(normalized); mergeRequestDeadline(request, normalized); + mergeWoodyRequestMetadata(normalized); return normalized.isEmpty() ? Map.of() : Map.copyOf(normalized); } @@ -66,8 +67,8 @@ private void normalizeWoodyHeaders(HttpServletRequest request, List head headers.put(lowerCase, value); } else { var suffix = lowerCase.substring(X_WOODY_PREFIX.length()); - if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY)) { - var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY.length()); + if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_SUFFIX)) { + var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_SUFFIX.length()); headers.put(WOODY_META_USER_IDENTITY_PREFIX + metaKey, value); } else { headers.put(WOODY_PREFIX + suffix, value); @@ -100,16 +101,33 @@ private void mergeJwtIntoHeaders(Map headers) { private void mergeRequestDeadline(HttpServletRequest request, Map headers) { var requestDeadlineHeader = request.getHeader(X_REQUEST_DEADLINE); var requestIdHeader = request.getHeader(X_REQUEST_ID); + if (requestIdHeader != null && !requestIdHeader.isEmpty()) { + headers.put(X_REQUEST_ID, requestIdHeader); + } if (requestDeadlineHeader == null) { return; } try { - headers.putIfAbsent(WOODY_DEADLINE, getInstant(requestDeadlineHeader, requestIdHeader).toString()); + var normalizedDeadline = getInstant(requestDeadlineHeader, requestIdHeader).toString(); + headers.putIfAbsent(WOODY_DEADLINE, normalizedDeadline); + headers.put(X_REQUEST_DEADLINE, normalizedDeadline); } catch (Exception e) { log.warn("Unable to parse 'X-Request-Deadline' header value '{}'", requestDeadlineHeader); } } + private void mergeWoodyRequestMetadata(Map headers) { + var woodyRequestId = headers.get(WOODY_META_REQUEST_ID); + if (woodyRequestId != null && !woodyRequestId.isEmpty()) { + headers.put(X_REQUEST_ID, woodyRequestId); + } + var woodyDeadline = headers.get(WOODY_META_REQUEST_DEADLINE); + if (woodyDeadline != null && !woodyDeadline.isEmpty()) { + headers.put(X_REQUEST_DEADLINE, woodyDeadline); + headers.putIfAbsent(WOODY_DEADLINE, woodyDeadline); + } + } + private void putJwtMetadata(Map headers, String extensionKey, String value) { var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); if (suffix.isEmpty() || value == null || value.isEmpty()) { @@ -135,9 +153,15 @@ private void normalizeWoodyResponseHeader(HttpHeaders headers, headers.addAll(lowerCase, values); } else { var suffix = lowerCase.substring(WOODY_PREFIX.length()); - if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_DOT)) { - var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_DOT.length()); + if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX)) { + var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX.length()); headers.addAll(X_WOODY_META_USER_IDENTITY_PREFIX + metaKey, values); + var metaKeyLower = metaKey.toLowerCase(Locale.ROOT); + if (metaKeyLower.equals("x-request-id")) { + headers.addAll(X_REQUEST_ID, values); + } else if (metaKeyLower.equals("x-request-deadline")) { + headers.addAll(X_REQUEST_DEADLINE, values); + } } else { headers.addAll(X_WOODY_PREFIX + suffix, values); } diff --git a/src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java b/src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java similarity index 97% rename from src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java rename to src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java index 658eb8f..5ac33bc 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/TraceContextRestorer.java +++ b/src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java @@ -1,4 +1,4 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; import dev.vality.woody.api.flow.WFlow; import dev.vality.woody.api.trace.TraceData; @@ -18,7 +18,7 @@ import java.util.Map; import java.util.function.Consumer; -import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; @Slf4j @UtilityClass diff --git a/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java similarity index 98% rename from src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java rename to src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java index 10827bd..f92fc6e 100644 --- a/src/main/java/dev/vality/wachter/config/tracing/WoodyTracingFilter.java +++ b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java @@ -1,4 +1,4 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; import dev.vality.woody.api.flow.WFlow; import jakarta.servlet.FilterChain; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e9539e1..9059e12 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,48 +49,84 @@ wachter: enabled: true serviceHeader: Service services: + Accounter: + name: Accounter + url: http://shumway:8022/accounter ClaimManagement: name: ClaimManagement - url: http://localhost:8097/v1/cm + url: http://claim-management:8022/v1/cm DepositManagement: name: DepositManagement - url: http://localhost:8022/v1/deposit + url: http://fistful:8022/v1/deposit Domain: name: Domain - url: http://localhost:8022/v1/domain/repository + url: http://dominant:8022/v1/domain/repository + RepositoryClient: + name: RepositoryClient + url: http://dominant:8022/v1/domain/repository_client DominantCache: name: DominantCache - url: http://localhost:8022/v1/dominant/cache + url: http://dominant-cache:8022/v1/dominant/cache FileStorage: name: FileStorage - url: http://localhost:8022/file_storage + url: http://file-storage-v2:8022/file_storage/v2 FistfulStatistics: name: FistfulStatistics - url: http://localhost:8022/fistful/stat + url: http://fistful-magista:8022/stat MerchantStatistics: name: MerchantStatistics - url: http://localhost:8022/v3/stat - Messages: - name: Messages - url: http://localhost:8097/v1/messages + url: http://magista:8022/v3/stat Invoicing: name: Invoicing - url: http://localhost:8097/v1/processing/invoicin + url: http://hellgate:8022/v1/processing/invoicing PartyManagement: name: PartyManagement - url: http://localhost:8097/v1/processing/partymgmt + url: http://party-management:8022/v1/processing/partymgmt PayoutManagement: name: PayoutManagement - url: http://localhost:8097/payout/management + url: http://payout-manager:8022/payout/management RepairManagement: name: repairManagement - url: http://localhost:8097/v1/repair + url: http://repairer:8022/v1/repair WalletManagement: name: WalletManagement - url: http://localhost:8022/v1/wallet + url: http://fistful:8022/v1/wallet WithdrawalManagement: name: WithdrawalManagement - url: http://localhost:8022/v1/wallet + url: http://fistful:8022/v1/withdrawal + Automaton: + name: Automaton + url: http://machinegun-ha:8022/v1/automaton + Repairer: + name: Repairer + url: http://repairer:8022/v1/repair/withdrawal/session + FistfulAdmin: + name: FistfulAdmin + url: http://fistful:8022/v1/admin + Deanonimus: + name: Deanonimus + url: http://deanonimus:8022/deanonimus + PaymentProcessing: + name: PaymentProcessing + url: http://hellgate:8022/v1/processing/invoicing + IdentityManagement: + name: IdentityManagement + url: http://fistful:8022/v1/identity + Scrooge: + name: Scrooge + url: http://scrooge:8022/terminal/balance + Dominator: + name: Dominator + url: http://dominator:8022/v1/dominator + DMT: + name: DMT + url: http://dmt.default:8022/v1/domain/repository + DMTAuthor: + name: DMTAuthor + url: http://dmt.default:8022/v1/domain/author + DMTClient: + name: DMTClient + url: http://dmt.default:8022/v1/domain/repository_client http-client: diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index 7e69af3..8ccb931 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -19,9 +19,9 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; -import static dev.vality.wachter.constants.HeadersConstants.WOODY_TRACE_ID; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_TRACE_ID; import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -69,12 +69,19 @@ void shouldSendRequestWithTracingHeaders() { final var servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); + servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); + servletRequest.addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + servletRequest.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + servletRequest.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); final var payload = "payload".getBytes(); final var expectedResponse = "response".getBytes(); server.expect(requestTo("http://upstream")) .andExpect(method(HttpMethod.POST)) .andExpect(content().bytes(payload)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(header(HttpHeaders.ACCEPT_ENCODING, "gzip")) .andRespond(withSuccess(expectedResponse, MediaType.APPLICATION_OCTET_STREAM)); final var client = new WachterClient(restClient); @@ -87,6 +94,42 @@ void shouldSendRequestWithTracingHeaders() { otelSpan.end(); } + @Test + void shouldFilterDisallowedHeaders() { + final var builder = RestClient.builder(); + final var server = MockRestServiceServer.bindTo(builder).build(); + final var restClient = builder.build(); + + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + 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"); + servletRequest.addHeader("Service", "Domain"); + servletRequest.addHeader("cf-ray", "test"); + servletRequest.addHeader(X_WOODY_TRACE_ID, "should-not-pass"); + + server.expect(requestTo("http://upstream/disallowed")) + .andExpect(method(HttpMethod.POST)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andExpect(headerDoesNotExist("Service")) + .andExpect(headerDoesNotExist("cf-ray")) + .andExpect(headerDoesNotExist(X_WOODY_TRACE_ID)) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + final var client = new WachterClient(restClient); + + client.send(servletRequest, null, "http://upstream/disallowed"); + + server.verify(); + otelSpan.end(); + } + @Test void shouldHandleGetRequestWithoutBody() { final var builder = RestClient.builder(); diff --git a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java index 77020e0..05ec03a 100644 --- a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java +++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java @@ -2,41 +2,43 @@ import dev.vality.wachter.WachterApplication; import dev.vality.wachter.auth.utils.KeycloakOpenIdStub; -import org.junit.jupiter.api.BeforeAll; +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.test.context.SpringBootTest; -import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.wiremock.spring.EnableWireMock; import java.security.PrivateKey; @SuppressWarnings("LineLength") @SpringBootTest( - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, classes = {WachterApplication.class}, properties = { - "wiremock.server.baseUrl=http://localhost:${wiremock.server.port}", - "spring.security.oauth2.resourceserver.url=http://localhost:${wiremock.server.port}", - "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:${wiremock.server.port}/auth/realms/" + + "server.port=8083", + "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}"}) @AutoConfigureMockMvc -@AutoConfigureWireMock(port = 0) +@EnableWireMock @ExtendWith(SpringExtension.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public abstract class AbstractKeycloakOpenIdAsWiremockConfig { @Autowired private KeycloakOpenIdStub keycloakOpenIdStub; - @BeforeAll - public static void setUp(@Autowired KeycloakOpenIdStub keycloakOpenIdStub) throws Exception { + @BeforeEach + public void setUp(@Autowired KeycloakOpenIdStub keycloakOpenIdStub) throws Exception { keycloakOpenIdStub.givenStub(); } protected String generateSimpleJwtWithRoles() { return keycloakOpenIdStub.generateJwt("Deanonimus", "unknown", "Domain", "messages:methodName", - "DominantCache", "!DominantCache:methodName"); + "DominantCache", "!DominantCache:methodName", "MerchantStatistics", "PaymentAdjustment"); } diff --git a/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java index 1e1a700..18c7db9 100644 --- a/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java @@ -64,7 +64,7 @@ void requestAccessDenied() { final var expected = "User darkside-the-best@mail.com don't have roles"; mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithoutRoles()) - .header("Service", "messages") + .header("Service", "Domain") .header("X-Request-ID", randomUUID()) .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java index d05ac67..3cf3139 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java @@ -63,7 +63,7 @@ void requestSuccess() { .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithoutRoles()) - .header("Service", "messages") + .header("Service", "Domain") .header("X-Request-ID", randomUUID()) .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java index b85e01c..b915c93 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java @@ -19,6 +19,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; import static java.util.UUID.randomUUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -62,8 +63,8 @@ void requestSuccessWithServiceRole() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header("X-Request-ID", randomUUID()) - .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(X_REQUEST_ID, randomUUID()) + .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -77,9 +78,9 @@ void requestSuccessWithMethodRole() { .thenReturn(new WachterClientResponse(HttpStatus.OK, new HttpHeaders(), new byte[0])); mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) - .header("Service", "messages") - .header("X-Request-ID", randomUUID()) - .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header("Service", "Domain") + .header(X_REQUEST_ID, randomUUID()) + .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -94,12 +95,12 @@ void requestSuccessWithWoodyHeaders() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header("X-Request-ID", randomUUID()) - .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) - .header("woody.parent-id", "parent") - .header("woody.trace-id", "trace") - .header("woody.span-id", "span") - .header("woody.deadline", "deadline") + .header(X_REQUEST_ID, randomUUID()) + .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(WOODY_PARENT_ID, "parent") + .header(WOODY_TRACE_ID, "trace") + .header(WOODY_SPAN_ID, "span") + .header(WOODY_DEADLINE, "deadline") .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -114,12 +115,12 @@ void requestSuccessWithWoodyWithDashHeaders() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header("X-Request-ID", randomUUID()) - .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) - .header("x-woody-parent-id", "parent") - .header("x-woody-trace-id", "trace") - .header("x-woody-span-id", "span") - .header("x-woody-deadline", "deadline") + .header(X_REQUEST_ID, randomUUID()) + .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(X_WOODY_PARENT_ID, "parent") + .header(X_WOODY_TRACE_ID, "trace") + .header(X_WOODY_SPAN_ID, "span") + .header(X_WOODY_DEADLINE, "deadline") .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -138,8 +139,8 @@ void shouldPropagateUpstreamError() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header("X-Request-ID", randomUUID()) - .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(X_REQUEST_ID, randomUUID()) + .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().isBadGateway()) diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 04945e6..cf54f1d 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -1,57 +1,66 @@ package dev.vality.wachter.integration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.client.WireMock; -import dev.vality.wachter.auth.utils.JwtTokenBuilder; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; -import dev.vality.wachter.constants.HeadersConstants; import dev.vality.wachter.testutil.TMessageUtil; import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; import org.apache.thrift.protocol.TProtocolFactory; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.client.RestClient; +import java.net.http.HttpClient; import java.time.Instant; +import java.util.Base64; +import java.util.List; import java.util.UUID; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static dev.vality.wachter.constants.HeadersConstants.*; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @TestPropertySource(properties = { - "wachter.services.Domain.url=http://localhost:${wiremock.server.port}/domain" + "wachter.services.Domain.url=${wiremock.server.baseUrl}/domain", + "wachter.services.Deanonimus.url=${wiremock.server.baseUrl}/deanonimus", + "wachter.services.MerchantStatistics.url=${wiremock.server.baseUrl}/magista" }) class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { - private static final String USER_ID_HEADER = WOODY_META_USER_IDENTITY_PREFIX + - HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityIdExtensionKit.KEY); - private static final String USER_NAME_HEADER = WOODY_META_USER_IDENTITY_PREFIX + - HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityUsernameExtensionKit.KEY); - private static final String USER_EMAIL_HEADER = WOODY_META_USER_IDENTITY_PREFIX + - HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityEmailExtensionKit.KEY); - private static final String USER_REALM_HEADER = WOODY_META_USER_IDENTITY_PREFIX + - HeadersConstants.WoodySuffixes.userIdentitySuffix(UserIdentityRealmExtensionKit.KEY); private static final String TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]"; - private static final String EXPECTED_REALM = "/internal"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - @Autowired - private MockMvc mockMvc; + @Value("${server.port}") + private int port; + + private RestClient restClient; @Autowired private TProtocolFactory protocolFactory; + @BeforeEach + void setUp() { + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .defaultStatusHandler(status -> true, (request, response) -> { + }) + .requestFactory(new JdkClientHttpRequestFactory(HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build())) + .build(); + } + @AfterEach void tearDown() { TraceContext.setCurrentTraceData(null); @@ -59,39 +68,199 @@ void tearDown() { } @Test - void shouldProxyRequestEndToEnd() throws Exception { - final var traceId = "integration-trace"; - final var spanId = "integration-span"; - final var parentId = "integration-parent"; + void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { + var traceId = "GZyWNGugAAA"; + var spanId = "GZyWNGugBBB"; + var parentId = "undefined"; + var deadline = Instant.now().plusSeconds(300); + var requestId = UUID.randomUUID().toString(); + var payload = TMessageUtil.createTMessage(protocolFactory); + var responseBody = "integration-response".getBytes(); + var upstreamTraceparent = "00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01"; + var jwt = generateSimpleJwtWithRoles(); + + stubFor(post(urlEqualTo("/deanonimus")) + .withRequestBody(binaryEqualTo(payload)) + .willReturn(aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader(WOODY_TRACE_ID, traceId) + .withHeader(WOODY_PARENT_ID, parentId) + .withHeader(WOODY_SPAN_ID, spanId) + .withHeader(OTEL_TRACE_PARENT, upstreamTraceparent) + .withHeader(HttpHeaders.CONTENT_TYPE, "application/x-thrift") + .withBody(responseBody))); + + var response = restClient.post() + .uri("/wachter") + .contentType(MediaType.valueOf("application/x-thrift")) + .headers(headers -> { + // Browser/CDN headers - should be filtered out + headers.set("user-agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0"); + headers.set("accept", "application/x-thrift"); + headers.set("accept-encoding", "gzip, br"); + headers.set("accept-language", "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3"); + headers.set("cache-control", "no-cache"); + headers.set("cdn-loop", "cloudflare; loops=1"); + headers.set("cf-connecting-ip", "185.121.234.44"); + headers.set("cf-ipcountry", "NL"); + headers.set("cf-ray", "98be96799e2ae4bc-AMS"); + headers.set("cf-visitor", "{\"scheme\":\"https\"}"); + headers.set("cf-warp-tag-id", "ef03c554-2080-404d-bd10-1928b8a59810"); + headers.set("dnt", "1"); + headers.set("origin", "https://iddqd.valitydev.com"); + headers.set("pragma", "no-cache"); + headers.set("priority", "u=4"); + headers.set("referer", "https://iddqd.valitydev.com/"); + headers.set("sec-fetch-dest", "empty"); + headers.set("sec-fetch-mode", "cors"); + headers.set("sec-fetch-site", "same-site"); + headers.set("sec-gpc", "1"); + headers.set("x-forwarded-proto", "https"); + + // Authentication and service + headers.set("Authorization", "Bearer " + jwt); + headers.set("Service", "Deanonimus"); + + // Woody tracing headers + headers.set(X_WOODY_TRACE_ID, traceId); + headers.set(X_WOODY_SPAN_ID, spanId); + headers.set(X_WOODY_PARENT_ID, parentId); + headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "id", "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); + headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "username", "noreply@valitydev.com"); + headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "email", "noreply@valitydev.com"); + headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "realm", "internal"); + + // Request metadata + headers.set(X_REQUEST_ID, requestId); + headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(OTEL_TRACE_PARENT, upstreamTraceparent); + }) + .body(payload) + .retrieve() + .toEntity(byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(upstreamTraceparent, response.getHeaders().getFirst(OTEL_TRACE_PARENT)); + assertArrayEquals(responseBody, response.getBody()); + + List requests = findAll(postRequestedFor(urlEqualTo("/deanonimus"))); + assertEquals(1, requests.size()); + LoggedRequest upstreamRequest = requests.get(0); + + assertEquals(traceId, upstreamRequest.getHeader(WOODY_TRACE_ID)); + assertEquals(spanId, upstreamRequest.getHeader(WOODY_SPAN_ID)); + assertEquals(parentId, upstreamRequest.getHeader(WOODY_PARENT_ID)); + assertTrue(upstreamRequest.containsHeader(WOODY_DEADLINE)); + + assertEquals("application/x-thrift", upstreamRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals("application/x-thrift", upstreamRequest.getHeader(HttpHeaders.ACCEPT)); + assertEquals("gzip, br", upstreamRequest.getHeader(HttpHeaders.ACCEPT_ENCODING)); + assertEquals("ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3", + upstreamRequest.getHeader(HttpHeaders.ACCEPT_LANGUAGE)); + assertEquals("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0", + upstreamRequest.getHeader(HttpHeaders.USER_AGENT)); + + var jwtClaims = decodeJwtPayload(jwt); + assertEquals(jwtClaims.get("sub").asText(), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals(jwtClaims.get("preferred_username").asText(), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals(jwtClaims.get("email").asText(), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals(extractRealm(jwtClaims), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + + assertNotNull(upstreamRequest.getHeader(OTEL_TRACE_PARENT)); + assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).matches(TRACEPARENT_PATTERN)); + + assertEquals(requestId, upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); + assertEquals(deadline.toString(), upstreamRequest.getHeader(WOODY_META_REQUEST_DEADLINE)); + + assertFalse(upstreamRequest.containsHeader("cf-ray")); + assertFalse(upstreamRequest.containsHeader("cdn-loop")); + assertFalse(upstreamRequest.containsHeader("cf-visitor")); + assertFalse(upstreamRequest.containsHeader("dnt")); + assertFalse(upstreamRequest.containsHeader("sec-fetch-mode")); + assertFalse(upstreamRequest.containsHeader("pragma")); + assertFalse(upstreamRequest.containsHeader("cache-control")); + assertFalse(upstreamRequest.containsHeader("origin")); + assertFalse(upstreamRequest.containsHeader("referer")); + } + + @Test + void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { final var deadline = Instant.now().plusSeconds(300); - final var requestId = UUID.randomUUID().toString(); final var payload = TMessageUtil.createTMessage(protocolFactory); - final var responseBody = "integration-response".getBytes(); - final var upstreamTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; + final var responseBody = "test-response".getBytes(); + final var jwt = generateSimpleJwtWithRoles(); + final var jwtClaims = decodeJwtPayload(jwt); + var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; - stubFor(WireMock.post(urlEqualTo("/domain")) + stubFor(post(urlEqualTo("/magista")) .withRequestBody(binaryEqualTo(payload)) .willReturn(aResponse() - .withStatus(HttpStatus.ACCEPTED.value()) - .withHeader("X-Upstream", "accepted") - .withHeader("traceparent", upstreamTraceparent) - .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE) + .withStatus(HttpStatus.OK.value()) + .withHeader(HttpHeaders.CONTENT_TYPE, "application/x-thrift") .withBody(responseBody))); - mockMvc.perform(post("/wachter") - .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) - .header("Service", "Domain") - .header(X_REQUEST_ID, requestId) - .header(X_REQUEST_DEADLINE, deadline.toString()) - .header(X_WOODY_TRACE_ID, traceId) - .header(X_WOODY_SPAN_ID, spanId) - .header(X_WOODY_PARENT_ID, parentId) - .content(payload)) - .andExpect(status().isAccepted()) - .andExpect(MockMvcResultMatchers.content().bytes(responseBody)); + final ResponseEntity response = restClient.post() + .uri("/wachter") + .contentType(MediaType.valueOf("application/x-thrift")) + .headers(headers -> { + headers.set("Authorization", "Bearer " + jwt); + headers.set("Service", "MerchantStatistics"); - verify(postRequestedFor(urlEqualTo("/domain")) - .withRequestBody(binaryEqualTo(payload))); + // Mixed woody and x-woody headers + headers.set(WOODY_TRACE_ID, "GZvsthKQAAA"); + headers.set(X_WOODY_SPAN_ID, "GZvsthKQBBB"); + headers.set(WOODY_PARENT_ID, "parent-woody"); + headers.set(X_WOODY_DEADLINE, deadline.toString()); + + // User identity in different formats + headers.set(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/woody-realm"); + headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "id", "header-user-id"); + + // Request metadata + headers.set(X_REQUEST_ID, "mixed-request-id"); + headers.set(X_REQUEST_DEADLINE, deadline.toString()); + + // Traceparent + headers.set(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); + }) + .body(payload) + .retrieve() + .toEntity(byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertArrayEquals(responseBody, response.getBody()); + + // Verify upstream request headers were normalized correctly + List requests = findAll(postRequestedFor(urlEqualTo("/magista"))); + assertEquals(1, requests.size()); + LoggedRequest upstreamRequest = requests.get(0); + + // All headers should be normalized to woody.* format + assertEquals("GZvsthKQAAA", upstreamRequest.getHeader(WOODY_TRACE_ID)); + assertEquals("GZvsthKQBBB", upstreamRequest.getHeader(WOODY_SPAN_ID)); + assertEquals("parent-woody", upstreamRequest.getHeader(WOODY_PARENT_ID)); + assertNotNull(upstreamRequest.getHeader(WOODY_DEADLINE)); + + // User identity metadata should be sourced from JWT when present + assertEquals(jwtClaims.get("sub").asText(), upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals(jwtClaims.get("preferred_username").asText(), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals(jwtClaims.get("email").asText(), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals(extractRealm(jwtClaims), + upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + + // Traceparent should be preserved + assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).contains(otelTraceId)); + + // Request metadata should be preserved + assertEquals("mixed-request-id", upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); + assertEquals(deadline.toString(), upstreamRequest.getHeader(WOODY_META_REQUEST_DEADLINE)); } @Test @@ -104,19 +273,29 @@ void shouldStripHopByHopHeadersBeforeProxying() throws Exception { .willReturn(aResponse() .withStatus(HttpStatus.OK.value()))); - mockMvc.perform(post("/wachter") - .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) - .header("Service", "Domain") - .header(X_REQUEST_ID, UUID.randomUUID().toString()) - .header(X_REQUEST_DEADLINE, deadline.toString()) - .header(HttpHeaders.TRANSFER_ENCODING, "chunked") - .header(HttpHeaders.CONNECTION, "keep-alive") - .header(HttpHeaders.TE, "trailers") - .header(HttpHeaders.HOST, "example.org") - .content(payload)) - .andExpect(status().isOk()); + final ResponseEntity response = restClient.post() + .uri("/wachter") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .headers(headers -> { + headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); + headers.set("Service", "Domain"); + headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); + headers.set(HttpHeaders.CONNECTION, "keep-alive"); + headers.set(HttpHeaders.TE, "trailers"); + headers.set(HttpHeaders.HOST, "example.org"); + }) + .body(payload) + .retrieve() + .toEntity(byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); verify(postRequestedFor(urlEqualTo("/domain")) + .withHeader(HttpHeaders.HOST, matching("localhost:\\d+")) + .withoutHeader(HttpHeaders.TRANSFER_ENCODING) + .withoutHeader(HttpHeaders.TE) .withRequestBody(binaryEqualTo(payload))); } @@ -131,16 +310,23 @@ void shouldReturnCorsHeadersOnSuccessfulResponse() throws Exception { .willReturn(aResponse() .withStatus(HttpStatus.OK.value()))); - mockMvc.perform(post("/wachter") - .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) - .header("Service", "Domain") - .header(X_REQUEST_ID, UUID.randomUUID().toString()) - .header(X_REQUEST_DEADLINE, deadline.toString()) - .header(HttpHeaders.ORIGIN, origin) - .content(payload)) - .andExpect(status().isOk()) - .andExpect(MockMvcResultMatchers.header() - .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + final ResponseEntity response = restClient.post() + .uri("/wachter") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .headers(headers -> { + headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); + headers.set("Service", "Domain"); + headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(HttpHeaders.ORIGIN, origin); + }) + .body(payload) + .retrieve() + .toEntity(byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("*", response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertNull(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); } @Test @@ -154,15 +340,48 @@ void shouldReturnCorsHeadersOnErrorResponse() throws Exception { .willReturn(aResponse() .withStatus(HttpStatus.BAD_GATEWAY.value()))); - mockMvc.perform(post("/wachter") - .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) - .header("Service", "Domain") - .header(X_REQUEST_ID, UUID.randomUUID().toString()) - .header(X_REQUEST_DEADLINE, deadline.toString()) - .header(HttpHeaders.ORIGIN, origin) - .content(payload)) - .andExpect(status().isBadGateway()) - .andExpect(MockMvcResultMatchers.header() - .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + final ResponseEntity response = restClient.post() + .uri("/wachter") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .headers(headers -> { + headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); + headers.set("Service", "Domain"); + headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(HttpHeaders.ORIGIN, origin); + }) + .body(payload) + .retrieve() + .toEntity(byte[].class); + + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + assertEquals("*", response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertNull(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + private static JsonNode decodeJwtPayload(String jwt) { + var parts = jwt.split("\\."); + if (parts.length < 2) { + throw new IllegalArgumentException("Invalid JWT"); + } + var payload = new String(Base64.getUrlDecoder().decode(parts[1])); + try { + return OBJECT_MAPPER.readTree(payload); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JWT payload", e); + } + } + + private static String extractRealm(JsonNode jwtClaims) { + var issuerNode = jwtClaims.get("iss"); + if (issuerNode == null || issuerNode.isNull()) { + return null; + } + var issuer = issuerNode.asText(); + if (issuer.isBlank()) { + return null; + } + var lastSlash = issuer.lastIndexOf('/'); + return lastSlash >= 0 ? issuer.substring(lastSlash) : issuer; } } diff --git a/src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java similarity index 53% rename from src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java rename to src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java index 9e464c0..296e4d5 100644 --- a/src/test/java/dev/vality/wachter/config/tracing/TraceContextHeadersExtractorTest.java +++ b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java @@ -1,5 +1,6 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; +import dev.vality.woody.api.flow.WFlow; import dev.vality.woody.api.trace.TraceData; import dev.vality.woody.api.trace.context.TraceContext; import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; @@ -20,9 +21,7 @@ import java.time.Instant; import java.util.Map; -import static dev.vality.wachter.constants.HeadersConstants.*; -import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue; -import static dev.vality.woody.api.trace.ContextUtils.setDeadline; +import static dev.vality.wachter.constants.TraceHeadersConstants.*; import static org.junit.jupiter.api.Assertions.*; class TraceContextHeadersExtractorTest { @@ -118,8 +117,8 @@ void shouldIncludeRequestMetadata() { final Map headers = TraceContextHeadersExtractor.extractHeaders(); - assertEquals("request-123", headers.get(X_REQUEST_ID)); - assertEquals("2030-12-31T23:59:59Z", headers.get(X_REQUEST_DEADLINE)); + assertEquals("request-123", headers.get(WOODY_META_REQUEST_ID)); + assertEquals("2030-12-31T23:59:59Z", headers.get(WOODY_META_REQUEST_DEADLINE)); otelSpan.end(); } @@ -159,4 +158,114 @@ void shouldNotIncludeEmptyValues() { otelSpan.end(); } + + @Test + void shouldExtractAllUserIdentityMetadata() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var activeSpan = traceData.getActiveSpan(); + final var span = activeSpan.getSpan(); + span.setTraceId("GZvsthKQAAA"); + span.setId("GZvsthKQBBB"); + span.setParentId("undefined"); + + activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); + activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, "noreply@valitydev.com"); + activeSpan.getCustomMetadata().putValue(UserIdentityEmailExtensionKit.KEY, "noreply@valitydev.com"); + activeSpan.getCustomMetadata().putValue(UserIdentityRealmExtensionKit.KEY, "/internal"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/internal", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + + otelSpan.end(); + } + + @Test + void shouldGenerateValidTraceparent() { + final var traceData = new TraceData(); + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var span = traceData.getActiveSpan().getSpan(); + span.setTraceId("trace-id"); + span.setId("span-id"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + final String traceparent = headers.get(OTEL_TRACE_PARENT); + assertNotNull(traceparent); + assertTrue(traceparent.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]")); + + otelSpan.end(); + } + + @Test + void shouldExtractComplexScenarioWithAllHeaders() { + final var traceData = new TraceData(); + TraceContext.initNewServiceTrace(traceData, WFlow.createDefaultIdGenerator(), WFlow.createDefaultIdGenerator()); + + final var otelSpan = tracer.spanBuilder("test-span").startSpan(); + traceData.setOtelSpan(otelSpan); + TraceContext.setCurrentTraceData(traceData); + + final var activeSpan = traceData.getActiveSpan(); + final var span = activeSpan.getSpan(); + span.setTraceId("GZyWNGugAAA"); + span.setId("GZyWNGugBBB"); + span.setParentId("undefined"); + span.setDeadline(Instant.parse("2030-01-01T00:00:00Z")); + + final var metadata = activeSpan.getCustomMetadata(); + metadata.putValue(UserIdentityIdExtensionKit.KEY, "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); + metadata.putValue(UserIdentityUsernameExtensionKit.KEY, "noreply@valitydev.com"); + metadata.putValue(UserIdentityEmailExtensionKit.KEY, "noreply@valitydev.com"); + metadata.putValue(UserIdentityRealmExtensionKit.KEY, "/internal"); + metadata.putValue(X_REQUEST_ID, "req-12345"); + metadata.putValue(X_REQUEST_DEADLINE, "2030-01-01T00:00:00Z"); + + final Map headers = TraceContextHeadersExtractor.extractHeaders(); + + assertEquals("GZyWNGugAAA", headers.get(WOODY_TRACE_ID)); + assertEquals("GZyWNGugBBB", headers.get(WOODY_SPAN_ID)); + assertEquals("undefined", headers.get(WOODY_PARENT_ID)); + assertEquals("2030-01-01T00:00:00Z", headers.get(WOODY_DEADLINE)); + assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/internal", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("req-12345", headers.get(WOODY_META_REQUEST_ID)); + assertEquals("2030-01-01T00:00:00Z", headers.get(WOODY_META_REQUEST_DEADLINE)); + assertNotNull(headers.get(OTEL_TRACE_PARENT)); + + otelSpan.end(); + } + + @Test + void shouldThrowWhenTraceDataIsNull() { + TraceContext.setCurrentTraceData(null); + TraceContext.getCurrentTraceData().setOtelSpan(null); + + assertThrows(NullPointerException.class, () -> { + TraceContextHeadersExtractor.extractHeaders(); + }); + } + + @Test + void shouldThrowWhenOtelSpanIsNull() { + final var traceData = new TraceData(); + traceData.setOtelSpan(null); + TraceContext.setCurrentTraceData(traceData); + + assertThrows(NullPointerException.class, () -> { + TraceContextHeadersExtractor.extractHeaders(); + }); + } } diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java new file mode 100644 index 0000000..a275ea6 --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java @@ -0,0 +1,325 @@ +package dev.vality.wachter.tracing; + +import dev.vality.wachter.security.JwtTokenDetailsExtractor; +import dev.vality.wachter.security.JwtTokenDetailsExtractor.JwtTokenDetails; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static dev.vality.wachter.constants.TraceHeadersConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TraceContextHeadersNormalizerTest { + + @Mock + private HttpServletRequest request; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldNormalizeWoodyHeadersFromLowercase() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + WOODY_TRACE_ID, WOODY_SPAN_ID, WOODY_PARENT_ID, WOODY_DEADLINE + ))); + when(request.getHeader(WOODY_TRACE_ID)).thenReturn("trace-123"); + when(request.getHeader(WOODY_SPAN_ID)).thenReturn("span-456"); + when(request.getHeader(WOODY_PARENT_ID)).thenReturn("parent-789"); + when(request.getHeader(WOODY_DEADLINE)).thenReturn("2030-01-01T00:00:00Z"); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("trace-123", normalized.get(WOODY_TRACE_ID)); + assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); + assertEquals("parent-789", normalized.get(WOODY_PARENT_ID)); + assertEquals("2030-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); + } + + @Test + void shouldNormalizeXWoodyHeadersToWoody() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + X_WOODY_TRACE_ID, X_WOODY_SPAN_ID, X_WOODY_PARENT_ID + ))); + when(request.getHeader(X_WOODY_TRACE_ID)).thenReturn("trace-123"); + when(request.getHeader(X_WOODY_SPAN_ID)).thenReturn("span-456"); + when(request.getHeader(X_WOODY_PARENT_ID)).thenReturn("parent-789"); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("trace-123", normalized.get(WOODY_TRACE_ID)); + assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); + assertEquals("parent-789", normalized.get(WOODY_PARENT_ID)); + } + + @Test + void shouldNormalizeUserIdentityMetadataFromXWoody() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + X_WOODY_META_USER_IDENTITY_PREFIX + "id", + X_WOODY_META_USER_IDENTITY_PREFIX + "username", + X_WOODY_META_USER_IDENTITY_PREFIX + "email", + X_WOODY_META_USER_IDENTITY_PREFIX + "realm" + ))); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "id")).thenReturn("user-id-123"); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "username")).thenReturn("john.doe"); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "email")).thenReturn("john@example.com"); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "realm")).thenReturn("/internal"); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("user-id-123", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("john.doe", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("john@example.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + } + + @Test + void shouldNormalizeTraceparentHeader() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of(OTEL_TRACE_PARENT))); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn("00-123abc-456def-01"); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("00-123abc-456def-01", normalized.get(OTEL_TRACE_PARENT)); + } + + @Test + void shouldMergeJwtMetadataIntoHeaders() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + + try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { + var tokenDetails = new JwtTokenDetails( + "user-jwt-id", + "jwt-username", + "jwt@email.com", + "/jwt-realm", + List.of("ROLE_USER") + ); + extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) + .thenReturn(Optional.of(tokenDetails)); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("user-jwt-id", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-username", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("jwt@email.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/jwt-realm", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + } + } + + @Test + void shouldMergeJwtMetadataWithHeaders() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + X_WOODY_META_USER_IDENTITY_PREFIX + "id" + ))); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "id")).thenReturn("header-user-id"); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + + try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { + var tokenDetails = new JwtTokenDetails( + "jwt-user-id", + "jwt-username", + null, + null, + List.of() + ); + extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) + .thenReturn(Optional.of(tokenDetails)); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("jwt-user-id", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("jwt-username", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + } + } + + @Test + void shouldMergeRequestDeadlineToWoodyDeadline() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-12-31T23:59:59Z"); + when(request.getHeader(X_REQUEST_ID)).thenReturn(null); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("2030-12-31T23:59:59Z", normalized.get(WOODY_DEADLINE)); + assertEquals("2030-12-31T23:59:59Z", normalized.get(X_REQUEST_DEADLINE)); + } + + @Test + void shouldMergeRelativeDeadline() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("30s"); + when(request.getHeader(X_REQUEST_ID)).thenReturn("req-123"); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertNotNull(normalized.get(WOODY_DEADLINE)); + assertEquals(normalized.get(WOODY_DEADLINE), normalized.get(X_REQUEST_DEADLINE)); + assertTrue(Instant.parse(normalized.get(WOODY_DEADLINE)).isAfter(Instant.now())); + } + + @Test + void shouldNotOverwriteExistingWoodyDeadline() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of(WOODY_DEADLINE))); + when(request.getHeader(WOODY_DEADLINE)).thenReturn("2025-01-01T00:00:00Z"); + when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-12-31T23:59:59Z"); + when(request.getHeader(X_REQUEST_ID)).thenReturn(null); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("2025-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); + assertEquals("2030-12-31T23:59:59Z", normalized.get(X_REQUEST_DEADLINE)); + } + + @Test + void shouldPreserveRequestIdWithoutDeadline() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + when(request.getHeader(X_REQUEST_ID)).thenReturn("req-no-deadline"); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); + when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn(null); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("req-no-deadline", normalized.get(X_REQUEST_ID)); + assertFalse(normalized.containsKey(X_REQUEST_DEADLINE)); + } + + @Test + void shouldHandleEmptyHeaders() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertTrue(normalized.isEmpty()); + } + + @Test + void shouldIgnoreNullHeaderValues() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + WOODY_TRACE_ID, WOODY_SPAN_ID + ))); + when(request.getHeader(WOODY_TRACE_ID)).thenReturn(null); + when(request.getHeader(WOODY_SPAN_ID)).thenReturn("span-456"); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertFalse(normalized.containsKey(WOODY_TRACE_ID)); + assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); + } + + @Test + void shouldHandleComplexScenarioWithAllHeaderTypes() { + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( + WOODY_TRACE_ID, + X_WOODY_SPAN_ID, + X_WOODY_PARENT_ID, + X_WOODY_META_USER_IDENTITY_PREFIX + "email", + OTEL_TRACE_PARENT, + "content-type", + "authorization" + ))); + when(request.getHeader(WOODY_TRACE_ID)).thenReturn("GZyWNGugAAA"); + when(request.getHeader(X_WOODY_SPAN_ID)).thenReturn("GZyWNGugBBB"); + when(request.getHeader(X_WOODY_PARENT_ID)).thenReturn("undefined"); + when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "email")).thenReturn("noreply@valitydev.com"); + when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn( + "00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01"); + when(request.getHeader(X_REQUEST_ID)).thenReturn("req-complex"); + when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-01-01T00:00:00Z"); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + + try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { + var tokenDetails = new JwtTokenDetails( + "b54a93c4-415d-4f33-a5e9-3608fd043ff4", + "noreply@valitydev.com", + "noreply@valitydev.com", + "/internal", + List.of("ROLE_USER") + ); + extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) + .thenReturn(Optional.of(tokenDetails)); + + var normalized = TraceContextHeadersNormalizer.normalize(request); + + assertEquals("GZyWNGugAAA", normalized.get(WOODY_TRACE_ID)); + assertEquals("GZyWNGugBBB", normalized.get(WOODY_SPAN_ID)); + assertEquals("undefined", normalized.get(WOODY_PARENT_ID)); + assertEquals("noreply@valitydev.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", + normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("noreply@valitydev.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("/internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01", normalized.get(OTEL_TRACE_PARENT)); + assertEquals("2030-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); + assertEquals("req-complex", normalized.get(X_REQUEST_ID)); + assertEquals("2030-01-01T00:00:00Z", normalized.get(X_REQUEST_DEADLINE)); + } + } + + @Test + void shouldNormalizeResponseHeaders() { + var responseHeaders = new HttpHeaders(); + responseHeaders.add(WOODY_TRACE_ID, "resp-trace"); + responseHeaders.add(WOODY_SPAN_ID, "resp-span"); + responseHeaders.add(X_WOODY_PARENT_ID, "resp-parent"); + responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "id", "resp-user"); + responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "x-request-id", "resp-req"); + responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline", "2030-01-01T00:00:00Z"); + responseHeaders.add(OTEL_TRACE_PARENT, "00-abc-def-01"); + responseHeaders.add(WOODY_DEADLINE, "2030-01-01T00:00:00Z"); + responseHeaders.add(WOODY_ERROR_CLASS, "resp-req"); + responseHeaders.add(WOODY_ERROR_REASON, "resp-req"); + responseHeaders.add("Content-Type", "application/json"); + responseHeaders.add("Cache-Control", "no-cache"); + + var normalized = TraceContextHeadersNormalizer.normalizeResponseHeaders(responseHeaders); + + assertTrue(normalized.containsKey(X_WOODY_TRACE_ID)); + assertTrue(normalized.containsKey(X_WOODY_SPAN_ID)); + assertTrue(normalized.containsKey(X_WOODY_PARENT_ID)); + assertTrue(normalized.containsKey(X_WOODY_DEADLINE)); + assertTrue(normalized.containsKey(X_WOODY_ERROR_CLASS)); + assertTrue(normalized.containsKey(X_WOODY_ERROR_REASON)); + assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "x-request-id")); + assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline")); + assertTrue(normalized.containsKey(OTEL_TRACE_PARENT)); + assertTrue(normalized.containsKey(X_REQUEST_ID)); + assertTrue(normalized.containsKey(X_REQUEST_DEADLINE)); + assertFalse(normalized.containsKey("Content-Type")); + assertFalse(normalized.containsKey("Cache-Control")); + } +} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java new file mode 100644 index 0000000..f0ff258 --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java @@ -0,0 +1,80 @@ +package dev.vality.wachter.tracing; + +import dev.vality.woody.api.flow.WFlow; +import dev.vality.woody.api.trace.context.TraceContext; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +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 java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static dev.vality.wachter.constants.TraceHeadersConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +class TraceContextPipelineTest { + + private SdkTracerProvider tracerProvider; + + @BeforeEach + void setUp() { + GlobalOpenTelemetry.resetForTest(); + tracerProvider = SdkTracerProvider.builder().build(); + 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 shouldRestoreAndExtractTraceContextWithinWFlow() { + var normalized = new HashMap(); + normalized.put(WOODY_TRACE_ID, "GZyWNGugAAA"); + normalized.put(WOODY_SPAN_ID, "GZyWNGugBBB"); + normalized.put(WOODY_PARENT_ID, "undefined"); + normalized.put(WOODY_DEADLINE, "2030-01-01T00:00:00Z"); + var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; + normalized.put(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); + normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "user-id"); + normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "user-name"); + normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "user@example.com"); + normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/internal"); + normalized.put(X_REQUEST_ID, "request-id"); + normalized.put(X_REQUEST_DEADLINE, "2030-01-01T00:00:00Z"); + + var traceData = TraceContextRestorer.restoreTraceData(normalized); + var extractedRef = new AtomicReference>(); + + WFlow.create(() -> extractedRef.set(TraceContextHeadersExtractor.extractHeaders()), traceData).run(); + + var extracted = extractedRef.get(); + assertNotNull(extracted); + assertEquals("GZyWNGugAAA", extracted.get(WOODY_TRACE_ID)); + assertEquals("GZyWNGugBBB", extracted.get(WOODY_SPAN_ID)); + assertEquals("undefined", extracted.get(WOODY_PARENT_ID)); + assertEquals("2030-01-01T00:00:00Z", extracted.get(WOODY_DEADLINE)); + assertEquals("user-id", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); + assertEquals("user-name", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); + assertEquals("user@example.com", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); + assertEquals("/internal", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("request-id", extracted.get(WOODY_META_REQUEST_ID)); + assertEquals("2030-01-01T00:00:00Z", extracted.get(WOODY_META_REQUEST_DEADLINE)); + assertTrue(extracted.get(OTEL_TRACE_PARENT).contains(otelTraceId)); + } +} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java new file mode 100644 index 0000000..75f4792 --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java @@ -0,0 +1,270 @@ +package dev.vality.wachter.tracing; + +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; +import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +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 java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import static dev.vality.wachter.constants.TraceHeadersConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +class TraceContextRestorerTest { + + private SdkTracerProvider tracerProvider; + + @BeforeEach + void setUp() { + GlobalOpenTelemetry.resetForTest(); + tracerProvider = SdkTracerProvider.builder().build(); + 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 shouldRestoreWoodyTraceHeaders() { + var headers = Map.of( + WOODY_TRACE_ID, "GZyWNGugAAA", + WOODY_SPAN_ID, "GZyWNGugBBB", + WOODY_PARENT_ID, "undefined", + WOODY_DEADLINE, "2030-01-01T00:00:00Z" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + assertNotNull(traceData); + var span = traceData.getServiceSpan().getSpan(); + assertEquals("GZyWNGugAAA", span.getTraceId()); + assertEquals("GZyWNGugBBB", span.getId()); + assertEquals("undefined", span.getParentId()); + assertEquals(Instant.parse("2030-01-01T00:00:00Z"), span.getDeadline()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldRestoreUserIdentityMetadata() { + var headers = new HashMap(); + headers.put(WOODY_TRACE_ID, "trace-123"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "noreply@valitydev.com"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "noreply@valitydev.com"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/internal"); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + var metadata = traceData.getActiveSpan().getCustomMetadata(); + assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", metadata.getValue(UserIdentityIdExtensionKit.KEY)); + assertEquals("noreply@valitydev.com", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); + assertEquals("noreply@valitydev.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); + assertEquals("/internal", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldRestoreRequestMetadata() { + var headers = Map.of( + WOODY_TRACE_ID, "trace-123", + X_REQUEST_ID, "req-456", + X_REQUEST_DEADLINE, "2030-12-31T23:59:59Z" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + var metadata = traceData.getActiveSpan().getCustomMetadata(); + assertEquals("req-456", metadata.getValue(X_REQUEST_ID)); + assertEquals("2030-12-31T23:59:59Z", metadata.getValue(X_REQUEST_DEADLINE)); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldRestoreTraceparentAndCreateOtelSpan() { + var traceId = "cfa3d3072a4e3e99fc14829a65311819"; + var headers = Map.of( + WOODY_TRACE_ID, "trace-123", + OTEL_TRACE_PARENT, "00-" + traceId + "-6e4609576fa4d077-01" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + assertEquals(traceId, traceData.getOtelSpan().getSpanContext().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandleEmptyHeaders() { + TraceData traceData = TraceContextRestorer.restoreTraceData(Map.of()); + + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandlePartialHeaders() { + var headers = Map.of( + WOODY_TRACE_ID, "trace-123" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); + assertNull(traceData.getServiceSpan().getSpan().getDeadline()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandleInvalidDeadline() { + var headers = Map.of( + WOODY_TRACE_ID, "trace-123", + WOODY_DEADLINE, "invalid-date" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); + assertNull(traceData.getServiceSpan().getSpan().getDeadline()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandleComplexScenarioWithAllData() { + var headers = new HashMap(); + headers.put(WOODY_TRACE_ID, "GZvsthKQAAA"); + headers.put(WOODY_SPAN_ID, "GZvsthKQBBB"); + headers.put(WOODY_PARENT_ID, "parent-123"); + headers.put(WOODY_DEADLINE, "2030-06-15T12:30:00Z"); + var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; + headers.put(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "user-uuid"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "john.doe"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "john@example.com"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/external"); + headers.put(X_REQUEST_ID, "complex-request-id"); + headers.put(X_REQUEST_DEADLINE, "2030-06-15T13:00:00Z"); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + var span = traceData.getServiceSpan().getSpan(); + assertEquals("GZvsthKQAAA", span.getTraceId()); + assertEquals("GZvsthKQBBB", span.getId()); + assertEquals("parent-123", span.getParentId()); + assertEquals(Instant.parse("2030-06-15T12:30:00Z"), span.getDeadline()); + + var metadata = traceData.getActiveSpan().getCustomMetadata(); + assertEquals("user-uuid", metadata.getValue(UserIdentityIdExtensionKit.KEY)); + assertEquals("john.doe", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); + assertEquals("john@example.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); + assertEquals("/external", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); + assertEquals("complex-request-id", metadata.getValue(X_REQUEST_ID)); + assertEquals("2030-06-15T13:00:00Z", metadata.getValue(X_REQUEST_DEADLINE)); + + assertEquals(otelTraceId, traceData.getOtelSpan().getSpanContext().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandleNullAndEmptyValues() { + var headers = new HashMap(); + headers.put(WOODY_TRACE_ID, "trace-123"); + headers.put(WOODY_SPAN_ID, ""); + headers.put(WOODY_PARENT_ID, null); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", ""); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", null); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + var span = traceData.getServiceSpan().getSpan(); + assertEquals("trace-123", span.getTraceId()); + assertNotNull(span.getId()); + assertNotNull(span.getParentId()); + + var metadata = traceData.getActiveSpan().getCustomMetadata(); + assertNull(metadata.getValue(UserIdentityIdExtensionKit.KEY)); + assertNull(metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldHandleInvalidTraceparentGracefully() { + var headers = Map.of( + WOODY_TRACE_ID, "trace-123", + OTEL_TRACE_PARENT, "invalid-traceparent" + ); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); + assertNotNull(traceData.getServiceSpan().getSpan().getId()); + assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); + assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); + } + + @Test + void shouldRestoreMetadataWithSpecialCharacters() { + var headers = new HashMap(); + headers.put(WOODY_TRACE_ID, "trace-123"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "user@domain.com"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "user+test@domain.com"); + headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/realm/with/slashes"); + headers.put(X_REQUEST_ID, "req-with-dashes-123"); + + TraceData traceData = TraceContextRestorer.restoreTraceData(headers); + + var metadata = traceData.getActiveSpan().getCustomMetadata(); + assertEquals("user@domain.com", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); + assertEquals("user+test@domain.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); + assertEquals("/realm/with/slashes", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); + assertEquals("req-with-dashes-123", metadata.getValue(X_REQUEST_ID)); + } +} diff --git a/src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java b/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java similarity index 91% rename from src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java rename to src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java index 259281f..fda6c99 100644 --- a/src/test/java/dev/vality/wachter/config/tracing/WoodyTracingFilterTest.java +++ b/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java @@ -1,6 +1,5 @@ -package dev.vality.wachter.config.tracing; +package dev.vality.wachter.tracing; -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.propagation.W3CTraceContextPropagator; @@ -15,8 +14,9 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import static dev.vality.wachter.constants.HeadersConstants.*; -import static org.junit.jupiter.api.Assertions.*; +import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_SPAN_ID; +import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_TRACE_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; class WoodyTracingFilterTest { From 8dcd41aadc22c037acd56a82125b2c7c1156ea6c Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 10 Oct 2025 15:20:58 +0700 Subject: [PATCH 10/16] add trace headers validation --- .../TraceContextHeadersValidation.java | 26 +++++++++++++++++++ .../wachter/tracing/WoodyTracingFilter.java | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java diff --git a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java new file mode 100644 index 0000000..1c98bba --- /dev/null +++ b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java @@ -0,0 +1,26 @@ +package dev.vality.wachter.tracing; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static dev.vality.wachter.constants.TraceHeadersConstants.*; + +@Slf4j +@UtilityClass +public class TraceContextHeadersValidation { + + public LinkedHashMap validate(Map normalized) { + var copy = new LinkedHashMap<>(normalized); + var traceId = copy.get(WOODY_TRACE_ID); + if (traceId != null && traceId.equals(copy.get(WOODY_SPAN_ID))) { + copy.remove(WOODY_SPAN_ID); + } + if ("undefined".equals(copy.get(WOODY_PARENT_ID))) { + copy.remove(WOODY_PARENT_ID); + } + return copy; + } +} diff --git a/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java index f92fc6e..4271ba1 100644 --- a/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java +++ b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java @@ -38,9 +38,10 @@ protected void doFilterInternal(HttpServletRequest request, var requestPath = getRequestPath(request); if ((request.getLocalPort() == serverPort) && requestPath.equals(wachterEndpoint)) { var normalized = TraceContextHeadersNormalizer.normalize(request); + var validated = TraceContextHeadersValidation.validate(normalized); log.info("-> Received {} {} | params: {}, headers: {}", request.getMethod(), getRequestPath(request), extractParams(request), sanitizeHeaders(request)); - var restoredTraceData = TraceContextRestorer.restoreTraceData(normalized); + var restoredTraceData = TraceContextRestorer.restoreTraceData(validated); WFlow.create(() -> doFilter(request, response, filterChain), restoredTraceData) .run(); log.info("<- Sent {} {} | status: {}, headers: {}", From e7978de83e784810f267bb41f36998219c623054 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Mon, 13 Oct 2025 16:12:42 +0700 Subject: [PATCH 11/16] realm without slash --- .../security/JwtTokenDetailsExtractor.java | 18 +++++++++++++----- .../integration/WachterIntegrationTest.java | 17 ++++++++++++++--- .../TraceContextHeadersNormalizerTest.java | 8 ++++---- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java index 78510d7..f3352de 100644 --- a/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java +++ b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java @@ -36,14 +36,22 @@ public static Optional extractFromContext(Authentication authen private static String extractRealm(Jwt token) { var issuer = token.getClaimAsString(ISSUER); - if (issuer == null || issuer.isBlank()) { + if (issuer == null) { return null; } - var lastSlash = issuer.lastIndexOf('/'); - if (lastSlash < 0) { - return issuer; + var normalized = issuer.trim(); + if (normalized.isEmpty()) { + return null; + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.isEmpty()) { + return null; } - return issuer.substring(lastSlash); + var lastSlash = normalized.lastIndexOf('/'); + var realm = lastSlash >= 0 ? normalized.substring(lastSlash + 1) : normalized; + return realm.isBlank() ? null : realm; } public record JwtTokenDetails(String subject, diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index cf54f1d..6097f49 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -378,10 +378,21 @@ private static String extractRealm(JsonNode jwtClaims) { return null; } var issuer = issuerNode.asText(); - if (issuer.isBlank()) { + if (issuer == null) { return null; } - var lastSlash = issuer.lastIndexOf('/'); - return lastSlash >= 0 ? issuer.substring(lastSlash) : issuer; + var normalized = issuer.trim(); + if (normalized.isEmpty()) { + return null; + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.isEmpty()) { + return null; + } + var lastSlash = normalized.lastIndexOf('/'); + var realm = lastSlash >= 0 ? normalized.substring(lastSlash + 1) : normalized; + return realm.isBlank() ? null : realm; } } diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java index a275ea6..c4c1cf6 100644 --- a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java +++ b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java @@ -117,7 +117,7 @@ void shouldMergeJwtMetadataIntoHeaders() { "user-jwt-id", "jwt-username", "jwt@email.com", - "/jwt-realm", + "jwt-realm", List.of("ROLE_USER") ); extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) @@ -128,7 +128,7 @@ void shouldMergeJwtMetadataIntoHeaders() { assertEquals("user-jwt-id", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); assertEquals("jwt-username", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); assertEquals("jwt@email.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/jwt-realm", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("jwt-realm", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); } } @@ -266,7 +266,7 @@ void shouldHandleComplexScenarioWithAllHeaderTypes() { "b54a93c4-415d-4f33-a5e9-3608fd043ff4", "noreply@valitydev.com", "noreply@valitydev.com", - "/internal", + "internal", List.of("ROLE_USER") ); extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) @@ -281,7 +281,7 @@ void shouldHandleComplexScenarioWithAllHeaderTypes() { assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); assertEquals("noreply@valitydev.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("/internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + assertEquals("internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); assertEquals("00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01", normalized.get(OTEL_TRACE_PARENT)); assertEquals("2030-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); assertEquals("req-complex", normalized.get(X_REQUEST_ID)); From c6a25449246fb531626cceaaa096cfe55c1213ca Mon Sep 17 00:00:00 2001 From: Anatolii Karlov Date: Thu, 16 Oct 2025 17:22:33 +0700 Subject: [PATCH 12/16] use Woody http bridge (#78) --- pom.xml | 30 +- .../wachter/client/ProxyHeadersExtractor.java | 11 +- .../vality/wachter/client/WachterClient.java | 13 +- .../wachter/client/WachterClientResponse.java | 7 - .../dev/vality/wachter/config/OtelConfig.java | 70 ---- .../dev/vality/wachter/config/WebConfig.java | 73 ---- .../properties/HttpClientProperties.java | 32 -- .../config/properties/OtelProperties.java | 17 - .../config/properties/WachterProperties.java | 1 - .../constants/RequestAttributeNames.java | 9 - .../constants/TraceHeadersConstants.java | 54 --- .../wachter/controller/WachterController.java | 5 +- .../wachter/security/AccessService.java | 2 +- .../security/JwtTokenDetailsExtractor.java | 63 ---- .../wachter/security/RoleAccessService.java | 4 +- .../wachter/service/WachterService.java | 5 +- .../tracing/TraceContextHeadersExtractor.java | 89 ----- .../TraceContextHeadersNormalizer.java | 170 --------- .../TraceContextHeadersValidation.java | 26 -- .../wachter/tracing/TraceContextRestorer.java | 100 ------ .../wachter/tracing/WoodyTracingFilter.java | 109 ------ .../vality/wachter/utils/DeadlineUtil.java | 110 ------ src/main/resources/application.yml | 27 +- .../client/WachterClientOperationsTest.java | 43 +-- ...bstractKeycloakOpenIdAsWiremockConfig.java | 7 +- .../WachterControllerDisabledAuthTest.java | 2 +- .../controller/WachterControllerTest.java | 32 +- .../integration/WachterIntegrationTest.java | 61 ++-- .../vality/wachter/testutil/ContextUtil.java | 33 -- .../TraceContextHeadersExtractorTest.java | 271 --------------- .../TraceContextHeadersNormalizerTest.java | 325 ------------------ .../tracing/TraceContextPipelineTest.java | 80 ----- .../tracing/TraceContextRestorerTest.java | 270 --------------- .../tracing/WoodyTracingFilterTest.java | 84 ----- 34 files changed, 117 insertions(+), 2118 deletions(-) delete mode 100644 src/main/java/dev/vality/wachter/client/WachterClientResponse.java delete mode 100644 src/main/java/dev/vality/wachter/config/OtelConfig.java delete mode 100644 src/main/java/dev/vality/wachter/config/WebConfig.java delete mode 100644 src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java delete mode 100644 src/main/java/dev/vality/wachter/config/properties/OtelProperties.java delete mode 100644 src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java delete mode 100644 src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java delete mode 100644 src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java delete mode 100644 src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java delete mode 100644 src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java delete mode 100644 src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java delete mode 100644 src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java delete mode 100644 src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java delete mode 100644 src/main/java/dev/vality/wachter/utils/DeadlineUtil.java delete mode 100644 src/test/java/dev/vality/wachter/testutil/ContextUtil.java delete mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java delete mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java delete mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java delete mode 100644 src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java delete mode 100644 src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java diff --git a/pom.xml b/pom.xml index 6f53c85..92a9f3c 100644 --- a/pom.xml +++ b/pom.xml @@ -14,11 +14,12 @@ 1.0.0 + 2.0.12 + 0.0.10 UTF-8 UTF-8 21 8022 - wachter 8023 ${server.port} ${management.port} @@ -34,6 +35,11 @@ dev.vality.geck serializer + + dev.vality + woody-http-bridge + ${woody-http-bridge.version} + @@ -81,11 +87,6 @@ - - org.projectlombok - lombok - provided - jakarta.servlet jakarta.servlet-api @@ -103,17 +104,6 @@ jakarta.xml.bind jakarta.xml.bind-api - - org.bouncycastle - bcpkix-jdk18on - 1.79 - test - - - io.opentelemetry - opentelemetry-semconv - 1.29.0-alpha - @@ -145,6 +135,12 @@ 3.10.0 test + + org.bouncycastle + bcpkix-jdk18on + 1.79 + test + diff --git a/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java index bacb6c3..5135111 100644 --- a/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java +++ b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java @@ -1,12 +1,11 @@ package dev.vality.wachter.client; -import dev.vality.wachter.constants.TraceHeadersConstants; +import dev.vality.woody.http.bridge.tracing.TraceHeadersConstants; import jakarta.servlet.http.HttpServletRequest; import lombok.experimental.UtilityClass; import org.springframework.http.HttpHeaders; import org.springframework.util.StringUtils; -import java.util.Enumeration; import java.util.List; import java.util.Locale; import java.util.Set; @@ -45,8 +44,10 @@ public class ProxyHeadersExtractor { "priority", "service", TraceHeadersConstants.OTEL_TRACE_PARENT, - TraceHeadersConstants.X_REQUEST_ID, - TraceHeadersConstants.X_REQUEST_DEADLINE + TraceHeadersConstants.OTEL_TRACE_STATE, + TraceHeadersConstants.ExternalHeaders.X_REQUEST_ID, + TraceHeadersConstants.ExternalHeaders.X_REQUEST_DEADLINE, + TraceHeadersConstants.ExternalHeaders.X_INVOICE_ID ).map(header -> header.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); private static final List EXCLUDED_PREFIXES = List.of( @@ -54,7 +55,7 @@ public class ProxyHeadersExtractor { "cdn-", "sec-", TraceHeadersConstants.WOODY_PREFIX, - TraceHeadersConstants.X_WOODY_PREFIX + TraceHeadersConstants.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 5afe388..804bb37 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,20 +1,18 @@ package dev.vality.wachter.client; -import dev.vality.wachter.tracing.TraceContextHeadersExtractor; -import dev.vality.wachter.tracing.TraceContextHeadersNormalizer; +import dev.vality.woody.http.bridge.tracing.TraceContextExtractor; +import dev.vality.woody.http.bridge.tracing.TraceContextHeadersNormalizer; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.client.RestClient; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.Objects; -import java.util.function.Consumer; @Slf4j @Component @@ -29,7 +27,7 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont var httpMethod = resolveMethod(servletRequest); var proxyHeaders = ProxyHeadersExtractor.extractHeaders(servletRequest); - var traceHeaders = TraceContextHeadersExtractor.extractHeaders(); + var traceHeaders = TraceContextExtractor.extractHeaders(); var httpHeaders = new HttpHeaders(); proxyHeaders.forEach(httpHeaders::addAll); @@ -62,4 +60,7 @@ private HttpMethod resolveMethod(HttpServletRequest servletRequest) { return HttpMethod.POST; } } + + public record WachterClientResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body) { + } } diff --git a/src/main/java/dev/vality/wachter/client/WachterClientResponse.java b/src/main/java/dev/vality/wachter/client/WachterClientResponse.java deleted file mode 100644 index c5a6f26..0000000 --- a/src/main/java/dev/vality/wachter/client/WachterClientResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.vality.wachter.client; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; - -public record WachterClientResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body) { -} diff --git a/src/main/java/dev/vality/wachter/config/OtelConfig.java b/src/main/java/dev/vality/wachter/config/OtelConfig.java deleted file mode 100644 index c700dad..0000000 --- a/src/main/java/dev/vality/wachter/config/OtelConfig.java +++ /dev/null @@ -1,70 +0,0 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.config.properties.OtelProperties; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.resources.Resource; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; -import io.opentelemetry.sdk.trace.samplers.Sampler; -import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.Duration; - -@Slf4j -@Configuration -@ConditionalOnProperty(value = "otel.enabled", havingValue = "true", matchIfMissing = true) -@RequiredArgsConstructor -public class OtelConfig { - - private final OtelProperties otelProperties; - - @Value("${spring.application.name}") - private String applicationName; - - @Bean - public OpenTelemetry openTelemetryConfig() { - var resource = Resource.getDefault() - .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, applicationName))); - var sdkTracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(BatchSpanProcessor.builder(OtlpHttpSpanExporter.builder() - .setEndpoint(otelProperties.getResource()) - .setTimeout(Duration.ofMillis(otelProperties.getTimeout())) - .build()) - .build()) - .setSampler(Sampler.alwaysOn()) - .setResource(resource) - .build(); - var openTelemetrySdk = OpenTelemetrySdk.builder() - .setTracerProvider(sdkTracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - registerGlobalOpenTelemetry(openTelemetrySdk); - return openTelemetrySdk; - } - - private static void registerGlobalOpenTelemetry(OpenTelemetry openTelemetry) { - try { - GlobalOpenTelemetry.set(openTelemetry); - } catch (Throwable ex) { - log.warn("Please initialize the ObservabilitySdk before starting the application", ex); - GlobalOpenTelemetry.resetForTest(); - try { - GlobalOpenTelemetry.set(openTelemetry); - } catch (Throwable ex1) { - log.warn("Unable to set GlobalOpenTelemetry", ex1); - } - } - } -} diff --git a/src/main/java/dev/vality/wachter/config/WebConfig.java b/src/main/java/dev/vality/wachter/config/WebConfig.java deleted file mode 100644 index 539c1f3..0000000 --- a/src/main/java/dev/vality/wachter/config/WebConfig.java +++ /dev/null @@ -1,73 +0,0 @@ -package dev.vality.wachter.config; - -import dev.vality.wachter.tracing.WoodyTracingFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Configuration -@Slf4j -public class WebConfig { - - @Value("${server.port}") - private int serverPort; - - @Value("/${wachter.endpoint}") - private String wachterEndpoint; - - @Bean - public FilterRegistrationBean externalPortRestrictingFilter() { - var filter = new OncePerRequestFilter() { - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - var requestPath = getRequestPath(request); - if ((request.getLocalPort() == serverPort) && !requestPath.equals(wachterEndpoint)) { - var status = HttpServletResponse.SC_NOT_FOUND; - log.warn("<- Sent [redirecting {}]: Unknown address {}", status, requestPath); - response.sendError(status, "Unknown address"); - return; - } - filterChain.doFilter(request, response); - } - }; - - var filterRegistrationBean = new FilterRegistrationBean(); - filterRegistrationBean.setFilter(filter); - filterRegistrationBean.setOrder(-100); - filterRegistrationBean.setName("externalPortRestrictingFilter"); - filterRegistrationBean.addUrlPatterns("/*"); - return filterRegistrationBean; - } - - @Bean - public FilterRegistrationBean woodyTracingFilter() { - var registrationBean = new FilterRegistrationBean<>(new WoodyTracingFilter(serverPort, wachterEndpoint)); - registrationBean.setOrder(-50); - registrationBean.setName("woodyTracingFilter"); - registrationBean.addUrlPatterns(wachterEndpoint); - return registrationBean; - } - - public static String getRequestPath(HttpServletRequest request) { - var servletPath = request.getServletPath(); - if (servletPath != null && !servletPath.isBlank()) { - return servletPath; - } - var requestPath = request.getRequestURI(); - if (requestPath != null && !requestPath.isBlank()) { - return requestPath; - } - return ""; - } -} diff --git a/src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java b/src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java deleted file mode 100644 index be4695f..0000000 --- a/src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.vality.wachter.config.properties; - -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@Configuration -@ConfigurationProperties("http-client") -public class HttpClientProperties { - - @NotNull - private int maxTotalPooling; - - @NotNull - private int defaultMaxPerRoute; - - @NotNull - private int socketTimeout; - - @NotNull - private int connectionRequestTimeout; - - @NotNull - private int connectTimeout; - -} diff --git a/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java b/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java deleted file mode 100644 index 2b88704..0000000 --- a/src/main/java/dev/vality/wachter/config/properties/OtelProperties.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.vality.wachter.config.properties; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "otel") -public class OtelProperties { - - private String resource; - private Long timeout; - -} diff --git a/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java b/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java index 80af146..6aece91 100644 --- a/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java +++ b/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java @@ -27,5 +27,4 @@ public static class Service { private String url; } - } diff --git a/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java b/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java deleted file mode 100644 index a8a58eb..0000000 --- a/src/main/java/dev/vality/wachter/constants/RequestAttributeNames.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.vality.wachter.constants; - -public final class RequestAttributeNames { - - public static final String NORMALIZED_WOODY_HEADERS = "wachter.normalizedWoodyHeaders"; - - private RequestAttributeNames() { - } -} diff --git a/src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java b/src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java deleted file mode 100644 index ecd6326..0000000 --- a/src/main/java/dev/vality/wachter/constants/TraceHeadersConstants.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.vality.wachter.constants; - -public class TraceHeadersConstants { - - 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_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_ERROR_CLASS = X_WOODY_PREFIX + "error-class"; - public static final String X_WOODY_ERROR_REASON = X_WOODY_PREFIX + "error-reason"; - public static final String X_WOODY_META_USER_IDENTITY_PREFIX = - X_WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_SUFFIX; - public static final String WOODY_PREFIX = "woody."; - public static final String WOODY_TRACE_ID = WOODY_PREFIX + "trace-id"; - public static final String WOODY_SPAN_ID = WOODY_PREFIX + "span-id"; - public static final String WOODY_PARENT_ID = WOODY_PREFIX + "parent-id"; - public static final String WOODY_DEADLINE = WOODY_PREFIX + "deadline"; - public static final String WOODY_ERROR_CLASS = WOODY_PREFIX + "error-class"; - public static final String WOODY_ERROR_REASON = WOODY_PREFIX + "error-reason"; - public static final String WOODY_META_USER_IDENTITY_PREFIX = - WOODY_PREFIX + WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX; - public static final String WOODY_META_REQUEST_ID = WOODY_META_USER_IDENTITY_PREFIX + "x-request-id"; - public static final String WOODY_META_REQUEST_DEADLINE = WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline"; - public static final String OTEL_TRACE_PARENT = "traceparent"; - - public static final class WoodySuffixes { - private static final String META = "meta"; - private static final String USER_IDENTITY = "user-identity"; - private static final String HYPHEN = "-"; - private static final String DOT = "."; - - public static final String META_USER_IDENTITY_SUFFIX = META + HYPHEN + USER_IDENTITY + HYPHEN; - public static final String META_USER_IDENTITY_DOT_SUFFIX = META + DOT + USER_IDENTITY + DOT; - public static final String USER_IDENTITY_KEY_SUFFIX = USER_IDENTITY + DOT; - - private WoodySuffixes() { - } - - public static String userIdentitySuffix(String extensionKey) { - if (extensionKey == null || extensionKey.isEmpty()) { - return ""; - } - if (extensionKey.startsWith(USER_IDENTITY_KEY_SUFFIX)) { - return extensionKey.substring(USER_IDENTITY_KEY_SUFFIX.length()); - } - int lastDot = extensionKey.lastIndexOf('.'); - return lastDot >= 0 ? extensionKey.substring(lastDot + 1) : extensionKey; - } - } - -} diff --git a/src/main/java/dev/vality/wachter/controller/WachterController.java b/src/main/java/dev/vality/wachter/controller/WachterController.java index c0ac04b..b9dd73c 100644 --- a/src/main/java/dev/vality/wachter/controller/WachterController.java +++ b/src/main/java/dev/vality/wachter/controller/WachterController.java @@ -3,7 +3,6 @@ import dev.vality.wachter.service.WachterService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,10 +20,8 @@ public class WachterController { @PostMapping("/wachter") public ResponseEntity proxyRequest(HttpServletRequest request) { var upstreamResponse = wachterService.process(request); - var responseHeaders = new HttpHeaders(); - responseHeaders.putAll(upstreamResponse.headers()); return ResponseEntity.status(upstreamResponse.statusCode()) - .headers(responseHeaders) + .headers(upstreamResponse.headers()) .body(upstreamResponse.body()); } } diff --git a/src/main/java/dev/vality/wachter/security/AccessService.java b/src/main/java/dev/vality/wachter/security/AccessService.java index 69b3248..e69a1eb 100644 --- a/src/main/java/dev/vality/wachter/security/AccessService.java +++ b/src/main/java/dev/vality/wachter/security/AccessService.java @@ -16,7 +16,7 @@ public class AccessService { private final RoleAccessService roleAccessService; public void checkUserAccess(AccessData accessData) { - log.info("Check the {} rights to perform the operation {} in service {} for roles {}", + log.debug("Check the {} rights to perform the operation {} in service {} for roles {}", accessData.getUserEmail(), accessData.getMethodName(), accessData.getServiceName(), diff --git a/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java b/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java deleted file mode 100644 index f3352de..0000000 --- a/src/main/java/dev/vality/wachter/security/JwtTokenDetailsExtractor.java +++ /dev/null @@ -1,63 +0,0 @@ -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 static final String PREFERRED_USERNAME = "preferred_username"; - private static final String EMAIL = "email"; - private static final String ISSUER = "iss"; - - private JwtTokenDetailsExtractor() { - } - - public static Optional extractFromContext(Authentication authentication) { - if (!(authentication instanceof JwtAuthenticationToken jwtAuthentication)) { - return Optional.empty(); - } - var token = jwtAuthentication.getToken(); - return Optional.of(new JwtTokenDetails( - token.getClaimAsString(JwtClaimNames.SUB), - 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(ISSUER); - if (issuer == null) { - return null; - } - var normalized = issuer.trim(); - if (normalized.isEmpty()) { - return null; - } - while (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - if (normalized.isEmpty()) { - return null; - } - 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/security/RoleAccessService.java b/src/main/java/dev/vality/wachter/security/RoleAccessService.java index 0c6866b..816da7f 100644 --- a/src/main/java/dev/vality/wachter/security/RoleAccessService.java +++ b/src/main/java/dev/vality/wachter/security/RoleAccessService.java @@ -29,14 +29,14 @@ public void checkRolesAccess(AccessData accessData) { for (String role : accessData.getTokenRoles()) { if (role.equalsIgnoreCase(getServiceAndMethodName(accessData))) { - log.info("Rights allowed in service {} and method {} for user {}", + log.debug("Rights allowed in service {} and method {} for user {}", accessData.getServiceName(), accessData.getMethodName(), accessData.getUserEmail()); return; } else if (role.equalsIgnoreCase(getServiceName(accessData))) { - log.info("Rights allowed in all service {} for user {}", + log.debug("Rights allowed in all service {} for user {}", accessData.getServiceName(), accessData.getUserEmail()); return; diff --git a/src/main/java/dev/vality/wachter/service/WachterService.java b/src/main/java/dev/vality/wachter/service/WachterService.java index 08d78b3..af0d40e 100644 --- a/src/main/java/dev/vality/wachter/service/WachterService.java +++ b/src/main/java/dev/vality/wachter/service/WachterService.java @@ -1,11 +1,10 @@ package dev.vality.wachter.service; import dev.vality.wachter.client.WachterClient; -import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.mapper.ServiceMapper; import dev.vality.wachter.security.AccessData; import dev.vality.wachter.security.AccessService; -import dev.vality.wachter.security.JwtTokenDetailsExtractor; +import dev.vality.woody.http.bridge.util.JwtTokenDetailsExtractor; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; @@ -15,6 +14,8 @@ import java.io.ByteArrayOutputStream; +import static dev.vality.wachter.client.WachterClient.WachterClientResponse; + @RequiredArgsConstructor @Service public class WachterService { diff --git a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java deleted file mode 100644 index 67735d6..0000000 --- a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersExtractor.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.trace.Metadata; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import dev.vality.woody.thrift.impl.http.TraceParentUtils; -import io.opentelemetry.api.trace.Span; -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; - -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; - -@Slf4j -@UtilityClass -public class TraceContextHeadersExtractor { - - public Map extractHeaders() { - var traceData = TraceContext.getCurrentTraceData(); - Objects.requireNonNull(traceData); - Objects.requireNonNull(traceData.getOtelSpan()); - Objects.requireNonNull(traceData.getOtelSpan().getSpanContext()); - if (!traceData.getOtelSpan().getSpanContext().isValid()) { - throw new IllegalStateException("SpanContext must be valid"); - } - - var span = traceData.getActiveSpan().getSpan(); - var headers = new HashMap(); - putIfNotNull(headers, WOODY_TRACE_ID, span.getTraceId()); - putIfNotNull(headers, WOODY_SPAN_ID, span.getId()); - putIfNotNull(headers, WOODY_PARENT_ID, span.getParentId()); - putIfNotNull(headers, WOODY_DEADLINE, - Optional.ofNullable(span.getDeadline()).map(Instant::toString).orElse(null)); - putIfNotNull(headers, OTEL_TRACE_PARENT, initParentTraceFromSpan(traceData.getOtelSpan())); - - var customMetadata = traceData.getActiveSpan().getCustomMetadata(); - extractUserIdentityHeader(headers, customMetadata, UserIdentityIdExtensionKit.KEY); - extractUserIdentityHeader(headers, customMetadata, UserIdentityUsernameExtensionKit.KEY); - extractUserIdentityHeader(headers, customMetadata, UserIdentityEmailExtensionKit.KEY); - extractUserIdentityHeader(headers, customMetadata, UserIdentityRealmExtensionKit.KEY); - putMetadataValue(headers, customMetadata, X_REQUEST_ID, WOODY_META_REQUEST_ID); - putMetadataValue(headers, customMetadata, X_REQUEST_DEADLINE, WOODY_META_REQUEST_DEADLINE); - return headers; - } - - private void extractUserIdentityHeader(Map headers, Metadata customMetadata, String extensionKey) { - var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); - if (suffix.isEmpty()) { - return; - } - - var value = (String) customMetadata.getValue(extensionKey); - putIfNotNull(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, value); - } - - private void putMetadataValue(Map headers, - Metadata customMetadata, - String metadataKey, - String headerKey) { - var value = (String) customMetadata.getValue(metadataKey); - putIfNotNull(headers, headerKey, value); - } - - private void putIfNotNull(Map headers, - String key, - String value) { - if (value != null && !value.isEmpty()) { - headers.put(key, value); - } - } - - private String initParentTraceFromSpan(Span otelSpan) { - var spanContext = otelSpan.getSpanContext(); - return TraceParentUtils.initParentTrace( - TraceParentUtils.DEFAULT_VERSION, - spanContext.getTraceId(), - spanContext.getSpanId(), - spanContext.getTraceFlags().asHex() - ); - } -} diff --git a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java deleted file mode 100644 index d9f9b1d..0000000 --- a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizer.java +++ /dev/null @@ -1,170 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.wachter.security.JwtTokenDetailsExtractor; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import jakarta.servlet.http.HttpServletRequest; -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.*; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; -import static dev.vality.wachter.utils.DeadlineUtil.*; - -@Slf4j -@UtilityClass -public class TraceContextHeadersNormalizer { - - public Map normalize(HttpServletRequest request) { - var normalized = new HashMap(); - var headerNamesEnumeration = request.getHeaderNames(); - if (headerNamesEnumeration != null) { - var headerNames = Collections.list(headerNamesEnumeration); - normalizeWoodyHeaders(request, headerNames, normalized); - normalizeOtelHeaders(request, normalized); - } - mergeJwtIntoHeaders(normalized); - mergeRequestDeadline(request, normalized); - mergeWoodyRequestMetadata(normalized); - return normalized.isEmpty() ? Map.of() : Map.copyOf(normalized); - } - - public HttpHeaders normalizeResponseHeaders(HttpHeaders responseHeaders) { - var normalized = new HttpHeaders(); - for (var entry : responseHeaders.entrySet()) { - var headerName = entry.getKey(); - var lowerCase = headerName.toLowerCase(Locale.ROOT); - if (lowerCase.startsWith(WOODY_PREFIX) || lowerCase.startsWith(X_WOODY_PREFIX)) { - normalizeWoodyResponseHeader(normalized, lowerCase, entry.getValue()); - } else if (lowerCase.equals(X_REQUEST_ID.toLowerCase(Locale.ROOT)) - || lowerCase.equals(X_REQUEST_DEADLINE.toLowerCase(Locale.ROOT)) - || lowerCase.equals(OTEL_TRACE_PARENT.toLowerCase(Locale.ROOT))) { - normalized.addAll(headerName, entry.getValue()); - } - } - return normalized; - } - - private void normalizeWoodyHeaders(HttpServletRequest request, List headerNames, - Map headers) { - for (var name : headerNames) { - var lowerCase = name.toLowerCase(Locale.ROOT); - if (!lowerCase.startsWith(WOODY_PREFIX) && !lowerCase.startsWith(X_WOODY_PREFIX)) { - continue; - } - var value = request.getHeader(name); - if (value == null) { - continue; - } - if (lowerCase.startsWith(WOODY_PREFIX)) { - headers.put(lowerCase, value); - } else { - var suffix = lowerCase.substring(X_WOODY_PREFIX.length()); - if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_SUFFIX)) { - var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_SUFFIX.length()); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + metaKey, value); - } else { - headers.put(WOODY_PREFIX + suffix, value); - } - } - } - } - - private void normalizeOtelHeaders(HttpServletRequest request, Map headers) { - var traceParent = request.getHeader(OTEL_TRACE_PARENT); - if (traceParent != null) { - headers.put(OTEL_TRACE_PARENT, traceParent); - } - } - - private void mergeJwtIntoHeaders(Map headers) { - var tokenDetails = JwtTokenDetailsExtractor.extractFromContext(SecurityContextHolder - .getContext() - .getAuthentication()); - if (tokenDetails.isEmpty()) { - return; - } - var details = tokenDetails.get(); - putJwtMetadata(headers, UserIdentityIdExtensionKit.KEY, details.subject()); - putJwtMetadata(headers, UserIdentityUsernameExtensionKit.KEY, details.preferredUsername()); - putJwtMetadata(headers, UserIdentityEmailExtensionKit.KEY, details.email()); - putJwtMetadata(headers, UserIdentityRealmExtensionKit.KEY, details.realm()); - } - - private void mergeRequestDeadline(HttpServletRequest request, Map headers) { - var requestDeadlineHeader = request.getHeader(X_REQUEST_DEADLINE); - var requestIdHeader = request.getHeader(X_REQUEST_ID); - if (requestIdHeader != null && !requestIdHeader.isEmpty()) { - headers.put(X_REQUEST_ID, requestIdHeader); - } - if (requestDeadlineHeader == null) { - return; - } - try { - var normalizedDeadline = getInstant(requestDeadlineHeader, requestIdHeader).toString(); - headers.putIfAbsent(WOODY_DEADLINE, normalizedDeadline); - headers.put(X_REQUEST_DEADLINE, normalizedDeadline); - } catch (Exception e) { - log.warn("Unable to parse 'X-Request-Deadline' header value '{}'", requestDeadlineHeader); - } - } - - private void mergeWoodyRequestMetadata(Map headers) { - var woodyRequestId = headers.get(WOODY_META_REQUEST_ID); - if (woodyRequestId != null && !woodyRequestId.isEmpty()) { - headers.put(X_REQUEST_ID, woodyRequestId); - } - var woodyDeadline = headers.get(WOODY_META_REQUEST_DEADLINE); - if (woodyDeadline != null && !woodyDeadline.isEmpty()) { - headers.put(X_REQUEST_DEADLINE, woodyDeadline); - headers.putIfAbsent(WOODY_DEADLINE, woodyDeadline); - } - } - - private void putJwtMetadata(Map headers, String extensionKey, String value) { - var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); - if (suffix.isEmpty() || value == null || value.isEmpty()) { - return; - } - headers.put(WOODY_META_USER_IDENTITY_PREFIX + suffix, value); - } - - private Instant getInstant(String requestDeadlineHeader, String requestIdHeader) { - if (containsRelativeValues(requestDeadlineHeader, requestIdHeader)) { - return Instant.now() - .plus(extractMilliseconds(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS) - .plus(extractSeconds(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS) - .plus(extractMinutes(requestDeadlineHeader, requestIdHeader), ChronoUnit.MILLIS); - } - return Instant.parse(requestDeadlineHeader); - } - - private void normalizeWoodyResponseHeader(HttpHeaders headers, - String lowerCase, - List values) { - if (lowerCase.startsWith(X_WOODY_PREFIX)) { - headers.addAll(lowerCase, values); - } else { - var suffix = lowerCase.substring(WOODY_PREFIX.length()); - if (suffix.startsWith(WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX)) { - var metaKey = suffix.substring(WoodySuffixes.META_USER_IDENTITY_DOT_SUFFIX.length()); - headers.addAll(X_WOODY_META_USER_IDENTITY_PREFIX + metaKey, values); - var metaKeyLower = metaKey.toLowerCase(Locale.ROOT); - if (metaKeyLower.equals("x-request-id")) { - headers.addAll(X_REQUEST_ID, values); - } else if (metaKeyLower.equals("x-request-deadline")) { - headers.addAll(X_REQUEST_DEADLINE, values); - } - } else { - headers.addAll(X_WOODY_PREFIX + suffix, values); - } - } - } -} diff --git a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java b/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java deleted file mode 100644 index 1c98bba..0000000 --- a/src/main/java/dev/vality/wachter/tracing/TraceContextHeadersValidation.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.vality.wachter.tracing; - -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; - -import java.util.LinkedHashMap; -import java.util.Map; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; - -@Slf4j -@UtilityClass -public class TraceContextHeadersValidation { - - public LinkedHashMap validate(Map normalized) { - var copy = new LinkedHashMap<>(normalized); - var traceId = copy.get(WOODY_TRACE_ID); - if (traceId != null && traceId.equals(copy.get(WOODY_SPAN_ID))) { - copy.remove(WOODY_SPAN_ID); - } - if ("undefined".equals(copy.get(WOODY_PARENT_ID))) { - copy.remove(WOODY_PARENT_ID); - } - return copy; - } -} diff --git a/src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java b/src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java deleted file mode 100644 index 5ac33bc..0000000 --- a/src/main/java/dev/vality/wachter/tracing/TraceContextRestorer.java +++ /dev/null @@ -1,100 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.flow.WFlow; -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import dev.vality.woody.thrift.impl.http.TraceParentUtils; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.*; -import io.opentelemetry.context.Context; -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; - -import java.time.Instant; -import java.util.Map; -import java.util.function.Consumer; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; - -@Slf4j -@UtilityClass -public class TraceContextRestorer { - - public TraceData restoreTraceData(Map headers) { - var traceData = new TraceData(); - - TraceContext.initNewServiceTrace(traceData, WFlow.createDefaultIdGenerator(), WFlow.createDefaultIdGenerator()); - - if (headers.isEmpty()) { - return traceData; - } - - var serviceSpan = traceData.getServiceSpan().getSpan(); - setIfPresent(headers, WOODY_TRACE_ID, serviceSpan::setTraceId); - setIfPresent(headers, WOODY_SPAN_ID, serviceSpan::setId); - setIfPresent(headers, WOODY_PARENT_ID, serviceSpan::setParentId); - setIfPresent(headers, WOODY_DEADLINE, value -> serviceSpan.setDeadline(Instant.parse(value))); - setIfPresent(headers, OTEL_TRACE_PARENT, value -> { - var otelSpan = initSpan(value); - otelSpan.makeCurrent(); - traceData.setOtelSpan(otelSpan); - }); - - var customMetadata = traceData.getActiveSpan().getCustomMetadata(); - applyUserIdentityHeader(headers, UserIdentityIdExtensionKit.KEY, - value -> customMetadata.putValue(UserIdentityIdExtensionKit.KEY, value)); - applyUserIdentityHeader(headers, UserIdentityUsernameExtensionKit.KEY, - value -> customMetadata.putValue(UserIdentityUsernameExtensionKit.KEY, value)); - applyUserIdentityHeader(headers, UserIdentityEmailExtensionKit.KEY, - value -> customMetadata.putValue(UserIdentityEmailExtensionKit.KEY, value)); - applyUserIdentityHeader(headers, UserIdentityRealmExtensionKit.KEY, - value -> customMetadata.putValue(UserIdentityRealmExtensionKit.KEY, value)); - setIfPresent(headers, X_REQUEST_ID, value -> customMetadata.putValue(X_REQUEST_ID, value)); - setIfPresent(headers, X_REQUEST_DEADLINE, value -> customMetadata.putValue(X_REQUEST_DEADLINE, value)); - return traceData; - } - - private void applyUserIdentityHeader(Map headers, - String extensionKey, - Consumer consumer) { - var suffix = WoodySuffixes.userIdentitySuffix(extensionKey); - if (suffix.isEmpty()) { - return; - } - setIfPresent(headers, WOODY_META_USER_IDENTITY_PREFIX + suffix, consumer); - } - - private void setIfPresent(Map headers, String key, Consumer consumer) { - var value = headers.get(key); - if (value != null && !value.isEmpty()) { - try { - consumer.accept(value); - } catch (Exception e) { - log.warn("Unable to set header with key '{}' value '{}'", key, value); - } - } - } - - private Span initSpan(String traceparent) { - return GlobalOpenTelemetry.getTracer(TraceData.WOODY) - .spanBuilder(TraceData.OTEL_CLIENT) - .setSpanKind(SpanKind.SERVER) - .setParent( - Context.current().with( - Span.wrap( - SpanContext.createFromRemoteParent( - TraceParentUtils.parseTraceId(traceparent), - TraceParentUtils.parseSpanId(traceparent), - TraceFlags.getSampled(), - TraceState.builder().build() - ) - ) - ) - ) - .startSpan(); - } -} diff --git a/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java deleted file mode 100644 index 4271ba1..0000000 --- a/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java +++ /dev/null @@ -1,109 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.flow.WFlow; -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import static dev.vality.wachter.config.WebConfig.getRequestPath; - -@Slf4j -@RequiredArgsConstructor -public final class WoodyTracingFilter extends OncePerRequestFilter { - - private static final Set SENSITIVE_HEADERS = Set.of( - HttpHeaders.AUTHORIZATION.toLowerCase(Locale.ROOT), - HttpHeaders.COOKIE.toLowerCase(Locale.ROOT), - HttpHeaders.SET_COOKIE.toLowerCase(Locale.ROOT) - ); - - private final int serverPort; - private final String wachterEndpoint; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) { - var requestPath = getRequestPath(request); - if ((request.getLocalPort() == serverPort) && requestPath.equals(wachterEndpoint)) { - var normalized = TraceContextHeadersNormalizer.normalize(request); - var validated = TraceContextHeadersValidation.validate(normalized); - log.info("-> Received {} {} | params: {}, headers: {}", - request.getMethod(), getRequestPath(request), extractParams(request), sanitizeHeaders(request)); - var restoredTraceData = TraceContextRestorer.restoreTraceData(validated); - WFlow.create(() -> doFilter(request, response, filterChain), restoredTraceData) - .run(); - log.info("<- Sent {} {} | status: {}, headers: {}", - request.getMethod(), getRequestPath(request), response.getStatus(), - sanitizeResponseHeaders(response)); - return; - } - doFilter(request, response, filterChain); - } - - @SneakyThrows - private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - filterChain.doFilter(request, response); - } - - public static String extractParams(HttpServletRequest servletRequest) { - return servletRequest.getParameterMap().entrySet().stream() - .map(entry -> entry.getKey() + "=" + String.join(",", entry.getValue())) - .collect(Collectors.joining(", ")); - } - - private static HttpHeaders sanitizeHeaders(HttpServletRequest request) { - var headers = new HttpHeaders(); - var collectedHeaders = collectHeaders(request); - collectedHeaders.forEach((name, value) -> { - if (isSensitive(name)) { - headers.add(name, "***"); - } else { - headers.add(name, value); - } - }); - return headers; - } - - private static Map collectHeaders(HttpServletRequest request) { - var headers = new LinkedHashMap(); - var headerNames = request.getHeaderNames(); - if (headerNames != null) { - while (headerNames.hasMoreElements()) { - var name = headerNames.nextElement(); - var value = request.getHeader(name); - if (value != null) { - headers.put(name, value); - } - } - } - return headers; - } - - private static boolean isSensitive(String headerName) { - return SENSITIVE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT)); - } - - private static HttpHeaders sanitizeResponseHeaders(HttpServletResponse response) { - var headers = new HttpHeaders(); - response.getHeaderNames().forEach(name -> { - if (isSensitive(name)) { - headers.add(name, "***"); - } else { - response.getHeaders(name).forEach(value -> headers.add(name, value)); - } - }); - return headers; - } -} diff --git a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java b/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java deleted file mode 100644 index 8606e50..0000000 --- a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.vality.wachter.utils; - -import dev.vality.wachter.exceptions.DeadlineException; -import jakarta.annotation.Nullable; -import lombok.experimental.UtilityClass; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.regex.Pattern; - -@UtilityClass -@SuppressWarnings("ParameterName") -public class DeadlineUtil { - - public static void checkDeadline(@Nullable String xRequestDeadline, String xRequestId) { - if (xRequestDeadline == null) { - return; - } - if (containsRelativeValues(xRequestDeadline, xRequestId)) { - return; - } - try { - Instant instant = Instant.parse(xRequestDeadline); - if (Instant.now().isAfter(instant)) { - throw new DeadlineException(String.format("Deadline has expired, xRequestId=%s ", xRequestId)); - } - } catch (Exception e) { - throw new DeadlineException( - String.format("Deadline has invalid 'Instant' format, xRequestId=%s ", xRequestId)); - } - } - - public static boolean containsRelativeValues(String xRequestDeadline, String xRequestId) { - return (extractMinutes(xRequestDeadline, xRequestId) + extractSeconds(xRequestDeadline, xRequestId) + - extractMilliseconds(xRequestDeadline, xRequestId)) > 0; - } - - public static Long extractMinutes(String xRequestDeadline, String xRequestId) { - var format = "minutes"; - - checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?(?!ms)[m])", format); - - var minutes = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?(?!ms)[m])", xRequestId, format); - - return Optional.ofNullable(minutes).map(min -> min * 60000.0).map(Double::longValue).orElse(0L); - } - - public static Long extractSeconds(String xRequestDeadline, String xRequestId) { - var format = "seconds"; - - checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?[s])", format); - - var seconds = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?[s])", xRequestId, format); - - return Optional.ofNullable(seconds).map(s -> s * 1000.0).map(Double::longValue).orElse(0L); - } - - public static Long extractMilliseconds(String xRequestDeadline, String xRequestId) { - var format = "milliseconds"; - - checkNegativeValues(xRequestDeadline, xRequestId, "([-][0-9]+([.][0-9]+)?[m][s])", format); - - var milliseconds = extractValue(xRequestDeadline, "([0-9]+([.][0-9]+)?[m][s])", xRequestId, format); - - if (milliseconds != null && Math.ceil(milliseconds % 1) > 0) { - throw new DeadlineException( - String.format("Deadline 'milliseconds' parameter can have only integer value, xRequestId=%s ", - xRequestId)); - } - - return Optional.ofNullable(milliseconds).map(Double::longValue).orElse(0L); - } - - private static void checkNegativeValues(String xRequestDeadline, String xRequestId, String regex, String format) { - if (!match(regex, xRequestDeadline).isEmpty()) { - throw new DeadlineException( - String.format("Deadline '%s' parameter has negative value, xRequestId=%s ", format, xRequestId)); - } - } - - private static Double extractValue(String xRequestDeadline, String formatRegex, String xRequestId, String format) { - var numberRegex = "([0-9]+([.][0-9]+)?)"; - - var doubles = new ArrayList(); - for (String string : match(formatRegex, xRequestDeadline)) { - doubles.addAll(match(numberRegex, string)); - } - if (doubles.size() > 1) { - throw new DeadlineException( - String.format("Deadline '%s' parameter has a few relative value, xRequestId=%s ", format, - xRequestId)); - } - if (doubles.isEmpty()) { - return null; - } - return Double.valueOf(doubles.getFirst()); - } - - private static List match(String regex, String data) { - var pattern = Pattern.compile(regex); - var matcher = pattern.matcher(data); - var strings = new ArrayList(); - while (matcher.find()) { - strings.add(matcher.group()); - } - return strings; - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9059e12..de40fc7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ management: port: ${management.port} metrics: tags: - application: '@project.name@' + application: ${project.name} endpoint: health: show-details: always @@ -26,7 +26,7 @@ management: spring: application: - name: '@project.name@' + name: ${project.name} output: ansi: enabled: always @@ -39,12 +39,8 @@ spring: issuer-uri: > ${spring.security.oauth2.resourceserver.url}/auth/realms/ ${spring.security.oauth2.resourceserver.jwt.realm} -info: - version: '@project.version@' - stage: dev wachter: - endpoint: ${wachter.endpoint} auth: enabled: true serviceHeader: Service @@ -128,20 +124,21 @@ wachter: name: DMTClient url: http://dmt.default:8022/v1/domain/repository_client +auth: + enabled: true -http-client: - connectTimeout: 10000 - connectionRequestTimeout: 10000 - socketTimeout: 10000 - maxTotalPooling: 200 - defaultMaxPerRoute: 200 - -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 - enabled: true http: requestTimeout: 60000 diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index 8ccb931..0c856e3 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -3,8 +3,9 @@ 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.Tracer; +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; @@ -19,9 +20,9 @@ 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 org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_TRACE_ID; import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -29,7 +30,6 @@ class WachterClientOperationsTest { private SdkTracerProvider tracerProvider; - private Tracer tracer; @BeforeEach void setUp() { @@ -40,7 +40,6 @@ void setUp() { .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) .build(); GlobalOpenTelemetry.set(openTelemetry); - tracer = openTelemetry.getTracer("test"); } @AfterEach @@ -58,10 +57,7 @@ void shouldSendRequestWithTracingHeaders() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); + final var traceData = prepareTraceData("test-span"); final var serviceSpan = traceData.getServiceSpan().getSpan(); serviceSpan.setTraceId("test-trace-id"); @@ -91,7 +87,7 @@ void shouldSendRequestWithTracingHeaders() { assertEquals(HttpStatus.OK, actualResponse.statusCode()); assertArrayEquals(expectedResponse, actualResponse.body()); server.verify(); - otelSpan.end(); + traceData.finishOtelSpan(); } @Test @@ -100,10 +96,7 @@ void shouldFilterDisallowedHeaders() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); + final var traceData = prepareTraceData("test-span"); traceData.getServiceSpan().getSpan().setTraceId("filter-trace-id"); traceData.getServiceSpan().getSpan().setId("filter-span-id"); @@ -127,7 +120,7 @@ void shouldFilterDisallowedHeaders() { client.send(servletRequest, null, "http://upstream/disallowed"); server.verify(); - otelSpan.end(); + traceData.finishOtelSpan(); } @Test @@ -136,10 +129,7 @@ void shouldHandleGetRequestWithoutBody() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); + final var traceData = prepareTraceData("test-span"); final var serviceSpan = traceData.getServiceSpan().getSpan(); serviceSpan.setTraceId("get-trace-id"); @@ -158,7 +148,7 @@ void shouldHandleGetRequestWithoutBody() { assertEquals(HttpStatus.OK, response.statusCode()); assertArrayEquals("{}".getBytes(), response.body()); server.verify(); - otelSpan.end(); + traceData.finishOtelSpan(); } @Test @@ -167,10 +157,7 @@ void shouldReturnErrorResponseWithoutThrowing() { final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); + final var traceData = prepareTraceData("test-span"); final var serviceSpan = traceData.getServiceSpan().getSpan(); serviceSpan.setTraceId("error-trace-id"); @@ -192,6 +179,14 @@ void shouldReturnErrorResponseWithoutThrowing() { assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode()); assertArrayEquals("bad-gateway".getBytes(), response.body()); server.verify(); - otelSpan.end(); + 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 05ec03a..1e2970c 100644 --- a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java +++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java @@ -21,7 +21,12 @@ "server.port=8083", "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}"}) + "${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) diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java index 3cf3139..ddc302b 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java @@ -1,7 +1,6 @@ package dev.vality.wachter.controller; import dev.vality.wachter.client.WachterClient; -import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; import lombok.SneakyThrows; @@ -20,6 +19,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import static dev.vality.wachter.client.WachterClient.WachterClientResponse; 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/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java index b915c93..07ae6da 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java @@ -1,7 +1,6 @@ package dev.vality.wachter.controller; import dev.vality.wachter.client.WachterClient; -import dev.vality.wachter.client.WachterClientResponse; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; import lombok.SneakyThrows; @@ -19,7 +18,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; -import static dev.vality.wachter.constants.TraceHeadersConstants.*; +import static dev.vality.wachter.client.WachterClient.WachterClientResponse; +import static dev.vality.woody.http.bridge.tracing.TraceHeadersConstants.*; import static java.util.UUID.randomUUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -63,8 +63,8 @@ void requestSuccessWithServiceRole() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header(X_REQUEST_ID, randomUUID()) - .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(ExternalHeaders.X_REQUEST_ID, randomUUID()) + .header(ExternalHeaders.X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -79,8 +79,8 @@ void requestSuccessWithMethodRole() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header(X_REQUEST_ID, randomUUID()) - .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(ExternalHeaders.X_REQUEST_ID, randomUUID()) + .header(ExternalHeaders.X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -95,8 +95,8 @@ void requestSuccessWithWoodyHeaders() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header(X_REQUEST_ID, randomUUID()) - .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(ExternalHeaders.X_REQUEST_ID, randomUUID()) + .header(ExternalHeaders.X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .header(WOODY_PARENT_ID, "parent") .header(WOODY_TRACE_ID, "trace") .header(WOODY_SPAN_ID, "span") @@ -115,12 +115,12 @@ void requestSuccessWithWoodyWithDashHeaders() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header(X_REQUEST_ID, randomUUID()) - .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) - .header(X_WOODY_PARENT_ID, "parent") - .header(X_WOODY_TRACE_ID, "trace") - .header(X_WOODY_SPAN_ID, "span") - .header(X_WOODY_DEADLINE, "deadline") + .header(ExternalHeaders.X_REQUEST_ID, randomUUID()) + .header(ExternalHeaders.X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(ExternalHeaders.X_WOODY_PARENT_ID, "parent") + .header(ExternalHeaders.X_WOODY_TRACE_ID, "trace") + .header(ExternalHeaders.X_WOODY_SPAN_ID, "span") + .header(ExternalHeaders.X_WOODY_DEADLINE, "deadline") .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -139,8 +139,8 @@ void shouldPropagateUpstreamError() { mvc.perform(post("/wachter") .header("Authorization", "Bearer " + generateSimpleJwtWithRoles()) .header("Service", "Domain") - .header(X_REQUEST_ID, randomUUID()) - .header(X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) + .header(ExternalHeaders.X_REQUEST_ID, randomUUID()) + .header(ExternalHeaders.X_REQUEST_DEADLINE, Instant.now().plus(1, ChronoUnit.DAYS).toString()) .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().isBadGateway()) diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index 6097f49..0e12a58 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -28,7 +28,7 @@ import java.util.UUID; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static dev.vality.wachter.constants.TraceHeadersConstants.*; +import static dev.vality.woody.http.bridge.tracing.TraceHeadersConstants.*; import static org.junit.jupiter.api.Assertions.*; @TestPropertySource(properties = { @@ -123,17 +123,17 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { headers.set("Service", "Deanonimus"); // Woody tracing headers - headers.set(X_WOODY_TRACE_ID, traceId); - headers.set(X_WOODY_SPAN_ID, spanId); - headers.set(X_WOODY_PARENT_ID, parentId); - headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "id", "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); - headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "username", "noreply@valitydev.com"); - headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "email", "noreply@valitydev.com"); - headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "realm", "internal"); + headers.set(ExternalHeaders.X_WOODY_TRACE_ID, traceId); + headers.set(ExternalHeaders.X_WOODY_SPAN_ID, spanId); + headers.set(ExternalHeaders.X_WOODY_PARENT_ID, parentId); + headers.set(ExternalHeaders.X_WOODY_META_ID, "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); + headers.set(ExternalHeaders.X_WOODY_META_USERNAME, "noreply@valitydev.com"); + headers.set(ExternalHeaders.X_WOODY_META_EMAIL, "noreply@valitydev.com"); + headers.set(ExternalHeaders.X_WOODY_META_REALM, "internal"); // Request metadata - headers.set(X_REQUEST_ID, requestId); - headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(ExternalHeaders.X_REQUEST_ID, requestId); + headers.set(ExternalHeaders.X_REQUEST_DEADLINE, deadline.toString()); headers.set(OTEL_TRACE_PARENT, upstreamTraceparent); }) .body(payload) @@ -163,15 +163,14 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { var jwtClaims = decodeJwtPayload(jwt); assertEquals(jwtClaims.get("sub").asText(), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "id")); + upstreamRequest.getHeader(WOODY_META_ID)); assertEquals(jwtClaims.get("preferred_username").asText(), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "username")); + upstreamRequest.getHeader(WOODY_META_USERNAME)); assertEquals(jwtClaims.get("email").asText(), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "email")); + upstreamRequest.getHeader(WOODY_META_EMAIL)); assertEquals(extractRealm(jwtClaims), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + upstreamRequest.getHeader(WOODY_META_REALM)); - assertNotNull(upstreamRequest.getHeader(OTEL_TRACE_PARENT)); assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).matches(TRACEPARENT_PATTERN)); assertEquals(requestId, upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); @@ -213,17 +212,17 @@ void shouldNormalizeAndForwardMixedWoodyHeaders() throws Exception { // Mixed woody and x-woody headers headers.set(WOODY_TRACE_ID, "GZvsthKQAAA"); - headers.set(X_WOODY_SPAN_ID, "GZvsthKQBBB"); + headers.set(ExternalHeaders.X_WOODY_SPAN_ID, "GZvsthKQBBB"); headers.set(WOODY_PARENT_ID, "parent-woody"); - headers.set(X_WOODY_DEADLINE, deadline.toString()); + headers.set(ExternalHeaders.X_WOODY_DEADLINE, deadline.toString()); // User identity in different formats - headers.set(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/woody-realm"); - headers.set(X_WOODY_META_USER_IDENTITY_PREFIX + "id", "header-user-id"); + headers.set(WOODY_META_REALM, "/woody-realm"); + headers.set(ExternalHeaders.X_WOODY_META_ID, "header-user-id"); // Request metadata - headers.set(X_REQUEST_ID, "mixed-request-id"); - headers.set(X_REQUEST_DEADLINE, deadline.toString()); + 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"); @@ -247,13 +246,13 @@ 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_USER_IDENTITY_PREFIX + "id")); + assertEquals(jwtClaims.get("sub").asText(), upstreamRequest.getHeader(WOODY_META_ID)); assertEquals(jwtClaims.get("preferred_username").asText(), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "username")); + upstreamRequest.getHeader(WOODY_META_USERNAME)); assertEquals(jwtClaims.get("email").asText(), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "email")); + upstreamRequest.getHeader(WOODY_META_EMAIL)); assertEquals(extractRealm(jwtClaims), - upstreamRequest.getHeader(WOODY_META_USER_IDENTITY_PREFIX + "realm")); + upstreamRequest.getHeader(WOODY_META_REALM)); // Traceparent should be preserved assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).contains(otelTraceId)); @@ -279,8 +278,8 @@ void shouldStripHopByHopHeadersBeforeProxying() throws Exception { .headers(headers -> { headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); headers.set("Service", "Domain"); - headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); - headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(ExternalHeaders.X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(ExternalHeaders.X_REQUEST_DEADLINE, deadline.toString()); headers.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); headers.set(HttpHeaders.CONNECTION, "keep-alive"); headers.set(HttpHeaders.TE, "trailers"); @@ -316,8 +315,8 @@ void shouldReturnCorsHeadersOnSuccessfulResponse() throws Exception { .headers(headers -> { headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); headers.set("Service", "Domain"); - headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); - headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(ExternalHeaders.X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(ExternalHeaders.X_REQUEST_DEADLINE, deadline.toString()); headers.set(HttpHeaders.ORIGIN, origin); }) .body(payload) @@ -346,8 +345,8 @@ void shouldReturnCorsHeadersOnErrorResponse() throws Exception { .headers(headers -> { headers.set("Authorization", "Bearer " + generateSimpleJwtWithRoles()); headers.set("Service", "Domain"); - headers.set(X_REQUEST_ID, UUID.randomUUID().toString()); - headers.set(X_REQUEST_DEADLINE, deadline.toString()); + headers.set(ExternalHeaders.X_REQUEST_ID, UUID.randomUUID().toString()); + headers.set(ExternalHeaders.X_REQUEST_DEADLINE, deadline.toString()); headers.set(HttpHeaders.ORIGIN, origin); }) .body(payload) diff --git a/src/test/java/dev/vality/wachter/testutil/ContextUtil.java b/src/test/java/dev/vality/wachter/testutil/ContextUtil.java deleted file mode 100644 index 34be37f..0000000 --- a/src/test/java/dev/vality/wachter/testutil/ContextUtil.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.vality.wachter.testutil; - -import dev.vality.geck.serializer.kit.mock.FieldHandler; -import dev.vality.geck.serializer.kit.mock.MockMode; -import dev.vality.geck.serializer.kit.mock.MockTBaseProcessor; -import dev.vality.geck.serializer.kit.tbase.TBaseHandler; -import lombok.SneakyThrows; -import lombok.experimental.UtilityClass; -import org.apache.thrift.TBase; - -import java.time.Instant; -import java.util.Map; - -@UtilityClass -public class ContextUtil { - - private static final MockTBaseProcessor mockRequiredTBaseProcessor; - - static { - mockRequiredTBaseProcessor = new MockTBaseProcessor(MockMode.REQUIRED_ONLY, 15, 1); - Map.Entry timeFields = Map.entry( - structHandler -> structHandler.value(Instant.now().toString()), - new String[] {"conversation_id", "messages", "status", "user_id", "email", "fullname", - "held_until", "from_time", "to_time"} - ); - mockRequiredTBaseProcessor.addFieldHandler(timeFields.getKey(), timeFields.getValue()); - } - - @SneakyThrows - public static T fillRequiredTBaseObject(T tbase, Class type) { - return ContextUtil.mockRequiredTBaseProcessor.process(tbase, new TBaseHandler<>(type)); - } -} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java deleted file mode 100644 index 296e4d5..0000000 --- a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersExtractorTest.java +++ /dev/null @@ -1,271 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.flow.WFlow; -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.Tracer; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -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 java.time.Instant; -import java.util.Map; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; -import static org.junit.jupiter.api.Assertions.*; - -class TraceContextHeadersExtractorTest { - - private SdkTracerProvider tracerProvider; - private Tracer tracer; - - @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); - tracer = openTelemetry.getTracer("test"); - } - - @AfterEach - void tearDown() { - TraceContext.setCurrentTraceData(null); - GlobalOpenTelemetry.resetForTest(); - if (tracerProvider != null) { - tracerProvider.close(); - } - } - - @Test - void shouldExtractWoodyHeadersFromTraceContext() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var activeSpan = traceData.getActiveSpan(); - final var span = activeSpan.getSpan(); - span.setTraceId("trace-id"); - span.setId("span-id"); - span.setParentId("parent-id"); - span.setDeadline(Instant.parse("2030-01-01T00:00:00Z")); - activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, "user-id"); - activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, "username"); - activeSpan.getCustomMetadata().putValue(UserIdentityEmailExtensionKit.KEY, "user@example.com"); - activeSpan.getCustomMetadata().putValue(UserIdentityRealmExtensionKit.KEY, "/realm"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertNotNull(headers); - assertTrue(headers.containsKey(WOODY_TRACE_ID)); - assertTrue(headers.containsKey(WOODY_SPAN_ID)); - assertTrue(headers.containsKey(OTEL_TRACE_PARENT)); - - otelSpan.end(); - } - - @Test - void shouldExtractOnlyAvailableHeaders() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var activeSpan = traceData.getActiveSpan(); - final var span = activeSpan.getSpan(); - span.setTraceId("trace-id"); - span.setId("span-id"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertEquals("trace-id", headers.get(WOODY_TRACE_ID)); - assertEquals("span-id", headers.get(WOODY_SPAN_ID)); - assertNull(headers.get(WOODY_PARENT_ID)); - assertNull(headers.get(WOODY_DEADLINE)); - assertNull(headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertNotNull(headers.get(OTEL_TRACE_PARENT)); - - otelSpan.end(); - } - - @Test - void shouldIncludeRequestMetadata() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var serviceSpan = traceData.getServiceSpan().getSpan(); - serviceSpan.setTraceId("trace-id"); - serviceSpan.setId("span-id"); - traceData.getActiveSpan().getCustomMetadata().putValue(X_REQUEST_ID, "request-123"); - traceData.getActiveSpan().getCustomMetadata().putValue(X_REQUEST_DEADLINE, "2030-12-31T23:59:59Z"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertEquals("request-123", headers.get(WOODY_META_REQUEST_ID)); - assertEquals("2030-12-31T23:59:59Z", headers.get(WOODY_META_REQUEST_DEADLINE)); - - otelSpan.end(); - } - - @Test - void shouldThrowWhenSpanContextIsInvalid() { - final var traceData = new TraceData(); - traceData.setOtelSpan(Span.getInvalid()); - TraceContext.setCurrentTraceData(traceData); - - try { - TraceContextHeadersExtractor.extractHeaders(); - fail("Expected IllegalStateException"); - } catch (IllegalStateException e) { - // Expected - } - } - - @Test - void shouldNotIncludeEmptyValues() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var activeSpan = traceData.getActiveSpan(); - final var span = activeSpan.getSpan(); - span.setTraceId("trace-id"); - span.setId("span-id"); - activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, ""); - activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, null); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertFalse(headers.containsKey(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertFalse(headers.containsKey(WOODY_META_USER_IDENTITY_PREFIX + "username")); - - otelSpan.end(); - } - - @Test - void shouldExtractAllUserIdentityMetadata() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var activeSpan = traceData.getActiveSpan(); - final var span = activeSpan.getSpan(); - span.setTraceId("GZvsthKQAAA"); - span.setId("GZvsthKQBBB"); - span.setParentId("undefined"); - - activeSpan.getCustomMetadata().putValue(UserIdentityIdExtensionKit.KEY, "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); - activeSpan.getCustomMetadata().putValue(UserIdentityUsernameExtensionKit.KEY, "noreply@valitydev.com"); - activeSpan.getCustomMetadata().putValue(UserIdentityEmailExtensionKit.KEY, "noreply@valitydev.com"); - activeSpan.getCustomMetadata().putValue(UserIdentityRealmExtensionKit.KEY, "/internal"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/internal", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - - otelSpan.end(); - } - - @Test - void shouldGenerateValidTraceparent() { - final var traceData = new TraceData(); - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var span = traceData.getActiveSpan().getSpan(); - span.setTraceId("trace-id"); - span.setId("span-id"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - final String traceparent = headers.get(OTEL_TRACE_PARENT); - assertNotNull(traceparent); - assertTrue(traceparent.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]")); - - otelSpan.end(); - } - - @Test - void shouldExtractComplexScenarioWithAllHeaders() { - final var traceData = new TraceData(); - TraceContext.initNewServiceTrace(traceData, WFlow.createDefaultIdGenerator(), WFlow.createDefaultIdGenerator()); - - final var otelSpan = tracer.spanBuilder("test-span").startSpan(); - traceData.setOtelSpan(otelSpan); - TraceContext.setCurrentTraceData(traceData); - - final var activeSpan = traceData.getActiveSpan(); - final var span = activeSpan.getSpan(); - span.setTraceId("GZyWNGugAAA"); - span.setId("GZyWNGugBBB"); - span.setParentId("undefined"); - span.setDeadline(Instant.parse("2030-01-01T00:00:00Z")); - - final var metadata = activeSpan.getCustomMetadata(); - metadata.putValue(UserIdentityIdExtensionKit.KEY, "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); - metadata.putValue(UserIdentityUsernameExtensionKit.KEY, "noreply@valitydev.com"); - metadata.putValue(UserIdentityEmailExtensionKit.KEY, "noreply@valitydev.com"); - metadata.putValue(UserIdentityRealmExtensionKit.KEY, "/internal"); - metadata.putValue(X_REQUEST_ID, "req-12345"); - metadata.putValue(X_REQUEST_DEADLINE, "2030-01-01T00:00:00Z"); - - final Map headers = TraceContextHeadersExtractor.extractHeaders(); - - assertEquals("GZyWNGugAAA", headers.get(WOODY_TRACE_ID)); - assertEquals("GZyWNGugBBB", headers.get(WOODY_SPAN_ID)); - assertEquals("undefined", headers.get(WOODY_PARENT_ID)); - assertEquals("2030-01-01T00:00:00Z", headers.get(WOODY_DEADLINE)); - assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("noreply@valitydev.com", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/internal", headers.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - assertEquals("req-12345", headers.get(WOODY_META_REQUEST_ID)); - assertEquals("2030-01-01T00:00:00Z", headers.get(WOODY_META_REQUEST_DEADLINE)); - assertNotNull(headers.get(OTEL_TRACE_PARENT)); - - otelSpan.end(); - } - - @Test - void shouldThrowWhenTraceDataIsNull() { - TraceContext.setCurrentTraceData(null); - TraceContext.getCurrentTraceData().setOtelSpan(null); - - assertThrows(NullPointerException.class, () -> { - TraceContextHeadersExtractor.extractHeaders(); - }); - } - - @Test - void shouldThrowWhenOtelSpanIsNull() { - final var traceData = new TraceData(); - traceData.setOtelSpan(null); - TraceContext.setCurrentTraceData(traceData); - - assertThrows(NullPointerException.class, () -> { - TraceContextHeadersExtractor.extractHeaders(); - }); - } -} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java deleted file mode 100644 index c4c1cf6..0000000 --- a/src/test/java/dev/vality/wachter/tracing/TraceContextHeadersNormalizerTest.java +++ /dev/null @@ -1,325 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.wachter.security.JwtTokenDetailsExtractor; -import dev.vality.wachter.security.JwtTokenDetailsExtractor.JwtTokenDetails; -import jakarta.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TraceContextHeadersNormalizerTest { - - @Mock - private HttpServletRequest request; - - @Mock - private SecurityContext securityContext; - - @Mock - private Authentication authentication; - - @BeforeEach - void setUp() { - SecurityContextHolder.clearContext(); - } - - @Test - void shouldNormalizeWoodyHeadersFromLowercase() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - WOODY_TRACE_ID, WOODY_SPAN_ID, WOODY_PARENT_ID, WOODY_DEADLINE - ))); - when(request.getHeader(WOODY_TRACE_ID)).thenReturn("trace-123"); - when(request.getHeader(WOODY_SPAN_ID)).thenReturn("span-456"); - when(request.getHeader(WOODY_PARENT_ID)).thenReturn("parent-789"); - when(request.getHeader(WOODY_DEADLINE)).thenReturn("2030-01-01T00:00:00Z"); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("trace-123", normalized.get(WOODY_TRACE_ID)); - assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); - assertEquals("parent-789", normalized.get(WOODY_PARENT_ID)); - assertEquals("2030-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); - } - - @Test - void shouldNormalizeXWoodyHeadersToWoody() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - X_WOODY_TRACE_ID, X_WOODY_SPAN_ID, X_WOODY_PARENT_ID - ))); - when(request.getHeader(X_WOODY_TRACE_ID)).thenReturn("trace-123"); - when(request.getHeader(X_WOODY_SPAN_ID)).thenReturn("span-456"); - when(request.getHeader(X_WOODY_PARENT_ID)).thenReturn("parent-789"); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("trace-123", normalized.get(WOODY_TRACE_ID)); - assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); - assertEquals("parent-789", normalized.get(WOODY_PARENT_ID)); - } - - @Test - void shouldNormalizeUserIdentityMetadataFromXWoody() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - X_WOODY_META_USER_IDENTITY_PREFIX + "id", - X_WOODY_META_USER_IDENTITY_PREFIX + "username", - X_WOODY_META_USER_IDENTITY_PREFIX + "email", - X_WOODY_META_USER_IDENTITY_PREFIX + "realm" - ))); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "id")).thenReturn("user-id-123"); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "username")).thenReturn("john.doe"); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "email")).thenReturn("john@example.com"); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "realm")).thenReturn("/internal"); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("user-id-123", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("john.doe", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("john@example.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - } - - @Test - void shouldNormalizeTraceparentHeader() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of(OTEL_TRACE_PARENT))); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn("00-123abc-456def-01"); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("00-123abc-456def-01", normalized.get(OTEL_TRACE_PARENT)); - } - - @Test - void shouldMergeJwtMetadataIntoHeaders() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - SecurityContextHolder.setContext(securityContext); - when(securityContext.getAuthentication()).thenReturn(authentication); - - try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { - var tokenDetails = new JwtTokenDetails( - "user-jwt-id", - "jwt-username", - "jwt@email.com", - "jwt-realm", - List.of("ROLE_USER") - ); - extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) - .thenReturn(Optional.of(tokenDetails)); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("user-jwt-id", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-username", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("jwt@email.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("jwt-realm", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - } - } - - @Test - void shouldMergeJwtMetadataWithHeaders() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - X_WOODY_META_USER_IDENTITY_PREFIX + "id" - ))); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "id")).thenReturn("header-user-id"); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); - - SecurityContextHolder.setContext(securityContext); - when(securityContext.getAuthentication()).thenReturn(authentication); - - try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { - var tokenDetails = new JwtTokenDetails( - "jwt-user-id", - "jwt-username", - null, - null, - List.of() - ); - extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) - .thenReturn(Optional.of(tokenDetails)); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("jwt-user-id", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("jwt-username", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - } - } - - @Test - void shouldMergeRequestDeadlineToWoodyDeadline() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-12-31T23:59:59Z"); - when(request.getHeader(X_REQUEST_ID)).thenReturn(null); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("2030-12-31T23:59:59Z", normalized.get(WOODY_DEADLINE)); - assertEquals("2030-12-31T23:59:59Z", normalized.get(X_REQUEST_DEADLINE)); - } - - @Test - void shouldMergeRelativeDeadline() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("30s"); - when(request.getHeader(X_REQUEST_ID)).thenReturn("req-123"); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertNotNull(normalized.get(WOODY_DEADLINE)); - assertEquals(normalized.get(WOODY_DEADLINE), normalized.get(X_REQUEST_DEADLINE)); - assertTrue(Instant.parse(normalized.get(WOODY_DEADLINE)).isAfter(Instant.now())); - } - - @Test - void shouldNotOverwriteExistingWoodyDeadline() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of(WOODY_DEADLINE))); - when(request.getHeader(WOODY_DEADLINE)).thenReturn("2025-01-01T00:00:00Z"); - when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-12-31T23:59:59Z"); - when(request.getHeader(X_REQUEST_ID)).thenReturn(null); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("2025-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); - assertEquals("2030-12-31T23:59:59Z", normalized.get(X_REQUEST_DEADLINE)); - } - - @Test - void shouldPreserveRequestIdWithoutDeadline() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - when(request.getHeader(X_REQUEST_ID)).thenReturn("req-no-deadline"); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn(null); - when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn(null); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("req-no-deadline", normalized.get(X_REQUEST_ID)); - assertFalse(normalized.containsKey(X_REQUEST_DEADLINE)); - } - - @Test - void shouldHandleEmptyHeaders() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertTrue(normalized.isEmpty()); - } - - @Test - void shouldIgnoreNullHeaderValues() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - WOODY_TRACE_ID, WOODY_SPAN_ID - ))); - when(request.getHeader(WOODY_TRACE_ID)).thenReturn(null); - when(request.getHeader(WOODY_SPAN_ID)).thenReturn("span-456"); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertFalse(normalized.containsKey(WOODY_TRACE_ID)); - assertEquals("span-456", normalized.get(WOODY_SPAN_ID)); - } - - @Test - void shouldHandleComplexScenarioWithAllHeaderTypes() { - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of( - WOODY_TRACE_ID, - X_WOODY_SPAN_ID, - X_WOODY_PARENT_ID, - X_WOODY_META_USER_IDENTITY_PREFIX + "email", - OTEL_TRACE_PARENT, - "content-type", - "authorization" - ))); - when(request.getHeader(WOODY_TRACE_ID)).thenReturn("GZyWNGugAAA"); - when(request.getHeader(X_WOODY_SPAN_ID)).thenReturn("GZyWNGugBBB"); - when(request.getHeader(X_WOODY_PARENT_ID)).thenReturn("undefined"); - when(request.getHeader(X_WOODY_META_USER_IDENTITY_PREFIX + "email")).thenReturn("noreply@valitydev.com"); - when(request.getHeader(OTEL_TRACE_PARENT)).thenReturn( - "00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01"); - when(request.getHeader(X_REQUEST_ID)).thenReturn("req-complex"); - when(request.getHeader(X_REQUEST_DEADLINE)).thenReturn("2030-01-01T00:00:00Z"); - - SecurityContextHolder.setContext(securityContext); - when(securityContext.getAuthentication()).thenReturn(authentication); - - try (MockedStatic extractor = mockStatic(JwtTokenDetailsExtractor.class)) { - var tokenDetails = new JwtTokenDetails( - "b54a93c4-415d-4f33-a5e9-3608fd043ff4", - "noreply@valitydev.com", - "noreply@valitydev.com", - "internal", - List.of("ROLE_USER") - ); - extractor.when(() -> JwtTokenDetailsExtractor.extractFromContext(authentication)) - .thenReturn(Optional.of(tokenDetails)); - - var normalized = TraceContextHeadersNormalizer.normalize(request); - - assertEquals("GZyWNGugAAA", normalized.get(WOODY_TRACE_ID)); - assertEquals("GZyWNGugBBB", normalized.get(WOODY_SPAN_ID)); - assertEquals("undefined", normalized.get(WOODY_PARENT_ID)); - assertEquals("noreply@valitydev.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", - normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("noreply@valitydev.com", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("internal", normalized.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - assertEquals("00-cfa3d3072a4e3e99fc14829a65311819-6e4609576fa4d077-01", normalized.get(OTEL_TRACE_PARENT)); - assertEquals("2030-01-01T00:00:00Z", normalized.get(WOODY_DEADLINE)); - assertEquals("req-complex", normalized.get(X_REQUEST_ID)); - assertEquals("2030-01-01T00:00:00Z", normalized.get(X_REQUEST_DEADLINE)); - } - } - - @Test - void shouldNormalizeResponseHeaders() { - var responseHeaders = new HttpHeaders(); - responseHeaders.add(WOODY_TRACE_ID, "resp-trace"); - responseHeaders.add(WOODY_SPAN_ID, "resp-span"); - responseHeaders.add(X_WOODY_PARENT_ID, "resp-parent"); - responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "id", "resp-user"); - responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "x-request-id", "resp-req"); - responseHeaders.add(WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline", "2030-01-01T00:00:00Z"); - responseHeaders.add(OTEL_TRACE_PARENT, "00-abc-def-01"); - responseHeaders.add(WOODY_DEADLINE, "2030-01-01T00:00:00Z"); - responseHeaders.add(WOODY_ERROR_CLASS, "resp-req"); - responseHeaders.add(WOODY_ERROR_REASON, "resp-req"); - responseHeaders.add("Content-Type", "application/json"); - responseHeaders.add("Cache-Control", "no-cache"); - - var normalized = TraceContextHeadersNormalizer.normalizeResponseHeaders(responseHeaders); - - assertTrue(normalized.containsKey(X_WOODY_TRACE_ID)); - assertTrue(normalized.containsKey(X_WOODY_SPAN_ID)); - assertTrue(normalized.containsKey(X_WOODY_PARENT_ID)); - assertTrue(normalized.containsKey(X_WOODY_DEADLINE)); - assertTrue(normalized.containsKey(X_WOODY_ERROR_CLASS)); - assertTrue(normalized.containsKey(X_WOODY_ERROR_REASON)); - assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "x-request-id")); - assertTrue(normalized.containsKey(X_WOODY_META_USER_IDENTITY_PREFIX + "x-request-deadline")); - assertTrue(normalized.containsKey(OTEL_TRACE_PARENT)); - assertTrue(normalized.containsKey(X_REQUEST_ID)); - assertTrue(normalized.containsKey(X_REQUEST_DEADLINE)); - assertFalse(normalized.containsKey("Content-Type")); - assertFalse(normalized.containsKey("Cache-Control")); - } -} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java deleted file mode 100644 index f0ff258..0000000 --- a/src/test/java/dev/vality/wachter/tracing/TraceContextPipelineTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.flow.WFlow; -import dev.vality.woody.api.trace.context.TraceContext; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -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 java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; -import static org.junit.jupiter.api.Assertions.*; - -class TraceContextPipelineTest { - - private SdkTracerProvider tracerProvider; - - @BeforeEach - void setUp() { - GlobalOpenTelemetry.resetForTest(); - tracerProvider = SdkTracerProvider.builder().build(); - 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 shouldRestoreAndExtractTraceContextWithinWFlow() { - var normalized = new HashMap(); - normalized.put(WOODY_TRACE_ID, "GZyWNGugAAA"); - normalized.put(WOODY_SPAN_ID, "GZyWNGugBBB"); - normalized.put(WOODY_PARENT_ID, "undefined"); - normalized.put(WOODY_DEADLINE, "2030-01-01T00:00:00Z"); - var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; - normalized.put(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); - normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "user-id"); - normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "user-name"); - normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "user@example.com"); - normalized.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/internal"); - normalized.put(X_REQUEST_ID, "request-id"); - normalized.put(X_REQUEST_DEADLINE, "2030-01-01T00:00:00Z"); - - var traceData = TraceContextRestorer.restoreTraceData(normalized); - var extractedRef = new AtomicReference>(); - - WFlow.create(() -> extractedRef.set(TraceContextHeadersExtractor.extractHeaders()), traceData).run(); - - var extracted = extractedRef.get(); - assertNotNull(extracted); - assertEquals("GZyWNGugAAA", extracted.get(WOODY_TRACE_ID)); - assertEquals("GZyWNGugBBB", extracted.get(WOODY_SPAN_ID)); - assertEquals("undefined", extracted.get(WOODY_PARENT_ID)); - assertEquals("2030-01-01T00:00:00Z", extracted.get(WOODY_DEADLINE)); - assertEquals("user-id", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "id")); - assertEquals("user-name", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "username")); - assertEquals("user@example.com", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "email")); - assertEquals("/internal", extracted.get(WOODY_META_USER_IDENTITY_PREFIX + "realm")); - assertEquals("request-id", extracted.get(WOODY_META_REQUEST_ID)); - assertEquals("2030-01-01T00:00:00Z", extracted.get(WOODY_META_REQUEST_DEADLINE)); - assertTrue(extracted.get(OTEL_TRACE_PARENT).contains(otelTraceId)); - } -} diff --git a/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java b/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java deleted file mode 100644 index 75f4792..0000000 --- a/src/test/java/dev/vality/wachter/tracing/TraceContextRestorerTest.java +++ /dev/null @@ -1,270 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.trace.TraceData; -import dev.vality.woody.api.trace.context.TraceContext; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit; -import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -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 java.time.Instant; -import java.util.HashMap; -import java.util.Map; - -import static dev.vality.wachter.constants.TraceHeadersConstants.*; -import static org.junit.jupiter.api.Assertions.*; - -class TraceContextRestorerTest { - - private SdkTracerProvider tracerProvider; - - @BeforeEach - void setUp() { - GlobalOpenTelemetry.resetForTest(); - tracerProvider = SdkTracerProvider.builder().build(); - 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 shouldRestoreWoodyTraceHeaders() { - var headers = Map.of( - WOODY_TRACE_ID, "GZyWNGugAAA", - WOODY_SPAN_ID, "GZyWNGugBBB", - WOODY_PARENT_ID, "undefined", - WOODY_DEADLINE, "2030-01-01T00:00:00Z" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - assertNotNull(traceData); - var span = traceData.getServiceSpan().getSpan(); - assertEquals("GZyWNGugAAA", span.getTraceId()); - assertEquals("GZyWNGugBBB", span.getId()); - assertEquals("undefined", span.getParentId()); - assertEquals(Instant.parse("2030-01-01T00:00:00Z"), span.getDeadline()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldRestoreUserIdentityMetadata() { - var headers = new HashMap(); - headers.put(WOODY_TRACE_ID, "trace-123"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "b54a93c4-415d-4f33-a5e9-3608fd043ff4"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "noreply@valitydev.com"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "noreply@valitydev.com"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/internal"); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - var metadata = traceData.getActiveSpan().getCustomMetadata(); - assertEquals("b54a93c4-415d-4f33-a5e9-3608fd043ff4", metadata.getValue(UserIdentityIdExtensionKit.KEY)); - assertEquals("noreply@valitydev.com", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); - assertEquals("noreply@valitydev.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); - assertEquals("/internal", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldRestoreRequestMetadata() { - var headers = Map.of( - WOODY_TRACE_ID, "trace-123", - X_REQUEST_ID, "req-456", - X_REQUEST_DEADLINE, "2030-12-31T23:59:59Z" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - var metadata = traceData.getActiveSpan().getCustomMetadata(); - assertEquals("req-456", metadata.getValue(X_REQUEST_ID)); - assertEquals("2030-12-31T23:59:59Z", metadata.getValue(X_REQUEST_DEADLINE)); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldRestoreTraceparentAndCreateOtelSpan() { - var traceId = "cfa3d3072a4e3e99fc14829a65311819"; - var headers = Map.of( - WOODY_TRACE_ID, "trace-123", - OTEL_TRACE_PARENT, "00-" + traceId + "-6e4609576fa4d077-01" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - assertEquals(traceId, traceData.getOtelSpan().getSpanContext().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandleEmptyHeaders() { - TraceData traceData = TraceContextRestorer.restoreTraceData(Map.of()); - - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandlePartialHeaders() { - var headers = Map.of( - WOODY_TRACE_ID, "trace-123" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); - assertNull(traceData.getServiceSpan().getSpan().getDeadline()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandleInvalidDeadline() { - var headers = Map.of( - WOODY_TRACE_ID, "trace-123", - WOODY_DEADLINE, "invalid-date" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); - assertNull(traceData.getServiceSpan().getSpan().getDeadline()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandleComplexScenarioWithAllData() { - var headers = new HashMap(); - headers.put(WOODY_TRACE_ID, "GZvsthKQAAA"); - headers.put(WOODY_SPAN_ID, "GZvsthKQBBB"); - headers.put(WOODY_PARENT_ID, "parent-123"); - headers.put(WOODY_DEADLINE, "2030-06-15T12:30:00Z"); - var otelTraceId = "3d8202ad198e4d37771c995246e1b356"; - headers.put(OTEL_TRACE_PARENT, "00-" + otelTraceId + "-9cfa814ae977266e-01"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", "user-uuid"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "john.doe"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "john@example.com"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/external"); - headers.put(X_REQUEST_ID, "complex-request-id"); - headers.put(X_REQUEST_DEADLINE, "2030-06-15T13:00:00Z"); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - var span = traceData.getServiceSpan().getSpan(); - assertEquals("GZvsthKQAAA", span.getTraceId()); - assertEquals("GZvsthKQBBB", span.getId()); - assertEquals("parent-123", span.getParentId()); - assertEquals(Instant.parse("2030-06-15T12:30:00Z"), span.getDeadline()); - - var metadata = traceData.getActiveSpan().getCustomMetadata(); - assertEquals("user-uuid", metadata.getValue(UserIdentityIdExtensionKit.KEY)); - assertEquals("john.doe", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); - assertEquals("john@example.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); - assertEquals("/external", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); - assertEquals("complex-request-id", metadata.getValue(X_REQUEST_ID)); - assertEquals("2030-06-15T13:00:00Z", metadata.getValue(X_REQUEST_DEADLINE)); - - assertEquals(otelTraceId, traceData.getOtelSpan().getSpanContext().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandleNullAndEmptyValues() { - var headers = new HashMap(); - headers.put(WOODY_TRACE_ID, "trace-123"); - headers.put(WOODY_SPAN_ID, ""); - headers.put(WOODY_PARENT_ID, null); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "id", ""); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", null); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - var span = traceData.getServiceSpan().getSpan(); - assertEquals("trace-123", span.getTraceId()); - assertNotNull(span.getId()); - assertNotNull(span.getParentId()); - - var metadata = traceData.getActiveSpan().getCustomMetadata(); - assertNull(metadata.getValue(UserIdentityIdExtensionKit.KEY)); - assertNull(metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldHandleInvalidTraceparentGracefully() { - var headers = Map.of( - WOODY_TRACE_ID, "trace-123", - OTEL_TRACE_PARENT, "invalid-traceparent" - ); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - assertEquals("trace-123", traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getTraceId()); - assertNotNull(traceData.getServiceSpan().getSpan().getId()); - assertTrue(traceData.getOtelSpan().getSpanContext().isValid()); - assertNotNull(traceData.getOtelSpan().getSpanContext().getTraceId()); - } - - @Test - void shouldRestoreMetadataWithSpecialCharacters() { - var headers = new HashMap(); - headers.put(WOODY_TRACE_ID, "trace-123"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "username", "user@domain.com"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "email", "user+test@domain.com"); - headers.put(WOODY_META_USER_IDENTITY_PREFIX + "realm", "/realm/with/slashes"); - headers.put(X_REQUEST_ID, "req-with-dashes-123"); - - TraceData traceData = TraceContextRestorer.restoreTraceData(headers); - - var metadata = traceData.getActiveSpan().getCustomMetadata(); - assertEquals("user@domain.com", metadata.getValue(UserIdentityUsernameExtensionKit.KEY)); - assertEquals("user+test@domain.com", metadata.getValue(UserIdentityEmailExtensionKit.KEY)); - assertEquals("/realm/with/slashes", metadata.getValue(UserIdentityRealmExtensionKit.KEY)); - assertEquals("req-with-dashes-123", metadata.getValue(X_REQUEST_ID)); - } -} diff --git a/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java b/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java deleted file mode 100644 index fda6c99..0000000 --- a/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package dev.vality.wachter.tracing; - -import dev.vality.woody.api.trace.context.TraceContext; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import jakarta.servlet.FilterChain; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_SPAN_ID; -import static dev.vality.wachter.constants.TraceHeadersConstants.X_WOODY_TRACE_ID; -import static org.junit.jupiter.api.Assertions.assertEquals; - -class WoodyTracingFilterTest { - - private SdkTracerProvider tracerProvider; - private WoodyTracingFilter filter; - - @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); - filter = new WoodyTracingFilter(8080, "/wachter"); - } - - @AfterEach - void tearDown() { - TraceContext.setCurrentTraceData(null); - GlobalOpenTelemetry.resetForTest(); - if (tracerProvider != null) { - tracerProvider.close(); - } - } - - @Test - void shouldInitializeTraceContext() throws Exception { - final var request = new MockHttpServletRequest("POST", "/wachter"); - final var response = new MockHttpServletResponse(); - request.setLocalPort(8080); - request.addHeader(X_WOODY_TRACE_ID, "test-trace"); - request.addHeader(X_WOODY_SPAN_ID, "test-span"); - - filter.doFilter(request, response, new MockFilterChain()); - - assertEquals(200, response.getStatus()); - } - - @Test - void shouldHandleRequestCorrectly() throws Exception { - final var request = new MockHttpServletRequest("GET", "/wachter"); - final var response = new MockHttpServletResponse(); - request.setLocalPort(8080); - - filter.doFilter(request, response, new MockFilterChain()); - - assertEquals(200, response.getStatus()); - } - - @Test - void shouldSetSpanStatusErrorForServerError() throws Exception { - final var request = new MockHttpServletRequest("GET", "/wachter"); - final var response = new MockHttpServletResponse(); - request.setLocalPort(8080); - final FilterChain chain = (req, res) -> { - ((MockHttpServletResponse) res).setStatus(503); - }; - - filter.doFilter(request, response, chain); - - assertEquals(503, response.getStatus()); - } -} From 9f481211cb310eddedcfc730a4dab8dd08ee9a7c Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 31 Oct 2025 20:46:08 +0700 Subject: [PATCH 13/16] bump --- pom.xml | 43 ++++++++++++++----------------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/pom.xml b/pom.xml index 92a9f3c..43379df 100644 --- a/pom.xml +++ b/pom.xml @@ -7,29 +7,33 @@ dev.vality service-parent-pom - 3.1.7 + 3.1.8 wachter 1.0.0 - 2.0.12 - 0.0.10 UTF-8 UTF-8 21 8022 8023 ${server.port} ${management.port} + 3.0.1 - dev.vality - shared-resources - ${shared-resources.version} + dev.vality.woody + woody-api + provided + + + dev.vality.woody + woody-thrift + provided dev.vality.geck @@ -38,7 +42,6 @@ dev.vality woody-http-bridge - ${woody-http-bridge.version} @@ -87,6 +90,10 @@ + + org.projectlombok + lombok + jakarta.servlet jakarta.servlet-api @@ -196,28 +203,6 @@ - - org.apache.maven.plugins - maven-surefire-plugin - - - org.cyclonedx - cyclonedx-maven-plugin - - - generate-resources - - makeAggregateBom - - - application - ${project.build.directory} - json - bom - - - - From b0432d37a325691d5b6725a5acaee9363a490050 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 31 Oct 2025 20:52:50 +0700 Subject: [PATCH 14/16] bump --- .github/settings.yml | 2 -- .github/workflows/basic-linters.yml | 10 ---------- .github/workflows/deploy.yml | 1 - 3 files changed, 13 deletions(-) delete mode 100644 .github/settings.yml delete mode 100644 .github/workflows/basic-linters.yml diff --git a/.github/settings.yml b/.github/settings.yml deleted file mode 100644 index 9267e7d..0000000 --- a/.github/settings.yml +++ /dev/null @@ -1,2 +0,0 @@ -# These settings are synced to GitHub by https://probot.github.io/apps/settings/ -_extends: .github diff --git a/.github/workflows/basic-linters.yml b/.github/workflows/basic-linters.yml deleted file mode 100644 index 6114f14..0000000 --- a/.github/workflows/basic-linters.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Vality basic linters - -on: - pull_request: - branches: - - "*" - -jobs: - lint: - uses: valitydev/base-workflows/.github/workflows/basic-linters.yml@v1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 015b7f5..ce19205 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,6 @@ on: branches: - 'master' - 'main' - - 'epic/**' env: REGISTRY: ghcr.io From c784e3053c9a5399016adcb5222cfbb340f6be86 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 31 Oct 2025 21:54:51 +0700 Subject: [PATCH 15/16] bump --- pom.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 43379df..c12c748 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ dev.vality service-parent-pom - 3.1.8 + 3.1.9 wachter @@ -20,7 +20,6 @@ 8022 8023 ${server.port} ${management.port} - 3.0.1 From 0c5db336ad1af0946299dbbe4259883474ac60a6 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Sat, 1 Nov 2025 12:00:51 +0700 Subject: [PATCH 16/16] bump --- pom.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pom.xml b/pom.xml index c12c748..b90d1ab 100644 --- a/pom.xml +++ b/pom.xml @@ -27,12 +27,10 @@ dev.vality.woody woody-api - provided dev.vality.woody woody-thrift - provided dev.vality.geck