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. - -Схема работы сервиса: - -![diagram-wachter](doc/diagram-wachter.svg) - +# wachter + +Сервис авторизации и прозрачного проксирования запросов от внешних систем к внутренним доменным сервисам. Представляет из себя HTTP пайплайн для Thrift вызовов с поддержкой заголовков woody и метаданных авторизации + +## Основной поток + +1. **Фильтрация входящего запроса.** `WoodyTracingFilter` нормализует заголовки `x-woody-*`/`woody.*`, восстанавливает `TraceContext` и создаёт серверный OpenTelemetry span с гарантированным `traceparent`. +2. **Авторизация.** `WachterService` считывает фактический метод из thrift-пакета, извлекает JWT из Spring Security, проверяет права пользователя через `AccessService`/`RoleAccessService`. +3. **Определение целевого сервиса.** `ServiceMapper` выбирает URL по заголовку `Service`. +4. **Формирование запроса.** `WachterRequestFactory` собирает исходные заголовки, накладывает нормализованные Woody-заголовки и значения из текущего `TraceContext`, дополняет идентификационные поля из JWT. +5. **Отправка и получение ответа.** `WachterClient` использует `RestClient` (JDK HTTP) для вызова доменного сервиса, возвращая `WachterClientResponse` со статусом, заголовками и телом. +6. **Ответ потребителю.** `WachterController` проверяет дедлайн, передаёт данные в `WachterService` и возвращает клиенту неизменённые статус, заголовки и тело от upstream. + +## Особенности + +- Поддержка двух семейств Woody-заголовков (новые `woody.*` и наследуемые `x-woody-*`). +- Автоматическая генерация и распространение OpenTelemetry `traceparent` при отсутствии входящего заголовка. +- Выделенный `JwtTokenDetailsExtractor` для повторного использования данных токена. +- Тестовый контур покрывает композицию фильтра, клиента и контроллера, включая WireMock-интеграцию. + +Схема взаимодействий остаётся доступной в [doc/diagram-wachter.svg](doc/diagram-wachter.svg). + diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..d3fe008 --- /dev/null +++ b/agents.md @@ -0,0 +1,48 @@ +# Agents Guide + +This document helps automation agents work effectively on the **Wachter** service. + +## Project Snapshot +- **Purpose:** authorize requests from Control Center and transparently proxy them to domain services. +- **Stack:** Java 21, Spring Boot 3, RestClient (JDK HTTP), OpenTelemetry (OTLP/HTTP), Woody tracing library, JWT via Spring Security. +- **Tracing Flow:** `WoodyTracingFilter` normalizes Woody headers, hydrates Woody trace context, starts an OpenTelemetry span, and stores normalized headers in the request. +- **Proxy Flow:** `WachterService` performs access checks, `WachterClient` (using `RestClient`) forwards requests, and `WachterController` returns upstream status/headers/body unchanged. + +## Key Components +- `dev.vality.wachter.config` – Spring configuration; `ApplicationConfig`, `WebConfig`, `OtelConfig`. +- `dev.vality.wachter.config.tracing` – tracing helpers (`WoodyHeadersNormalizer`, `WoodyTraceContextApplier`, `WoodyTelemetrySupport`, `WoodyTracingFilter`). +- `dev.vality.wachter.client` – outbound proxy pieces (`WachterClient`, `WachterRequestFactory`, `WachterClientResponse`). +- `dev.vality.wachter.security` – access control (`AccessService`, `RoleAccessService`, `JwtTokenDetailsExtractor`). +- `dev.vality.wachter.service` – application services (`WachterService`, `MethodNameReaderService`). +- `dev.vality.wachter.controller` – REST controllers (`WachterController`, error handling tests). +- `dev.vality.wachter.constants` – header/attribute constants. + +## Tests & Verification +- **Unit tests:** `mvn test` (runs JUnit + WireMock integration suite). +- **Key suites:** `WachterClient*Test`, `WebConfigTest`, controller tests, `WachterIntegrationTest` (WireMock-based end-to-end proxy verification). +- Ensure new features include coverage across: header normalization, trace context propagation, authorization checks, proxy behaviour. + +## Conventions & Practices +- Maintain dual Woody headers (`woody.*` + `x-woody-*`). +- Preserve upstream responses exactly (status, headers, body). +- Use `JwtTokenDetailsExtractor` for JWT-derived values; avoid duplicating claim parsing. +- When touching tracing, ensure OpenTelemetry span attributes (`HTTP_*`, `traceparent`) remain intact. +- Follow existing Checkstyle conventions: `final` for immutable locals, concise logging via SLF4J, minimal inline comments. +- Update documentation only when requested. + +## Common Tasks +1. **Modify tracing behaviour:** inspect `config/tracing` classes; update tests in `WebConfigTest` and `WachterIntegrationTest`. +2. **Adjust outbound proxying:** change `WachterRequestFactory`/`WachterClient`; add/update tests in `client` package and integration suite. +3. **Authorization changes:** update `AccessService`, `RoleAccessService`; extend security tests accordingly. + +## Useful Commands +- Run full suite: `mvn test` +- Format/imports: rely on IDE (no automatic formatter configured). +- Generate coverage (optional): `mvn test -Pcoverage` (if profile exists; verify before use). + +## Gotchas +- `WachterController` enforces deadline checks before proxying; always keep `DeadlineUtil` behaviour in mind. +- `WoodyHeadersNormalizer` merges JWT metadata and deadlines with priority rules—changing behaviour requires revisiting corresponding tests. +- Integration tests spin up WireMock; avoid port conflicts by keeping default configuration. + +Stay aligned with the README and trace plans when planning new work. diff --git a/pom.xml b/pom.xml index 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.