From 755c5e3ca65ab88ca24091676450a0bd3644b04d Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Thu, 13 Aug 2026 18:27:54 +0700 Subject: [PATCH 1/2] revert woody wflow logic --- pom.xml | 4 + .../vality/wachter/client/WachterClient.java | 3 +- .../wachter/config/WoodyTracingConfig.java | 25 ++++++ .../wachter/tracing/WoodyTraceContext.java | 83 +++++++++++++++++++ .../wachter/tracing/WoodyTracingFilter.java | 33 ++++++++ .../client/WachterClientOperationsTest.java | 21 +++-- .../controller/WachterControllerTest.java | 4 +- .../integration/WachterIntegrationTest.java | 27 ++++++ .../tracing/WoodyTracingFilterTest.java | 81 ++++++++++++++++++ 9 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java create mode 100644 src/main/java/dev/vality/wachter/tracing/WoodyTraceContext.java create mode 100644 src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java create mode 100644 src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java diff --git a/pom.xml b/pom.xml index 6a27770..fa36110 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,10 @@ + + dev.vality.woody + woody-api + dev.vality.woody woody-thrift diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java index 26df8b4..6d25135 100644 --- a/src/main/java/dev/vality/wachter/client/WachterClient.java +++ b/src/main/java/dev/vality/wachter/client/WachterClient.java @@ -1,6 +1,7 @@ package dev.vality.wachter.client; import dev.vality.wachter.tracing.TraceHeaderNormalizer; +import dev.vality.wachter.tracing.WoodyTraceContext; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,7 +27,7 @@ public WachterClientResponse send(HttpServletRequest servletRequest, byte[] cont var httpMethod = resolveMethod(servletRequest); var proxyHeaders = ProxyHeadersExtractor.extractHeaders(servletRequest); - var traceHeaders = TraceHeaderNormalizer.normalizeRequest(servletRequest); + var traceHeaders = WoodyTraceContext.extractHeaders(); var httpHeaders = new HttpHeaders(); proxyHeaders.forEach(httpHeaders::addAll); diff --git a/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java b/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java new file mode 100644 index 0000000..507575a --- /dev/null +++ b/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java @@ -0,0 +1,25 @@ +package dev.vality.wachter.config; + +import dev.vality.wachter.tracing.WoodyTracingFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WoodyTracingConfig { + + @Bean + public WoodyTracingFilter woodyTracingFilter() { + return new WoodyTracingFilter(); + } + + @Bean + public FilterRegistrationBean woodyTracingFilterRegistration( + WoodyTracingFilter woodyTracingFilter) { + var registration = new FilterRegistrationBean<>(woodyTracingFilter); + registration.setName("woodyTracingFilter"); + registration.setOrder(-50); + registration.addUrlPatterns("/wachter"); + return registration; + } +} diff --git a/src/main/java/dev/vality/wachter/tracing/WoodyTraceContext.java b/src/main/java/dev/vality/wachter/tracing/WoodyTraceContext.java new file mode 100644 index 0000000..96e85d7 --- /dev/null +++ b/src/main/java/dev/vality/wachter/tracing/WoodyTraceContext.java @@ -0,0 +1,83 @@ +package dev.vality.wachter.tracing; + +import dev.vality.woody.api.flow.WFlow; +import dev.vality.woody.api.trace.TraceData; +import dev.vality.woody.api.trace.context.TraceContext; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpHeaders; + +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; + +import static dev.vality.wachter.tracing.TraceHeaders.*; + +@UtilityClass +public class WoodyTraceContext { + + public TraceData restore(HttpHeaders headers) { + var traceData = TraceContext.initNewServiceTrace( + new TraceData(), WFlow.createDefaultIdGenerator(), WFlow.createDefaultIdGenerator()); + var span = traceData.getActiveSpan().getSpan(); + var traceId = headers.getFirst(WOODY_TRACE_ID); + var spanId = headers.getFirst(WOODY_SPAN_ID); + setIfPresent(traceId, span::setTraceId); + if (!Objects.equals(traceId, spanId)) { + setIfPresent(spanId, span::setId); + } + var parentId = headers.getFirst(WOODY_PARENT_ID); + if (!"undefined".equals(parentId)) { + setIfPresent(parentId, span::setParentId); + } + setIfPresent(headers, WOODY_DEADLINE, value -> span.setDeadline(Instant.parse(value))); + span.setTimestamp(0); + span.setDuration(0); + + var customMetadata = traceData.getActiveSpan().getCustomMetadata(); + headers.forEach((name, values) -> { + var normalizedName = name.toLowerCase(Locale.ROOT); + if (normalizedName.startsWith(WOODY_META_PREFIX) && !values.isEmpty()) { + customMetadata.putValue( + normalizedName.substring(WOODY_META_PREFIX.length()), values.getFirst()); + } + }); + return traceData; + } + + public HttpHeaders extractHeaders() { + var traceData = Objects.requireNonNull( + TraceContext.getCurrentTraceData(), "Woody TraceData must be initialized for the request"); + var activeSpan = traceData.getActiveSpan(); + var span = activeSpan.getSpan(); + var headers = new HttpHeaders(); + setIfNotNull(headers, WOODY_TRACE_ID, span.getTraceId()); + setIfNotNull(headers, WOODY_SPAN_ID, span.getId()); + setIfNotNull(headers, WOODY_PARENT_ID, span.getParentId()); + setIfNotNull(headers, WOODY_DEADLINE, + span.getDeadline() == null ? null : span.getDeadline().toString()); + var customMetadata = activeSpan.getCustomMetadata(); + customMetadata.getKeys().forEach(key -> + setIfNotNull(headers, WOODY_META_PREFIX + key, customMetadata.getValue(key))); + return headers; + } + + private void setIfPresent(HttpHeaders headers, String name, Consumer setter) { + setIfPresent(headers.getFirst(name), setter); + } + + private void setIfPresent(String value, Consumer setter) { + if (value != null && !value.isBlank()) { + setter.accept(value); + } + } + + private void setIfNotNull(HttpHeaders headers, String name, Object value) { + if (value != null) { + var stringValue = value.toString(); + if (!stringValue.isBlank()) { + headers.set(name, stringValue); + } + } + } +} diff --git a/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java new file mode 100644 index 0000000..f0bfb14 --- /dev/null +++ b/src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java @@ -0,0 +1,33 @@ +package dev.vality.wachter.tracing; + +import dev.vality.woody.api.flow.WFlow; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.SneakyThrows; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class WoodyTracingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws IOException { + var normalizedHeaders = TraceHeaderNormalizer.normalizeRequest(request); + var traceData = WoodyTraceContext.restore(normalizedHeaders); + WFlow.create(() -> continueFilterChain(request, response, filterChain), traceData).run(); + } + + @SneakyThrows + private void continueFilterChain( + ServletRequest request, + ServletResponse response, + FilterChain filterChain) { + filterChain.doFilter(request, response); + } +} diff --git a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java index 07f7619..28fba78 100644 --- a/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java +++ b/src/test/java/dev/vality/wachter/client/WachterClientOperationsTest.java @@ -1,5 +1,6 @@ package dev.vality.wachter.client; +import dev.vality.woody.api.flow.WFlow; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -19,7 +20,7 @@ class WachterClientOperationsTest { @Test - void shouldSendRequestWithTracingHeaders() { + void shouldSendRequestWithTracingHeaders() throws Exception { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); @@ -43,7 +44,8 @@ void shouldSendRequestWithTracingHeaders() { final var client = new WachterClient(restClient); - final var actualResponse = client.send(servletRequest, payload, "http://upstream"); + final var actualResponse = new WFlow().createServiceFork( + () -> client.send(servletRequest, payload, "http://upstream")).call(); assertEquals(HttpStatus.OK, actualResponse.statusCode()); assertArrayEquals(expectedResponse, actualResponse.body()); @@ -51,7 +53,7 @@ void shouldSendRequestWithTracingHeaders() { } @Test - void shouldFilterDisallowedHeaders() { + void shouldFilterDisallowedHeaders() throws Exception { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); @@ -73,13 +75,14 @@ void shouldFilterDisallowedHeaders() { final var client = new WachterClient(restClient); - client.send(servletRequest, null, "http://upstream/disallowed"); + new WFlow().createServiceFork( + () -> client.send(servletRequest, null, "http://upstream/disallowed")).call(); server.verify(); } @Test - void shouldHandleGetRequestWithoutBody() { + void shouldHandleGetRequestWithoutBody() throws Exception { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); @@ -93,7 +96,8 @@ void shouldHandleGetRequestWithoutBody() { final var client = new WachterClient(restClient); - final var response = client.send(servletRequest, null, "http://upstream/resource"); + final var response = new WFlow().createServiceFork( + () -> client.send(servletRequest, null, "http://upstream/resource")).call(); assertEquals(HttpStatus.OK, response.statusCode()); assertArrayEquals("{}".getBytes(), response.body()); @@ -101,7 +105,7 @@ void shouldHandleGetRequestWithoutBody() { } @Test - void shouldReturnErrorResponseWithoutThrowing() { + void shouldReturnErrorResponseWithoutThrowing() throws Exception { final var builder = RestClient.builder(); final var server = MockRestServiceServer.bindTo(builder).build(); final var restClient = builder.build(); @@ -118,7 +122,8 @@ void shouldReturnErrorResponseWithoutThrowing() { final var client = new WachterClient(restClient); - final var response = client.send(servletRequest, payload, "http://upstream/fail"); + final var response = new WFlow().createServiceFork( + () -> client.send(servletRequest, payload, "http://upstream/fail")).call(); assertEquals(HttpStatus.BAD_GATEWAY, response.statusCode()); assertArrayEquals("bad-gateway".getBytes(), response.body()); diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java index 4807def..7511c5b 100644 --- a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java +++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java @@ -100,7 +100,7 @@ void requestSuccessWithWoodyHeaders() { .header(WOODY_PARENT_ID, "parent") .header(WOODY_TRACE_ID, "trace") .header(WOODY_SPAN_ID, "span") - .header(WOODY_DEADLINE, "deadline") + .header(WOODY_DEADLINE, "2030-01-02T03:04:05Z") .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); @@ -120,7 +120,7 @@ void requestSuccessWithWoodyWithDashHeaders() { .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") + .header(ExternalHeaders.X_WOODY_DEADLINE, "2030-01-02T03:04:05Z") .content(TMessageUtil.createTMessage(protocolFactory))) .andDo(print()) .andExpect(status().is2xxSuccessful()); diff --git a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java index e62e0f5..7ca47b2 100644 --- a/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java +++ b/src/test/java/dev/vality/wachter/integration/WachterIntegrationTest.java @@ -1,16 +1,21 @@ package dev.vality.wachter.integration; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import dev.vality.wachter.client.WachterClient; import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig; import dev.vality.wachter.testutil.TMessageUtil; 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.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -44,12 +49,18 @@ class WachterIntegrationTest extends AbstractKeycloakOpenIdAsWiremockConfig { private int port; private RestClient restClient; + private Logger wachterClientLogger; + private ListAppender logAppender; @Autowired private TProtocolFactory protocolFactory; @BeforeEach void setUp() { + wachterClientLogger = (Logger) LoggerFactory.getLogger(WachterClient.class); + logAppender = new ListAppender<>(); + logAppender.start(); + wachterClientLogger.addAppender(logAppender); restClient = RestClient.builder() .baseUrl("http://localhost:" + port) .defaultStatusHandler(status -> true, (request, response) -> { @@ -62,6 +73,8 @@ void setUp() { @AfterEach void tearDown() { + wachterClientLogger.detachAppender(logAppender); + logAppender.stop(); resetAllRequests(); } @@ -169,6 +182,20 @@ void shouldProxyRequestWithCompleteTracingHeaders() throws Exception { assertEquals(extractRealm(jwtClaims), upstreamRequest.getHeader(WOODY_META_REALM)); + var sendLog = logAppender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("-> Send request")) + .findFirst() + .orElseThrow(); + var mdc = sendLog.getMDCPropertyMap(); + assertEquals(jwtClaims.get("sub").asString(), + mdc.get("rpc.server.metadata.user-identity.id")); + assertEquals(jwtClaims.get("preferred_username").asString(), + mdc.get("rpc.server.metadata.user-identity.username")); + assertEquals(jwtClaims.get("email").asString(), + mdc.get("rpc.server.metadata.user-identity.email")); + assertEquals(extractRealm(jwtClaims), + mdc.get("rpc.server.metadata.user-identity.realm")); + assertFalse(upstreamRequest.containsHeader(OTEL_TRACE_PARENT)); assertEquals(requestId, upstreamRequest.getHeader(WOODY_META_REQUEST_ID)); diff --git a/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java b/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java new file mode 100644 index 0000000..14eeccd --- /dev/null +++ b/src/test/java/dev/vality/wachter/tracing/WoodyTracingFilterTest.java @@ -0,0 +1,81 @@ +package dev.vality.wachter.tracing; + +import dev.vality.woody.api.trace.context.TraceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static dev.vality.wachter.tracing.TraceHeaders.*; +import static org.junit.jupiter.api.Assertions.*; + +class WoodyTracingFilterTest { + + @AfterEach + void cleanUp() { + SecurityContextHolder.clearContext(); + TraceContext.setCurrentTraceData(null); + MDC.clear(); + } + + @Test + void shouldExposeJwtIdentityAsWoodyServerMetadataDuringRequest() throws Exception { + var request = new MockHttpServletRequest("POST", "/wachter"); + request.addHeader(ExternalHeaders.X_WOODY_TRACE_ID, "trace-id"); + request.addHeader(ExternalHeaders.X_WOODY_SPAN_ID, "span-id"); + request.addHeader(ExternalHeaders.X_REQUEST_ID, "request-id"); + var jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("user-id") + .claim("preferred_username", "user@example.com") + .claim("email", "user@example.com") + .issuer("https://auth.example.com/realms/internal") + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + var invoked = new AtomicBoolean(); + var previousTraceData = TraceContext.getCurrentTraceData(); + + new WoodyTracingFilter().doFilter(request, new MockHttpServletResponse(), (servletRequest, response) -> { + invoked.set(true); + assertAll( + () -> assertEquals("user-id", + MDC.get("rpc.server.metadata.user-identity.id")), + () -> assertEquals("user@example.com", + MDC.get("rpc.server.metadata.user-identity.username")), + () -> assertEquals("user@example.com", + MDC.get("rpc.server.metadata.user-identity.email")), + () -> assertEquals("internal", + MDC.get("rpc.server.metadata.user-identity.realm")), + () -> assertEquals("request-id", + MDC.get("rpc.server.metadata.user-identity.x-request-id")), + () -> assertEquals("trace-id", WoodyTraceContext.extractHeaders().getFirst(WOODY_TRACE_ID)), + () -> assertEquals("user-id", WoodyTraceContext.extractHeaders().getFirst(WOODY_META_ID))); + }); + + assertTrue(invoked.get()); + assertSame(previousTraceData, TraceContext.getCurrentTraceData()); + assertNull(MDC.get("rpc.server.metadata.user-identity.email")); + } + + @Test + void shouldGenerateWoodySpanWhenIncomingSpanDuplicatesTrace() throws Exception { + var request = new MockHttpServletRequest("POST", "/wachter"); + request.addHeader(ExternalHeaders.X_WOODY_TRACE_ID, "duplicated-id"); + request.addHeader(ExternalHeaders.X_WOODY_SPAN_ID, "duplicated-id"); + request.addHeader(ExternalHeaders.X_WOODY_PARENT_ID, "undefined"); + + new WoodyTracingFilter().doFilter(request, new MockHttpServletResponse(), (servletRequest, response) -> { + var headers = WoodyTraceContext.extractHeaders(); + assertAll( + () -> assertEquals("duplicated-id", headers.getFirst(WOODY_TRACE_ID)), + () -> assertNotEquals("duplicated-id", headers.getFirst(WOODY_SPAN_ID)), + () -> assertEquals("undefined", headers.getFirst(WOODY_PARENT_ID))); + }); + } +} From 73f9930026ee724b660cc60b5f2ecfc678f46bfc Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Thu, 13 Aug 2026 18:44:56 +0700 Subject: [PATCH 2/2] revert woody wflow logic --- README.md | 60 ++++++++++++----- .../wachter/config/WoodyTracingConfig.java | 12 ++++ .../ExternalPortRestrictingFilter.java | 32 +++++++++ .../ExternalPortRestrictingFilterTest.java | 66 +++++++++++++++++++ 4 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 src/main/java/dev/vality/wachter/security/ExternalPortRestrictingFilter.java create mode 100644 src/test/java/dev/vality/wachter/security/ExternalPortRestrictingFilterTest.java diff --git a/README.md b/README.md index 6487057..6da90ec 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,52 @@ -# wachter +# Wachter -Сервис авторизации и прозрачного проксирования запросов от внешних систем к внутренним доменным сервисам. Представляет из себя HTTP пайплайн для Thrift вызовов с поддержкой заголовков woody и метаданных авторизации +Wachter — HTTP-шлюз для авторизации и прозрачного проксирования Thrift-запросов от внешних систем к внутренним сервисам. Сервис определяет целевой адрес по заголовку `Service`, проверяет доступ пользователя по JWT и Thrift-методу, а затем передаёт запрос вместе с Woody-контекстом и метаданными пользователя. -## Основной поток +## HTTP-интерфейс -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. +- `POST /wachter` на порту `8022` — единственный эндпоинт прикладного API. +- Остальные пути на прикладном порту возвращают `404 Unknown address`. +- Actuator-эндпоинты `health`, `info` и `prometheus` доступны на management-порту `8023`. -## Особенности +Запрос должен содержать: -- Поддержка двух семейств Woody-заголовков (новые `woody.*` и наследуемые `x-woody-*`). -- Автоматическая генерация и распространение OpenTelemetry `traceparent` при отсутствии входящего заголовка. -- Выделенный `JwtTokenDetailsExtractor` для повторного использования данных токена. -- Тестовый контур покрывает композицию фильтра, клиента и контроллера, включая WireMock-интеграцию. +- Bearer JWT в заголовке `Authorization`; +- имя целевого сервиса в заголовке `Service`; +- бинарное тело Thrift-вызова. -Схема взаимодействий остаётся доступной в [doc/diagram-wachter.svg](doc/diagram-wachter.svg). +Соответствие значений `Service` внутренним URL настраивается в `wachter.services` в `application.yml` или переопределяется при развёртывании. +## Обработка запроса + +1. `WoodyTracingFilter` нормализует входящие Woody-заголовки и создаёт Woody `TraceData` через `WFlow`. +2. Данные пользователя из JWT и служебные заголовки запроса добавляются в `woody.meta.user-identity.*`. +3. `WachterService` читает имя метода из бинарного Thrift-пакета. +4. `ServiceMapper` определяет целевой сервис по заголовку `Service`. +5. `AccessService` проверяет доступ с учётом метода, сервиса, email пользователя и ролей JWT. +6. `WachterClient` отправляет запрос в upstream через Spring `RestClient` и Apache HttpClient 5. +7. Клиент получает статус и тело upstream-ответа, а Woody-заголовки ответа преобразуются обратно во внешнее представление. + +## Woody metadata + +Wachter поддерживает внутренние заголовки `woody.*` и внешние заголовки `x-woody-*`. Для каждого запроса сервис восстанавливает либо создаёт Woody trace context и передаёт его в upstream. + +В `WFlow` также добавляются данные пользователя из JWT: + +- `user-identity.id`; +- `user-identity.username`; +- `user-identity.email`; +- `user-identity.realm`. + +Эти значения доступны как Woody custom metadata и публикуются в MDC с префиксом `rpc.server.metadata.`. Заголовки `X-Request-ID`, `X-Request-Deadline` и `X-Invoice-ID` также преобразуются в Woody metadata. + +## OpenTelemetry + +W3C trace context (`traceparent` и `tracestate`) обрабатывает OpenTelemetry Java Agent. Сборка помещает agent в runtime image и запускает приложение с параметром `-javaagent`. Wachter не создаёт OTEL span вручную и не переносит эти заголовки как обычные proxy-заголовки. + +При штатном запуске agent: + +- продолжает входящий W3C trace context либо создаёт новый trace; +- создаёт серверный span для входящего запроса; +- внедряет актуальный context в исходящий HTTP-запрос. + +Woody tracing и OpenTelemetry — независимые механизмы: `WFlow` отвечает за Woody trace context и `woody.meta`, Java Agent — за OTEL spans и W3C propagation. diff --git a/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java b/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java index 507575a..54e5dbc 100644 --- a/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java +++ b/src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java @@ -1,6 +1,8 @@ package dev.vality.wachter.config; +import dev.vality.wachter.security.ExternalPortRestrictingFilter; import dev.vality.wachter.tracing.WoodyTracingFilter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,6 +10,16 @@ @Configuration public class WoodyTracingConfig { + @Bean + public FilterRegistrationBean externalPortRestrictingFilter( + @Value("${server.port}") int apiPort) { + var registration = new FilterRegistrationBean<>(new ExternalPortRestrictingFilter(apiPort)); + registration.setName("httpPortFilter"); + registration.setOrder(-100); + registration.addUrlPatterns("/*"); + return registration; + } + @Bean public WoodyTracingFilter woodyTracingFilter() { return new WoodyTracingFilter(); diff --git a/src/main/java/dev/vality/wachter/security/ExternalPortRestrictingFilter.java b/src/main/java/dev/vality/wachter/security/ExternalPortRestrictingFilter.java new file mode 100644 index 0000000..7c605bc --- /dev/null +++ b/src/main/java/dev/vality/wachter/security/ExternalPortRestrictingFilter.java @@ -0,0 +1,32 @@ +package dev.vality.wachter.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class ExternalPortRestrictingFilter extends OncePerRequestFilter { + + private static final String WACHTER_ENDPOINT = "/wachter"; + + private final int apiPort; + + public ExternalPortRestrictingFilter(int apiPort) { + this.apiPort = apiPort; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (request.getLocalPort() == apiPort && !WACHTER_ENDPOINT.equals(request.getServletPath())) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Unknown address"); + return; + } + filterChain.doFilter(request, response); + } +} diff --git a/src/test/java/dev/vality/wachter/security/ExternalPortRestrictingFilterTest.java b/src/test/java/dev/vality/wachter/security/ExternalPortRestrictingFilterTest.java new file mode 100644 index 0000000..2227c5c --- /dev/null +++ b/src/test/java/dev/vality/wachter/security/ExternalPortRestrictingFilterTest.java @@ -0,0 +1,66 @@ +package dev.vality.wachter.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ExternalPortRestrictingFilterTest { + + private static final int API_PORT = 8022; + private static final int MANAGEMENT_PORT = 8023; + + private final ExternalPortRestrictingFilter filter = new ExternalPortRestrictingFilter(API_PORT); + + @Test + void shouldAllowWachterEndpointOnApiPort() throws Exception { + var request = request(API_PORT, "/wachter"); + var response = new MockHttpServletResponse(); + var filterChain = new MockFilterChain(); + + filter.doFilter(request, response, filterChain); + + assertAll( + () -> assertNotNull(filterChain.getRequest()), + () -> assertEquals(200, response.getStatus())); + } + + @Test + void shouldRejectOtherEndpointOnApiPort() throws Exception { + var request = request(API_PORT, "/actuator/health"); + var response = new MockHttpServletResponse(); + var filterChain = new MockFilterChain(); + + filter.doFilter(request, response, filterChain); + + assertAll( + () -> assertNull(filterChain.getRequest()), + () -> assertEquals(404, response.getStatus()), + () -> assertEquals("Unknown address", response.getErrorMessage())); + } + + @Test + void shouldNotRestrictManagementPort() throws Exception { + var request = request(MANAGEMENT_PORT, "/actuator/health/readiness"); + var response = new MockHttpServletResponse(); + var filterChain = new MockFilterChain(); + + filter.doFilter(request, response, filterChain); + + assertAll( + () -> assertNotNull(filterChain.getRequest()), + () -> assertEquals(200, response.getStatus())); + } + + private MockHttpServletRequest request(int port, String servletPath) { + var request = new MockHttpServletRequest("GET", servletPath); + request.setLocalPort(port); + request.setServletPath(servletPath); + return request; + } +}