Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@

<dependencies>
<!--vality-->
<dependency>
<groupId>dev.vality.woody</groupId>
<artifactId>woody-api</artifactId>
</dependency>
<dependency>
<groupId>dev.vality.woody</groupId>
<artifactId>woody-thrift</artifactId>
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/dev/vality/wachter/client/WachterClient.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/dev/vality/wachter/config/WoodyTracingConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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;

@Configuration
public class WoodyTracingConfig {

@Bean
public FilterRegistrationBean<ExternalPortRestrictingFilter> 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();
}

@Bean
public FilterRegistrationBean<WoodyTracingFilter> woodyTracingFilterRegistration(
WoodyTracingFilter woodyTracingFilter) {
var registration = new FilterRegistrationBean<>(woodyTracingFilter);
registration.setName("woodyTracingFilter");
registration.setOrder(-50);
registration.addUrlPatterns("/wachter");
return registration;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
83 changes: 83 additions & 0 deletions src/main/java/dev/vality/wachter/tracing/WoodyTraceContext.java
Original file line number Diff line number Diff line change
@@ -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<String> setter) {
setIfPresent(headers.getFirst(name), setter);
}

private void setIfPresent(String value, Consumer<String> 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);
}
}
}
}
33 changes: 33 additions & 0 deletions src/main/java/dev/vality/wachter/tracing/WoodyTracingFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand All @@ -43,15 +44,16 @@ 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());
server.verify();
}

@Test
void shouldFilterDisallowedHeaders() {
void shouldFilterDisallowedHeaders() throws Exception {
final var builder = RestClient.builder();
final var server = MockRestServiceServer.bindTo(builder).build();
final var restClient = builder.build();
Expand All @@ -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();
Expand All @@ -93,15 +96,16 @@ 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());
server.verify();
}

@Test
void shouldReturnErrorResponseWithoutThrowing() {
void shouldReturnErrorResponseWithoutThrowing() throws Exception {
final var builder = RestClient.builder();
final var server = MockRestServiceServer.bindTo(builder).build();
final var restClient = builder.build();
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand Down
Loading
Loading