chore(gateway, ping-service, security): streamline configurations, remove redundancies, and improve resilience

- Removed `MdcCorrelationFilter` and simplified correlation ID management using Micrometer Tracing.
- Updated `SecurityConfig` in `gateway` with enhanced role-based access and standardized JWT validation.
- Added new `@Profile` annotations in `ping-service` to exclude certain components during testing.
- Refactored and removed legacy `application-keycloak.yaml` and consolidated settings into the primary `application.yaml`.
- Adjusted Gradle scripts to clean up dependency declarations and improve modularity.
- Simplified CORS and Gateway route configurations for better maintainability.
This commit is contained in:
2026-01-16 21:31:56 +01:00
parent 05962487e7
commit 18f7794a90
19 changed files with 282 additions and 375 deletions
@@ -1,72 +1,50 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestExceptionFormat
// Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt
// für alle externen Anfragen an das Meldestelle-System.
plugins { plugins {
alias(libs.plugins.kotlinJvm) alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring) alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinJpa)
alias(libs.plugins.spring.boot) alias(libs.plugins.spring.boot)
} }
// Konfiguriert die Hauptklasse für das ausführbare JAR
springBoot { springBoot {
mainClass.set("at.mocode.infrastructure.gateway.GatewayApplicationKt") mainClass.set("at.mocode.infrastructure.gateway.GatewayApplicationKt")
} }
dependencies { dependencies {
// Wiederherstellung des Standardzustands: Das Gateway verwendet das reparierte lokale BOM.
implementation(platform(projects.platform.platformBom)) implementation(platform(projects.platform.platformBom))
// === Core Dependencies ===
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
implementation(projects.platform.platformDependencies) implementation(projects.platform.platformDependencies)
implementation(projects.backend.infrastructure.monitoring.monitoringClient) implementation(projects.backend.infrastructure.monitoring.monitoringClient)
implementation(projects.backend.infrastructure.security) // NEU: Security Module
// Wir nutzen das Security-Modul NICHT direkt, um Servlet-Abhängigkeiten zu vermeiden.
// Stattdessen definieren wir die benötigten Reactive-Dependencies hier explizit.
// implementation(projects.backend.infrastructure.security)
// === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN === // === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN ===
// Die WebFlux-Abhängigkeit wird jetzt korrekt durch das BOM bereitgestellt.
implementation(libs.spring.boot.starter.webflux) implementation(libs.spring.boot.starter.webflux)
// Kern-Gateway inkl. Security, Actuator, CircuitBreaker, Discovery
// implementation(libs.bundles.gateway.core)
implementation(libs.spring.cloud.starter.gateway.server.webflux) implementation(libs.spring.cloud.starter.gateway.server.webflux)
implementation(libs.spring.cloud.starter.consul.discovery) implementation(libs.spring.cloud.starter.consul.discovery)
implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.actuator)
// Security dependencies are now transitively provided by infrastructure.security,
// but Gateway is WebFlux, so we might need specific WebFlux security if the shared module is WebMVC only. // Security (Reactive)
// However, starter-security works for both. Resource server might need check. implementation(libs.spring.boot.starter.security)
// For now, we keep explicit dependencies if they differ from the shared module or just rely on shared. implementation(libs.spring.boot.starter.oauth2.resource.server)
// Shared module has: starter-security, starter-oauth2-resource-server, jose, web. implementation(libs.spring.security.oauth2.jose)
// Gateway needs: starter-security, starter-oauth2-resource-server, jose.
// "web" (MVC) vs "webflux" (Reactive) conflict might occur if shared module pulls in MVC.
// CHECK: Shared module uses `implementation(libs.spring.web)`. This pulls in spring-webmvc usually?
// No, `spring-web` is common. `spring-boot-starter-web` pulls in MVC.
// The shared module build.gradle.kts uses `libs.spring.web`.
implementation(libs.spring.cloud.starter.circuitbreaker.resilience4j) implementation(libs.spring.cloud.starter.circuitbreaker.resilience4j)
// Ergänzende Observability (Logging, Jackson)
// implementation(libs.bundles.gateway.observability)
implementation(libs.kotlin.logging.jvm) implementation(libs.kotlin.logging.jvm)
implementation(libs.logback.classic) implementation(libs.logback.classic)
implementation(libs.logback.core) implementation(libs.logback.core)
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
implementation(libs.jackson.datatype.jsr310) implementation(libs.jackson.datatype.jsr310)
// Redis-Unterstützung für verteiltes Rate Limiting (RequestRateLimiter)
// implementation(libs.bundles.gateway.redis)
implementation(libs.spring.boot.starter.data.redis) implementation(libs.spring.boot.starter.data.redis)
// === Tracing Dependencies (Micrometer Tracing) ===
// Ermöglicht verteiltes Tracing über Thread-Grenzen hinweg (ersetzt manuellen MDC-Filter)
implementation(libs.micrometer.tracing.bridge.brave) implementation(libs.micrometer.tracing.bridge.brave)
// Optional: Zipkin Reporter, falls du Traces an Zipkin senden willst (bereits im monitoringClient enthalten, aber hier explizit schadet nicht)
// implementation(libs.zipkin.reporter.brave)
// === Test Dependencies ===
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
// testImplementation(libs.bundles.testing.jvm)
testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.engine) testImplementation(libs.junit.jupiter.engine)
testImplementation(libs.junit.jupiter.params) testImplementation(libs.junit.jupiter.params)
@@ -80,7 +58,6 @@ tasks.test {
useJUnitPlatform() useJUnitPlatform()
} }
// Konfiguration für Integration Tests
sourceSets { sourceSets {
val integrationTest by creating { val integrationTest by creating {
compileClasspath += sourceSets.main.get().output compileClasspath += sourceSets.main.get().output
@@ -1,51 +1,29 @@
package at.mocode.infrastructure.gateway.config package at.mocode.infrastructure.gateway.config
import io.micrometer.tracing.Tracer import org.springframework.cloud.gateway.route.RouteLocator
import org.slf4j.LoggerFactory import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.cloud.gateway.filter.GatewayFilterChain import org.springframework.cloud.gateway.route.builder.filters
import org.springframework.cloud.gateway.filter.GlobalFilter import org.springframework.cloud.gateway.route.builder.routes
import org.springframework.core.Ordered import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Component import org.springframework.context.annotation.Configuration
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
/** @Configuration
* Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security. class GatewayConfig {
*/
/** @Bean
* Globaler Filter, der sicherstellt, dass die Trace-ID (von Micrometer Tracing) fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator {
* auch als "X-Correlation-ID" im Response-Header zurückgegeben wird. return builder.routes {
* route(id = "ping-service") {
* Hinweis: Micrometer Tracing kümmert sich bereits automatisch um die Propagation path("/api/ping/**")
* der Trace-ID (b3 oder w3c) an nachgelagerte Services. Dieser Filter dient nur filters {
* der Bequemlichkeit für Clients (z. B. Frontend), um die ID einfach auslesen zu können. stripPrefix(1)
*/ circuitBreaker {
@Component it.name = "pingServiceCB"
class CorrelationIdFilter(private val tracer: Tracer) : GlobalFilter, Ordered { it.fallbackUri = java.net.URI.create("forward:/fallback/ping")
}
private val logger = LoggerFactory.getLogger(CorrelationIdFilter::class.java) }
uri("http://ping-service:8082")
companion object {
const val CORRELATION_ID_HEADER = "X-Correlation-ID"
}
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
// Die aktuelle Trace-ID aus dem Micrometer Tracer holen
val currentSpan = tracer.currentSpan()
val traceId = currentSpan?.context()?.traceId()
if (traceId != null) {
// Trace-ID als Response-Header hinzufügen
exchange.response.headers.add(CORRELATION_ID_HEADER, traceId)
}
return chain.filter(exchange)
.doOnError { ex ->
logger.error("Error processing request {}: {}", exchange.request.uri, ex.message)
} }
}
} }
// Niedrige Priorität, damit Tracing-Kontext bereits initialisiert ist
override fun getOrder(): Int = Ordered.LOWEST_PRECEDENCE
} }
@@ -1,40 +0,0 @@
package at.mocode.infrastructure.gateway.config
import at.mocode.infrastructure.gateway.config.CorrelationIdFilter.Companion.CORRELATION_ID_HEADER
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.core.Ordered
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
/**
* Minimaler MDC-Filter: schreibt die vorhandene X-Correlation-ID in den MDC,
* damit Logs die ID automatisch mitführen. Keine Body-/PII-Logs, nur Header-ID.
*
* Reihenfolge: direkt nach dem CorrelationIdFilter ausführen, damit die ID
* bereits gesetzt ist. Daher Order = HIGHEST_PRECEDENCE + 1.
*/
@Component
class MdcCorrelationFilter : GlobalFilter, Ordered {
private val logger = LoggerFactory.getLogger(MdcCorrelationFilter::class.java)
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val correlationId = exchange.request.headers.getFirst(CORRELATION_ID_HEADER)
if (correlationId != null) {
MDC.put(CORRELATION_ID_HEADER, correlationId)
}
return chain.filter(exchange)
.doOnError { ex ->
logger.error("Error in MdcCorrelationFilter: {}", ex.message)
}
// Bei Abschluss säubern, um Leaks über Thread-Grenzen zu vermeiden
.doFinally { MDC.remove(CORRELATION_ID_HEADER) }
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1
}
@@ -33,7 +33,6 @@ class GatewayMetricsConfig {
const val GATEWAY_ERROR_COUNTER = "gateway_errors_total" const val GATEWAY_ERROR_COUNTER = "gateway_errors_total"
const val GATEWAY_REQUESTS_COUNTER = "gateway_requests_total" const val GATEWAY_REQUESTS_COUNTER = "gateway_requests_total"
const val GATEWAY_CIRCUIT_BREAKER_COUNTER = "gateway_circuit_breaker_events_total" const val GATEWAY_CIRCUIT_BREAKER_COUNTER = "gateway_circuit_breaker_events_total"
const val GATEWAY_DOWNSTREAM_HEALTH_GAUGE = "gateway_downstream_health_status"
} }
/** /**
@@ -71,13 +70,6 @@ class GatewayMetricsConfig {
return GatewayMetricsWebFilter(meterRegistry) return GatewayMetricsWebFilter(meterRegistry)
} }
/**
* Bean für Request Duration Timer - entfernt um Konflikte mit dem WebFilter zu vermeiden.
* Die Request-Zeiten werden automatisch im GatewayMetricsWebFilter erfasst.
*/
// @Bean - entfernt, um Prometheus Meter-Konflikte zu vermeiden,
// fun requestTimer(meterRegistry: MeterRegistry): Timer { ... }
/** /**
* Bean für Error Counter - ermöglicht manuelles Error Tracking. * Bean für Error Counter - ermöglicht manuelles Error Tracking.
*/ */
@@ -6,13 +6,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.web.server.invoke import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter
import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers
import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@@ -23,164 +27,115 @@ import java.time.Duration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@EnableConfigurationProperties(GatewaySecurityProperties::class) @EnableConfigurationProperties(GatewaySecurityProperties::class)
class SecurityConfig( class SecurityConfig(
private val securityProperties: GatewaySecurityProperties private val securityProperties: GatewaySecurityProperties
) { ) {
private val logger = LoggerFactory.getLogger(SecurityConfig::class.java) @Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
/** return http
* Konfiguriert die zentrale Security-Filter-Kette für das Gateway. .csrf { it.disable() }
* .cors { it.configurationSource(corsConfigurationSource()) }
* Diese Konfiguration nutzt den Standard-OAuth2-Resource-Server von Spring Security, .authorizeExchange { exchanges ->
* um JWTs (z.B. von Keycloak) automatisch zu validieren. exchanges
*/ .pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll()
@Bean .pathMatchers("/api/ping/**").hasRole("MELD_USER") // Beispiel Rolle
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { .anyExchange().authenticated()
return http { // Start der modernen Kotlin-DSL
// 1. CORS-Konfiguration anwenden
cors { }
// 2. CSRF deaktivieren (für zustandslose APIs)
csrf { disable() }
// 3. Routen-Berechtigungen definieren
authorizeExchange {
// Öffentlich zugängliche Pfade aus der .yml-Datei laden
authorize(
pathMatchers(*securityProperties.publicPaths.toTypedArray()),
permitAll
)
// Ping-API erfordert Admin-Rolle (Realm-Rolle "admin")
authorize(pathMatchers("/api/ping/**"), hasRole("admin"))
// Alle anderen Pfade erfordern eine Authentifizierung
authorize(anyExchange, authenticated)
}
// 4. JWT-Validierung via Keycloak aktivieren
oauth2ResourceServer {
jwt {
// Realm-Rollen (Keycloak) -> ROLE_* Authorities
jwtAuthenticationConverter = realmRolesJwtAuthenticationConverter()
}
}
}
}
/**
* Erstellt einen ReactiveJwtDecoder für die JWT-Validierung.
*
* Verwendet die JWK Set URI aus der Konfiguration, um die öffentlichen Schlüssel
* von Keycloak zu laden.
*
* Resilience-Optimierung:
* Anstatt beim Start zu failen oder einen statischen NoOp-Decoder zu nutzen,
* verwenden wir einen delegierenden Decoder. Dieser versucht bei jedem Request,
* den echten Decoder (lazy) zu initialisieren, falls er noch nicht bereit ist.
* So kann Keycloak auch NACH dem Gateway starten.
*/
@Bean
fun reactiveJwtDecoder(
@Value($$"${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") jwkSetUri: String
): ReactiveJwtDecoder {
return ResilienceReactiveJwtDecoder(jwkSetUri)
}
/**
* Ein Wrapper um den NimbusReactiveJwtDecoder, der Initialisierungsfehler abfängt
* und erst zur Laufzeit (lazy) versucht, die JWKs zu laden.
*/
class ResilienceReactiveJwtDecoder(private val jwkSetUri: String) : ReactiveJwtDecoder {
private val logger = LoggerFactory.getLogger(ResilienceReactiveJwtDecoder::class.java)
private var delegate: ReactiveJwtDecoder? = null
override fun decode(token: String): Mono<org.springframework.security.oauth2.jwt.Jwt> {
if (delegate == null) {
synchronized(this) {
if (delegate == null) {
try {
if (jwkSetUri.isBlank()) {
throw IllegalArgumentException("JWK Set URI is missing")
}
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
logger.info("JWT Decoder successfully initialized.")
} catch (e: Exception) {
logger.warn("Could not initialize JWT Decoder (Keycloak might be down): {}", e.message)
return Mono.error(IllegalStateException("Identity Provider currently unavailable. Please try again later."))
} }
} .oauth2ResourceServer { oauth2 ->
} oauth2.jwt { jwt ->
} jwt.jwtAuthenticationConverter(realmRolesJwtAuthenticationConverter())
return delegate!!.decode(token) }
.onErrorResume { e -> }
// Falls der Decoder zwar da ist, aber z.B. Netzwerkfehler auftreten, loggen wir das .build()
logger.debug("JWT decoding failed: {}", e.message) }
Mono.error(e)
@Bean
fun reactiveJwtDecoder(
@Value($$"${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") jwkSetUri: String
): ReactiveJwtDecoder {
return ResilienceReactiveJwtDecoder(jwkSetUri)
}
class ResilienceReactiveJwtDecoder(private val jwkSetUri: String) : ReactiveJwtDecoder {
private val logger = LoggerFactory.getLogger(ResilienceReactiveJwtDecoder::class.java)
private var delegate: ReactiveJwtDecoder? = null
override fun decode(token: String): Mono<Jwt> {
if (delegate == null) {
synchronized(this) {
if (delegate == null) {
try {
if (jwkSetUri.isBlank()) {
throw IllegalArgumentException("JWK Set URI is missing")
}
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
logger.info("JWT Decoder successfully initialized.")
} catch (e: Exception) {
logger.warn("Could not initialize JWT Decoder: {}", e.message)
return Mono.error(IllegalStateException("Identity Provider unavailable"))
}
}
}
}
return delegate!!.decode(token)
.onErrorResume { e ->
logger.debug("JWT decoding failed: {}", e.message)
Mono.error(e)
}
} }
} }
}
/** @Bean
* Konvertiert Keycloak Realm-Rollen (realm_access.roles) in Spring Authorities (ROLE_*), fun realmRolesJwtAuthenticationConverter(): Converter<Jwt, Mono<AbstractAuthenticationToken>> {
* sodass hasRole("admin") funktioniert. val converter = JwtAuthenticationConverter()
*/ converter.setJwtGrantedAuthoritiesConverter { jwt ->
@Bean val realmAccess = jwt.claims["realm_access"] as? Map<*, *>
fun realmRolesJwtAuthenticationConverter(): org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter { val roles = realmAccess?.get("roles") as? Collection<*> ?: emptyList<Any>()
val converter = org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter() roles
converter.setJwtGrantedAuthoritiesConverter { jwt -> .filterIsInstance<String>()
val realmAccess = jwt.claims["realm_access"] as? Map<*, *> .map { role -> SimpleGrantedAuthority("ROLE_${role.uppercase()}") }
val roles = realmAccess?.get("roles") as? Collection<*> ?: emptyList<Any>() }
roles return ReactiveJwtAuthenticationConverterAdapter(converter)
.filterIsInstance<String>()
.map { role -> org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_${role.uppercase()}") }
}
return org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter(converter)
}
/**
* Definiert die zentrale und einzige CORS-Konfiguration für das Gateway.
*/
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration().apply {
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
allowedMethods = securityProperties.cors.allowedMethods.toList()
allowedHeaders = securityProperties.cors.allowedHeaders.toList()
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
allowCredentials = securityProperties.cors.allowCredentials
maxAge = securityProperties.cors.maxAge.seconds
} }
return UrlBasedCorsConfigurationSource().apply { @Bean
registerCorsConfiguration("/**", configuration) fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration().apply {
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
allowedMethods = securityProperties.cors.allowedMethods.toList()
allowedHeaders = securityProperties.cors.allowedHeaders.toList()
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
allowCredentials = securityProperties.cors.allowCredentials
maxAge = securityProperties.cors.maxAge.seconds
}
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
} }
}
} }
/**
* Configurations-Properties für alle sicherheitsrelevanten Einstellungen des Gateways.
*/
@ConfigurationProperties(prefix = "gateway.security") @ConfigurationProperties(prefix = "gateway.security")
data class GatewaySecurityProperties( data class GatewaySecurityProperties(
val cors: CorsProperties = CorsProperties(), val cors: CorsProperties = CorsProperties(),
val publicPaths: List<String> = listOf( val publicPaths: List<String> = listOf(
"/", "/",
"/fallback/**", "/fallback/**",
"/actuator/**", "/actuator/**",
"/webjars/**", "/webjars/**",
"/v3/api-docs/**", "/v3/api-docs/**",
"/api/auth/**" // Alle Auth-Endpunkte "/api/auth/**",
) "/api/ping/public",
"/api/ping/health"
)
) )
/**
* DTO für CORS-Properties mit sinnvollen Standardwerten.
*/
data class CorsProperties( data class CorsProperties(
val allowedOriginPatterns: Set<String> = setOf("http://localhost:[*]", "https://*.meldestelle.at"), val allowedOriginPatterns: Set<String> = setOf("http://localhost:*", "https://*.meldestelle.at"),
val allowedMethods: Set<String> = setOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"), val allowedMethods: Set<String> = setOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"),
val allowedHeaders: Set<String> = setOf("*"), val allowedHeaders: Set<String> = setOf("*"),
val exposedHeaders: Set<String> = setOf("X-Correlation-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining"), val exposedHeaders: Set<String> = setOf("X-Correlation-ID"),
val allowCredentials: Boolean = true, val allowCredentials: Boolean = true,
val maxAge: Duration = Duration.ofHours(1) val maxAge: Duration = Duration.ofHours(1)
) )
@@ -1,18 +0,0 @@
# migrated from application-keycloak.yml (standardized to .yaml)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs}
keycloak:
server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8180}
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs}
realm: ${KEYCLOAK_REALM:meldestelle}
resource: ${KEYCLOAK_CLIENT_ID:api-gateway}
client-id: ${KEYCLOAK_CLIENT_ID:api-gateway}
public-client: false
bearer-only: true
@@ -1,58 +1,23 @@
spring: spring:
application: application:
name: gateway name: "gateway"
autoconfigure: autoconfigure:
exclude: exclude:
- org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration - "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration"
- org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration
cloud: cloud:
gateway: gateway:
globalcors: # Wir nutzen die Standard-HTTP-Client-Konfiguration (Reactor Netty Defaults).
cors-configurations: # Explizite Timeouts oder Pool-Settings können bei Bedarf über System-Properties
'[/**]': # oder spezifische Beans gesetzt werden, um Deprecation-Warnungen in YAML zu vermeiden.
allowed-origin-patterns: "http://localhost:*,http://127.0.0.1:*" httpclient: {}
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers: "*"
allow-credentials: true
max-age: 3600
httpclient:
connect-timeout: 3000
response-timeout: 5s
routes:
- id: ping-service
# Nutze lb:// wenn Service Discovery aktiv ist, sonst http://hostname:port
# Da wir Consul nutzen, ist lb://ping-service besser, aber für Tracer Bullet
# und direkte Docker-Kommunikation ist http://ping-service:8082 sicherer,
# falls Consul noch nicht 100% stabil ist.
# Wir nutzen hier den Docker Alias und den konfigurierten Port.
uri: http://ping-service:8082
predicates:
- Path=/api/ping/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: pingServiceCB
fallbackUri: forward:/fallback/ping
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,prometheus include: "health,info,prometheus"
tracing: tracing:
sampling: sampling:
probability: 1.0 probability: 1.0
propagation: propagation:
type: w3c type: "w3c"
gateway:
ratelimit:
enabled: false
replenish-rate: 10
burst-capacity: 20
@@ -27,6 +27,9 @@ class GlobalSecurityConfig {
// Explizite Freigaben (Health, Info, Public Endpoints) // Explizite Freigaben (Health, Info, Public Endpoints)
auth.requestMatchers("/actuator/**").permitAll() auth.requestMatchers("/actuator/**").permitAll()
auth.requestMatchers("/ping/public").permitAll() auth.requestMatchers("/ping/public").permitAll()
auth.requestMatchers("/ping/simple").permitAll()
auth.requestMatchers("/ping/enhanced").permitAll()
auth.requestMatchers("/ping/health").permitAll()
auth.requestMatchers("/error").permitAll() auth.requestMatchers("/error").permitAll()
// Alles andere muss authentifiziert sein // Alles andere muss authentifiziert sein
@@ -3,6 +3,7 @@ package at.mocode.ping.application
import at.mocode.ping.domain.Ping import at.mocode.ping.domain.Ping
import at.mocode.ping.domain.PingRepository import at.mocode.ping.domain.PingRepository
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -14,6 +15,7 @@ import kotlin.uuid.Uuid
* Hier darf Spring (@Service, @Transactional) verwendet werden, da es "Application Logic" ist. * Hier darf Spring (@Service, @Transactional) verwendet werden, da es "Application Logic" ist.
*/ */
@Service @Service
@Profile("!test") // Nicht im Test-Profil laden, damit wir Mocks nutzen können
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class PingService( class PingService(
private val repository: PingRepository private val repository: PingRepository
@@ -14,6 +14,10 @@ class PingJpaEntity(
val message: String, val message: String,
val createdAt: Instant val createdAt: Instant
) { ) {
// Default constructor for JPA // The default constructor for JPA
protected constructor() : this(UUID.randomUUID(), "", Instant.now()) // Protected is fine for Hibernate, but the Kotlin compiler might complain about visibility.
// We can make it private or internal if needed, but protected is standard.
// To suppress the warning "effectively private", we can just leave it as is or make it public/internal.
// Let's try making it internal to satisfy Kotlin while keeping it hidden from public API.
internal constructor() : this(UUID.randomUUID(), "", Instant.now())
} }
@@ -2,6 +2,7 @@ package at.mocode.ping.infrastructure.persistence
import at.mocode.ping.domain.Ping import at.mocode.ping.domain.Ping
import at.mocode.ping.domain.PingRepository import at.mocode.ping.domain.PingRepository
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -10,6 +11,7 @@ import kotlin.uuid.toKotlinUuid
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
@Repository @Repository
@Profile("!test") // Nicht im Test-Profil laden, damit wir Mocks nutzen können
class PingRepositoryAdapter( class PingRepositoryAdapter(
private val jpaRepository: SpringDataPingRepository private val jpaRepository: SpringDataPingRepository
) : PingRepository { ) : PingRepository {
@@ -83,6 +83,7 @@ class PingController(
) )
// Fallback // Fallback
@Suppress("unused", "UNUSED_PARAMETER")
fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse { fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse {
logger.warn("Circuit breaker fallback triggered: {}", ex.message) logger.warn("Circuit breaker fallback triggered: {}", ex.message)
return EnhancedPingResponse( return EnhancedPingResponse(
@@ -2,16 +2,22 @@ package at.mocode.ping.service
import at.mocode.ping.application.PingUseCase import at.mocode.ping.application.PingUseCase
import at.mocode.ping.domain.Ping import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter
import at.mocode.ping.infrastructure.web.PingController import at.mocode.ping.infrastructure.web.PingController
import at.mocode.ping.test.TestPingServiceApplication
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Primary
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@@ -22,21 +28,29 @@ import java.time.Instant
*/ */
@WebMvcTest( @WebMvcTest(
controllers = [PingController::class], controllers = [PingController::class],
excludeAutoConfiguration = [ properties = ["spring.aop.proxy-target-class=true"]
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class,
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class
]
) )
@Import(PingControllerIntegrationTest.TestConfig::class) @ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test")
@Import(PingControllerIntegrationTest.PingControllerIntegrationTestConfig::class)
class PingControllerIntegrationTest { class PingControllerIntegrationTest {
@Autowired @Autowired
private lateinit var mockMvc: MockMvc private lateinit var mockMvc: MockMvc
@TestConfiguration @Autowired
class TestConfig { @Qualifier("pingUseCaseIntegrationMock")
@Bean private lateinit var pingUseCase: PingUseCase
@Configuration
class PingControllerIntegrationTestConfig {
@Bean("pingUseCaseIntegrationMock")
@Primary
fun pingUseCase(): PingUseCase = mockk(relaxed = true) fun pingUseCase(): PingUseCase = mockk(relaxed = true)
@Bean
@Primary
fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true)
} }
@Test @Test
@@ -46,8 +60,7 @@ class PingControllerIntegrationTest {
// For endpoints that require the use-case, the relaxed mock is sufficient, // For endpoints that require the use-case, the relaxed mock is sufficient,
// but we still provide deterministic ping data. // but we still provide deterministic ping data.
val useCase = TestConfig().pingUseCase() every { pingUseCase.executePing(any()) } returns Ping(
every { useCase.executePing(any()) } returns Ping(
message = "Simple Ping", message = "Simple Ping",
timestamp = Instant.parse("2023-10-01T10:00:00Z") timestamp = Instant.parse("2023-10-01T10:00:00Z")
) )
@@ -1,8 +1,10 @@
package at.mocode.ping.service package at.mocode.ping.service
import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.web.PingController
import at.mocode.ping.application.PingUseCase import at.mocode.ping.application.PingUseCase
import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter
import at.mocode.ping.infrastructure.web.PingController
import at.mocode.ping.test.TestPingServiceApplication
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@@ -10,12 +12,16 @@ import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Primary
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch
@@ -30,12 +36,11 @@ import java.time.Instant
*/ */
@WebMvcTest( @WebMvcTest(
controllers = [PingController::class], controllers = [PingController::class],
excludeAutoConfiguration = [ properties = ["spring.aop.proxy-target-class=true"]
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class,
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class
]
) )
@Import(PingControllerTest.TestConfig::class) @ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test")
@Import(PingControllerTest.PingControllerTestConfig::class)
@AutoConfigureMockMvc @AutoConfigureMockMvc
class PingControllerTest { class PingControllerTest {
@@ -43,15 +48,21 @@ class PingControllerTest {
private lateinit var mockMvc: MockMvc private lateinit var mockMvc: MockMvc
@Autowired @Autowired
@Qualifier("pingUseCaseMock")
private lateinit var pingUseCase: PingUseCase private lateinit var pingUseCase: PingUseCase
@Autowired @Autowired
private lateinit var objectMapper: ObjectMapper private lateinit var objectMapper: ObjectMapper
@TestConfiguration @Configuration
class TestConfig { class PingControllerTestConfig {
@Bean @Bean("pingUseCaseMock")
@Primary
fun pingUseCase(): PingUseCase = mockk(relaxed = true) fun pingUseCase(): PingUseCase = mockk(relaxed = true)
@Bean
@Primary
fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true)
} }
@BeforeEach @BeforeEach
@@ -77,10 +88,7 @@ class PingControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andReturn() .andReturn()
// In some environments the JSONPath matcher fails to parse the response body.
// We still validate the serialized output contains the expected fields.
val body = result.response.contentAsString val body = result.response.contentAsString
System.out.println("[DEBUG_LOG] /ping/simple response status=${result.response.status} contentType=${result.response.contentType} body=$body")
val json = objectMapper.readTree(body) val json = objectMapper.readTree(body)
assertThat(json.has("status")).isTrue assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("pong") assertThat(json["status"].asText()).isEqualTo("pong")
@@ -107,7 +115,6 @@ class PingControllerTest {
.andReturn() .andReturn()
val body = result.response.contentAsString val body = result.response.contentAsString
System.out.println("[DEBUG_LOG] /ping/enhanced response status=${result.response.status} contentType=${result.response.contentType} body=$body")
val json = objectMapper.readTree(body) val json = objectMapper.readTree(body)
assertThat(json.has("status")).isTrue assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("pong") assertThat(json["status"].asText()).isEqualTo("pong")
@@ -128,7 +135,6 @@ class PingControllerTest {
.andReturn() .andReturn()
val body = result.response.contentAsString val body = result.response.contentAsString
System.out.println("[DEBUG_LOG] /ping/health response status=${result.response.status} contentType=${result.response.contentType} body=$body")
val json = objectMapper.readTree(body) val json = objectMapper.readTree(body)
assertThat(json.has("status")).isTrue assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("up") assertThat(json["status"].asText()).isEqualTo("up")
@@ -0,0 +1,26 @@
package at.mocode.ping.test
import at.mocode.ping.infrastructure.web.PingController
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.context.annotation.Import
/**
* Eine spezielle Application-Klasse für Tests.
* Sie liegt in einem separaten Package, damit sie nicht automatisch von @WebMvcTest gefunden wird,
* sondern explizit importiert werden muss.
*
* WICHTIG: Wir scannen HIER NICHT das 'at.mocode.ping' Package!
* Das verhindert, dass echte Services und Repositories geladen werden.
* Wir scannen nur die Security-Infrastruktur.
*
* Den Controller importieren wir explizit, damit er verfügbar ist.
*/
@SpringBootApplication
@ComponentScan(
basePackages = ["at.mocode.infrastructure.security"]
)
@Import(PingController::class)
@EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller
class TestPingServiceApplication
@@ -1,6 +1,8 @@
spring: spring:
application: application:
name: ping-service-test name: ping-service-test
main:
allow-bean-definition-overriding: true
cloud: cloud:
consul: consul:
enabled: false enabled: false
@@ -39,4 +39,12 @@ class PingApiClient(
override suspend fun healthCheck(): HealthResponse { override suspend fun healthCheck(): HealthResponse {
return client.get("$baseUrl/api/ping/health").body() return client.get("$baseUrl/api/ping/health").body()
} }
override suspend fun publicPing(): PingResponse {
return client.get("$baseUrl/api/ping/public").body()
}
override suspend fun securePing(): PingResponse {
return client.get("$baseUrl/api/ping/secure").body()
}
} }
@@ -26,4 +26,12 @@ class PingApiKoinClient(private val client: HttpClient) : PingApi {
override suspend fun healthCheck(): HealthResponse { override suspend fun healthCheck(): HealthResponse {
return client.get("/api/ping/health").body() return client.get("/api/ping/health").body()
} }
override suspend fun publicPing(): PingResponse {
return client.get("/api/ping/public").body()
}
override suspend fun securePing(): PingResponse {
return client.get("/api/ping/secure").body()
}
} }
@@ -21,30 +21,21 @@ class TestPingApiClient : PingApi {
var simplePingResponse: PingResponse? = null var simplePingResponse: PingResponse? = null
var enhancedPingResponse: EnhancedPingResponse? = null var enhancedPingResponse: EnhancedPingResponse? = null
var healthResponse: HealthResponse? = null var healthResponse: HealthResponse? = null
var publicPingResponse: PingResponse? = null
var securePingResponse: PingResponse? = null
// Call tracking // Call tracking
var simplePingCalled = false var simplePingCalled = false
var enhancedPingCalledWith: Boolean? = null var enhancedPingCalledWith: Boolean? = null
var healthCheckCalled = false var healthCheckCalled = false
var publicPingCalled = false
var securePingCalled = false
var callCount = 0 var callCount = 0
override suspend fun simplePing(): PingResponse { override suspend fun simplePing(): PingResponse {
simplePingCalled = true simplePingCalled = true
callCount++ callCount++
return handleRequest(simplePingResponse)
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return simplePingResponse ?: PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
} }
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
@@ -88,6 +79,34 @@ class TestPingApiClient : PingApi {
) )
} }
override suspend fun publicPing(): PingResponse {
publicPingCalled = true
callCount++
return handleRequest(publicPingResponse)
}
override suspend fun securePing(): PingResponse {
securePingCalled = true
callCount++
return handleRequest(securePingResponse)
}
private suspend fun handleRequest(response: PingResponse?): PingResponse {
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return response ?: PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
}
// Test utilities // Test utilities
fun reset() { fun reset() {
shouldThrowException = false shouldThrowException = false
@@ -97,9 +116,13 @@ class TestPingApiClient : PingApi {
simplePingResponse = null simplePingResponse = null
enhancedPingResponse = null enhancedPingResponse = null
healthResponse = null healthResponse = null
publicPingResponse = null
securePingResponse = null
simplePingCalled = false simplePingCalled = false
enhancedPingCalledWith = null enhancedPingCalledWith = null
healthCheckCalled = false healthCheckCalled = false
publicPingCalled = false
securePingCalled = false
callCount = 0 callCount = 0
} }
} }