From fc68854413a8e7183d30015bcde6ba9b4d7543c6 Mon Sep 17 00:00:00 2001 From: malkoas <41993717+malkoas@users.noreply.github.com> Date: Wed, 29 Jun 2022 14:34:02 +0300 Subject: [PATCH] OPS-104: Initial implementation (#1) --- .github/settings.yml | 2 + .github/workflows/basic-linters.yml | 10 + .github/workflows/build.yml | 10 + .github/workflows/deploy.yml | 18 ++ .gitignore | 78 ++++++ pom.xml | 247 ++++++++++++++++++ renovate.json | 4 + .../vality/wachter/WachterApplication.java | 15 ++ .../vality/wachter/client/WachterClient.java | 41 +++ .../wachter/config/ApplicationConfig.java | 75 ++++++ .../vality/wachter/config/KeycloakConfig.java | 60 +++++ .../vality/wachter/config/SecurityConfig.java | 65 +++++ .../config/ThriftGatewayConfiguration.java | 18 ++ .../dev/vality/wachter/config/WebConfig.java | 103 ++++++++ .../config/properties/BouncerProperties.java | 26 ++ .../config/properties/KeycloakProperties.java | 34 +++ .../config/properties/WachterProperties.java | 30 +++ .../controller/ErrorControllerAdvice.java | 60 +++++ .../wachter/controller/WachterController.java | 26 ++ .../exeptions/AuthorizationException.java | 8 + .../wachter/exeptions/BouncerException.java | 12 + .../wachter/exeptions/DeadlineException.java | 8 + .../exeptions/OrgManagerException.java | 8 + .../wachter/exeptions/WachterException.java | 12 + .../vality/wachter/mapper/ServiceMapper.java | 32 +++ .../vality/wachter/security/AccessData.java | 19 ++ .../wachter/security/AccessService.java | 49 ++++ .../security/BouncerContextFactory.java | 68 +++++ .../wachter/service/BouncerService.java | 37 +++ .../wachter/service/KeycloakService.java | 22 ++ .../wachter/service/OrgManagerService.java | 24 ++ .../wachter/service/WachterService.java | 52 ++++ .../vality/wachter/utils/DeadlineUtil.java | 120 +++++++++ .../wachter/utils/MethodNameReader.java | 23 ++ src/main/resources/application.yml | 101 +++++++ .../auth/JwtTokenTestConfiguration.java | 45 ++++ .../auth/KeycloakOpenIdTestConfiguration.java | 18 ++ .../wachter/auth/utils/JwtTokenBuilder.java | 73 ++++++ .../auth/utils/KeycloakOpenIdStub.java | 52 ++++ ...bstractKeycloakOpenIdAsWiremockConfig.java | 35 +++ .../controller/ErrorControllerTest.java | 140 ++++++++++ .../controller/WachterControllerTest.java | 86 ++++++ .../vality/wachter/testutil/ContextUtil.java | 61 +++++ .../vality/wachter/testutil/TMessageUtil.java | 42 +++ .../wachter/util/MethodNameReaderTest.java | 25 ++ src/test/resources/logback-test.xml | 10 + 46 files changed, 2104 insertions(+) create mode 100644 .github/settings.yml create mode 100644 .github/workflows/basic-linters.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 renovate.json create mode 100644 src/main/java/dev/vality/wachter/WachterApplication.java create mode 100644 src/main/java/dev/vality/wachter/client/WachterClient.java create mode 100644 src/main/java/dev/vality/wachter/config/ApplicationConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/KeycloakConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/SecurityConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/ThriftGatewayConfiguration.java create mode 100644 src/main/java/dev/vality/wachter/config/WebConfig.java create mode 100644 src/main/java/dev/vality/wachter/config/properties/BouncerProperties.java create mode 100644 src/main/java/dev/vality/wachter/config/properties/KeycloakProperties.java create mode 100644 src/main/java/dev/vality/wachter/config/properties/WachterProperties.java create mode 100644 src/main/java/dev/vality/wachter/controller/ErrorControllerAdvice.java create mode 100644 src/main/java/dev/vality/wachter/controller/WachterController.java create mode 100644 src/main/java/dev/vality/wachter/exeptions/AuthorizationException.java create mode 100644 src/main/java/dev/vality/wachter/exeptions/BouncerException.java create mode 100644 src/main/java/dev/vality/wachter/exeptions/DeadlineException.java create mode 100644 src/main/java/dev/vality/wachter/exeptions/OrgManagerException.java create mode 100644 src/main/java/dev/vality/wachter/exeptions/WachterException.java create mode 100644 src/main/java/dev/vality/wachter/mapper/ServiceMapper.java create mode 100644 src/main/java/dev/vality/wachter/security/AccessData.java create mode 100644 src/main/java/dev/vality/wachter/security/AccessService.java create mode 100644 src/main/java/dev/vality/wachter/security/BouncerContextFactory.java create mode 100644 src/main/java/dev/vality/wachter/service/BouncerService.java create mode 100644 src/main/java/dev/vality/wachter/service/KeycloakService.java create mode 100644 src/main/java/dev/vality/wachter/service/OrgManagerService.java create mode 100644 src/main/java/dev/vality/wachter/service/WachterService.java create mode 100644 src/main/java/dev/vality/wachter/utils/DeadlineUtil.java create mode 100644 src/main/java/dev/vality/wachter/utils/MethodNameReader.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/dev/vality/wachter/auth/JwtTokenTestConfiguration.java create mode 100644 src/test/java/dev/vality/wachter/auth/KeycloakOpenIdTestConfiguration.java create mode 100644 src/test/java/dev/vality/wachter/auth/utils/JwtTokenBuilder.java create mode 100644 src/test/java/dev/vality/wachter/auth/utils/KeycloakOpenIdStub.java create mode 100644 src/test/java/dev/vality/wachter/config/AbstractKeycloakOpenIdAsWiremockConfig.java create mode 100644 src/test/java/dev/vality/wachter/controller/ErrorControllerTest.java create mode 100644 src/test/java/dev/vality/wachter/controller/WachterControllerTest.java create mode 100644 src/test/java/dev/vality/wachter/testutil/ContextUtil.java create mode 100644 src/test/java/dev/vality/wachter/testutil/TMessageUtil.java create mode 100644 src/test/java/dev/vality/wachter/util/MethodNameReaderTest.java create mode 100644 src/test/resources/logback-test.xml 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 @@ + + + + + + + + + +