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/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.
-
-Схема работы сервиса:
-
-
-
+# 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 02b6c92..b90d1ab 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,11 +7,11 @@
dev.vality
service-parent-pom
- 3.1.7
+ 3.1.9
wachter
- 1.0-SNAPSHOT
+ 1.0.0
UTF-8
@@ -25,22 +25,12 @@
- io.micrometer
- micrometer-core
+ dev.vality.woody
+ woody-api
- io.micrometer
- micrometer-registry-prometheus
-
-
- dev.vality
- shared-resources
- ${shared-resources.version}
-
-
- dev.vality
- bouncer-proto
- 1.57-31866c3
+ dev.vality.woody
+ woody-thrift
dev.vality.geck
@@ -48,7 +38,7 @@
dev.vality
- damsel
+ woody-http-bridge
@@ -97,15 +87,9 @@
-
- org.apache.httpcomponents
- httpclient
- 4.5.14
-
org.projectlombok
lombok
- provided
jakarta.servlet
@@ -124,12 +108,6 @@
jakarta.xml.bind
jakarta.xml.bind-api
-
- org.bouncycastle
- bcpkix-jdk18on
- 1.79
- test
-
@@ -156,9 +134,15 @@
test
- org.springframework.cloud
- spring-cloud-contract-wiremock
- 4.1.0
+ org.wiremock.integrations
+ wiremock-spring-boot
+ 3.10.0
+ test
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.79
test
@@ -216,28 +200,6 @@
-
- org.apache.maven.plugins
- maven-surefire-plugin
-
-
- org.cyclonedx
- cyclonedx-maven-plugin
-
-
- generate-resources
-
- makeAggregateBom
-
-
- application
- ${project.build.directory}
- json
- bom
-
-
-
-
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..5135111
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/client/ProxyHeadersExtractor.java
@@ -0,0 +1,95 @@
+package dev.vality.wachter.client;
+
+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.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.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(
+ "cf-",
+ "cdn-",
+ "sec-",
+ TraceHeadersConstants.WOODY_PREFIX,
+ TraceHeadersConstants.ExternalHeaders.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 7c2349b..804bb37 100644
--- a/src/main/java/dev/vality/wachter/client/WachterClient.java
+++ b/src/main/java/dev/vality/wachter/client/WachterClient.java
@@ -1,68 +1,66 @@
package dev.vality.wachter.client;
+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.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.http.HttpStatusCode;
+import org.springframework.stereotype.Component;
+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
+@Component
@RequiredArgsConstructor
public class WachterClient {
- private final HttpClient httpclient;
- private final WachterResponseHandler responseHandler;
+ private final RestClient restClient;
- @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);
- }
+ private static final byte[] EMPTY_BODY = new byte[0];
+
+ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] contentData, String url) {
+ var httpMethod = resolveMethod(servletRequest);
+
+ var proxyHeaders = ProxyHeadersExtractor.extractHeaders(servletRequest);
+ var traceHeaders = TraceContextExtractor.extractHeaders();
+
+ var httpHeaders = new HttpHeaders();
+ proxyHeaders.forEach(httpHeaders::addAll);
+ traceHeaders.forEach(httpHeaders::set);
- 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));
- }
+ log.info("-> Send request to {} {} | headers: {}", httpMethod, url, httpHeaders);
+
+ var requestSpec = restClient.method(httpMethod)
+ .uri(url)
+ .headers(h -> h.addAll(httpHeaders));
+
+ if (!ObjectUtils.isEmpty(contentData)) {
+ requestSpec = requestSpec.body(contentData);
}
- 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());
+
+ 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 = TraceContextHeadersNormalizer.normalizeResponseHeaders(response.getHeaders());
+ return new WachterClientResponse(status, responseHeaders, responseBody);
+ });
+ }
+
+ private HttpMethod resolveMethod(HttpServletRequest servletRequest) {
+ try {
+ return HttpMethod.valueOf(servletRequest.getMethod());
+ } catch (IllegalArgumentException ex) {
+ return HttpMethod.POST;
}
}
- private String getTraceId(HttpServletRequest request) {
- return Optional.ofNullable(request.getHeader(X_WOODY_TRACE_ID))
- .orElse(request.getHeader(WOODY_TRACE_ID_DEPRECATED));
+ public record WachterClientResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body) {
}
}
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
deleted file mode 100644
index b939518..0000000
--- a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java
+++ /dev/null
@@ -1,47 +0,0 @@
-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);
- }
- }
-
-}
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..ad3650e
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/RestClientConfig.java
@@ -0,0 +1,109 @@
+package dev.vality.wachter.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.wachter.config.properties.HttpProperties;
+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;
+
+ @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()
+ .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/SecurityConfig.java b/src/main/java/dev/vality/wachter/config/SecurityConfig.java
index 28372a9..3423ed6 100644
--- a/src/main/java/dev/vality/wachter/config/SecurityConfig.java
+++ b/src/main/java/dev/vality/wachter/config/SecurityConfig.java
@@ -1,54 +1,52 @@
-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;
-
-import java.util.List;
-
-@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.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() {
+ 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
deleted file mode 100644
index 2b1cb8e..0000000
--- a/src/main/java/dev/vality/wachter/config/WebConfig.java
+++ /dev/null
@@ -1,106 +0,0 @@
-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);
- }
- }
-}
diff --git a/src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java b/src/main/java/dev/vality/wachter/config/properties/HttpProperties.java
similarity index 74%
rename from src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java
rename to src/main/java/dev/vality/wachter/config/properties/HttpProperties.java
index be4695f..b575129 100644
--- a/src/main/java/dev/vality/wachter/config/properties/HttpClientProperties.java
+++ b/src/main/java/dev/vality/wachter/config/properties/HttpProperties.java
@@ -11,8 +11,8 @@
@Setter
@Validated
@Configuration
-@ConfigurationProperties("http-client")
-public class HttpClientProperties {
+@ConfigurationProperties(prefix = "http")
+public class HttpProperties {
@NotNull
private int maxTotalPooling;
@@ -21,12 +21,12 @@ public class HttpClientProperties {
private int defaultMaxPerRoute;
@NotNull
- private int socketTimeout;
+ private long requestTimeout;
@NotNull
- private int connectionRequestTimeout;
+ private long poolTimeout;
@NotNull
- private int connectTimeout;
+ private long connectionTimeout;
}
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/HeadersConstants.java b/src/main/java/dev/vality/wachter/constants/HeadersConstants.java
deleted file mode 100644
index 52ca6e4..0000000
--- a/src/main/java/dev/vality/wachter/constants/HeadersConstants.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.vality.wachter.constants;
-
-public class HeadersConstants {
-
- 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";
-
-}
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 5e1ed6a..b9dd73c 100644
--- a/src/main/java/dev/vality/wachter/controller/WachterController.java
+++ b/src/main/java/dev/vality/wachter/controller/WachterController.java
@@ -3,6 +3,7 @@
import dev.vality.wachter.service.WachterService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
+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;
@@ -11,13 +12,16 @@
@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 upstreamResponse = wachterService.process(request);
+ return ResponseEntity.status(upstreamResponse.statusCode())
+ .headers(upstreamResponse.headers())
+ .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..e69a1eb 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.debug("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/RoleAccessService.java b/src/main/java/dev/vality/wachter/security/RoleAccessService.java
index 35e316b..816da7f 100644
--- a/src/main/java/dev/vality/wachter/security/RoleAccessService.java
+++ b/src/main/java/dev/vality/wachter/security/RoleAccessService.java
@@ -16,29 +16,27 @@ 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.debug("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))) {
- 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/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..af0d40e 100644
--- a/src/main/java/dev/vality/wachter/service/WachterService.java
+++ b/src/main/java/dev/vality/wachter/service/WachterService.java
@@ -4,21 +4,17 @@
import dev.vality.wachter.mapper.ServiceMapper;
import dev.vality.wachter.security.AccessData;
import dev.vality.wachter.security.AccessService;
+import dev.vality.woody.http.bridge.util.JwtTokenDetailsExtractor;
import 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;
+import static dev.vality.wachter.client.WachterClient.WachterClientResponse;
@RequiredArgsConstructor
@Service
@@ -30,31 +26,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
deleted file mode 100644
index 23badaa..0000000
--- a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package dev.vality.wachter.utils;
-
-import dev.vality.wachter.exceptions.DeadlineException;
-import lombok.experimental.UtilityClass;
-
-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 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) {
- String format = "minutes";
-
- checkNegativeValues(
- xRequestDeadline,
- xRequestId,
- "([-]" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")",
- format);
-
- Double minutes = extractValue(
- xRequestDeadline,
- "(" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")",
- 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";
-
- checkNegativeValues(
- xRequestDeadline,
- xRequestId,
- "([-]" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")",
- format);
-
- Double seconds = extractValue(
- xRequestDeadline,
- "(" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")",
- 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";
-
- checkNegativeValues(
- xRequestDeadline,
- xRequestId,
- "([-]" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")",
- format);
-
- Double milliseconds = extractValue(
- xRequestDeadline,
- "(" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")",
- 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) {
- String numberRegex = "(" + FLOATING_NUMBER_REGEXP + ")";
-
- List 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) {
- Pattern pattern = Pattern.compile(regex);
- Matcher matcher = pattern.matcher(data);
- List 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 7bd9fad..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,64 +39,110 @@ spring:
issuer-uri: >
${spring.security.oauth2.resourceserver.url}/auth/realms/
${spring.security.oauth2.resourceserver.jwt.realm}
-info:
- version: '@project.version@'
- stage: dev
wachter:
auth:
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
+auth:
+ enabled: true
-http-client:
- connectTimeout: 10000
- connectionRequestTimeout: 10000
- socketTimeout: 10000
+woody-http-bridge:
+ tracing:
+ endpoints:
+ - path: /wachter
+ port: ${server.port}
+ request-header-mode: WOODY_OR_X_WOODY
+ response-header-mode: OFF
+
+otel:
+ enabled: true
+ resource: http://localhost:4318/v1/traces
+ timeout: 60000
+
+http:
+ requestTimeout: 60000
+ poolTimeout: 10000
+ connectionTimeout: 10000
maxTotalPooling: 200
defaultMaxPerRoute: 200
-
-auth.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/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java
new file mode 100644
index 0000000..0c856e3
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java
@@ -0,0 +1,192 @@
+package dev.vality.wachter.client;
+
+import dev.vality.woody.api.trace.TraceData;
+import dev.vality.woody.api.trace.context.TraceContext;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+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 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 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 {
+
+ private SdkTracerProvider tracerProvider;
+
+ @BeforeEach
+ void setUp() {
+ GlobalOpenTelemetry.resetForTest();
+ tracerProvider = SdkTracerProvider.builder().build();
+ final var openTelemetry = OpenTelemetrySdk.builder()
+ .setTracerProvider(tracerProvider)
+ .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
+ .build();
+ GlobalOpenTelemetry.set(openTelemetry);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TraceContext.setCurrentTraceData(null);
+ GlobalOpenTelemetry.resetForTest();
+ if (tracerProvider != null) {
+ tracerProvider.close();
+ }
+ }
+
+ @Test
+ void shouldSendRequestWithTracingHeaders() {
+ final var builder = RestClient.builder();
+ final var server = MockRestServiceServer.bindTo(builder).build();
+ final var restClient = builder.build();
+
+ final var traceData = prepareTraceData("test-span");
+
+ final var serviceSpan = traceData.getServiceSpan().getSpan();
+ serviceSpan.setTraceId("test-trace-id");
+ serviceSpan.setId("test-span-id");
+
+ final var servletRequest = new MockHttpServletRequest();
+ servletRequest.setMethod("POST");
+ servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ 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);
+
+ final var actualResponse = client.send(servletRequest, payload, "http://upstream");
+
+ assertEquals(HttpStatus.OK, actualResponse.statusCode());
+ assertArrayEquals(expectedResponse, actualResponse.body());
+ server.verify();
+ traceData.finishOtelSpan();
+ }
+
+ @Test
+ void shouldFilterDisallowedHeaders() {
+ final var builder = RestClient.builder();
+ final var server = MockRestServiceServer.bindTo(builder).build();
+ final var restClient = builder.build();
+
+ final var traceData = prepareTraceData("test-span");
+ traceData.getServiceSpan().getSpan().setTraceId("filter-trace-id");
+ traceData.getServiceSpan().getSpan().setId("filter-span-id");
+
+ final var servletRequest = new MockHttpServletRequest();
+ servletRequest.setMethod("POST");
+ servletRequest.addHeader(HttpHeaders.AUTHORIZATION, "Bearer secret");
+ 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();
+ traceData.finishOtelSpan();
+ }
+
+ @Test
+ void shouldHandleGetRequestWithoutBody() {
+ final var builder = RestClient.builder();
+ final var server = MockRestServiceServer.bindTo(builder).build();
+ final var restClient = builder.build();
+
+ final var traceData = prepareTraceData("test-span");
+
+ final var serviceSpan = traceData.getServiceSpan().getSpan();
+ serviceSpan.setTraceId("get-trace-id");
+
+ final var servletRequest = new MockHttpServletRequest();
+ servletRequest.setMethod("GET");
+
+ server.expect(requestTo("http://upstream/resource"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
+
+ final var client = new WachterClient(restClient);
+
+ final var response = client.send(servletRequest, null, "http://upstream/resource");
+
+ assertEquals(HttpStatus.OK, response.statusCode());
+ assertArrayEquals("{}".getBytes(), response.body());
+ server.verify();
+ traceData.finishOtelSpan();
+ }
+
+ @Test
+ void shouldReturnErrorResponseWithoutThrowing() {
+ final var builder = RestClient.builder();
+ final var server = MockRestServiceServer.bindTo(builder).build();
+ final var restClient = builder.build();
+
+ final var traceData = prepareTraceData("test-span");
+
+ final var serviceSpan = traceData.getServiceSpan().getSpan();
+ serviceSpan.setTraceId("error-trace-id");
+
+ final var servletRequest = new MockHttpServletRequest();
+ servletRequest.setMethod("POST");
+ 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 client = new WachterClient(restClient);
+
+ final var response = client.send(servletRequest, payload, "http://upstream/fail");
+
+ assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode());
+ assertArrayEquals("bad-gateway".getBytes(), response.body());
+ server.verify();
+ traceData.finishOtelSpan();
+ }
+
+ private TraceData prepareTraceData(String spanName) {
+ final var traceData = new TraceData();
+ traceData.startNewOtelSpan(spanName, SpanKind.SERVER, Context.current());
+ traceData.openOtelScope();
+ TraceContext.setCurrentTraceData(traceData);
+ return traceData;
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java
index 99e5350..1e2970c 100644
--- a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java
+++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java
@@ -2,46 +2,54 @@
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/" +
- "${spring.security.oauth2.resourceserver.jwt.realm}"})
+ "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}",
+ "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
-@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");
}
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/controller/ErrorControllerTest.java b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java
index 40fffeb..18c7db9 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,18 +61,18 @@ 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")
+ .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().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..ddc302b 100644
--- a/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java
+++ b/src/test/java/dev/vality/wachter/controller/WachterControllerDisabledAuthTest.java
@@ -1,20 +1,17 @@
package dev.vality.wachter.controller;
-import dev.vality.wachter.client.WachterResponseHandler;
+import dev.vality.wachter.client.WachterClient;
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;
@@ -22,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.*;
@@ -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,18 +59,17 @@ 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")
+ .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());
- 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..07ae6da 100644
--- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java
+++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java
@@ -1,42 +1,36 @@
package dev.vality.wachter.controller;
-import dev.vality.wachter.client.WachterResponseHandler;
+import dev.vality.wachter.client.WachterClient;
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;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
+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.*;
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 +46,7 @@ class WachterControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig {
@BeforeEach
public void init() {
mocks = MockitoAnnotations.openMocks(this);
- preparedMocks = new Object[]{httpClient};
+ preparedMocks = new Object[] {wachterClient};
}
@AfterEach
@@ -64,76 +58,94 @@ 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")
- .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());
- 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")
- .header("X-Request-ID", randomUUID())
- .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
+ .header("Service", "Domain")
+ .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());
- 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")
- .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(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")
+ .header(WOODY_DEADLINE, "deadline")
.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")
- .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());
- 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(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())
+ .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..0e12a58
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java
@@ -0,0 +1,397 @@
+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 com.github.tomakehurst.wiremock.verification.LoggedRequest;
+import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig;
+import dev.vality.wachter.testutil.TMessageUtil;
+import dev.vality.woody.api.trace.context.TraceContext;
+import org.apache.thrift.protocol.TProtocolFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+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.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.woody.http.bridge.tracing.TraceHeadersConstants.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+@TestPropertySource(properties = {
+ "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 TRACEPARENT_PATTERN = "00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-1]";
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @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);
+ resetAllRequests();
+ }
+
+ @Test
+ 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(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(ExternalHeaders.X_REQUEST_ID, requestId);
+ headers.set(ExternalHeaders.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_ID));
+ assertEquals(jwtClaims.get("preferred_username").asText(),
+ upstreamRequest.getHeader(WOODY_META_USERNAME));
+ assertEquals(jwtClaims.get("email").asText(),
+ upstreamRequest.getHeader(WOODY_META_EMAIL));
+ assertEquals(extractRealm(jwtClaims),
+ upstreamRequest.getHeader(WOODY_META_REALM));
+
+ 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 payload = TMessageUtil.createTMessage(protocolFactory);
+ final var responseBody = "test-response".getBytes();
+ final var jwt = generateSimpleJwtWithRoles();
+ final var jwtClaims = decodeJwtPayload(jwt);
+ var otelTraceId = "3d8202ad198e4d37771c995246e1b356";
+
+ stubFor(post(urlEqualTo("/magista"))
+ .withRequestBody(binaryEqualTo(payload))
+ .willReturn(aResponse()
+ .withStatus(HttpStatus.OK.value())
+ .withHeader(HttpHeaders.CONTENT_TYPE, "application/x-thrift")
+ .withBody(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");
+
+ // Mixed woody and x-woody headers
+ headers.set(WOODY_TRACE_ID, "GZvsthKQAAA");
+ headers.set(ExternalHeaders.X_WOODY_SPAN_ID, "GZvsthKQBBB");
+ headers.set(WOODY_PARENT_ID, "parent-woody");
+ headers.set(ExternalHeaders.X_WOODY_DEADLINE, deadline.toString());
+
+ // User identity in different formats
+ headers.set(WOODY_META_REALM, "/woody-realm");
+ headers.set(ExternalHeaders.X_WOODY_META_ID, "header-user-id");
+
+ // Request metadata
+ 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");
+ })
+ .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_ID));
+ assertEquals(jwtClaims.get("preferred_username").asText(),
+ upstreamRequest.getHeader(WOODY_META_USERNAME));
+ assertEquals(jwtClaims.get("email").asText(),
+ upstreamRequest.getHeader(WOODY_META_EMAIL));
+ assertEquals(extractRealm(jwtClaims),
+ upstreamRequest.getHeader(WOODY_META_REALM));
+
+ // Traceparent should be preserved
+ assertTrue(upstreamRequest.getHeader(OTEL_TRACE_PARENT).contains(otelTraceId));
+
+ // 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
+ 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())));
+
+ 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(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");
+ 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)));
+ }
+
+ @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())));
+
+ 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(ExternalHeaders.X_REQUEST_ID, UUID.randomUUID().toString());
+ headers.set(ExternalHeaders.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
+ 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())));
+
+ 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(ExternalHeaders.X_REQUEST_ID, UUID.randomUUID().toString());
+ headers.set(ExternalHeaders.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 == 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;
+ }
+}
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 d6d7114..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/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.