diff --git a/.github/settings.yml b/.github/settings.yml
new file mode 100644
index 0000000..9267e7d
--- /dev/null
+++ b/.github/settings.yml
@@ -0,0 +1,2 @@
+# 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
new file mode 100644
index 0000000..6114f14
--- /dev/null
+++ b/.github/workflows/basic-linters.yml
@@ -0,0 +1,10 @@
+name: Vality basic linters
+
+on:
+ pull_request:
+ branches:
+ - "*"
+
+jobs:
+ lint:
+ uses: valitydev/base-workflows/.github/workflows/basic-linters.yml@v1
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..fb5e7ed
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,10 @@
+name: Build Artifact
+
+on:
+ pull_request:
+ branches:
+ - '*'
+
+jobs:
+ build:
+ uses: valitydev/base-workflow/.github/workflows/maven-service-build.yml@v1
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..b08508e
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,18 @@
+name: Deploy Docker Image
+
+on:
+ push:
+ branches:
+ - 'master'
+ - 'main'
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ deploy:
+ uses: valitydev/base-workflow/.github/workflows/maven-service-deploy.yml@v1
+ secrets:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..721f9b9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,78 @@
+# Created by .ignore support plugin (hsz.mobi)
+.eunit
+deps
+*.o
+*.beam
+*.plt
+erl_crash.dump
+ebin/*.beam
+rel/example_project
+.concrete/DEV_MODE
+.rebar
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+
+# Sensitive or high-churn files:
+.idea/dataSources.ids
+.idea/dataSources.xml
+.idea/dataSources.local.xml
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+
+# Gradle:
+.idea/gradle.xml
+.idea/libraries
+
+# Mongo Explorer plugin:
+.idea/mongoSettings.xml
+
+*.iws
+*.ipr
+*.iml
+
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+*.class
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+env.list
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..b85dfb3
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,247 @@
+
+
+ 4.0.0
+
+
+ dev.vality
+ service-parent-pom
+ 1.0.17
+
+
+ wachter
+ 1.0-SNAPSHOT
+
+
+ UTF-8
+ UTF-8
+ 15
+ 8022
+ 8023
+ ${server.port} ${management.port}
+ 2.12.5
+ 2.5.3
+ 1.3.2
+ 2.3.1
+
+
+
+
+
+
+ io.micrometer
+ micrometer-core
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
+ dev.vality
+ shared-resources
+
+
+ dev.vality
+ bouncer-proto
+ 1.39-633ba73
+
+
+ dev.vality
+ org-management-proto
+ 1.10-f433223
+
+
+ dev.vality.geck
+ serializer
+ 0.0.1
+
+
+ dev.vality
+ damsel
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.security
+ spring-security-config
+
+
+ org.springframework.security
+ spring-security-web
+
+
+ org.keycloak
+ keycloak-admin-client
+ 18.0.0
+
+
+ org.jboss.resteasy
+ resteasy-client
+
+
+ org.jboss.resteasy
+ resteasy-multipart-provider
+
+
+ org.jboss.resteasy
+ resteasy-jackson2-provider
+
+
+ org.jboss.resteasy
+ resteasy-jaxb-provider
+
+
+
+
+ org.keycloak
+ keycloak-spring-security-adapter
+ 18.0.0
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.hibernate
+ hibernate-validator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ javax.servlet
+ javax.servlet-api
+ 4.0.1
+
+
+ javax.annotation
+ javax.annotation-api
+ ${javax-annotation-api-version}
+
+
+ javax.validation
+ validation-api
+ 2.0.1.Final
+ provided
+
+
+ javax.xml.bind
+ jaxb-api
+ ${jaxb-version}
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+ 1.69
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.jsonwebtoken
+ jjwt
+ 0.9.1
+ test
+
+
+ org.springframework.cloud
+ spring-cloud-contract-wiremock
+ 3.0.3
+ test
+
+
+
+
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ ${project.build.directory}
+
+ Dockerfile
+
+ true
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ true
+
+ Dockerfile
+
+
+
+ src/main/resources
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-remote-resources-plugin
+ 1.6.0
+
+
+ org.apache.maven.shared
+ maven-filtering
+ 1.3
+
+
+
+
+ dev.vality:shared-resources:${shared-resources.version}
+
+ false
+ false
+
+
+
+
+ process
+
+
+
+
+
+
+
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..a20bfd6
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": ["local>valitydev/.github:renovate-config"]
+}
diff --git a/src/main/java/dev/vality/wachter/WachterApplication.java b/src/main/java/dev/vality/wachter/WachterApplication.java
new file mode 100644
index 0000000..dc41a12
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/WachterApplication.java
@@ -0,0 +1,15 @@
+package dev.vality.wachter;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.web.servlet.ServletComponentScan;
+
+@ServletComponentScan
+@SpringBootApplication
+public class WachterApplication extends SpringApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(WachterApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/client/WachterClient.java b/src/main/java/dev/vality/wachter/client/WachterClient.java
new file mode 100644
index 0000000..9e2c69c
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/client/WachterClient.java
@@ -0,0 +1,41 @@
+package dev.vality.wachter.client;
+
+import dev.vality.wachter.config.properties.WachterProperties;
+import lombok.RequiredArgsConstructor;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Service;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.util.Enumeration;
+
+@Service
+@RequiredArgsConstructor
+public class WachterClient {
+
+ private final HttpClient httpclient;
+
+ public byte[] send(HttpServletRequest request,
+ byte[] contentData,
+ WachterProperties.Services service) throws IOException {
+ HttpPost httppost = new HttpPost(service.getUrl());
+ setHeader(request, httppost);
+ httppost.setEntity(new ByteArrayEntity(contentData));
+ HttpResponse response = httpclient.execute(httppost);
+ return EntityUtils.toByteArray(response.getEntity());
+ }
+
+ private void setHeader(HttpServletRequest request, HttpPost httppost) {
+ Enumeration headerNames = request.getHeaderNames();
+ if (headerNames != null) {
+ while (headerNames.hasMoreElements()) {
+ String next = headerNames.nextElement();
+ httppost.setHeader(next, request.getHeader(next));
+ }
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/config/ApplicationConfig.java b/src/main/java/dev/vality/wachter/config/ApplicationConfig.java
new file mode 100644
index 0000000..e9bc878
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/ApplicationConfig.java
@@ -0,0 +1,75 @@
+package dev.vality.wachter.config;
+
+import dev.vality.bouncer.decisions.ArbiterSrv;
+import dev.vality.orgmanagement.AuthContextProviderSrv;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit;
+import dev.vality.woody.thrift.impl.http.THSpawnClientBuilder;
+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.protocol.HTTP;
+import org.apache.http.protocol.HttpContext;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+
+import java.io.IOException;
+import java.util.List;
+
+@Configuration
+public class ApplicationConfig {
+
+ @Bean
+ public AuthContextProviderSrv.Iface orgManagerClient(
+ @Value("${orgManager.url}") Resource resource,
+ @Value("${orgManager.networkTimeout}") int networkTimeout) throws IOException {
+ return new THSpawnClientBuilder()
+ .withNetworkTimeout(networkTimeout)
+ .withMetaExtensions(List.of(
+ UserIdentityIdExtensionKit.INSTANCE,
+ UserIdentityEmailExtensionKit.INSTANCE,
+ UserIdentityUsernameExtensionKit.INSTANCE,
+ UserIdentityRealmExtensionKit.INSTANCE))
+ .withAddress(resource.getURI())
+ .build(AuthContextProviderSrv.Iface.class);
+ }
+
+ @Bean
+ public ArbiterSrv.Iface bouncerClient(
+ @Value("${bouncer.url}") Resource resource,
+ @Value("${bouncer.networkTimeout}") int networkTimeout) throws IOException {
+ return new THSpawnClientBuilder()
+ .withNetworkTimeout(networkTimeout)
+ .withAddress(resource.getURI())
+ .build(ArbiterSrv.Iface.class);
+ }
+
+ @Bean
+ public HttpClient httpclient(@Value("${http-client.connectTimeout}") int connectTimeout,
+ @Value("${http-client.connectionRequestTimeout}") int connectionRequestTimeout,
+ @Value("${http-client.socketTimeout}") int socketTimeout) {
+ return HttpClients.custom()
+ .setDefaultRequestConfig(RequestConfig
+ .custom()
+ .setConnectTimeout(connectTimeout)
+ .setConnectionRequestTimeout(connectionRequestTimeout)
+ .setSocketTimeout(socketTimeout)
+ .build())
+ .addInterceptorFirst(new ContentLengthHeaderRemover())
+ .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/KeycloakConfig.java b/src/main/java/dev/vality/wachter/config/KeycloakConfig.java
new file mode 100644
index 0000000..8681f6e
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/KeycloakConfig.java
@@ -0,0 +1,60 @@
+package dev.vality.wachter.config;
+
+import dev.vality.wachter.config.properties.KeycloakProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import org.keycloak.adapters.KeycloakConfigResolver;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.KeycloakDeploymentBuilder;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.util.StringUtils;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Configuration
+@ConditionalOnProperty(value = "auth.enabled", havingValue = "true")
+public class KeycloakConfig {
+
+ private final KeycloakProperties keycloakProperties;
+
+ @Bean
+ public KeycloakConfigResolver keycloakConfigResolver() {
+ return facade -> {
+ KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(adapterConfig());
+ deployment.setNotBefore(keycloakProperties.getNotBefore());
+ return deployment;
+ };
+ }
+
+ private AdapterConfig adapterConfig() {
+ if (StringUtils.hasLength(keycloakProperties.getRealmPublicKeyPath())) {
+ keycloakProperties.setRealmPublicKey(readKeyFromFile(keycloakProperties.getRealmPublicKeyPath()));
+ }
+
+ AdapterConfig adapterConfig = new AdapterConfig();
+ adapterConfig.setRealm(keycloakProperties.getRealm());
+ adapterConfig.setRealmKey(keycloakProperties.getRealmPublicKey());
+ adapterConfig.setResource(keycloakProperties.getResource());
+ adapterConfig.setAuthServerUrl(keycloakProperties.getAuthServerUrl());
+ adapterConfig.setUseResourceRoleMappings(true);
+ adapterConfig.setBearerOnly(true);
+ adapterConfig.setSslRequired(keycloakProperties.getSslRequired());
+ return adapterConfig;
+ }
+
+ @SneakyThrows
+ private String readKeyFromFile(String filePath) {
+ List strings = Files.readAllLines(Paths.get(filePath));
+ strings.remove(strings.size() - 1);
+ strings.remove(0);
+ return strings.stream().map(String::trim).collect(Collectors.joining());
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/config/SecurityConfig.java b/src/main/java/dev/vality/wachter/config/SecurityConfig.java
new file mode 100644
index 0000000..5ef6c78
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/SecurityConfig.java
@@ -0,0 +1,65 @@
+package dev.vality.wachter.config;
+
+import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents;
+import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy;
+import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+@Configuration
+@EnableWebSecurity
+@ComponentScan(
+ basePackageClasses = KeycloakSecurityComponents.class,
+ excludeFilters = @ComponentScan.Filter(
+ type = FilterType.REGEX,
+ pattern = "org.keycloak.adapters.springsecurity.management.HttpSessionManager"))
+@EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true)
+@ConditionalOnProperty(value = "auth.enabled", havingValue = "true")
+public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
+
+ @Bean
+ @Override
+ protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
+ return new NullAuthenticatedSessionStrategy();
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ super.configure(http);
+ http.cors().and()
+ .csrf().disable()
+ .authorizeRequests()
+ .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
+ .antMatchers(HttpMethod.GET, "/**/health").permitAll()
+ .anyRequest().authenticated();
+ }
+
+ @Autowired
+ public void configureGlobal(AuthenticationManagerBuilder auth) {
+ auth.authenticationProvider(keycloakAuthenticationProvider());
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.applyPermitDefaultValues();
+ configuration.addAllowedMethod(HttpMethod.PUT);
+ configuration.addAllowedMethod(HttpMethod.DELETE);
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/config/ThriftGatewayConfiguration.java b/src/main/java/dev/vality/wachter/config/ThriftGatewayConfiguration.java
new file mode 100644
index 0000000..8fd8b22
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/ThriftGatewayConfiguration.java
@@ -0,0 +1,18 @@
+package dev.vality.wachter.config;
+
+import org.apache.thrift.protocol.TBinaryProtocol;
+import org.apache.thrift.protocol.TProtocolFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ThriftGatewayConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(TProtocolFactory.class)
+ TProtocolFactory thriftProtocolFactory() {
+ return new TBinaryProtocol.Factory();
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/config/WebConfig.java b/src/main/java/dev/vality/wachter/config/WebConfig.java
new file mode 100644
index 0000000..9c40859
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/WebConfig.java
@@ -0,0 +1,103 @@
+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 org.keycloak.KeycloakSecurityContext;
+import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
+import org.keycloak.representations.AccessToken;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.security.Principal;
+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;
+
+@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) throws ServletException, IOException {
+ woodyFlow.createServiceFork(
+ () -> {
+ try {
+ if (request.getUserPrincipal() != null) {
+ addWoodyContext(request.getUserPrincipal());
+ }
+
+ 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(Principal principal) {
+ KeycloakSecurityContext keycloakSecurityContext =
+ ((KeycloakAuthenticationToken) principal).getAccount().getKeycloakSecurityContext();
+ AccessToken accessToken = keycloakSecurityContext.getToken();
+
+ setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, accessToken.getSubject());
+ setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, accessToken.getPreferredUsername());
+ setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, accessToken.getEmail());
+ setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, keycloakSecurityContext.getRealm());
+ }
+
+ 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/BouncerProperties.java b/src/main/java/dev/vality/wachter/config/properties/BouncerProperties.java
new file mode 100644
index 0000000..a5cf3b8
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/properties/BouncerProperties.java
@@ -0,0 +1,26 @@
+package dev.vality.wachter.config.properties;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.NotEmpty;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "bouncer")
+public class BouncerProperties {
+
+ @NotEmpty
+ private String deploymentId;
+ @NotEmpty
+ private String authMethod;
+ @NotEmpty
+ private String realm;
+ @NotEmpty
+ private String ruleSetId;
+}
diff --git a/src/main/java/dev/vality/wachter/config/properties/KeycloakProperties.java b/src/main/java/dev/vality/wachter/config/properties/KeycloakProperties.java
new file mode 100644
index 0000000..f7a2dc3
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/properties/KeycloakProperties.java
@@ -0,0 +1,34 @@
+package dev.vality.wachter.config.properties;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.NotEmpty;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "keycloak")
+public class KeycloakProperties {
+
+ @NotEmpty
+ private String realm;
+ @NotEmpty
+ private String authServerUrl;
+ @NotEmpty
+ private String resource;
+
+ private Integer notBefore;
+
+ @NotEmpty
+ private String sslRequired;
+
+ private String realmPublicKey;
+
+ private String realmPublicKeyPath;
+
+}
diff --git a/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java b/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java
new file mode 100644
index 0000000..fbbbff7
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/config/properties/WachterProperties.java
@@ -0,0 +1,30 @@
+package dev.vality.wachter.config.properties;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.Map;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "wachter")
+public class WachterProperties {
+
+ private String serviceHeader;
+ private Map services;
+
+ @Getter
+ @Setter
+ public static class Services {
+
+ private String name;
+ private String url;
+
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java b/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java
new file mode 100644
index 0000000..cb959fe
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java
@@ -0,0 +1,60 @@
+package dev.vality.wachter.controller;
+
+import dev.vality.wachter.exeptions.AuthorizationException;
+import dev.vality.wachter.exeptions.WachterException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+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.FORBIDDEN)
+ public void handleAccessDeniedException(AuthorizationException e) {
+ log.warn("<- Res [403]: Request denied access", 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(HttpTimeoutException.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public void handleHttpTimeoutException(HttpTimeoutException e) {
+ log.error("<- Res [500]: Timeout with using inner http client", e);
+ }
+
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public void handleException(Exception 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
new file mode 100644
index 0000000..63aa163
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/controller/WachterController.java
@@ -0,0 +1,26 @@
+package dev.vality.wachter.controller;
+
+import dev.vality.wachter.service.WachterService;
+import lombok.RequiredArgsConstructor;
+import org.apache.thrift.TException;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("")
+public class WachterController {
+
+ private final WachterService wachterService;
+
+ @PostMapping("/wachter")
+ public byte[] getRequest(HttpServletRequest request) throws IOException, TException {
+ return wachterService.process(request);
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/exeptions/AuthorizationException.java b/src/main/java/dev/vality/wachter/exeptions/AuthorizationException.java
new file mode 100644
index 0000000..9496127
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/exeptions/AuthorizationException.java
@@ -0,0 +1,8 @@
+package dev.vality.wachter.exeptions;
+
+public class AuthorizationException extends WachterException {
+
+ public AuthorizationException(String s) {
+ super(s);
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/exeptions/BouncerException.java b/src/main/java/dev/vality/wachter/exeptions/BouncerException.java
new file mode 100644
index 0000000..f8f9571
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/exeptions/BouncerException.java
@@ -0,0 +1,12 @@
+package dev.vality.wachter.exeptions;
+
+public class BouncerException extends WachterException {
+
+ public BouncerException(String s) {
+ super(s);
+ }
+
+ public BouncerException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/exeptions/DeadlineException.java b/src/main/java/dev/vality/wachter/exeptions/DeadlineException.java
new file mode 100644
index 0000000..743dd3b
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/exeptions/DeadlineException.java
@@ -0,0 +1,8 @@
+package dev.vality.wachter.exeptions;
+
+public class DeadlineException extends WachterException {
+
+ public DeadlineException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/exeptions/OrgManagerException.java b/src/main/java/dev/vality/wachter/exeptions/OrgManagerException.java
new file mode 100644
index 0000000..d4e85f5
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/exeptions/OrgManagerException.java
@@ -0,0 +1,8 @@
+package dev.vality.wachter.exeptions;
+
+public class OrgManagerException extends WachterException {
+
+ public OrgManagerException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/exeptions/WachterException.java b/src/main/java/dev/vality/wachter/exeptions/WachterException.java
new file mode 100644
index 0000000..f0c212e
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/exeptions/WachterException.java
@@ -0,0 +1,12 @@
+package dev.vality.wachter.exeptions;
+
+public class WachterException extends RuntimeException {
+
+ public WachterException(String message) {
+ super(message);
+ }
+
+ public WachterException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/mapper/ServiceMapper.java b/src/main/java/dev/vality/wachter/mapper/ServiceMapper.java
new file mode 100644
index 0000000..1757f31
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/mapper/ServiceMapper.java
@@ -0,0 +1,32 @@
+package dev.vality.wachter.mapper;
+
+import dev.vality.wachter.config.properties.WachterProperties;
+import dev.vality.wachter.exeptions.WachterException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+
+@Component
+@RequiredArgsConstructor
+public class ServiceMapper {
+
+ private final WachterProperties wachterProperties;
+
+ public WachterProperties.Services getService(HttpServletRequest request) {
+ if (request.getHeader(wachterProperties.getServiceHeader()) == null) {
+ throw new WachterException(
+ String.format("Header \"%s\" must be set", wachterProperties.getServiceHeader()));
+ }
+ WachterProperties.Services service = wachterProperties.getServices()
+ .get(request.getHeader(wachterProperties.getServiceHeader()));
+
+ if (service == null) {
+ throw new WachterException(
+ String.format("Service \"%s\" not found in configuration",
+ request.getHeader(wachterProperties.getServiceHeader())));
+ }
+ return service;
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/security/AccessData.java b/src/main/java/dev/vality/wachter/security/AccessData.java
new file mode 100644
index 0000000..4b0dd24
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/security/AccessData.java
@@ -0,0 +1,19 @@
+package dev.vality.wachter.security;
+
+import dev.vality.wachter.config.properties.WachterProperties;
+import lombok.Builder;
+import lombok.Data;
+
+@Builder
+@Data
+public class AccessData {
+
+ private final String operationId;
+ private final String partyId;
+ private final long tokenExpirationSec;
+ private final String tokenId;
+ private final String userId;
+ private final String userEmail;
+ private final WachterProperties.Services service;
+
+}
diff --git a/src/main/java/dev/vality/wachter/security/AccessService.java b/src/main/java/dev/vality/wachter/security/AccessService.java
new file mode 100644
index 0000000..1317b06
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/security/AccessService.java
@@ -0,0 +1,49 @@
+package dev.vality.wachter.security;
+
+import dev.vality.wachter.exeptions.AuthorizationException;
+import dev.vality.wachter.exeptions.BouncerException;
+import dev.vality.wachter.service.BouncerService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class AccessService {
+
+ private final BouncerService bouncerService;
+
+ @Value("${bouncer.auth.enabled}")
+ private boolean authEnabled;
+
+ public void checkUserAccess(AccessData accessData) {
+ log.info("Check the {} rights to perform the operation {} in service {}",
+ accessData.getUserEmail(),
+ accessData.getOperationId(),
+ accessData.getService().getName());
+ var resolution = bouncerService.getResolution(accessData);
+ switch (resolution.getSetField()) {
+ case FORBIDDEN, RESTRICTED -> {
+ if (authEnabled) {
+ throw new AuthorizationException(
+ String.format("No rights for %s to perform %s in service %s",
+ accessData.getUserEmail(),
+ accessData.getOperationId(),
+ accessData.getService().getName()));
+ } else {
+ log.warn("No rights for {} to perform {} in service {}",
+ accessData.getUserEmail(),
+ accessData.getOperationId(),
+ accessData.getService().getName());
+ }
+ }
+ case ALLOWED -> log.info("Rights for {} to perform {} in service {} are allowed",
+ accessData.getUserEmail(),
+ accessData.getOperationId(),
+ accessData.getService().getName());
+ default -> throw new BouncerException(String.format("Resolution %s cannot be processed", resolution));
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/security/BouncerContextFactory.java b/src/main/java/dev/vality/wachter/security/BouncerContextFactory.java
new file mode 100644
index 0000000..7475b8a
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/security/BouncerContextFactory.java
@@ -0,0 +1,68 @@
+package dev.vality.wachter.security;
+
+import dev.vality.bouncer.base.Entity;
+import dev.vality.bouncer.context.v1.*;
+import dev.vality.bouncer.ctx.ContextFragmentType;
+import dev.vality.bouncer.decisions.Context;
+import dev.vality.wachter.config.properties.BouncerProperties;
+import dev.vality.wachter.service.KeycloakService;
+import dev.vality.wachter.service.OrgManagerService;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import org.apache.thrift.TSerializer;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+@RequiredArgsConstructor
+@Component
+public class BouncerContextFactory {
+
+ private final BouncerProperties bouncerProperties;
+ private final OrgManagerService orgManagerService;
+ private final KeycloakService keycloakService;
+
+ @SneakyThrows
+ public Context buildContext(AccessData accessData) {
+ var contextFragment = buildContextFragment(accessData);
+ var serializer = new TSerializer();
+ var fragment = new dev.vality.bouncer.ctx.ContextFragment()
+ .setType(ContextFragmentType.v1_thrift_binary)
+ .setContent(serializer.serialize(contextFragment));
+ var userFragment = orgManagerService.getUserAuthContext(
+ keycloakService.getAccessToken().getSubject());
+ var context = new Context();
+ context.putToFragments(accessData.getService().getName(), fragment);
+ context.putToFragments("user", userFragment);
+ return context;
+ }
+
+ private ContextFragment buildContextFragment(AccessData accessData) {
+ var env = buildEnvironment();
+ return new ContextFragment()
+ .setAuth(buildAuth(accessData))
+ .setEnv(env);
+ }
+
+ private Auth buildAuth(AccessData accessData) {
+ var auth = new Auth();
+ Set authScopeSet = new HashSet<>();
+ authScopeSet.add(new AuthScope()
+ .setParty(new Entity().setId(accessData.getPartyId())));
+ return auth.setToken(new Token().setId(accessData.getTokenId()))
+ .setMethod(bouncerProperties.getAuthMethod())
+ .setExpiration(Instant.ofEpochSecond(accessData.getTokenExpirationSec()).toString())
+ .setScope(authScopeSet);
+ }
+
+ private Environment buildEnvironment() {
+ var deployment = new Deployment()
+ .setId(bouncerProperties.getDeploymentId());
+ return new Environment()
+ .setDeployment(deployment)
+ .setNow(Instant.now().toString());
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/service/BouncerService.java b/src/main/java/dev/vality/wachter/service/BouncerService.java
new file mode 100644
index 0000000..03a93eb
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/service/BouncerService.java
@@ -0,0 +1,37 @@
+package dev.vality.wachter.service;
+
+import dev.vality.bouncer.decisions.ArbiterSrv;
+import dev.vality.bouncer.decisions.Resolution;
+import dev.vality.wachter.config.properties.BouncerProperties;
+import dev.vality.wachter.exeptions.BouncerException;
+import dev.vality.wachter.security.AccessData;
+import dev.vality.wachter.security.BouncerContextFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.thrift.TException;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BouncerService {
+
+ private final BouncerProperties bouncerProperties;
+ private final BouncerContextFactory bouncerContextFactory;
+ private final ArbiterSrv.Iface bouncerClient;
+
+ public Resolution getResolution(AccessData accessData) {
+ log.debug("Check access with bouncer context");
+ var context = bouncerContextFactory.buildContext(accessData);
+ log.debug("Built thrift context: {}", context);
+ try {
+ var judge = bouncerClient.judge(bouncerProperties.getRuleSetId(), context);
+ log.debug("Have judge: {}", judge);
+ var resolution = judge.getResolution();
+ log.debug("Resolution: {}", resolution);
+ return resolution;
+ } catch (TException e) {
+ throw new BouncerException("Error while call bouncer", e);
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/service/KeycloakService.java b/src/main/java/dev/vality/wachter/service/KeycloakService.java
new file mode 100644
index 0000000..e1def26
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/service/KeycloakService.java
@@ -0,0 +1,22 @@
+package dev.vality.wachter.service;
+
+import org.keycloak.KeycloakPrincipal;
+import org.keycloak.representations.AccessToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+@Service
+public class KeycloakService {
+
+ public String getPartyId() {
+ return ((KeycloakPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getName();
+ }
+
+ public AccessToken getAccessToken() {
+ KeycloakPrincipal keycloakPrincipal = (KeycloakPrincipal) SecurityContextHolder.getContext()
+ .getAuthentication()
+ .getPrincipal();
+
+ return keycloakPrincipal.getKeycloakSecurityContext().getToken();
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/service/OrgManagerService.java b/src/main/java/dev/vality/wachter/service/OrgManagerService.java
new file mode 100644
index 0000000..dc2e994
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/service/OrgManagerService.java
@@ -0,0 +1,24 @@
+package dev.vality.wachter.service;
+
+import dev.vality.bouncer.ctx.ContextFragment;
+import dev.vality.orgmanagement.AuthContextProviderSrv;
+import dev.vality.wachter.exeptions.OrgManagerException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@RequiredArgsConstructor
+@Service
+public class OrgManagerService {
+
+ private final AuthContextProviderSrv.Iface orgManagerClient;
+
+ public ContextFragment getUserAuthContext(String userId) {
+ try {
+ return orgManagerClient.getUserContext(userId);
+ } catch (Exception e) {
+ throw new OrgManagerException(
+ String.format("Can't get user auth context on orgManager call: userId = %s", userId),
+ e);
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/wachter/service/WachterService.java b/src/main/java/dev/vality/wachter/service/WachterService.java
new file mode 100644
index 0000000..4bdada1
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/service/WachterService.java
@@ -0,0 +1,52 @@
+package dev.vality.wachter.service;
+
+import dev.vality.wachter.client.WachterClient;
+import dev.vality.wachter.security.AccessData;
+import dev.vality.wachter.security.AccessService;
+import dev.vality.wachter.mapper.ServiceMapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.thrift.TException;
+import org.apache.tomcat.util.http.fileupload.IOUtils;
+import org.springframework.stereotype.Service;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static dev.vality.wachter.utils.MethodNameReader.getMethodName;
+
+@RequiredArgsConstructor
+@Service
+public class WachterService {
+
+ private final KeycloakService keycloakService;
+ private final AccessService accessService;
+ private final WachterClient wachterClient;
+ private final ServiceMapper serviceMapper;
+
+
+ public byte[] process(HttpServletRequest request) throws IOException, TException {
+ byte[] contentData = getContentData(request);
+ var methodName = getMethodName(contentData);
+ var partyID = keycloakService.getPartyId();
+ var token = keycloakService.getAccessToken();
+ var service = serviceMapper.getService(request);
+ accessService.checkUserAccess(AccessData.builder()
+ .operationId(methodName)
+ .partyId(partyID)
+ .tokenExpirationSec(token.getExp())
+ .tokenId(token.getId())
+ .userId(token.getSubject())
+ .userEmail(token.getEmail())
+ .service(service)
+ .build());
+ return wachterClient.send(request, contentData, service);
+ }
+
+ private byte[] getContentData(HttpServletRequest request) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ IOUtils.copy(request.getInputStream(), baos);
+ return baos.toByteArray();
+ }
+
+}
diff --git a/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java b/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java
new file mode 100644
index 0000000..06c99ac
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/utils/DeadlineUtil.java
@@ -0,0 +1,120 @@
+package dev.vality.wachter.utils;
+
+import dev.vality.wachter.exeptions.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.get(0));
+ }
+
+ 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/java/dev/vality/wachter/utils/MethodNameReader.java b/src/main/java/dev/vality/wachter/utils/MethodNameReader.java
new file mode 100644
index 0000000..c96371a
--- /dev/null
+++ b/src/main/java/dev/vality/wachter/utils/MethodNameReader.java
@@ -0,0 +1,23 @@
+package dev.vality.wachter.utils;
+
+import org.apache.thrift.TException;
+import org.apache.thrift.protocol.TBinaryProtocol;
+import org.apache.thrift.protocol.TMessage;
+import org.apache.thrift.protocol.TProtocol;
+import org.apache.thrift.transport.TMemoryInputTransport;
+import org.apache.thrift.transport.TTransportException;
+
+public class MethodNameReader {
+
+ public static String getMethodName(byte[] thriftBody) throws TException {
+ TProtocol protocol = createProtocol(thriftBody);
+ TMessage message = protocol.readMessageBegin();
+ protocol.readMessageEnd();
+ return message.name;
+ }
+
+ private static TProtocol createProtocol(byte[] thriftBody) throws TTransportException {
+ return new TBinaryProtocol.Factory().getProtocol(new TMemoryInputTransport(thriftBody));
+ }
+
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..20bbc89
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,101 @@
+server:
+ port: '@server.port@'
+management:
+ security:
+ flag: false
+ server:
+ port: '@management.port@'
+ metrics:
+ tags:
+ application: '@project.name@'
+ export:
+ prometheus:
+ enabled: true
+ endpoint:
+ health:
+ show-details: always
+ metrics:
+ enabled: true
+ prometheus:
+ enabled: true
+ endpoints:
+ web:
+ exposure:
+ include: health,info,prometheus
+
+spring:
+ application:
+ name: '@project.name@'
+ output:
+ ansi:
+ enabled: always
+info:
+ version: '@project.version@'
+ stage: dev
+
+wachter:
+ serviceHeader: Service
+ services:
+ messages:
+ name: messages
+ url: http://localhost:8097/v1/messages
+ automaton:
+ name: automaton
+ url: http://localhost:8022/v1/automaton
+ repairer:
+ name: repairer
+ url: http://localhost:8022/v1/repair/withdrawal/session
+ claimManagement:
+ name: claimManagement
+ url: http://localhost:8097/v1/cm
+ fistfulAdmin:
+ name: fistfulAdmin
+ url: http://localhost:8022/v1/admin
+ fistfulStatistics:
+ name: fistfulStatistics
+ url: http://localhost:8022/fistful/stat
+ fileStorage:
+ name: fileStorage
+ url: http://localhost:8022/file_storage
+ deanonimus:
+ name: deanonimus
+ url: http://localhost:8022/deanonimus
+ merchantStatistics:
+ name: merchantStatistics
+ url: http://localhost:8022/stat
+ paymentProcessing:
+ name: paymentProcessing
+ url: http://localhost:8022/v1/processing/invoicing
+ domain:
+ name: domain
+ url: http://localhost:8022/v1/domain/repository
+
+
+http-client:
+ connectTimeout: 10000
+ connectionRequestTimeout: 10000
+ socketTimeout: 10000
+
+orgManager:
+ url: http://localhost:8022/org/v1/auth-context
+ networkTimeout: 5000
+
+bouncer:
+ url: http://localhost:8022/v1/arbiter
+ networkTimeout: 10000
+ deployment-id: production
+ auth-method: SessionToken
+ realm: external
+ rule-set-id: change_it
+ auth:
+ enabled: true
+
+keycloak:
+ realm: internal
+ auth-server-url: http://keycloak:8080/auth/
+ resource: koffing
+ not-before: 0
+ ssl-required: none
+ realm-public-key:
+
+auth.enabled: true
diff --git a/src/test/java/dev/vality/wachter/auth/JwtTokenTestConfiguration.java b/src/test/java/dev/vality/wachter/auth/JwtTokenTestConfiguration.java
new file mode 100644
index 0000000..af02898
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/auth/JwtTokenTestConfiguration.java
@@ -0,0 +1,45 @@
+package dev.vality.wachter.auth;
+
+import dev.vality.wachter.auth.utils.JwtTokenBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.security.*;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Properties;
+
+@Configuration
+public class JwtTokenTestConfiguration {
+
+ @Bean
+ public static PropertySourcesPlaceholderConfigurer properties(KeyPair keyPair)
+ throws NoSuchAlgorithmException, InvalidKeySpecException, IOException {
+ KeyFactory fact = KeyFactory.getInstance("RSA");
+ X509EncodedKeySpec spec = fact.getKeySpec(keyPair.getPublic(), X509EncodedKeySpec.class);
+ String publicKey = Base64.getEncoder().encodeToString(spec.getEncoded());
+ PropertySourcesPlaceholderConfigurer pspc = new PropertySourcesPlaceholderConfigurer();
+ Properties properties = new Properties();
+ properties.load(new ClassPathResource("application.yml").getInputStream());
+ properties.setProperty("keycloak.realm-public-key", publicKey);
+ pspc.setProperties(properties);
+ pspc.setLocalOverride(true);
+ return pspc;
+ }
+
+ @Bean
+ public JwtTokenBuilder jwtTokenBuilder(KeyPair keyPair) {
+ return new JwtTokenBuilder(keyPair.getPrivate());
+ }
+
+ @Bean
+ public KeyPair keyPair() throws GeneralSecurityException {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+ return keyGen.generateKeyPair();
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/auth/KeycloakOpenIdTestConfiguration.java b/src/test/java/dev/vality/wachter/auth/KeycloakOpenIdTestConfiguration.java
new file mode 100644
index 0000000..111a3fe
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/auth/KeycloakOpenIdTestConfiguration.java
@@ -0,0 +1,18 @@
+package dev.vality.wachter.auth;
+
+import dev.vality.wachter.auth.utils.JwtTokenBuilder;
+import dev.vality.wachter.auth.utils.KeycloakOpenIdStub;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class KeycloakOpenIdTestConfiguration {
+
+ @Bean
+ public KeycloakOpenIdStub keycloakOpenIdStub(@Value("${keycloak.auth-server-url}") String keycloakAuthServerUrl,
+ @Value("${keycloak.realm}") String keycloakRealm,
+ JwtTokenBuilder jwtTokenBuilder) {
+ return new KeycloakOpenIdStub(keycloakAuthServerUrl, keycloakRealm, jwtTokenBuilder);
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/auth/utils/JwtTokenBuilder.java b/src/test/java/dev/vality/wachter/auth/utils/JwtTokenBuilder.java
new file mode 100644
index 0000000..4c3447e
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/auth/utils/JwtTokenBuilder.java
@@ -0,0 +1,73 @@
+package dev.vality.wachter.auth.utils;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.security.PrivateKey;
+import java.time.Instant;
+import java.util.UUID;
+
+public class JwtTokenBuilder {
+
+ public static final String DEFAULT_USERNAME = "Darth Vader";
+
+ public static final String DEFAULT_EMAIL = "darkside-the-best@mail.com";
+
+ private final String userId;
+
+ private final String username;
+
+ private final String email;
+
+ private final PrivateKey privateKey;
+
+ public JwtTokenBuilder(PrivateKey privateKey) {
+ this(UUID.randomUUID().toString(), DEFAULT_USERNAME, DEFAULT_EMAIL, privateKey);
+ }
+
+ public JwtTokenBuilder(String userId, String username, String email, PrivateKey privateKey) {
+ this.userId = userId;
+ this.username = username;
+ this.email = email;
+ this.privateKey = privateKey;
+ }
+
+ public String generateJwtWithRoles(String issuer, String... roles) {
+ long iat = Instant.now().getEpochSecond();
+ long exp = iat + 60 * 10;
+ return generateJwtWithRoles(iat, exp, issuer, roles);
+ }
+
+ public String generateJwtWithRoles(long iat, long exp, String issuer, String... roles) {
+ String payload;
+ try {
+ payload = new JSONObject()
+ .put("jti", UUID.randomUUID().toString())
+ .put("exp", exp)
+ .put("nbf", "0")
+ .put("iat", iat)
+ .put("iss", issuer)
+ .put("aud", "private-api")
+ .put("sub", userId)
+ .put("typ", "Bearer")
+ .put("azp", "private-api")
+ .put("resource_access", new JSONObject()
+ .put("common-api", new JSONObject()
+ .put("roles", new JSONArray(roles))))
+ .put("preferred_username", username)
+ .put("email", email).toString();
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+
+ String jwt = Jwts.builder()
+ .setPayload(payload)
+ .signWith(SignatureAlgorithm.RS256, privateKey)
+ .compact();
+ return jwt;
+ }
+
+}
diff --git a/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java b/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java
new file mode 100644
index 0000000..00a4899
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java
@@ -0,0 +1,52 @@
+package dev.vality.wachter.auth.utils;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+
+public class KeycloakOpenIdStub {
+
+ private final String keycloakRealm;
+ private final String issuer;
+ private final String openidConfig;
+ private final JwtTokenBuilder jwtTokenBuilder;
+
+ public KeycloakOpenIdStub(String keycloakAuthServerUrl, String keycloakRealm, JwtTokenBuilder jwtTokenBuilder) {
+ this.keycloakRealm = keycloakRealm;
+ this.jwtTokenBuilder = jwtTokenBuilder;
+ this.issuer = keycloakAuthServerUrl + "/realms/" + keycloakRealm;
+ this.openidConfig = "{\n" +
+ " \"issuer\": \"" + issuer + "\",\n" +
+ " \"authorization_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/auth\",\n" +
+ " \"token_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/token\",\n" +
+ " \"token_introspection_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/token/introspect\",\n" +
+ " \"userinfo_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/userinfo\",\n" +
+ " \"end_session_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/logout\",\n" +
+ " \"jwks_uri\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/certs\",\n" +
+ " \"check_session_iframe\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/login-status-iframe.html\",\n" +
+ " \"registration_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/clients-registrations/openid-connect\",\n" +
+ " \"introspection_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
+ "/protocol/openid-connect/token/introspect\"\n" +
+ "}";
+ }
+
+ public void givenStub() {
+ stubFor(get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
+ .willReturn(aResponse()
+ .withHeader("Content-Type", "application/json")
+ .withBody(openidConfig)
+ )
+ );
+ }
+
+ public String generateJwt(String... roles) {
+ return jwtTokenBuilder.generateJwtWithRoles(issuer, roles);
+ }
+
+}
diff --git a/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java
new file mode 100644
index 0000000..103f0a5
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java
@@ -0,0 +1,35 @@
+package dev.vality.wachter.config;
+
+import dev.vality.wachter.WachterApplication;
+import dev.vality.wachter.auth.utils.KeycloakOpenIdStub;
+import org.junit.jupiter.api.BeforeAll;
+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.context.junit.jupiter.SpringExtension;
+
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ classes = {WachterApplication.class},
+ properties = {
+ "wiremock.server.baseUrl=http://localhost:${wiremock.server.port}",
+ "keycloak.auth-server-url=${wiremock.server.baseUrl}/auth"})
+@AutoConfigureMockMvc
+@AutoConfigureWireMock(port = 0)
+@ExtendWith(SpringExtension.class)
+public abstract class AbstractKeycloakOpenIdAsWiremockConfig {
+
+ @Autowired
+ private KeycloakOpenIdStub keycloakOpenIdStub;
+
+ @BeforeAll
+ public static void setUp(@Autowired KeycloakOpenIdStub keycloakOpenIdStub) throws Exception {
+ keycloakOpenIdStub.givenStub();
+ }
+
+ protected String generateSimpleJwt() {
+ return keycloakOpenIdStub.generateJwt();
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java
new file mode 100644
index 0000000..579c2d9
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java
@@ -0,0 +1,140 @@
+package dev.vality.wachter.controller;
+
+import dev.vality.bouncer.decisions.ArbiterSrv;
+import dev.vality.orgmanagement.AuthContextProviderSrv;
+import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig;
+import dev.vality.wachter.exeptions.AuthorizationException;
+import dev.vality.wachter.exeptions.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;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockitoAnnotations;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static dev.vality.wachter.testutil.ContextUtil.*;
+import static java.util.UUID.randomUUID;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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;
+
+@TestPropertySource(properties = {"auth.enabled=true"})
+class ErrorControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig {
+
+ @MockBean
+ public AuthContextProviderSrv.Iface orgManagerClient;
+ @MockBean
+ public ArbiterSrv.Iface bouncerClient;
+ @MockBean
+ private HttpClient httpClient;
+
+ @Autowired
+ private MockMvc mvc;
+
+ @Autowired
+ private TProtocolFactory protocolFactory;
+
+ private AutoCloseable mocks;
+
+ private Object[] preparedMocks;
+
+
+ @BeforeEach
+ public void init() {
+ mocks = MockitoAnnotations.openMocks(this);
+ preparedMocks = new Object[]{httpClient, orgManagerClient, bouncerClient};
+ }
+
+ @AfterEach
+ public void clean() throws Exception {
+ verifyNoMoreInteractions(preparedMocks);
+ mocks.close();
+ }
+
+
+ @Test
+ @SneakyThrows
+ void requestJudgementRestricted() {
+ when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
+ when(bouncerClient.judge(any(), any())).thenReturn(createJudgementRestricted());
+ mvc.perform(post("/wachter")
+ .header("Authorization", "Bearer " + generateSimpleJwt())
+ .header("Service", "messages")
+ .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 -> assertTrue(result.getResolvedException() instanceof AuthorizationException))
+ .andExpect(result -> assertEquals("No rights for darkside-the-best@mail.com to " +
+ "perform methodName in service messages",
+ result.getResolvedException().getMessage()));
+ verify(orgManagerClient, times(1)).getUserContext(any());
+ verify(bouncerClient, times(1)).judge(any(), any());
+ }
+
+ @Test
+ @SneakyThrows
+ void requestJudgementForbidden() {
+ when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
+ when(bouncerClient.judge(any(), any())).thenReturn(createJudgementForbidden());
+ mvc.perform(post("/wachter")
+ .header("Authorization", "Bearer " + generateSimpleJwt())
+ .header("Service", "messages")
+ .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 -> assertTrue(result.getResolvedException() instanceof AuthorizationException))
+ .andExpect(result -> assertEquals("No rights for darkside-the-best@mail.com to " +
+ "perform methodName in service messages",
+ result.getResolvedException().getMessage()));
+ verify(orgManagerClient, times(1)).getUserContext(any());
+ verify(bouncerClient, times(1)).judge(any(), any());
+ }
+
+ @Test
+ @SneakyThrows
+ void requestWithoutServiceHeader() {
+ mvc.perform(post("/wachter")
+ .header("Authorization", "Bearer " + generateSimpleJwt())
+ .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 -> assertTrue(result.getResolvedException() instanceof WachterException))
+ .andExpect(result -> assertEquals("Header \"Service\" must be set",
+ result.getResolvedException().getMessage()));
+ }
+
+ @Test
+ @SneakyThrows
+ void requestWithWrongServiceHeader() {
+ mvc.perform(post("/wachter")
+ .header("Authorization", "Bearer " + generateSimpleJwt())
+ .header("X-Request-ID", randomUUID())
+ .header("Service", "wrong")
+ .header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
+ .content(TMessageUtil.createTMessage(protocolFactory)))
+ .andDo(print())
+ .andExpect(status().is4xxClientError())
+ .andExpect(result -> assertTrue(result.getResolvedException() instanceof WachterException))
+ .andExpect(result -> assertEquals("Service \"wrong\" not found in configuration",
+ result.getResolvedException().getMessage()));
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java
new file mode 100644
index 0000000..e744db2
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/controller/WachterControllerTest.java
@@ -0,0 +1,86 @@
+package dev.vality.wachter.controller;
+
+import dev.vality.bouncer.decisions.ArbiterSrv;
+import dev.vality.orgmanagement.AuthContextProviderSrv;
+import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig;
+import dev.vality.wachter.testutil.TMessageUtil;
+import lombok.SneakyThrows;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.entity.StringEntity;
+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.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static dev.vality.wachter.testutil.ContextUtil.createContextFragment;
+import static dev.vality.wachter.testutil.ContextUtil.createJudgementAllowed;
+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;
+
+class WachterControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig {
+
+ @MockBean
+ public AuthContextProviderSrv.Iface orgManagerClient;
+ @MockBean
+ public ArbiterSrv.Iface bouncerClient;
+ @MockBean
+ private HttpClient httpClient;
+ @MockBean
+ private HttpResponse httpResponse;
+
+ @Autowired
+ private MockMvc mvc;
+
+ @Autowired
+ private TProtocolFactory protocolFactory;
+
+ private AutoCloseable mocks;
+
+ private Object[] preparedMocks;
+
+
+ @BeforeEach
+ public void init() {
+ mocks = MockitoAnnotations.openMocks(this);
+ preparedMocks = new Object[]{httpClient, orgManagerClient, bouncerClient};
+ }
+
+ @AfterEach
+ public void clean() throws Exception {
+ verifyNoMoreInteractions(preparedMocks);
+ mocks.close();
+ }
+
+ @Test
+ @SneakyThrows
+ void requestSuccess() {
+ when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
+ when(bouncerClient.judge(any(), any())).thenReturn(createJudgementAllowed());
+ when(httpResponse.getEntity()).thenReturn(new StringEntity(""));
+ when(httpClient.execute(any())).thenReturn(httpResponse);
+ mvc.perform(post("/wachter")
+ .header("Authorization", "Bearer " + generateSimpleJwt())
+ .header("Service", "messages")
+ .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(orgManagerClient, times(1)).getUserContext(any());
+ verify(bouncerClient, times(1)).judge(any(), any());
+ verify(httpClient, times(1)).execute(any());
+ }
+
+}
diff --git a/src/test/java/dev/vality/wachter/testutil/ContextUtil.java b/src/test/java/dev/vality/wachter/testutil/ContextUtil.java
new file mode 100644
index 0000000..6937f5e
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/testutil/ContextUtil.java
@@ -0,0 +1,61 @@
+package dev.vality.wachter.testutil;
+
+import dev.vality.bouncer.ctx.ContextFragment;
+import dev.vality.bouncer.decisions.*;
+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 org.apache.thrift.TSerializer;
+
+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));
+ }
+
+ @SneakyThrows
+ public static ContextFragment createContextFragment() {
+ ContextFragment fragment = ContextUtil.fillRequiredTBaseObject(new ContextFragment(), ContextFragment.class);
+ fragment.setContent(new TSerializer().serialize(new dev.vality.bouncer.context.v1.ContextFragment()));
+ return fragment;
+ }
+
+ public static Judgement createJudgementAllowed() {
+ Resolution resolution = new Resolution();
+ resolution.setAllowed(new ResolutionAllowed());
+ return new Judgement().setResolution(resolution);
+ }
+
+ public static Judgement createJudgementRestricted() {
+ Resolution resolution = new Resolution();
+ resolution.setRestricted(new ResolutionRestricted());
+ return new Judgement().setResolution(resolution);
+ }
+
+ public static Judgement createJudgementForbidden() {
+ Resolution resolution = new Resolution();
+ resolution.setForbidden(new ResolutionForbidden());
+ return new Judgement().setResolution(resolution);
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/testutil/TMessageUtil.java b/src/test/java/dev/vality/wachter/testutil/TMessageUtil.java
new file mode 100644
index 0000000..c17102b
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/testutil/TMessageUtil.java
@@ -0,0 +1,42 @@
+package dev.vality.wachter.testutil;
+
+
+import org.apache.thrift.TException;
+import org.apache.thrift.protocol.TMessage;
+import org.apache.thrift.protocol.TMessageType;
+import org.apache.thrift.protocol.TProtocol;
+import org.apache.thrift.protocol.TProtocolFactory;
+import org.apache.thrift.transport.TMemoryBuffer;
+import org.apache.thrift.transport.TTransportException;
+
+import java.util.Arrays;
+
+public class TMessageUtil {
+
+ public static byte[] createTMessage(TProtocolFactory protocolFactory) throws TException {
+ TMemoryBufferWithLength memoryBuffer = new TMemoryBufferWithLength(1024);
+ TProtocol protocol = protocolFactory.getProtocol(memoryBuffer);
+ protocol.writeMessageBegin(new TMessage("methodName", TMessageType.REPLY, 0));
+ protocol.writeMessageEnd();
+ return Arrays.copyOf(memoryBuffer.getArray(), memoryBuffer.length());
+ }
+
+ public static class TMemoryBufferWithLength extends TMemoryBuffer {
+ private int actualLength = 0;
+
+ public TMemoryBufferWithLength(int size) throws TTransportException {
+ super(size);
+ }
+
+ @Override
+ public void write(byte[] buf, int off, int len) {
+ super.write(buf, off, len);
+ actualLength += len;
+ }
+
+ @Override
+ public int length() {
+ return actualLength;
+ }
+ }
+}
diff --git a/src/test/java/dev/vality/wachter/util/MethodNameReaderTest.java b/src/test/java/dev/vality/wachter/util/MethodNameReaderTest.java
new file mode 100644
index 0000000..f731004
--- /dev/null
+++ b/src/test/java/dev/vality/wachter/util/MethodNameReaderTest.java
@@ -0,0 +1,25 @@
+package dev.vality.wachter.util;
+
+import dev.vality.wachter.testutil.TMessageUtil;
+import dev.vality.wachter.utils.MethodNameReader;
+import org.apache.thrift.TException;
+import org.apache.thrift.protocol.TProtocolFactory;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@SpringBootTest
+class MethodNameReaderTest {
+
+ @Autowired
+ private TProtocolFactory protocolFactory;
+
+ @Test
+ void readMethodName() throws TException {
+ byte[] message = TMessageUtil.createTMessage(protocolFactory);
+ assertEquals("methodName", MethodNameReader.getMethodName(message));
+ }
+
+}
diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..4bb5766
--- /dev/null
+++ b/src/test/resources/logback-test.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+