diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9a0da63..abea618 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,3 +13,23 @@ include: variables: PUSH_TO_HARBOR: "true" MTR_TARGET_IMAGE: ${CI_PROJECT_NAME} + +build_deploy: + tags: + - otc_run_sysbox_m + variables: + DOCKER_HOST: "tcp://docker:2375" + DOCKER_TLS_CERTDIR: "" + DOCKER_DRIVER: overlay2 + services: + - name: 'dockerhub.devops.telekom.de/docker:20.10.23-dind' + command: [ '--tls=false', '--registry-mirror=https://dockerhub.devops.telekom.de' ] + alias: docker + +code_quality: + tags: + - otc_run_sysbox_m + services: + - name: 'dockerhub.devops.telekom.de/docker:20.10.12-dind' + command: ['--tls=false', '--host=tcp://0.0.0.0:2375', '--registry-mirror=https://dockerhub.devops.telekom.de'] + alias: docker \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index df16de7..64b3805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,145 @@ # Changelog +## [3.14.3](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.2...3.14.3) (2024-05-30) + + +### ๐ŸฆŠ CI/CD + +* **dhei-00000:** token span name evaluation ([0fe3506](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/0fe3506a62944c8b51eab84e6432eec6ef0ea74a)) + + +### ๐Ÿ›  Fixes + +* token span name evaluation ([fbbc297](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/fbbc297ab7d78a8ab79226f92742f98fc2a1bfb0)) + + +### Other + +* **release:** 3.14.2 ([f531bd2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f531bd2c5819dc4b208729e0dbe2518e4736c266)) + +## [3.14.2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.1...3.14.2) (2024-05-28) + + +### ๐Ÿ›  Fixes + +* DHEI-15534 redis tests for zone health ([54a7e92](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/54a7e92dc443cb773dd0d6b2bd45cf141147607a)) +* redis tests for zone health ([56bbd1c](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/56bbd1c47a243b1b2cb8bb52805e3ce6ab2ca79f)) + + +### Other + +* **release:** 3.14.1 ([4036226](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/4036226b8d2cc69aac0f8e86dfd9d90e2594c132)) + +## [3.14.1](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.0...3.14.1) (2024-05-21) + + +### ๐Ÿ›  Fixes + +* async subscribe to redis channel ([e7d86bd](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/e7d86bdda45cf2dfe08f68b3e91cb90c15d5b155)) +* DHEI-15534 async subscribe to redis channel ([652b07b](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/652b07b0ce8224d47a8354e35c7e272607b973f0)) + + +### Other + +* **release:** 3.14.0 ([f4841c2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f4841c2a42e74db3616962f889d0bbc4d4b1d3d1)) + +## [3.14.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.13.0...3.14.0) (2024-05-21) + + +### :scissors: Refactor + +* removed "Bearer " ([7c49271](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/7c492715ea8a4fbe66678eb1d6ff02fb1b407b90)) + + +### ๐Ÿš€ Features + +* Added x-token-exchange header handling ([160e12f](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/160e12fd071fd2cc8bad21ab5bc6163a09534d30)) +* changed from targetZone to currentZone (env variable) ([517af44](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/517af44f0ec6fb4f4990b386672d18f02658edc7)) +* DHEI-15383 Added x-token-exchange header handling ([f89a586](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f89a586a30c4517c029aaedf8e2fed3cf52e62cb)) + + +### Other + +* **release:** 3.13.0 ([7eba358](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/7eba358cd4b7ed7b9a0a7e76863172810bded0f6)) + +## [3.13.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.12.0...3.13.0) (2024-05-16) + + +### ๐Ÿš€ Features + +* DHEI-15534 Introduce redis based zone health ([13012f1](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/13012f1b279243041227bed38538377801d6a0d7)) +* DHEI-15534 Introduce redis based zone health ([8a580a0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/8a580a059c15f0ee4f11af032bcdba5c96f2797b)) + + +### Other + +* **release:** 3.12.0 ([8b675cc](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/8b675cc09935b6fcf5403b929985c30f2c4a02a3)) + +## [3.12.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.11.0...3.12.0) (2024-05-03) + + +### :scissors: Refactor + +* logging revised ([1d750c9](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/1d750c9a90b20a87c91b3c7fb8ea4ee004b6cdb5)) +* merge RoutingConfig to JumperConfig ([28591ef](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/28591efc6a9d460972c1892f97e1fc350768246e)) +* targetZone variable rename ([565c119](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/565c11946a905fd0a2db23c19223bd12dd42a221)) + + +### ๐Ÿ’ˆ Style + +* spotless friendly ([60958ae](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/60958aeb37773b63772280899f5e5e121239b1a9)) + + +### ๐ŸฆŠ CI/CD + +* **dhei-15533:** provider failover (routing part) ([cb08c2e](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/cb08c2eda4e2d70c12280c31a85a1b9a419a7c14)) + + +### ๐Ÿงช Tests + +* added tests for zone failover (routing part) ([dd31ce5](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/dd31ce5cc9ecb84668e5ea6db019e30d2974b669)) + + +### ๐Ÿš€ Features + +* audit log for failover, refactoring of duplicate methods ([40afd74](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/40afd740095f71758c3adcb7a6f7eb7a0e36ea18)) +* error span draft ([358f2e8](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/358f2e8f0140cdc3cf58746025c27964c4b59f2e)) +* support Spectre related jumperConfig for failover ([84eef25](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/84eef25ebc58d3363602ec033811787e3616f054)) + + +### ๐Ÿ›  Fixes + +* set scope for error span ([6db6030](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/6db60303d227b2d332b808aa8d21d52b23025fe1)) +* span names adjustment ([827f398](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/827f3985c9379dbc4abf1a3b23dcb59a7447521e)) + + +### Other + +* **release:** 3.11.0 ([be70acd](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/be70acd49ef655faeb4508934368f80d1f10eddb)) + +## [3.11.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.10.0...3.11.0) (2024-04-19) + + +### ๐ŸฆŠ CI/CD + +* **dhei-12345:** graceful shutdown, enable pool metrics by default, configurable oauth pool ([28edc5e](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/28edc5e8008ccc2b3f32700332b54547c4514a1e)) + + +### ๐Ÿš€ Features + +* configurable oauth, pool metrics enable by default ([5539f0f](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/5539f0fb5c4d19f24d4dcd60d696261316b97e58)) + + +### ๐Ÿ›  Fixes + +* increase server idle-timeout ([e6149f6](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/e6149f6332e1424c504f4a7994289d6f97f70007)) +* use graceful shutdown ([5d70e45](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/5d70e451ff79b9d2c4cc3f161a9713bdd55f35cc)) + + +### Other + +* **release:** 3.10.0 ([f9af081](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f9af0813c0b21e242a480a6b68421d8e2dedd6c8)) + ## [3.10.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.9.0...3.10.0) (2024-02-27) diff --git a/README.md b/README.md index b1d7e8c..b89bf2d 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ Headers expected on incoming side: } } `` + +#### X-Token-Exchange header +Spacegate allows for more flexibility by supporting the Authorization header token exchange. A consumer can set the "X-Token-Exchange" header containing an external provider specific token while calling an exposed external API. Jumper will then store value of the X-Token-Exchange header in the Authorization header field of the external API request and forward it to the provider. Available only for Spacegate. +`` ### Scenarios from used route perspective #### Proxy route Default route to be used for processing majority off traffic. All scenarios described within "token perspective" are supported. @@ -278,6 +282,13 @@ Request/Response events are created, if consumer/API combination matches. Creat } `` +### Zone failover +If enabled, Jumper ensures that in case of a zone failure requests to that zone are re-routed to the configured failover-zone. +Following picture depicts how Jumper processes requests in case of zone failover: + +![jumper request processing with failover!](pictures/jumper_request_processing_with_failover.png) +`` + ### Header enhancement/manipulation * X-Spacegate-Token - if any Spacegate is involved, incoming token is copied to X-Spacegate-Token header * X-Forwarded-Host/Port/Proto - to avoid additional reporting Kong + Jumper as separate hop, these headers needs to be adapted diff --git a/pictures/jumper_request_processing_with_failover.png b/pictures/jumper_request_processing_with_failover.png new file mode 100644 index 0000000..83d7d96 Binary files /dev/null and b/pictures/jumper_request_processing_with_failover.png differ diff --git a/pictures/jumper_request_processing_with_failover.png.license b/pictures/jumper_request_processing_with_failover.png.license new file mode 100644 index 0000000..3b62d58 --- /dev/null +++ b/pictures/jumper_request_processing_with_failover.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2023 Deutsche Telekom AG + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/pom.xml b/pom.xml index 99b048c..020e4d4 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ SPDX-License-Identifier: Apache-2.0 de.telekom.ei.jumper jumper-sse - 3.10.0 + 3.14.3 org.springframework.boot @@ -24,11 +24,13 @@ SPDX-License-Identifier: Apache-2.0 17 2021.0.8 + 6.3.2.RELEASE 7.14.0 2020.0.37 4.1.100.Final 5.9.3 1.10.0 + 1.19.8 @@ -167,6 +169,26 @@ SPDX-License-Identifier: Apache-2.0 test + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.data + spring-data-redis + + + + io.lettuce + lettuce-core + ${redis-lettuce.version} + + + org.springframework.retry + spring-retry + + org.junit.jupiter junit-jupiter-engine @@ -227,6 +249,24 @@ SPDX-License-Identifier: Apache-2.0 test + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.awaitility + awaitility + test + + com.diffplug.spotless @@ -284,6 +324,7 @@ SPDX-License-Identifier: Apache-2.0 maven-surefire-plugin ignore + true diff --git a/src/main/java/jumper/Application.java b/src/main/java/jumper/Application.java index 1490826..b8aa9a8 100644 --- a/src/main/java/jumper/Application.java +++ b/src/main/java/jumper/Application.java @@ -51,6 +51,7 @@ public RouteLocator proxyRoute( new HashSet<>( Arrays.asList( Constants.HEADER_JUMPER_CONFIG, + Constants.HEADER_ROUTING_CONFIG, Constants.HEADER_TOKEN_ENDPOINT, Constants.HEADER_REMOTE_API_URL, Constants.HEADER_ISSUER, diff --git a/src/main/java/jumper/Constants.java b/src/main/java/jumper/Constants.java index 1849de0..e65e0c0 100644 --- a/src/main/java/jumper/Constants.java +++ b/src/main/java/jumper/Constants.java @@ -15,6 +15,8 @@ public class Constants { public static final String HEADER_X_SPACEGATE_CLIENT_SECRET = "X-Spacegate-Client-Secret"; public static final String HEADER_X_SPACEGATE_SCOPE = "X-Spacegate-Scope"; public static final String HEADER_JUMPER_CONFIG = "jumper_config"; + public static final String HEADER_ROUTING_CONFIG = "routing_config"; + public static final String HEADER_ISSUER = "issuer"; public static final String HEADER_TOKEN_ENDPOINT = "token_endpoint"; public static final String HEADER_CLIENT_ID = "client_id"; @@ -44,6 +46,7 @@ public class Constants { public static final String HEADER_X_PUBSUB_SUBSCRIBER_ID = "x-pubsub-subscriber-id"; public static final String HEADER_B3 = "b3"; public static final String HEADER_X_SPACEGATE_TOKEN = "X-Spacegate-Token"; + public static final String HEADER_X_TOKEN_EXCHANGE = "X-Token-Exchange"; public static final String HEADER_API_BASE_PATH = "api_base_path"; public static final String HEADER_X_FORWARDED_HOST = "X-Forwarded-Host"; public static final String HEADER_X_FORWARDED_PORT = "X-Forwarded-Port"; @@ -51,6 +54,8 @@ public class Constants { public static final String HEADER_X_FORWARDED_PORT_PORT = "443"; public static final String HEADER_X_FORWARDED_PROTO_HTTPS = "https"; + public static final String HEADER_X_FAILOVER_SKIP_ZONE = "x-failover-skip-zone"; + public static final String QUERY_PARAM_LISTENER = "listener"; public static final String LISTENER_ROOT_PATH_PREFIX = "/listener"; public static final String PROXY_ROOT_PATH_PREFIX = "/proxy"; @@ -85,7 +90,7 @@ public class Constants { public static final String TOKEN_CLAIM_ACCESS_TOKEN_PUBLISHER_ID = "publisherId"; public static final String TOKEN_CLAIM_ACCESS_TOKEN_SUBSCRIBER_ID = "subscriberId"; - public static final List SPACE_ZONES = List.of("space", "spacex", "canis", "aries"); + public static final List SPACE_ZONES = List.of("space", "canis", "aries"); public static final String BASIC_AUTH_PROVIDER_KEY = "default"; diff --git a/src/main/java/jumper/config/HttpClientConfiguration.java b/src/main/java/jumper/config/HttpClientConfiguration.java index 24b1270..11a808a 100644 --- a/src/main/java/jumper/config/HttpClientConfiguration.java +++ b/src/main/java/jumper/config/HttpClientConfiguration.java @@ -34,6 +34,18 @@ public class HttpClientConfiguration { @Value("${CUSTOM_CIPHERS:}") List customCiphers; + @Value("${spring.cloud.oauth.connect-timeout:10000}") + private int oauthConnectTimeout; + + @Value("${spring.cloud.oauth.pool.max-life-time:300}") + private int oauthPoolMaxLifeTime; + + @Value("${spring.cloud.oauth.pool.max-idle-time:2}") + private int oauthPoolMaxIdleTime; + + @Value("${spring.cloud.oauth.pool.metrics:true}") + private boolean oauthPoolMetrics; + private final HttpClientProperties properties; @Bean @@ -53,7 +65,7 @@ public WebClient createWebClientForOauthTokenUtil() throws SSLException { HttpClient httpClient = HttpClient.create(getProvider()) .secure(t -> t.sslContext(sslContext)) - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000); + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, oauthConnectTimeout); httpClient = configureProxy(httpClient); return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build(); @@ -131,9 +143,10 @@ private ProxyProvider.Builder configureProxyProvider( private ConnectionProvider getProvider() { return ConnectionProvider.builder("oauth") .maxConnections(100) - .maxIdleTime(Duration.ofSeconds(5)) - .maxLifeTime(Duration.ofSeconds(60)) + .maxIdleTime(Duration.ofSeconds(oauthPoolMaxIdleTime)) + .maxLifeTime(Duration.ofSeconds(oauthPoolMaxLifeTime)) .pendingAcquireMaxCount(-1) + .metrics(oauthPoolMetrics) .build(); } } diff --git a/src/main/java/jumper/config/RedisConfig.java b/src/main/java/jumper/config/RedisConfig.java new file mode 100644 index 0000000..5611b60 --- /dev/null +++ b/src/main/java/jumper/config/RedisConfig.java @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.config; + +import io.lettuce.core.metrics.MicrometerOptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Slf4j +@Configuration +@ConditionalOnProperty(value = "jumper.zone.health.enabled", havingValue = "true") +public class RedisConfig { + + // in newer versions this should not prevent app startup on missing redis connection + // https://stackoverflow.com/questions/72436006/spring-boot-2-7-redis-pub-sub-fails-startup-on-missing-redis-connection + @Bean + public RedisMessageListenerContainer redisContainer( + LettuceConnectionFactory lettuceConnectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(lettuceConnectionFactory); + // the following causes the app to fail on startup if redis is not available + // container.addMessageListener(statusService, new ChannelTopic(statusService.getChannelKey())); + + return container; + } + + @Bean + MicrometerOptions micrometerOptions() { + return MicrometerOptions.builder().histogram(false).build(); + } +} diff --git a/src/main/java/jumper/config/SleuthConfiguration.java b/src/main/java/jumper/config/SleuthConfiguration.java index 489a531..34d709f 100644 --- a/src/main/java/jumper/config/SleuthConfiguration.java +++ b/src/main/java/jumper/config/SleuthConfiguration.java @@ -27,19 +27,20 @@ HttpResponseParser httpResponseParser() { @Bean(name = HttpClientRequestParser.NAME) HttpRequestParser httpRequestParser() { return (request, context, span) -> { - String url = request.url(); String xTardisTraceId = request.header(Constants.HEADER_X_TARDIS_TRACE_ID); - String spanName = "Provider"; - if (request.header(Constants.HEADER_CONSUMER_TOKEN) != null) { + String spanName; + if (request.path().contains("token")) { + spanName = "Idp"; + } else if (request.header(Constants.HEADER_CONSUMER_TOKEN) != null) { spanName = "Gateway"; + } else { + spanName = "Provider"; } span.name("Outgoing Request: " + spanName); - if (url != null) { - span.tag("http.url", url); - } + span.tag("http.url", request.url()); if (xTardisTraceId != null) { span.tag(Constants.HEADER_X_TARDIS_TRACE_ID, xTardisTraceId); diff --git a/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java b/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java index 4ef75d9..95985d9 100644 --- a/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java +++ b/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java @@ -19,6 +19,7 @@ import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.web.reactive.error.ErrorAttributes; import org.springframework.cloud.sleuth.CurrentTraceContext; +import org.springframework.cloud.sleuth.Span; import org.springframework.cloud.sleuth.Tracer; import org.springframework.cloud.sleuth.instrument.web.WebFluxSleuthOperators; import org.springframework.context.ApplicationContext; @@ -94,6 +95,12 @@ protected Map getErrorAttributes( ? request.headers().firstHeader(Constants.HEADER_X_TARDIS_TRACE_ID) : ""); + WebFluxSleuthOperators.withSpanInScope( + tracer, + currentTraceContext, + request.exchange(), + () -> writeErrorSpan(error, errorAttributes)); + // should also evaluate include options (stacktrace, message, bindingErrors) return errorAttributes; } @@ -198,4 +205,17 @@ private String determineMessage( } } } + + private void writeErrorSpan(Throwable error, Map errorAttributes) { + Span errorSpan = this.tracer.nextSpan().name("error").start(); + tracer.withSpan(errorSpan); + + errorSpan.tag("message", (String) errorAttributes.get("message")); + errorSpan.tag("http.status_code", errorAttributes.get("status").toString()); + errorSpan.tag("http.method", (String) errorAttributes.get("method")); + errorSpan.tag("x-tardis-traceid", (String) errorAttributes.get("tardisTraceId")); + errorSpan.error(error); + + errorSpan.end(); + } } diff --git a/src/main/java/jumper/filter/RequestFilter.java b/src/main/java/jumper/filter/RequestFilter.java index 82c0787..96ba2f8 100644 --- a/src/main/java/jumper/filter/RequestFilter.java +++ b/src/main/java/jumper/filter/RequestFilter.java @@ -8,7 +8,7 @@ import java.net.URI; import java.net.URISyntaxException; -import java.util.HashMap; +import java.util.List; import java.util.Objects; import java.util.Optional; import jumper.Constants; @@ -18,11 +18,10 @@ import jumper.model.config.OauthCredentials; import jumper.model.request.IncomingRequest; import jumper.model.request.JumperInfoRequest; -import jumper.service.BasicAuthUtil; -import jumper.service.HeaderUtil; -import jumper.service.OauthTokenUtil; +import jumper.service.*; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -43,16 +42,21 @@ @Component @Slf4j +@Setter public class RequestFilter extends AbstractGatewayFilterFactory { private final CurrentTraceContext currentTraceContext; private final Tracer tracer; private final OauthTokenUtil oauthTokenUtil; private final BasicAuthUtil basicAuthUtil; + private final ZoneHealthCheckService zoneHealthCheckService; @Value("${jumper.issuer.url}") private String localIssuerUrl; + @Value("${jumper.zone.name}") + private String currentZone; + @Value("${spring.application.name}") private String applicationName; @@ -63,12 +67,14 @@ public RequestFilter( CurrentTraceContext currentTraceContext, Tracer tracer, OauthTokenUtil oauthTokenUtil, - BasicAuthUtil basicAuthUtil) { + BasicAuthUtil basicAuthUtil, + ZoneHealthCheckService zoneHealthCheckService) { super(Config.class); this.currentTraceContext = currentTraceContext; this.tracer = tracer; this.oauthTokenUtil = oauthTokenUtil; this.basicAuthUtil = basicAuthUtil; + this.zoneHealthCheckService = zoneHealthCheckService; } @Override @@ -81,17 +87,36 @@ public GatewayFilter apply(Config config) { exchange, () -> { ServerHttpRequest request = exchange.getRequest(); + addTracingInfo(request); + + JumperConfig jumperConfig; + // failover logic if routing_config header present + if (request.getHeaders().containsKey(Constants.HEADER_ROUTING_CONFIG)) { + // evaluate routingConfig for failover scenario + List jumperConfigList = + JumperConfig.parseJumperConfigListFrom(request); + log.debug("failover case, routing_config: {}", jumperConfigList); + jumperConfig = + evaluateTargetZone( + jumperConfigList, + request.getHeaders().getFirst(Constants.HEADER_X_FAILOVER_SKIP_ZONE)); + jumperConfig.fillProcessingInfo(request); + log.debug("failover case, enhanced jumper_config: {}", jumperConfig); - // checking to prevent later nullPointer on inconsistent state from Kong - if (!request.getHeaders().containsKey(Constants.HEADER_REMOTE_API_URL)) { - throw new RuntimeException( - "missing mandatory header " + Constants.HEADER_REMOTE_API_URL); } - // Prepare and extract JumperConfigValues - JumperConfig jumperConfig = JumperConfig.parseConfigFrom(request); - log.debug("JumperConfig encodedAsBase64: {}", JumperConfig.toBase64(jumperConfig)); - log.debug("JumperConfig decoded: {}", jumperConfig); + // no failover + else { + // checking to prevent later nullPointer on inconsistent state from Kong + if (!request.getHeaders().containsKey(Constants.HEADER_REMOTE_API_URL)) { + throw new RuntimeException( + "missing mandatory header " + Constants.HEADER_REMOTE_API_URL); + } + + // Prepare and extract JumperConfigValues + jumperConfig = JumperConfig.parseAndFillJumperConfigFrom(request); + log.debug("JumperConfig decoded: {}", jumperConfig); + } // calculate routing stuff and add it to exchange and JumperConfig calculateRoutingStuff(request, exchange, config.getRoutePathPrefix(), jumperConfig); @@ -104,6 +129,11 @@ public GatewayFilter apply(Config config) { .put(Constants.HEADER_JUMPER_CONFIG, JumperConfig.toBase64(jumperConfig)); } + // write audit log if needed + if (jumperConfig.getAuditLog()) { + AuditLogService.writeFailoverAuditLog(jumperConfig); + } + // handle request Optional jumperInfoRequest = initializeJumperInfoRequest(jumperConfig); @@ -116,7 +146,7 @@ public GatewayFilter apply(Config config) { // GW-2-GW MESH TOKEN GENERATION log.debug("----------------GATEWAY MESH-------------"); jumperInfoRequest.ifPresent( - i -> i.setInfoScenario(false, false, true, false, false)); + i -> i.setInfoScenario(false, false, true, false, false, false)); TokenInfo meshTokenInfo = oauthTokenUtil.getInternalMeshAccessToken(jumperConfig); @@ -137,100 +167,112 @@ public GatewayFilter apply(Config config) { } else { // ALL NON MESH SCENARIOS - Optional basicAuthCredentials = - jumperConfig.getBasicAuthCredentials(); - if (basicAuthCredentials.isPresent()) { - // External Authorization with BasicAuth - log.debug("----------------BASIC AUTH HEADER-------------"); - jumperInfoRequest.ifPresent( - i -> i.setInfoScenario(false, false, false, false, true)); + if (request.getHeaders().containsKey(Constants.HEADER_X_TOKEN_EXCHANGE) + && isSpaceZone(currentZone)) { - String encodedBasicAuth = - basicAuthUtil.encodeBasicAuth( - basicAuthCredentials.get().getUsername(), - basicAuthCredentials.get().getPassword()); + log.debug("----------------X-TOKEN-EXCHANGE HEADER-------------"); + jumperInfoRequest.ifPresent( + i -> i.setInfoScenario(false, false, false, false, false, true)); - HeaderUtil.addHeader( - exchange, - Constants.HEADER_AUTHORIZATION, - Constants.BASIC + " " + encodedBasicAuth); + addXtokenExchange(exchange); } else { - if (Objects.nonNull(jumperConfig.getExternalTokenEndpoint())) { - // External Authorization with OAuth - log.debug("----------------EXTERNAL AUTHORIZATION-------------"); - log.debug( - "Remote TokenEndpoint is set to: {}", - jumperConfig.getExternalTokenEndpoint()); + Optional basicAuthCredentials = + jumperConfig.getBasicAuthCredentials(); + if (basicAuthCredentials.isPresent()) { + // External Authorization with BasicAuth + log.debug("----------------BASIC AUTH HEADER-------------"); jumperInfoRequest.ifPresent( - i -> i.setInfoScenario(false, false, false, true, false)); + i -> i.setInfoScenario(false, false, false, false, true, false)); - Optional oauthCredentials = - jumperConfig.getOauthCredentials(); - if (oauthCredentials.isPresent() - && StringUtils.isNotBlank(oauthCredentials.get().getGrantType())) { + String encodedBasicAuth = + basicAuthUtil.encodeBasicAuth( + basicAuthCredentials.get().getUsername(), + basicAuthCredentials.get().getPassword()); - TokenInfo tokenInfo = - oauthTokenUtil.getAccessTokenWithOauthCredentialsObject( - jumperConfig.getExternalTokenEndpoint(), - oauthCredentials.get(), - jumperConfig.getConsumer()); + HeaderUtil.addHeader( + exchange, + Constants.HEADER_AUTHORIZATION, + Constants.BASIC + " " + encodedBasicAuth); + + } else { + + if (Objects.nonNull(jumperConfig.getExternalTokenEndpoint())) { + // External Authorization with OAuth + log.debug("----------------EXTERNAL AUTHORIZATION-------------"); + log.debug( + "Remote TokenEndpoint is set to: {}", + jumperConfig.getExternalTokenEndpoint()); + jumperInfoRequest.ifPresent( + i -> i.setInfoScenario(false, false, false, true, false, false)); + + Optional oauthCredentials = + jumperConfig.getOauthCredentials(); + if (oauthCredentials.isPresent() + && StringUtils.isNotBlank(oauthCredentials.get().getGrantType())) { + + TokenInfo tokenInfo = + oauthTokenUtil.getAccessTokenWithOauthCredentialsObject( + jumperConfig.getExternalTokenEndpoint(), + oauthCredentials.get(), + jumperConfig.getConsumer()); + + HeaderUtil.addHeader( + exchange, + Constants.HEADER_AUTHORIZATION, + Constants.BEARER + " " + tokenInfo.getAccessToken()); + + } else { + getAccessTokenFromExternalIdpLegacy(exchange, jumperConfig); + } + + } else if (Boolean.FALSE.equals(jumperConfig.getAccessTokenForwarding())) { + // Enhanced Last Mile Security Token scenario + log.debug("----------------LAST MILE SECURITY (ONE TOKEN)-------------"); + jumperInfoRequest.ifPresent( + i -> i.setInfoScenario(true, true, false, false, false, false)); + + String enhancedLastmileSecurityToken = + oauthTokenUtil.generateEnhancedLastMileGatewayToken( + jumperConfig, + String.valueOf(request.getMethod()), + localIssuerUrl + "/" + jumperConfig.getRealmName(), + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID), + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID), + false); HeaderUtil.addHeader( exchange, Constants.HEADER_AUTHORIZATION, - Constants.BEARER + " " + tokenInfo.getAccessToken()); + Constants.BEARER + " " + enhancedLastmileSecurityToken); + log.debug("lastMileSecurityToken: " + enhancedLastmileSecurityToken); } else { - getAccessTokenFromExternalIdpLegacy(exchange, jumperConfig); - } - - } else if (Boolean.FALSE.equals(jumperConfig.getAccessTokenForwarding())) { - // Enhanced Last Mile Security Token scenario - log.debug("----------------LAST MILE SECURITY (ONE TOKEN)-------------"); - jumperInfoRequest.ifPresent( - i -> i.setInfoScenario(true, true, false, false, false)); - - String enhancedLastmileSecurityToken = - oauthTokenUtil.generateEnhancedLastMileGatewayToken( - jumperConfig, - String.valueOf(request.getMethod()), - localIssuerUrl + "/" + jumperConfig.getRealmName(), - HeaderUtil.getLastValueFromHeaderField( - request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID), - HeaderUtil.getLastValueFromHeaderField( - request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID), - false); - - HeaderUtil.addHeader( - exchange, - Constants.HEADER_AUTHORIZATION, - Constants.BEARER + " " + enhancedLastmileSecurityToken); - log.debug("lastMileSecurityToken: " + enhancedLastmileSecurityToken); + // (Legacy) Last Mile Security Token scenario + log.debug("----------------LAST MILE SECURITY (LEGACY)-------------"); + jumperInfoRequest.ifPresent( + i -> i.setInfoScenario(true, false, false, false, false, false)); + + String legacyLastmileSecurityToken = + oauthTokenUtil.generateEnhancedLastMileGatewayToken( + jumperConfig, + String.valueOf(request.getMethod()), + localIssuerUrl + "/" + jumperConfig.getRealmName(), + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID), + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID), + true); - } else { - // (Legacy) Last Mile Security Token scenario - log.debug("----------------LAST MILE SECURITY (LEGACY)-------------"); - jumperInfoRequest.ifPresent( - i -> i.setInfoScenario(true, false, false, false, false)); - - String legacyLastmileSecurityToken = - oauthTokenUtil.generateEnhancedLastMileGatewayToken( - jumperConfig, - String.valueOf(request.getMethod()), - localIssuerUrl + "/" + jumperConfig.getRealmName(), - HeaderUtil.getLastValueFromHeaderField( - request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID), - HeaderUtil.getLastValueFromHeaderField( - request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID), - true); - - HeaderUtil.addHeader( - exchange, - Constants.HEADER_LASTMILE_SECURITY_TOKEN, - Constants.BEARER + " " + legacyLastmileSecurityToken); - log.debug("lastMileSecurityToken: " + legacyLastmileSecurityToken); + HeaderUtil.addHeader( + exchange, + Constants.HEADER_LASTMILE_SECURITY_TOKEN, + Constants.BEARER + " " + legacyLastmileSecurityToken); + log.debug("lastMileSecurityToken: " + legacyLastmileSecurityToken); + } } } } @@ -251,7 +293,7 @@ public GatewayFilter apply(Config config) { log.info("logging request: {}", value("jumperInfo", infoRequest)); }); - addTracingInfo(request); + tracer.currentSpan().event("jrqf"); }); return chain.filter(exchange); @@ -263,7 +305,6 @@ private Optional initializeJumperInfoRequest(JumperConfig jum if (log.isInfoEnabled()) { JumperInfoRequest jumperInfoRequest = new JumperInfoRequest(); - jumperInfoRequest.setEnvironment(jumperConfig.getEnvName()); return Optional.of(jumperInfoRequest); } @@ -273,15 +314,12 @@ private Optional initializeJumperInfoRequest(JumperConfig jum private IncomingRequest createIncomingRequest( JumperConfig jumperConfig, ServerHttpRequest request) { IncomingRequest incReq = new IncomingRequest(); + incReq.setConsumer(jumperConfig.getConsumer()); incReq.setBasePath(jumperConfig.getApiBasePath()); - incReq.setHost(jumperConfig.getRemoteApiUrl()); - incReq.setMethod(String.valueOf(request.getMethod())); - incReq.setResource(jumperConfig.getRoutingPath()); + incReq.setFinalApiUrl(jumperConfig.getFinalApiUrl()); + incReq.setMethod((request.getMethodValue())); + incReq.setRequestPath(jumperConfig.getRequestPath()); - HashMap logEntries = new HashMap<>(); - logEntries.put("Thread name", Thread.currentThread().getName()); - - incReq.setLogEntries(logEntries); return incReq; } @@ -318,6 +356,7 @@ private void calculateRoutingStuff( // add calculated stuff to jumperConfig jumperConfig.setRequestPath(requestPath); jumperConfig.setRoutingPath(routingPath); + jumperConfig.setFinalApiUrl(finalApiUrl); } catch (URISyntaxException e) { throw new RuntimeException("can not construct URL from " + request.getURI(), e); @@ -422,12 +461,50 @@ private static String determineClientId( return clientId; } + private JumperConfig evaluateTargetZone( + List jumperConfigList, String forceSkipZone) { + for (JumperConfig jc : jumperConfigList) { + // secondary route, failover in place => audit logs + if (StringUtils.isEmpty(jc.getTargetZoneName())) { + jc.setAuditLog(true); + return jc; + } + // targetZoneName present, check it against force skip header and zones state + // map + if (!(jc.getTargetZoneName().equalsIgnoreCase(forceSkipZone) + || !zoneHealthCheckService.getZoneHealth(jc.getTargetZoneName()))) { + return jc; + } + } + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "Non of defined failover zones available"); + } + private void checkForSpaceZone(ServerWebExchange exchange, String zone, String token) { - if (zone != null && Constants.SPACE_ZONES.contains(zone)) { + if (isSpaceZone(zone)) { HeaderUtil.addHeader(exchange, Constants.HEADER_X_SPACEGATE_TOKEN, token); } } + private void addXtokenExchange(ServerWebExchange exchange) { + + HeaderUtil.addHeader( + exchange, + Constants.HEADER_AUTHORIZATION, + HeaderUtil.getFirstValueFromHeaderField( + exchange.getRequest(), Constants.HEADER_X_TOKEN_EXCHANGE)); + + log.debug( + "x-token-exchange: " + + HeaderUtil.getFirstValueFromHeaderField( + exchange.getRequest(), Constants.HEADER_X_TOKEN_EXCHANGE)); + } + + private boolean isSpaceZone(String zone) { + + return zone != null && Constants.SPACE_ZONES.contains(zone); + } + private void addTracingInfo(ServerHttpRequest request) { String xTardisTraceId = @@ -437,7 +514,6 @@ private void addTracingInfo(ServerHttpRequest request) { Span incomingRequestSpan = tracer.currentSpan(); incomingRequestSpan.name("Incoming Request"); - // todo would prefer to set NA for this (chunked transfer?) scenario incomingRequestSpan.tag("message.size", Objects.requireNonNullElse(contentLength, "0")); if (xTardisTraceId != null) { @@ -445,7 +521,6 @@ private void addTracingInfo(ServerHttpRequest request) { } incomingRequestSpan.remoteServiceName(applicationName); - incomingRequestSpan.event("jrqf"); } @AllArgsConstructor diff --git a/src/main/java/jumper/filter/ResponseFilter.java b/src/main/java/jumper/filter/ResponseFilter.java index b1c84de..6f9e2c2 100644 --- a/src/main/java/jumper/filter/ResponseFilter.java +++ b/src/main/java/jumper/filter/ResponseFilter.java @@ -6,6 +6,7 @@ import static net.logstash.logback.argument.StructuredArguments.value; +import java.util.Objects; import jumper.model.response.IncomingResponse; import jumper.model.response.JumperInfoResponse; import lombok.AllArgsConstructor; @@ -15,6 +16,7 @@ import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.OrderedGatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; import org.springframework.cloud.sleuth.CurrentTraceContext; import org.springframework.cloud.sleuth.Span; import org.springframework.cloud.sleuth.Tracer; @@ -54,31 +56,37 @@ public GatewayFilter apply(Config config) { ServerHttpResponse response = exchange.getResponse(); ServerHttpRequest request = exchange.getRequest(); - if (isLogLevelEnabled()) { + if (log.isDebugEnabled()) { JumperInfoResponse jumperInfoResponse = new JumperInfoResponse(); IncomingResponse incomingResponse = new IncomingResponse(); - incomingResponse.setPath(request.getPath().toString()); + incomingResponse.setHost( + Objects.requireNonNull( + exchange.getAttribute( + ServerWebExchangeUtils + .GATEWAY_REQUEST_URL_ATTR)) + .toString()); incomingResponse.setHttpStatusCode( - response.getStatusCode().value()); - + Objects.requireNonNull(response.getStatusCode()).value()); + incomingResponse.setMethod(request.getMethodValue()); + incomingResponse.setRequestHeaders( + request.getHeaders().toSingleValueMap()); jumperInfoResponse.setIncomingResponse(incomingResponse); - log.info( + log.debug( "logging response: {}", value("jumperInfo", jumperInfoResponse)); } - Long contentLength = response.getHeaders().getContentLength(); + long contentLength = response.getHeaders().getContentLength(); Span span = tracer.currentSpan(); - if (contentLength == null - || contentLength.toString().equals("-1")) { + if (Long.toString(contentLength).equals("-1")) { span.tag("message.size_response", "0"); } else { - span.tag("message.size_response", contentLength.toString()); + span.tag("message.size_response", Long.toString(contentLength)); } span.event("jrpf"); @@ -86,10 +94,6 @@ public GatewayFilter apply(Config config) { RequestFilter.REQUEST_FILTER_ORDER); } - private boolean isLogLevelEnabled() { - return log.isInfoEnabled(); - } - @Getter @Setter @AllArgsConstructor diff --git a/src/main/java/jumper/filter/SpectreRequestFilter.java b/src/main/java/jumper/filter/SpectreRequestFilter.java index 33f8afc..8e67066 100644 --- a/src/main/java/jumper/filter/SpectreRequestFilter.java +++ b/src/main/java/jumper/filter/SpectreRequestFilter.java @@ -41,7 +41,7 @@ public GatewayFilter apply(Config config) { request.getHeaders().toSingleValueMap(), requestBody); - JumperConfig jc = JumperConfig.parseConfigFrom(exchange); + JumperConfig jc = JumperConfig.parseJumperConfigFrom(exchange); if (!jc.isListenerMatched()) { return chain.filter(exchange.mutate().request(request).build()); } diff --git a/src/main/java/jumper/filter/SpectreResponseFilter.java b/src/main/java/jumper/filter/SpectreResponseFilter.java index 535ad18..a9afb07 100644 --- a/src/main/java/jumper/filter/SpectreResponseFilter.java +++ b/src/main/java/jumper/filter/SpectreResponseFilter.java @@ -55,7 +55,7 @@ public GatewayFilter apply(AbstractGatewayFilterFactory.NameConfig config) { responseBody); // use jumperConfig passed with exchange - JumperConfig jumperConfig = JumperConfig.parseConfigFrom(exchange); + JumperConfig jumperConfig = JumperConfig.parseJumperConfigFrom(exchange); if (jumperConfig.isListenerMatched()) { RouteListener listener = jumperConfig.getRouteListener().get(jumperConfig.getConsumer()); diff --git a/src/main/java/jumper/job/RedisHealthCheck.java b/src/main/java/jumper/job/RedisHealthCheck.java new file mode 100644 index 0000000..5c9dffc --- /dev/null +++ b/src/main/java/jumper/job/RedisHealthCheck.java @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.job; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.concurrent.atomic.AtomicInteger; +import jumper.config.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.data.redis.connection.RedisClusterConnection; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisConnectionUtils; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Slf4j +@ConditionalOnBean(RedisConfig.class) +@Component +@EnableScheduling +public class RedisHealthCheck { + + private static final String CONNECT = "CONNECTED"; + + private final RedisConnectionFactory redisConnectionFactory; + private final AtomicInteger redisConnectionStatus; + + public RedisHealthCheck(RedisConnectionFactory connectionFactory, MeterRegistry meterRegistry) { + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); + this.redisConnectionFactory = connectionFactory; + + this.redisConnectionStatus = new AtomicInteger(0); + Gauge.builder("zone.health.redis.connection", () -> redisConnectionStatus) + .description("redis connection status") + .tag("status", CONNECT) + .register(meterRegistry); + } + + @Scheduled(fixedRateString = "${jumper.zone.health.redis.checkConnectionInterval}") + public void refreshZoneHealthStatus() { + try { + this.doHealthCheck(); + gaugeEvaluation(true); + } catch (Exception e) { + log.error("Error while checking Redis health", e); + gaugeEvaluation(false); + } + } + + private void doHealthCheck() { + RedisConnection connection = RedisConnectionUtils.getConnection(this.redisConnectionFactory); + try { + this.doHealthCheck(connection); + } finally { + RedisConnectionUtils.releaseConnection(connection, this.redisConnectionFactory); + } + } + + private void doHealthCheck(RedisConnection connection) { + if (connection instanceof RedisClusterConnection) { + ((RedisClusterConnection) connection).clusterGetClusterInfo(); + } else { + connection.info("server"); + } + } + + private void gaugeEvaluation(Boolean isConnected) { + redisConnectionStatus.set(isConnected ? 1 : 0); + } +} diff --git a/src/main/java/jumper/model/config/AuditLog.java b/src/main/java/jumper/model/config/AuditLog.java new file mode 100644 index 0000000..a82f3f1 --- /dev/null +++ b/src/main/java/jumper/model/config/AuditLog.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.model.config; + +import java.util.Date; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AuditLog { + private Date timestamp; + private String consumer; + private String consumerOriginZone; + private String apiBasePath; + private String upstreamPath; +} diff --git a/src/main/java/jumper/model/config/HealthStatus.java b/src/main/java/jumper/model/config/HealthStatus.java new file mode 100644 index 0000000..6f67bce --- /dev/null +++ b/src/main/java/jumper/model/config/HealthStatus.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.model.config; + +public enum HealthStatus { + HEALTHY, + UNHEALTHY +} diff --git a/src/main/java/jumper/model/config/JumperConfig.java b/src/main/java/jumper/model/config/JumperConfig.java index e8bce9c..f2000e8 100644 --- a/src/main/java/jumper/model/config/JumperConfig.java +++ b/src/main/java/jumper/model/config/JumperConfig.java @@ -6,16 +6,15 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Header; import io.jsonwebtoken.Jwt; -import java.util.Base64; -import java.util.HashMap; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import jumper.Constants; import jumper.service.HeaderUtil; import jumper.service.OauthTokenUtil; @@ -33,6 +32,7 @@ public class JumperConfig { private HashMap routeListener; private GatewayClient gatewayClient; + String targetZoneName; String scopes; String apiBasePath; String consumer; @@ -40,13 +40,22 @@ public class JumperConfig { String consumerOriginZone; String consumerToken; String externalTokenEndpoint; + + @JsonProperty("issuer") String internalTokenEndpoint; + String clientId; String clientSecret; Boolean accessTokenForwarding; + + @JsonProperty("realm") String realmName; + String remoteApiUrl; + + @JsonProperty("environment") String envName; + String xSpacegateClientId; String xSpacegateClientSecret; String xSpacegateScope; @@ -54,12 +63,15 @@ public class JumperConfig { // calculated routing stuff within requestFilter String requestPath; String routingPath; + String finalApiUrl; + + Boolean auditLog = false; @JsonIgnore - public static String toBase64(JumperConfig jc) { + public static String toBase64(Object o) { String jsonConfigBase64 = null; try { - String decodedJson = new ObjectMapper().writeValueAsString(jc); + String decodedJson = new ObjectMapper().writeValueAsString(o); jsonConfigBase64 = Base64.getEncoder().encodeToString(decodedJson.getBytes()); } catch (JsonProcessingException e) { e.printStackTrace(); @@ -69,57 +81,59 @@ public static String toBase64(JumperConfig jc) { } @JsonIgnore - public static JumperConfig fromBase64(String jsonConfigBase64) { + private static T fromBase64(String jsonConfigBase64, TypeReference typeReference) { String decodedJson = new String(Base64.getDecoder().decode(jsonConfigBase64.getBytes())); try { return new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .readValue(decodedJson, JumperConfig.class); + .readValue(decodedJson, typeReference); } catch (JsonProcessingException e) { - e.printStackTrace(); + throw new RuntimeException("can not base64decode header: " + jsonConfigBase64); + } + } + + private static JumperConfig fromBase64(String jsonConfigBase64) { + if (StringUtils.isNotBlank(jsonConfigBase64)) { + return JumperConfig.fromBase64(jsonConfigBase64, new TypeReference<>() {}); + } else { return new JumperConfig(); } } @JsonIgnore - public void fillWithLegacyHeaders(ServerHttpRequest request) { + private void fillWithLegacyHeaders(ServerHttpRequest request) { - setScopes(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SCOPES)); - setApiBasePath(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_API_BASE_PATH)); - setExternalTokenEndpoint( - HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_TOKEN_ENDPOINT)); + // proxy + setRemoteApiUrl( + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_REMOTE_API_URL)); // also real setInternalTokenEndpoint( HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ISSUER)); - setClientId(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_ID)); + setClientId( + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_CLIENT_ID)); // also external setClientSecret( - HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SECRET)); + HeaderUtil.getLastValueFromHeaderField( + request, Constants.HEADER_CLIENT_SECRET)); // also external + // real + setApiBasePath(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_API_BASE_PATH)); if (request.getHeaders().containsKey(Constants.HEADER_ACCESS_TOKEN_FORWARDING)) { setAccessTokenForwarding( Boolean.valueOf( HeaderUtil.getLastValueFromHeaderField( request, Constants.HEADER_ACCESS_TOKEN_FORWARDING))); } - - setConsumerToken( - HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION)); - Jwt consumerTokenClaims = - OauthTokenUtil.getAllClaimsFromToken( - OauthTokenUtil.getTokenWithoutSignature(consumerToken)); - setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class)); - setConsumerOriginStargate( - consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class)); - setConsumerOriginZone( - consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class)); - setRealmName(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_REALM)); if (StringUtils.isBlank(getRealmName())) { setRealmName(Constants.DEFAULT_REALM); } - - setRemoteApiUrl( - HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_REMOTE_API_URL)); setEnvName(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ENVIRONMENT)); + + // external oauth + setScopes(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SCOPES)); + setExternalTokenEndpoint( + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_TOKEN_ENDPOINT)); setXSpacegateClientId( HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_X_SPACEGATE_CLIENT_ID)); setXSpacegateClientSecret( @@ -127,22 +141,60 @@ public void fillWithLegacyHeaders(ServerHttpRequest request) { request, Constants.HEADER_X_SPACEGATE_CLIENT_SECRET)); setXSpacegateScope( HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_X_SPACEGATE_SCOPE)); + + // processing + setConsumerToken( + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION)); + Jwt consumerTokenClaims = + OauthTokenUtil.getAllClaimsFromToken( + OauthTokenUtil.getTokenWithoutSignature(consumerToken)); + setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class)); + setConsumerOriginStargate( + consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class)); + setConsumerOriginZone( + consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class)); } @JsonIgnore - public static JumperConfig parseConfigFrom(ServerHttpRequest request) { + public void fillProcessingInfo(ServerHttpRequest request) { + setConsumerToken( + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION)); + Jwt consumerTokenClaims = + OauthTokenUtil.getAllClaimsFromToken( + OauthTokenUtil.getTokenWithoutSignature(consumerToken)); + setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class)); + setConsumerOriginStargate( + consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class)); + setConsumerOriginZone( + consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class)); + + // Spectre stuff + JumperConfig jc = + JumperConfig.fromBase64( + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG)); + this.setRouteListener(jc.getRouteListener()); + this.setGatewayClient(jc.getGatewayClient()); + } - JumperConfig jc; - String jumperConfigBase64 = - HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG); + public static List parseJumperConfigListFrom(ServerHttpRequest request) { - if (StringUtils.isNotBlank(jumperConfigBase64)) { - jc = JumperConfig.fromBase64(jumperConfigBase64); + String routingConfigBase64 = + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ROUTING_CONFIG); - } else { - jc = new JumperConfig(); + if (StringUtils.isNotBlank(routingConfigBase64)) { + return JumperConfig.fromBase64(routingConfigBase64, new TypeReference<>() {}); } + throw new RuntimeException("can not base64decode header: " + routingConfigBase64); + } + + @JsonIgnore + public static JumperConfig parseAndFillJumperConfigFrom(ServerHttpRequest request) { + + JumperConfig jc = + JumperConfig.fromBase64( + HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG)); + jc.fillWithLegacyHeaders( request); // TODO: remove as soon we have completely shifted to json_config @@ -150,13 +202,9 @@ public static JumperConfig parseConfigFrom(ServerHttpRequest request) { } @JsonIgnore - public static JumperConfig parseConfigFrom(ServerWebExchange exchange) { - String jumperConfigBase64 = exchange.getAttribute(Constants.HEADER_JUMPER_CONFIG); - if (jumperConfigBase64 != null && !jumperConfigBase64.isEmpty()) { - return JumperConfig.fromBase64(jumperConfigBase64); - } else { - return new JumperConfig(); - } + public static JumperConfig parseJumperConfigFrom(ServerWebExchange exchange) { + + return JumperConfig.fromBase64(exchange.getAttribute(Constants.HEADER_JUMPER_CONFIG)); } public boolean isListenerMatched() { diff --git a/src/main/java/jumper/model/config/ZoneHealthMessage.java b/src/main/java/jumper/model/config/ZoneHealthMessage.java new file mode 100644 index 0000000..3b7cc61 --- /dev/null +++ b/src/main/java/jumper/model/config/ZoneHealthMessage.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.model.config; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class ZoneHealthMessage { + private String zone; + private HealthStatus status; +} diff --git a/src/main/java/jumper/model/request/JumperInfoRequest.java b/src/main/java/jumper/model/request/JumperInfoRequest.java index 36cbb79..9dde022 100644 --- a/src/main/java/jumper/model/request/JumperInfoRequest.java +++ b/src/main/java/jumper/model/request/JumperInfoRequest.java @@ -5,6 +5,7 @@ package jumper.model.request; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import lombok.Getter; import lombok.Setter; @@ -18,23 +19,23 @@ public class JumperInfoRequest { private boolean lastMileSecurityEnhanced; private boolean externalAuthorization; private boolean basicAuth; - - private String environment; + private boolean xTokenExchangeAuthorization; private IncomingRequest incomingRequest; - private OutgoingRequest outgoingRequest; public void setInfoScenario( boolean lastMileSecurity, boolean lastMileSecurityEnhanced, boolean meshActivated, boolean externalAuthorization, - boolean basicAuth) { + boolean basicAuth, + boolean xTokenExchangeauthorization) { this.meshActivated = meshActivated; this.lastMileSecurity = lastMileSecurity; this.lastMileSecurityEnhanced = lastMileSecurityEnhanced; this.externalAuthorization = externalAuthorization; this.basicAuth = basicAuth; + this.xTokenExchangeAuthorization = xTokenExchangeauthorization; } @Override @@ -53,6 +54,8 @@ public String toString() { sb.append(lastMileSecurityEnhanced); sb.append(" ExternalAuthorization: "); sb.append(externalAuthorization); + sb.append(" XtokenExchangeAuthorization: "); + sb.append(xTokenExchangeAuthorization); sb.append(" BasicAuth: "); sb.append(basicAuth); sb.append(lineSeparator); @@ -63,19 +66,24 @@ public String toString() { sb.append("HTTP-Method: ").append(incomingRequest.getMethod()); sb.append(lineSeparator); - sb.append("Host: ").append(incomingRequest.getHost()); + sb.append("Consumer: ").append(incomingRequest.getConsumer()); sb.append(lineSeparator); sb.append("BasePath: ").append(incomingRequest.getBasePath()); sb.append(lineSeparator); - sb.append("Resource: ").append(incomingRequest.getResource()); + sb.append("Resource: ").append(incomingRequest.getRequestPath()); + sb.append(lineSeparator); + + sb.append("Host: ").append(incomingRequest.getFinalApiUrl()); sb.append(lineSeparator); - Set> entrySet = incomingRequest.getLogEntries().entrySet(); - for (Entry entry : entrySet) { - sb.append(entry.getKey()).append(": ").append(entry.getValue()); - sb.append(lineSeparator); + if (Objects.nonNull(incomingRequest.getLogEntries())) { + Set> entrySet = incomingRequest.getLogEntries().entrySet(); + for (Entry entry : entrySet) { + sb.append(entry.getKey()).append(": ").append(entry.getValue()); + sb.append(lineSeparator); + } } return sb.toString(); diff --git a/src/main/java/jumper/model/request/OutgoingRequest.java b/src/main/java/jumper/model/request/OutgoingRequest.java deleted file mode 100644 index d186092..0000000 --- a/src/main/java/jumper/model/request/OutgoingRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG -// -// SPDX-License-Identifier: Apache-2.0 - -package jumper.model.request; - -import java.util.Map; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class OutgoingRequest extends Request { - - private Map zuulHeader; -} diff --git a/src/main/java/jumper/model/request/Request.java b/src/main/java/jumper/model/request/Request.java index 34496e4..782263f 100644 --- a/src/main/java/jumper/model/request/Request.java +++ b/src/main/java/jumper/model/request/Request.java @@ -4,7 +4,6 @@ package jumper.model.request; -import java.util.Map; import lombok.Getter; import lombok.Setter; @@ -12,10 +11,9 @@ @Setter public abstract class Request { - private String host; + private String finalApiUrl; + private String consumer; private String basePath; - private String resource; + private String requestPath; private String method; - - private Map originHeader; } diff --git a/src/main/java/jumper/model/response/IncomingResponse.java b/src/main/java/jumper/model/response/IncomingResponse.java index 7b16fe8..01fc850 100644 --- a/src/main/java/jumper/model/response/IncomingResponse.java +++ b/src/main/java/jumper/model/response/IncomingResponse.java @@ -4,7 +4,7 @@ package jumper.model.response; -import java.util.List; +import java.util.Map; import lombok.Getter; import lombok.Setter; @@ -13,8 +13,7 @@ public class IncomingResponse { private String host; - private String path; private Integer httpStatusCode; - - List originHeaderResponse; + private String method; + Map requestHeaders; } diff --git a/src/main/java/jumper/model/response/JumperInfoResponse.java b/src/main/java/jumper/model/response/JumperInfoResponse.java index f55b859..b09cba5 100644 --- a/src/main/java/jumper/model/response/JumperInfoResponse.java +++ b/src/main/java/jumper/model/response/JumperInfoResponse.java @@ -4,6 +4,8 @@ package jumper.model.response; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Getter; import lombok.Setter; @@ -11,4 +13,14 @@ @Setter public class JumperInfoResponse { IncomingResponse incomingResponse; + + @Override + public String toString() { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.writeValueAsString(incomingResponse); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/jumper/service/AuditLogService.java b/src/main/java/jumper/service/AuditLogService.java new file mode 100644 index 0000000..ca8be1e --- /dev/null +++ b/src/main/java/jumper/service/AuditLogService.java @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.service; + +import java.util.Date; +import jumper.model.config.AuditLog; +import jumper.model.config.JumperConfig; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AuditLogService { + public static void logInfo(String msg) { + log.info(msg); + } + + public static void writeFailoverAuditLog(JumperConfig jumperConfig) { + logInfo( + AuditLog.builder() + .upstreamPath(jumperConfig.getFinalApiUrl()) + .apiBasePath(jumperConfig.getApiBasePath()) + .consumer(jumperConfig.getConsumer()) + .consumerOriginZone(jumperConfig.getConsumerOriginZone()) + .timestamp(new Date()) + .build() + .toString()); + } +} diff --git a/src/main/java/jumper/service/RedisZoneHealthStatusService.java b/src/main/java/jumper/service/RedisZoneHealthStatusService.java new file mode 100644 index 0000000..4db29f0 --- /dev/null +++ b/src/main/java/jumper/service/RedisZoneHealthStatusService.java @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.concurrent.CompletableFuture; +import jumper.config.RedisConfig; +import jumper.model.config.HealthStatus; +import jumper.model.config.ZoneHealthMessage; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.retry.support.RetryTemplateBuilder; +import org.springframework.stereotype.Component; + +@Slf4j +@ConditionalOnBean(RedisConfig.class) +@Component +public class RedisZoneHealthStatusService implements MessageListener { + + private final ObjectMapper objectMapper; + private final ZoneHealthCheckService zoneHealthCheckService; + private final RedisMessageListenerContainer redisMessageListenerContainer; + + private final String channelKey; + + @Getter private boolean isInitiallySubscribed = false; + + public RedisZoneHealthStatusService( + ObjectMapper objectMapper, + ZoneHealthCheckService zoneHealthCheckService, + RedisMessageListenerContainer redisMessageListenerContainer, + @Value("${jumper.zone.health.redis.channel}") String channelKey) { + this.objectMapper = objectMapper; + this.zoneHealthCheckService = zoneHealthCheckService; + this.redisMessageListenerContainer = redisMessageListenerContainer; + this.channelKey = channelKey; + + this.lazyInitializeRedisMessageListenerContainer(); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + ZoneHealthMessage zoneHealthMessage = + objectMapper.readValue(message.toString(), ZoneHealthMessage.class); + log.debug("Received message {}", zoneHealthMessage); + if (zoneHealthMessage.getZone() == null) { + log.error("Zone is null in message {}, ignoring set status", zoneHealthMessage); + return; + } + zoneHealthCheckService.setZoneHealth( + zoneHealthMessage.getZone(), zoneHealthMessage.getStatus() == HealthStatus.HEALTHY); + } catch (JsonProcessingException e) { + log.error("Error deserializing message", e); + } catch (Exception e) { + log.error("Error processing message", e); + } + } + + void lazyInitializeRedisMessageListenerContainer() { + CompletableFuture.supplyAsync( + () -> { + var template = + new RetryTemplateBuilder() + .maxAttempts(Integer.MAX_VALUE) + .fixedBackoff(5000) + .build(); + return template.execute( + context -> { + try { + if (redisMessageListenerContainer.getConnectionFactory() == null) { + log.debug( + "Redis connection factory not available, skipping initialization"); + return false; + } + + var connection = + redisMessageListenerContainer.getConnectionFactory().getConnection(); + if (connection.isSubscribed()) { + log.debug("Redis connection already subscribed, skipping initialization"); + return false; + } + } catch (Exception e) { + log.error( + "Connection failure occurred. Restarting subscription task after 5000 ms"); + throw e; + } + redisMessageListenerContainer.addMessageListener( + this, new ChannelTopic(channelKey)); + log.info( + "Listeners registered successfully after {} retries.", + context.getRetryCount()); + return true; + }); + }) + .exceptionally( + throwable -> { + log.error( + "Stopped initializing Redis message listener container with errors", throwable); + return false; + }) + .thenApply(result -> isInitiallySubscribed = result); + } +} diff --git a/src/main/java/jumper/service/ZoneHealthCheckService.java b/src/main/java/jumper/service/ZoneHealthCheckService.java new file mode 100644 index 0000000..07d8582 --- /dev/null +++ b/src/main/java/jumper/service/ZoneHealthCheckService.java @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.service; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import jumper.model.config.HealthStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class ZoneHealthCheckService { + + @Value("${jumper.zone.health.defaultZoneHealth}") + private Boolean defaultZoneHealth; + + private final Map zoneHealthCache = new ConcurrentHashMap<>(); + private final MeterRegistry meterRegistry; + private final Map atomicIntegerMap; + + public ZoneHealthCheckService(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + this.atomicIntegerMap = new HashMap<>(); + } + + public Boolean getZoneHealth(String zone) { + return zoneHealthCache.getOrDefault(zone, defaultZoneHealth); + } + + public void setZoneHealth(String zone, Boolean health) { + gaugeEvaluation(zone, health); + zoneHealthCache.put(zone, health); + } + + private void gaugeEvaluation(String zone, Boolean health) { + if (atomicIntegerMap.containsKey(zone)) { + AtomicInteger ai = atomicIntegerMap.get(zone); + ai.set(health ? 1 : 0); + } else { + AtomicInteger ai = new AtomicInteger(health ? 1 : 0); + Gauge.builder("zone.health.status", () -> ai) + .description("Zone health status") + .tag("zone", zone) + .tag("status", HealthStatus.HEALTHY.toString()) + .register(meterRegistry); + atomicIntegerMap.put(zone, ai); + } + } + + public void clearCache() { + zoneHealthCache.clear(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c617cdc..232fb43 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,10 +5,15 @@ server: port: ${JUMPER_PORT:8080} max-http-header-size: 16384 + shutdown: GRACEFUL netty: max-initial-line-length: 8192 + idle-timeout: 60000 management: + health: + redis: + enabled: false endpoint: gateway.enabled: true # having actuators endpoint for filters e.g. host/actuator/gateway/routefilters loggers.enabled: true # having actuators endpoint for setting log level @@ -20,6 +25,8 @@ management: exposure.include: gateway, loggers, prometheus, health spring: + lifecycle: + timeout-per-shutdown-phase: 1m application: name: ${JUMPER_NAME:Jumper} cloud: @@ -43,6 +50,7 @@ spring: max-life-time: 300s max-idle-time: 2s #eviction-interval: 120s + metrics: true connect-timeout: 10000 response-timeout: 61s # ingress 60s #ssl: @@ -71,12 +79,30 @@ spring: instrumentation-type: MANUAL #https://docs.spring.io/spring-cloud-sleuth/docs/current-SNAPSHOT/reference/html/integrations.html codec: max-in-memory-size: 4194304 - + data: + redis: + repositories: + enabled: false + redis: + connect-timeout: ${ZONE_HEALTH_DATABASE_CONNECTTIMEOUT:500} + timeout: ${ZONE_HEALTH_DATABASE_TIMEOUT:500} + host: ${ZONE_HEALTH_DATABASE_HOST:localhost} + port: ${ZONE_HEALTH_DATABASE_PORT:6379} + database: ${ZONE_HEALTH_DATABASE_INDEX:2} + password: ${ZONE_HEALTH_DATABASE_PASSWORD:foobar} jumper: issuer: url: ${JUMPER_ISSUER_URL:https://stargate-integration.test.dhei.telekom.de/auth/realms} stargate: url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de} + zone: + name: ${JUMPER_ZONE_NAME:default} + health: + enabled: ${ZONE_HEALTH_ENABLED:false} + defaultZoneHealth: ${ZONE_HEALTH_DEFAULT:true} + redis: + channel: ${ZONE_HEALTH_KEY_CHANNEL:stargate-zone-status} + checkConnectionInterval: ${ZONE_HEALTH_REQUEST_GET_RATE:5000} jumpercache.ttlOffset: 10 jumpercache.cleanCacheInSeconds: 3600 @@ -86,4 +112,5 @@ jumpercache.cleanCacheInSeconds: 3600 ############################## horizon: publishEventUrl: ${PUBLISH_EVENT_URL:http://producer.stage:8080/v1/events} +logging.level.jumper.service.AuditLogService: INFO #logging.pattern.level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" #https://github.com/micrometer-metrics/tracing/wiki/Spring-Cloud-Sleuth-3.1-Migration-Guide diff --git a/src/test/java/jumper/BaseSteps.java b/src/test/java/jumper/BaseSteps.java index f61fc5c..6dd2118 100644 --- a/src/test/java/jumper/BaseSteps.java +++ b/src/test/java/jumper/BaseSteps.java @@ -4,11 +4,13 @@ package jumper; +import static jumper.config.Config.*; import static org.junit.jupiter.api.Assertions.fail; import io.cucumber.java.en.And; import io.cucumber.java.en.When; import java.util.function.Consumer; +import jumper.filter.RequestFilter; import jumper.mocks.MockHorizonServer; import jumper.mocks.MockIrisServer; import jumper.mocks.MockUpstreamServer; @@ -18,6 +20,7 @@ import lombok.Setter; import org.json.JSONException; import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -40,6 +43,8 @@ public class BaseSteps { private WebTestClient.ResponseSpec requestExchange; private String id; + @Autowired private RequestFilter rf; + @Value("${jumper.stargate.url:https://stargate-integration.test.dhei.telekom.de}") private String stargateUrl; @@ -48,6 +53,15 @@ public void apiProviderWillRespondWithAStatusCode(int statusCode) { responseStatusCode = String.valueOf(statusCode); } + @And("API provider set to respond on {word} path") + public void apiProviderWillRespondWithAStatusCodeOnPath(String path_case) { + switch (path_case) { + case "real" -> mockUpstreamServer.failoverRequest(REMOTE_BASE_PATH); + case "failover" -> mockUpstreamServer.failoverRequest(REMOTE_FAILOVER_BASE_PATH); + case "provider" -> mockUpstreamServer.failoverRequest(REMOTE_PROVIDER_BASE_PATH); + } + } + @And("Event provider set to respond with a {int} status code") public void eventProviderWillRespondWithAStatusCode(int statusCode) { responseStatusCode = String.valueOf(statusCode); @@ -175,6 +189,11 @@ public void consumerCallsTheAPI() { .exchange(); } + @When("consumer calls the proxy route without base path") + public void consumerCallsProxy() { + requestExchange = webTestClient.get().uri("/proxy").headers(httpHeadersOfRequest).exchange(); + } + @When("consumer calls the proxy route and runs into timeout") public void consumerCallsTheAPIAndProviderRunsIntoTimeout() { mockUpstreamServer.callbackRequestWithTimeout(); @@ -345,6 +364,11 @@ public void verifyQueryParam(String name, String value) { mockUpstreamServer.verifyQueryParam(name, value); } + @And("current zone is {string}") + public void currentZoneIs(String zone) { + rf.setCurrentZone(zone); + } + public static JSONObject getTestJson() { JSONObject jsonObject = new JSONObject(); diff --git a/src/test/java/jumper/FailoverSteps.java b/src/test/java/jumper/FailoverSteps.java new file mode 100644 index 0000000..476040c --- /dev/null +++ b/src/test/java/jumper/FailoverSteps.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper; + +import static jumper.config.Config.REMOTE_ZONE_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.cucumber.java.After; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import jumper.model.config.HealthStatus; +import jumper.service.ZoneHealthCheckService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class FailoverSteps { + + private final ZoneHealthCheckService zoneHealthCheckService; + + @Then("failover service for {} is returning {}") + public void failoverServiceIsReturning(String zone, HealthStatus healthStatus) { + boolean wantIsHealthy = healthStatus == HealthStatus.HEALTHY; + boolean zoneStatus = zoneHealthCheckService.getZoneHealth(zone); + + assertEquals( + wantIsHealthy, + zoneStatus, + "Want:" + healthStatus + "\n" + "Got: " + zoneHealthCheckService.getZoneHealth(zone)); + } + + @And("set zone state to unhealthy") + public void setZoneUnhealthy() { + zoneHealthCheckService.setZoneHealth(REMOTE_ZONE_NAME, false); + } + + @After("@failover") + public void clearCache() { + zoneHealthCheckService.clearCache(); + } +} diff --git a/src/test/java/jumper/HeaderSteps.java b/src/test/java/jumper/HeaderSteps.java index dc03a65..97e2dcc 100644 --- a/src/test/java/jumper/HeaderSteps.java +++ b/src/test/java/jumper/HeaderSteps.java @@ -10,6 +10,7 @@ import io.cucumber.java.en.And; import io.cucumber.java.en.Given; +import jumper.util.RoutingConfigUtil; import jumper.util.TokenUtil; import lombok.RequiredArgsConstructor; @@ -23,12 +24,36 @@ public void proxyRouteHeadersSet() { baseSteps.setHttpHeadersOfRequest(TokenUtil.getProxyRouteHeaders(baseSteps)); } + @Given("ProxyRoute headers are set with x-token-exchange") + public void proxyRouteHeadersSetWithXtokenExchange() { + baseSteps.authHeader = TokenUtil.getConsumerAccessToken(); + baseSteps.setHttpHeadersOfRequest(TokenUtil.getProxyRouteHeadersWithXtokenExchange(baseSteps)); + } + @Given("RealRoute headers are set") public void realRouteHeadersSet() { baseSteps.setHttpHeadersOfRequest( TokenUtil.getRealRouteHeaders(TokenUtil.getConsumerAccessToken())); } + @Given("RealRoute headers are set with x-token-exchange") + public void realRouteHeadersSetWithXtokenExchange() { + baseSteps.setHttpHeadersOfRequest( + TokenUtil.getRealRouteHeadersWithXtokenExchange(TokenUtil.getConsumerAccessToken())); + } + + @Given("Secondary routing_config header set") + public void secondaryRoutingConfigHeaderSet() { + baseSteps.authHeader = TokenUtil.getConsumerAccessToken(); + baseSteps.setHttpHeadersOfRequest(RoutingConfigUtil.getSecondaryRouteHeaders(baseSteps)); + } + + @Given("Proxy routing_config header set") + public void proxyRoutingConfigHeaderSet() { + baseSteps.authHeader = TokenUtil.getConsumerAccessToken(); + baseSteps.setHttpHeadersOfRequest(RoutingConfigUtil.getProxyRouteHeaders(baseSteps)); + } + @Given("RealRoute headers without Authorization are set") public void realRouteHeadersNoAuth() { baseSteps.setHttpHeadersOfRequest(TokenUtil.getRealRouteHeaders()); @@ -97,4 +122,13 @@ public void addTechnicalHeaders() { httpHeaders.add("x-forwarded-prefix", "dummy"); })); } + + @And("skip zone header set") + public void setSkipZoneHeader() { + baseSteps.setHttpHeadersOfRequest( + baseSteps.httpHeadersOfRequest.andThen( + httpHeaders -> { + httpHeaders.set(Constants.HEADER_X_FAILOVER_SKIP_ZONE, REMOTE_ZONE_NAME); + })); + } } diff --git a/src/test/java/jumper/VerificationSteps.java b/src/test/java/jumper/VerificationSteps.java index 17518d2..0ab37b2 100644 --- a/src/test/java/jumper/VerificationSteps.java +++ b/src/test/java/jumper/VerificationSteps.java @@ -81,6 +81,11 @@ public void apiProvidersReceivesNoTechnicalHeaders() { .doesNotExist("access_token_forwarding"); } + @Then("API Provider receives no failover headers") + public void apiProvidersReceivesNoFailoverHeaders() { + this.baseSteps.getRequestExchange().expectHeader().doesNotExist("routing_config"); + } + @Then("API Provider receives authorization {word}") public void apiProviderReceivesToken(String tokenType) { if (tokenType.equalsIgnoreCase("OneToken")) { @@ -88,6 +93,11 @@ public void apiProviderReceivesToken(String tokenType) { .getRequestExchange() .expectHeader() .value(HttpHeaders.AUTHORIZATION, this::checkOneToken); + } else if (tokenType.equalsIgnoreCase("OneTokenSimple")) { + this.baseSteps + .getRequestExchange() + .expectHeader() + .value(HttpHeaders.AUTHORIZATION, this::checkOneTokenSimple); } else if (tokenType.equalsIgnoreCase("OneTokenWithPubSub")) { this.baseSteps .getRequestExchange() @@ -136,6 +146,11 @@ public void apiProviderReceivesToken(String tokenType) { .getRequestExchange() .expectHeader() .value(HttpHeaders.AUTHORIZATION, this::checkBasicAuthProvider); + } else if (tokenType.equalsIgnoreCase("XTokenExchangeHeader")) { + this.baseSteps + .getRequestExchange() + .expectHeader() + .valueMatches(HttpHeaders.AUTHORIZATION, "Bearer XTokenExchangeHeader"); } else { fail("unknown authorization received"); } @@ -183,6 +198,23 @@ private void checkOneToken(String token) { assertNotNull(claimsFromToken.getBody().getIssuedAt()); } + private void checkOneTokenSimple(String token) { + Jwt claimsFromToken = + OauthTokenUtil.getAllClaimsFromToken(OauthTokenUtil.getTokenWithoutSignature(token)); + + assertEquals("Bearer", claimsFromToken.getBody().get("typ", String.class)); + assertEquals(CONSUMER, claimsFromToken.getBody().get("clientId", String.class)); + assertEquals("stargate", claimsFromToken.getBody().get("azp", String.class)); + assertEquals(ENVIRONMENT, claimsFromToken.getBody().get("env", String.class)); + assertEquals("GET", claimsFromToken.getBody().get("operation", String.class)); + assertEquals(ORIGIN_ZONE, claimsFromToken.getBody().get("originZone", String.class)); + assertEquals(ORIGIN_STARGATE, claimsFromToken.getBody().get("originStargate", String.class)); + assertEquals( + localIssuerUrl + "/" + Constants.DEFAULT_REALM, claimsFromToken.getBody().getIssuer()); + assertNotNull(claimsFromToken.getBody().getExpiration()); + assertNotNull(claimsFromToken.getBody().getIssuedAt()); + } + private void checkPubSub(String token) { Jwt claimsFromToken = OauthTokenUtil.getAllClaimsFromToken(OauthTokenUtil.getTokenWithoutSignature(token)); diff --git a/src/test/java/jumper/config/Config.java b/src/test/java/jumper/config/Config.java index fb1dbf9..3000517 100644 --- a/src/test/java/jumper/config/Config.java +++ b/src/test/java/jumper/config/Config.java @@ -27,4 +27,11 @@ public class Config { public static final String PUBSUB_SUBSCRIBER = "testSubscriber"; public static final String LISTENER_ISSUE = "issue"; public static final String LISTENER_PROVIDER = "serviceOwner"; + public static final int REMOTE_HOST_PORT = 1080; + public static final String REMOTE_BASE_PATH = "/real"; + public static final String REMOTE_FAILOVER_BASE_PATH = "/failover"; + public static final String REMOTE_PROVIDER_BASE_PATH = "/provider"; + public static final String REMOTE_HOST = "http://localhost:" + REMOTE_HOST_PORT; + public static final String REMOTE_ZONE_NAME = "realZone"; + public static final String REMOTE_FAILOVER_ZONE_NAME = "failoverZone"; } diff --git a/src/test/java/jumper/mocks/MockUpstreamServer.java b/src/test/java/jumper/mocks/MockUpstreamServer.java index 62d9562..bdef58c 100644 --- a/src/test/java/jumper/mocks/MockUpstreamServer.java +++ b/src/test/java/jumper/mocks/MockUpstreamServer.java @@ -4,6 +4,7 @@ package jumper.mocks; +import static jumper.config.Config.REMOTE_HOST_PORT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockserver.integration.ClientAndServer.startClientAndServer; import static org.mockserver.model.HttpClassCallback.callback; @@ -27,9 +28,9 @@ public class MockUpstreamServer { private ClientAndServer mockServer; private MockServerClient mockServerClient; + int upstreamLocalPort = REMOTE_HOST_PORT; public void startServer() { - int upstreamLocalPort = 1080; mockServer = startClientAndServer(upstreamLocalPort); String upstreamLocalHost = "localhost"; mockServerClient = new MockServerClient(upstreamLocalHost, upstreamLocalPort); @@ -48,6 +49,12 @@ public void callbackRequest() { .respond(callback().withCallbackClass("jumper.mocks.TestExpectationCallback")); } + public void failoverRequest(String path) { + mockServerClient + .when(request().withPath(path), Times.exactly(1)) + .respond(callback().withCallbackClass("jumper.mocks.TestExpectationCallback")); + } + public void callbackRequestWithTimeout() { mockServerClient .when(request().withPath("/callback")) diff --git a/src/test/java/jumper/mocks/TestExpectationCallback.java b/src/test/java/jumper/mocks/TestExpectationCallback.java index 4d7e336..fad4f16 100644 --- a/src/test/java/jumper/mocks/TestExpectationCallback.java +++ b/src/test/java/jumper/mocks/TestExpectationCallback.java @@ -4,6 +4,7 @@ package jumper.mocks; +import static jumper.config.Config.*; import static org.mockserver.model.HttpResponse.notFoundResponse; import static org.mockserver.model.HttpResponse.response; @@ -15,7 +16,9 @@ public class TestExpectationCallback implements ExpectationResponseCallback { @Override public HttpResponse handle(HttpRequest httpRequest) { - if (httpRequest.getPath().getValue().endsWith("/callback")) { + if (isFailoverPath(httpRequest.getPath().getValue())) { + return response().withHeaders(httpRequest.getHeaders()).withStatusCode(200); + } else if (httpRequest.getPath().getValue().endsWith("/callback")) { return response() .withHeaders(httpRequest.getHeaders()) .withBody(httpRequest.getBodyAsString()) @@ -28,4 +31,9 @@ public HttpResponse handle(HttpRequest httpRequest) { return notFoundResponse(); } } + + private boolean isFailoverPath(String path) { + return List.of(REMOTE_BASE_PATH, REMOTE_FAILOVER_BASE_PATH, REMOTE_PROVIDER_BASE_PATH) + .contains(path); + } } diff --git a/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java b/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java new file mode 100644 index 0000000..254fe5b --- /dev/null +++ b/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.service; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import jumper.model.config.HealthStatus; +import jumper.model.config.ZoneHealthMessage; +import jumper.util.AbstractIntegrationTest; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RedisZoneHealthStatusServiceTest extends AbstractIntegrationTest { + + @Value("${jumper.zone.health.redis.channel}") + private String channelKey; + + @Autowired private RedisTemplate redisTemplate; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private RedisZoneHealthStatusService redisZoneHealthStatusService; + + @SpyBean private ZoneHealthCheckService zoneHealthCheckService; + + @BeforeEach + void setUp() { + Mockito.reset(zoneHealthCheckService); + } + + @Test + @DisplayName( + "Test if a zone is marked correctly after receiving message via redis with a unhealthy status message") + void getZoneUnhealthyWithRedisPubSubListener() throws JsonProcessingException { + // given + String zoneToTest = "zoneToTest"; + ZoneHealthMessage message = new ZoneHealthMessage(zoneToTest, HealthStatus.UNHEALTHY); + var messageString = objectMapper.writeValueAsString(message); + await() + .atMost(Duration.ofSeconds(15)) + .until(() -> redisZoneHealthStatusService.isInitiallySubscribed()); + + // when + redisTemplate.convertAndSend(channelKey, messageString); + + // then + Mockito.verify(zoneHealthCheckService, Mockito.timeout(5000L).times(1)) + .setZoneHealth(Mockito.eq(zoneToTest), Mockito.eq(false)); + assertFalse(zoneHealthCheckService.getZoneHealth(zoneToTest)); + } + + @ParameterizedTest + @DisplayName( + "Test if a zone is marked correctly healthy after receiving a incompatible message via redis with a unhealthy status message") + @ValueSource( + strings = { + """ + { + "zone": "%s", + "status": "UNHEALTHYHELLO" + } + """, + """ + { + "status": "UNHEALTHY" + } + """ + }) + void getZoneHealthyWithRedisPubSubListenerForMalformedMessage(String messageTemplate) { + // given + String zoneToTest = "wrongFormatZone"; + var messageString = String.format(messageTemplate, zoneToTest); + + // when + redisZoneHealthStatusService.onMessage( + new DefaultMessage(channelKey.getBytes(), messageString.getBytes()), null); + + // then + Mockito.verify(zoneHealthCheckService, Mockito.times(0)) + .setZoneHealth(Mockito.anyString(), Mockito.anyBoolean()); + Mockito.verify(zoneHealthCheckService, Mockito.times(0)) + .setZoneHealth(Mockito.isNull(), Mockito.anyBoolean()); + assertTrue(zoneHealthCheckService.getZoneHealth(zoneToTest)); + } +} diff --git a/src/test/java/jumper/util/AbstractIntegrationTest.java b/src/test/java/jumper/util/AbstractIntegrationTest.java new file mode 100644 index 0000000..d29df6d --- /dev/null +++ b/src/test/java/jumper/util/AbstractIntegrationTest.java @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.util; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +public class AbstractIntegrationTest { + + private static final GenericContainer REDIS_CONTAINER; + private static final int REDIS_PORT = 6379; + + static { + REDIS_CONTAINER = + new GenericContainer<>("bitnami/redis:latest") + .withEnv("REDIS_PASSWORD", "foobar") + .withExposedPorts(REDIS_PORT) + .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1)); + REDIS_CONTAINER.start(); + } + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT)); + registry.add("jumper.zone.health.enabled", () -> true); + } +} diff --git a/src/test/java/jumper/util/RoutingConfigUtil.java b/src/test/java/jumper/util/RoutingConfigUtil.java new file mode 100644 index 0000000..e555c00 --- /dev/null +++ b/src/test/java/jumper/util/RoutingConfigUtil.java @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +// +// SPDX-License-Identifier: Apache-2.0 + +package jumper.util; + +import static jumper.config.Config.*; +import static jumper.model.config.JumperConfig.toBase64; + +import java.util.List; +import java.util.function.Consumer; +import jumper.BaseSteps; +import jumper.Constants; +import jumper.model.config.*; +import org.springframework.http.HttpHeaders; + +public class RoutingConfigUtil { + + public static Consumer getSecondaryRouteHeaders(BaseSteps baseSteps) { + return httpHeaders -> { + httpHeaders.setBearerAuth(baseSteps.getAuthHeader()); + httpHeaders.set(Constants.HEADER_ROUTING_CONFIG, getRcSecondary(baseSteps.getId())); + }; + } + + public static Consumer getProxyRouteHeaders(BaseSteps baseSteps) { + return httpHeaders -> { + httpHeaders.setBearerAuth(baseSteps.getAuthHeader()); + httpHeaders.set(Constants.HEADER_ROUTING_CONFIG, getRcProxy(baseSteps.getId())); + }; + } + + public static String getRcSecondary(String id) { + // proxy + real + return toBase64(List.of(getProxyRouteJc(REMOTE_ZONE_NAME, id), getRealRouteJc())); + } + + public static String getRcProxy(String id) { + // proxy + proxy + return toBase64( + List.of( + getProxyRouteJc(REMOTE_ZONE_NAME, id), getProxyRouteJc(REMOTE_FAILOVER_ZONE_NAME, id))); + } + + private static JumperConfig getProxyRouteJc(String targetZone, String id) { + JumperConfig jc = new JumperConfig(); + jc.setInternalTokenEndpoint("http://localhost:1081/auth/realms/default"); + jc.setClientId(addIdSuffix("stargate", id)); + jc.setClientSecret("secret"); + + switch (targetZone) { + case REMOTE_ZONE_NAME -> { + jc.setTargetZoneName(REMOTE_ZONE_NAME); + jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_BASE_PATH); + } + case REMOTE_FAILOVER_ZONE_NAME -> { + jc.setTargetZoneName(REMOTE_FAILOVER_ZONE_NAME); + jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_FAILOVER_BASE_PATH); + } + } + return jc; + } + + private static JumperConfig getRealRouteJc() { + JumperConfig jc = new JumperConfig(); + jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_PROVIDER_BASE_PATH); + jc.setApiBasePath(BASE_PATH); + jc.setRealmName(REALM); + jc.setEnvName(ENVIRONMENT); + jc.setAccessTokenForwarding(false); + return jc; + } + + public static String addIdSuffix(String from, String id) { + return from + "_" + id; + } +} diff --git a/src/test/java/jumper/util/TokenUtil.java b/src/test/java/jumper/util/TokenUtil.java index aa0170c..63b5b62 100644 --- a/src/test/java/jumper/util/TokenUtil.java +++ b/src/test/java/jumper/util/TokenUtil.java @@ -59,6 +59,18 @@ public static Consumer getProxyRouteHeaders(BaseSteps baseSteps) { }; } + public static Consumer getProxyRouteHeadersWithXtokenExchange(BaseSteps baseSteps) { + return httpHeaders -> { + httpHeaders.setBearerAuth(baseSteps.getAuthHeader()); + httpHeaders.set(Constants.HEADER_REMOTE_API_URL, "http://localhost:1080"); + httpHeaders.set(Constants.HEADER_ISSUER, "http://localhost:1081/auth/realms/default"); + httpHeaders.set(Constants.HEADER_CLIENT_ID, addIdSuffix("stargate", baseSteps.getId())); + httpHeaders.set(Constants.HEADER_CLIENT_SECRET, "secret"); + httpHeaders.set(Constants.HEADER_JUMPER_CONFIG, "e30="); + httpHeaders.set(Constants.HEADER_X_TOKEN_EXCHANGE, "Bearer XTokenExchangeHeader"); + }; + } + public static Consumer getRealRouteHeaders() { return getRealRouteHeaders(null); } @@ -75,6 +87,19 @@ public static Consumer getRealRouteHeaders(String authorization) { }; } + public static Consumer getRealRouteHeadersWithXtokenExchange(String authorization) { + return httpHeaders -> { + if (authorization != null) httpHeaders.setBearerAuth(authorization); + httpHeaders.set(Constants.HEADER_REMOTE_API_URL, "http://localhost:1080"); + httpHeaders.set(Constants.HEADER_API_BASE_PATH, BASE_PATH); + httpHeaders.set(Constants.HEADER_ENVIRONMENT, ENVIRONMENT); + httpHeaders.set(Constants.HEADER_REALM, REALM); + httpHeaders.set(Constants.HEADER_ACCESS_TOKEN_FORWARDING, "false"); + httpHeaders.set(Constants.HEADER_JUMPER_CONFIG, "e30="); + httpHeaders.set(Constants.HEADER_X_TOKEN_EXCHANGE, "Bearer XTokenExchangeHeader"); + }; + } + public static Consumer getEmptyHeaders() { return httpHeaders -> {}; } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 10ba891..16b9ca5 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -68,15 +68,34 @@ spring: instrumentation-type: MANUAL #https://docs.spring.io/spring-cloud-sleuth/docs/current-SNAPSHOT/reference/html/integrations.html codec: max-in-memory-size: 4194304 + data: + redis: + repositories: + enabled: false + redis: + connect-timeout: 500 + timeout: 500 + host: localhost + port: 6379 + database: 2 + password: foobar jumper: issuer: url: ${JUMPER_ISSUER_URL:https://stargate-test.de/auth/realms} stargate: - url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de} + url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de} security: dir: src/test/resources/keypair file: private.json + zone: + name: ${JUMPER_ZONE_NAME:default} + health: + enabled: :true + defaultZoneHealth: true + redis: + channel: stargate-zone-status + checkConnectionInterval: 15000 jumpercache.ttlOffset: 10 jumpercache.cleanCacheInSeconds: 3600 diff --git a/src/test/resources/features/failover.feature b/src/test/resources/features/failover.feature new file mode 100644 index 0000000..424cfea --- /dev/null +++ b/src/test/resources/features/failover.feature @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +# +# SPDX-License-Identifier: Apache-2.0 + +@upstream @iris @failover +Feature: request containing routing_config properly handled + + ################ secondary route ################ + Scenario: Consumer calls proxy route with secondary config, proxy called + Given Secondary routing_config header set + And IDP set to provide internal token + And API provider set to respond on real path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization MeshToken + + Scenario: Consumer calls proxy route with secondary config and skip header, provider called + Given Secondary routing_config header set + And skip zone header set + And API provider set to respond on provider path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization OneTokenSimple + + Scenario: Consumer calls proxy route with secondary config and zone is unhealthy, provider called + Given Secondary routing_config header set + And set zone state to unhealthy + And API provider set to respond on provider path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization OneTokenSimple + +################ proxy route ################ + Scenario: Consumer calls proxy route with proxy config, real proxy called + Given Proxy routing_config header set + And IDP set to provide internal token + And API provider set to respond on real path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization MeshToken + + Scenario: Consumer calls proxy route with proxy config and skip header, failover proxy called + Given Proxy routing_config header set + And skip zone header set + And IDP set to provide internal token + And API provider set to respond on failover path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization MeshToken + + Scenario: Consumer calls proxy route with proxy config and zone is unhealthy, failover proxy called + Given Proxy routing_config header set + And set zone state to unhealthy + And IDP set to provide internal token + And API provider set to respond on failover path + When consumer calls the proxy route without base path + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization MeshToken + +################ headers ################ + Scenario: Consumer calls proxy route with secondary config, check header stripped + Given Secondary routing_config header set + And IDP set to provide internal token + And API provider set to respond on real path + When consumer calls the proxy route without base path + Then API Provider receives no failover headers diff --git a/src/test/resources/features/xTokenExchange.feature b/src/test/resources/features/xTokenExchange.feature new file mode 100644 index 0000000..f25d31d --- /dev/null +++ b/src/test/resources/features/xTokenExchange.feature @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2023 Deutsche Telekom AG +# +# SPDX-License-Identifier: Apache-2.0 + +@upstream @iris @xtokenexchange +Feature: proper authorization token reaches provider endpoint if x-token-exchange header set + + Scenario Outline: Consumer calls proxy route with XtokenExchange Header and currentZone space || canis || aries + Given RealRoute headers are set with x-token-exchange + And current zone is "" + And API provider set to respond with a 200 status code + When consumer calls the proxy route + Then API Provider receives authorization XTokenExchangeHeader + Examples: + | zone | + | canis | + | aries | + | space | + + Scenario Outline: Consumer calls proxy route with XtokenExchange Header and currentZone aws || cetus + Given RealRoute headers are set with x-token-exchange + And current zone is "" + And API provider set to respond with a 200 status code + When consumer calls the proxy route + Then API Provider receives authorization OneToken + Examples: + | zone | + | aws | + | cetus | + + + + Scenario Outline: Consumer calls proxy route with proxy route headers with xTokenExchange header, mesh token sent + Given ProxyRoute headers are set with x-token-exchange + And current zone is "" + And IDP set to provide internal token + And API provider set to respond with a 200 status code + When consumer calls the proxy route + Then API Provider receives default bearer authorization headers + Then API Provider receives authorization MeshToken + And API consumer receives a 200 status code + Examples: + | zone | + | canis | + | aries | + | space | + | aws | + | cetus | + + Scenario Outline: Consumer calls proxy route with real route headers with xTokenExchange header and currentZone space, jc with consumer and provider specific basic auth provided, xTokenExchange sent + Given RealRoute headers are set with x-token-exchange + And current zone is "" + And API provider set to respond with a 200 status code + And jumperConfig basic auth "consumer and provider" set + When consumer calls the proxy route + Then API Provider receives authorization XTokenExchangeHeader + And API consumer receives a 200 status code + Examples: + | zone | + | canis | + | aries | + | space | + + Scenario Outline: Consumer calls proxy route with real route headers with xTokenExchange header and currentZone space, jc with consumer and provider specific basic auth provided, xTokenExchange sent + Given RealRoute headers are set with x-token-exchange + And current zone is "" + And API provider set to respond with a 200 status code + And jumperConfig basic auth "consumer and provider" set + When consumer calls the proxy route + Then API Provider receives authorization BasicAuthConsumer + And API consumer receives a 200 status code + Examples: + | zone | + | aws | + | cetus | \ No newline at end of file