chore(ping-service, security): integrate centralized security module and enhance Ping-Service

- Replaced local `SecurityConfig` in `ping-service` with the shared `infrastructure:security` module.
- Added `GlobalSecurityConfig` to standardize OAuth2, JWT validation, and CORS for all services.
- Introduced new endpoints (`/ping/public`, `/ping/secure`) with role-based access control.
- Updated database schema with Flyway migration (`V1__init_ping.sql`) and refactored persistence layer to align with the standardized approach (`createdAt` field).
- Enhanced application configuration (`application.yaml`) to use shared security and Flyway settings.
This commit is contained in:
Stefan Mogeritsch 2026-01-16 19:11:48 +01:00
parent 9456f28562
commit 05962487e7
14 changed files with 234 additions and 124 deletions

View File

@ -25,7 +25,12 @@ spring:
response-timeout: 5s
routes:
- id: ping-service
uri: http://ping-service:8080
# 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:
@ -42,13 +47,12 @@ management:
include: health,info,prometheus
tracing:
sampling:
probability: 1.0 # 100% der Requests tracen (für Dev/Test sinnvoll, in Prod reduzieren)
probability: 1.0
propagation:
type: w3c # Standard W3C Trace Context (kompatibel mit OpenTelemetry)
type: w3c
# Gateway-spezifische Einstellungen
gateway:
ratelimit:
enabled: false # Start: ausgeschaltet; zum Aktivieren default-filters plus RequestRateLimiter in YAML hinzufügen
enabled: false
replenish-rate: 10
burst-capacity: 20

View File

@ -0,0 +1,64 @@
package at.mocode.infrastructure.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // Erlaubt @PreAuthorize in Services/Controllern
class GlobalSecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() } // CSRF nicht nötig für Stateless REST APIs
.cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
// Explizite Freigaben (Health, Info, Public Endpoints)
auth.requestMatchers("/actuator/**").permitAll()
auth.requestMatchers("/ping/public").permitAll()
auth.requestMatchers("/error").permitAll()
// Alles andere muss authentifiziert sein
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}
@Bean
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter(KeycloakRoleConverter())
return converter
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
// Erlaube Frontend (localhost, docker host)
configuration.allowedOriginPatterns = listOf("*") // Für Dev; in Prod einschränken!
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
configuration.allowedHeaders = listOf("*")
configuration.allowCredentials = true
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
}

View File

@ -0,0 +1,40 @@
package at.mocode.infrastructure.security
import org.springframework.core.convert.converter.Converter
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
/**
* Konvertiert Keycloak-Rollen aus dem JWT (Realm Access & Resource Access)
* in Spring Security GrantedAuthorities.
*
* Erwartetes Format im Token:
* "realm_access": { "roles": ["admin", "user"] }
* "resource_access": { "my-client": { "roles": ["client-role"] } }
*/
class KeycloakRoleConverter : Converter<Jwt, Collection<GrantedAuthority>> {
override fun convert(jwt: Jwt): Collection<GrantedAuthority> {
val roles = mutableSetOf<String>()
// 1. Realm Roles extrahieren
val realmAccess = jwt.claims["realm_access"] as? Map<*, *>
if (realmAccess != null) {
(realmAccess["roles"] as? List<*>)?.forEach { role ->
if (role is String) {
roles.add(role)
}
}
}
// 2. Resource (Client) Roles extrahieren
// Optional: Falls wir Client-spezifische Rollen brauchen.
// Hier mappen wir vorerst nur Realm-Rollen global.
// 3. Mapping zu GrantedAuthority (Prefix "ROLE_" ist Standard in Spring Security)
return roles.map { role ->
SimpleGrantedAuthority("ROLE_${role.uppercase()}")
}
}
}

View File

@ -2,11 +2,13 @@ package at.mocode.ping
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.EnableAspectJAutoProxy
@SpringBootApplication
// Scannt explizit alle Sub-Packages (infrastructure, application, domain)
@EnableAspectJAutoProxy
// Scannt das eigene Service-Package UND das Security-Infrastruktur-Package
@ComponentScan(basePackages = ["at.mocode.ping", "at.mocode.infrastructure.security"])
class PingServiceApplication
fun main(args: Array<String>) {

View File

@ -1,32 +0,0 @@
package at.mocode.ping.infrastructure
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.authorizeHttpRequests { auth ->
auth
// Public endpoints
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/ping/simple", "/ping/enhanced", "/ping/health", "/ping/history").permitAll()
// Secure endpoints
.requestMatchers("/ping/secure").authenticated()
// Default deny
.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { }
}
return http.build()
}
}

View File

@ -6,18 +6,13 @@ import jakarta.persistence.Table
import java.time.Instant
import java.util.UUID
/**
* JPA Entity (Infrastructure Detail).
* Spiegelt die Datenbank-Tabelle wider.
* Nutzt java.util.UUID für JPA-Kompatibilität (bis Hibernate kotlin.uuid nativ unterstützt).
*/
@Entity
@Table(name = "pings")
@Table(name = "ping")
class PingJpaEntity(
@Id
val id: UUID,
val message: String,
val timestamp: Instant
val createdAt: Instant
) {
// Default constructor for JPA
protected constructor() : this(UUID.randomUUID(), "", Instant.now())

View File

@ -2,48 +2,41 @@ package at.mocode.ping.infrastructure.persistence
import at.mocode.ping.domain.Ping
import at.mocode.ping.domain.PingRepository
import org.springframework.stereotype.Component
import org.springframework.stereotype.Repository
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Driven Adapter.
* Implementiert den Domain-Port `PingRepository` mithilfe von Spring Data JPA.
* Mappt zwischen Domain-Entity und JPA-Entity.
*/
@Component
@OptIn(ExperimentalUuidApi::class)
@Repository
class PingRepositoryAdapter(
private val jpaRepository: SpringDataPingRepository
) : PingRepository {
override fun save(ping: Ping): Ping {
val jpaEntity = PingJpaEntity(
id = ping.id.toJavaUuid(),
message = ping.message,
timestamp = ping.timestamp
)
val saved = jpaRepository.save(jpaEntity)
return mapToDomain(saved)
val entity = ping.toEntity()
val savedEntity = jpaRepository.save(entity)
return savedEntity.toDomain()
}
override fun findAll(): List<Ping> {
return jpaRepository.findAll().map { mapToDomain(it) }
return jpaRepository.findAll().map { it.toDomain() }
}
override fun findById(id: Uuid): Ping? {
return jpaRepository.findById(id.toJavaUuid())
.map { mapToDomain(it) }
.orElse(null)
return jpaRepository.findById(id.toJavaUuid()).map { it.toDomain() }.orElse(null)
}
private fun mapToDomain(entity: PingJpaEntity): Ping {
return Ping(
id = entity.id.toKotlinUuid(),
message = entity.message,
timestamp = entity.timestamp
)
}
private fun Ping.toEntity() = PingJpaEntity(
id = this.id.toJavaUuid(),
message = this.message,
createdAt = this.timestamp
)
private fun PingJpaEntity.toDomain() = Ping(
id = this.id.toKotlinUuid(),
message = this.message,
timestamp = this.createdAt
)
}

View File

@ -1,8 +1,6 @@
package at.mocode.ping.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface SpringDataPingRepository : JpaRepository<PingJpaEntity, UUID>

View File

@ -7,6 +7,7 @@ import at.mocode.ping.api.PingResponse
import at.mocode.ping.application.PingUseCase
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.slf4j.LoggerFactory
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@ -32,14 +33,8 @@ class PingController(
@GetMapping("/ping/simple")
override suspend fun simplePing(): PingResponse {
// Ruft Use Case auf -> Speichert in DB
val domainPing = pingUseCase.executePing("Simple Ping")
return PingResponse(
status = "pong",
timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter),
service = "ping-service"
)
return createResponse(domainPing, "pong")
}
@GetMapping("/ping/enhanced")
@ -53,9 +48,7 @@ class PingController(
throw RuntimeException("Simulated service failure")
}
// Use Case Aufruf
val domainPing = pingUseCase.executePing("Enhanced Ping")
val elapsedMs = (System.nanoTime() - start) / 1_000_000
return EnhancedPingResponse(
@ -67,7 +60,29 @@ class PingController(
)
}
// Fallback muss public sein für Resilience4j Proxy
// Neue Endpunkte
@GetMapping("/ping/public")
override suspend fun publicPing(): PingResponse {
val domainPing = pingUseCase.executePing("Public Ping")
return createResponse(domainPing, "public-pong")
}
@GetMapping("/ping/secure")
@PreAuthorize("hasRole('MELD_USER') or hasRole('MELD_ADMIN')") // Beispiel-Rollen
override suspend fun securePing(): PingResponse {
val domainPing = pingUseCase.executePing("Secure Ping")
return createResponse(domainPing, "secure-pong")
}
// Helper
private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse(
status = status,
timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter),
service = "ping-service"
)
// Fallback
fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse {
logger.warn("Circuit breaker fallback triggered: {}", ex.message)
return EnhancedPingResponse(
@ -89,7 +104,6 @@ class PingController(
)
}
// Zusätzlicher Endpunkt um die DB zu prüfen (History)
@GetMapping("/ping/history")
fun getHistory() = pingUseCase.getPingHistory().map {
mapOf("id" to it.id.toString(), "message" to it.message, "time" to it.timestamp.toString())

View File

@ -8,27 +8,29 @@ spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# datasource:
# url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
# username: ${SPRING_DATASOURCE_USERNAME:pg-user}
# password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
# driver-class-name: org.postgresql.Driver
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/meldestelle}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# # --- REDIS KONFIGURATION (NEU) ---
# data:
# redis:
# host: ${SPRING_DATA_REDIS_HOST:localhost}
# port: ${SPRING_DATA_REDIS_PORT:6379}
# password: ${SPRING_DATA_REDIS_PASSWORD:redis-password} # Leer lassen als Default
# # Optional: Timeouts für Stabilität
# connect-timeout: 5s
# timeout: 2s
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate # Flyway kümmert sich um Schema!
open-in-view: false
# jpa:
# database-platform: org.hibernate.dialect.PostgreSQLDialect
# hibernate:
# ddl-auto: update
# open-in-view: false
flyway:
enabled: true
baseline-on-migrate: true
security:
oauth2:
resourceserver:
jwt:
# Keycloak URL (innerhalb Docker Netzwerk oder Localhost)
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:9090/realms/meldestelle}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://localhost:9090/realms/meldestelle/protocol/openid-connect/certs}
cloud:
consul:
@ -54,49 +56,29 @@ management:
probes:
enabled: true
tracing:
# Disable tracing by default to avoid Zipkin connection errors
enabled: ${TRACING_ENABLED:false}
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:0.1}
zipkin:
tracing:
# Only configure endpoint if tracing is explicitly enabled
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
# Configure timeout and connection settings to handle missing Zipkin gracefully
connect-timeout: 1s
read-timeout: 5s
# Resilience4j Circuit Breaker Configuration
resilience4j:
circuitbreaker:
configs:
default:
# Circuit breaker opens when the failure rate exceeds 50%
failure-rate-threshold: 50
# Minimum number of calls to calculate the failure rate
minimum-number-of-calls: 5
# Time to wait before transitioning from OPEN to HALF_OPEN
wait-duration-in-open-state: 10s
# Number of calls in HALF_OPEN state before deciding to close/open
permitted-number-of-calls-in-half-open-state: 3
# Sliding window size for calculating failure rate
sliding-window-size: 10
# Type of sliding window (COUNT_BASED or TIME_BASED)
sliding-window-type: COUNT_BASED
# Record exceptions that should be considered as failures
record-exceptions:
- java.lang.Exception
# Ignore certain exceptions (don't count as failures)
ignore-exceptions:
- java.lang.IllegalArgumentException
instances:
pingCircuitBreaker:
# Use default configuration
base-config: default
# Override specific settings for this instance if needed
failure-rate-threshold: 60
minimum-number-of-calls: 4
wait-duration-in-open-state: 5s
# Metrics configuration removed to avoid property resolution warnings
# Use micrometer and actuator endpoints for metrics instead

View File

@ -0,0 +1,8 @@
CREATE TABLE ping (
id UUID PRIMARY KEY,
message VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Index für schnelle Sortierung nach Zeit (wichtig für Sync später)
CREATE INDEX idx_ping_created_at ON ping(created_at);

View File

@ -4,4 +4,8 @@ interface PingApi {
suspend fun simplePing(): PingResponse
suspend fun enhancedPing(simulate: Boolean = false): EnhancedPingResponse
suspend fun healthCheck(): HealthResponse
// Neue Endpunkte für Security Hardening
suspend fun publicPing(): PingResponse
suspend fun securePing(): PingResponse
}

View File

@ -0,0 +1,38 @@
# Ping Service
Der `ping-service` ist der "Tracer Bullet" Service für die Meldestelle-Architektur. Er dient als Blueprint für alle weiteren Microservices.
## Verantwortlichkeit
* Technischer Durchstich (Frontend -> Gateway -> Service -> DB).
* Validierung der Infrastruktur (Security, Resilience, Observability).
* Referenzimplementierung für DDD, Hexagonal Architecture und KMP-Integration.
## API Endpunkte
| Methode | Pfad | Beschreibung | Auth |
| :--- | :--- | :--- | :--- |
| GET | `/ping/simple` | Einfacher Ping, speichert in DB | Public |
| GET | `/ping/enhanced` | Ping mit Circuit Breaker Simulation | Public |
| GET | `/ping/public` | Expliziter Public Endpoint | Public |
| GET | `/ping/secure` | Geschützter Endpoint (benötigt Rolle) | **Secure** (MELD_USER) |
| GET | `/ping/health` | Health Check | Public |
| GET | `/ping/history` | Historie aller Pings | Public (Debug) |
## Architektur
Der Service folgt der Hexagonalen Architektur (Ports & Adapters):
* **Domain:** `at.mocode.ping.domain` (Pure Kotlin, keine Frameworks).
* **Application:** `at.mocode.ping.application` (Use Cases, Spring Service).
* **Infrastructure:** `at.mocode.ping.infrastructure` (Web, Persistence, Security).
## Security
* Nutzt das zentrale Modul `backend:infrastructure:security`.
* OAuth2 Resource Server (JWT Validation via Keycloak).
* Rollen-Mapping: Keycloak Realm Roles -> Spring Security Authorities (`ROLE_...`).
## Persistence
* Datenbank: PostgreSQL.
* Migration: Flyway (`V1__init_ping.sql`).
* ORM: Spring Data JPA (für Write Model).
## Resilience
* Circuit Breaker: Resilience4j (für DB-Zugriffe und simulierte Fehler).

View File

@ -34,7 +34,7 @@ Dieses Modul wurde neu angelegt. Fülle es mit Leben.
* Implementiere OAuth2 Resource Server Support (JWT Validierung).
* Definiere globale CORS-Regeln (Frontend darf zugreifen).
* [ ] **Role Converter:**
* Implementiere einen `KeycloakRoleConverter`, der die Rollen aus dem JWT (Realm/Resource Access) in Spring Security `GrantedAuthority` mappt.
* Implementiere einen `KeycloakRoleConverter`, der die Rollen aus dem JWT (Realm/Resource Access) in Spring Security `GrantedAuthority` mapping.
* **Wichtig:** Achte auf Kompatibilität. Das Gateway nutzt WebFlux (Reactive), die Services nutzen WebMVC (Servlet). Falls nötig, trenne die Konfigurationen oder nutze `ConditionalOnWebApplication`.
### B. Persistence Layer (`backend/infrastructure/persistence`)
@ -46,7 +46,7 @@ Das Modul ist bereits konfiguriert.
* Nutze `JpaRepository` für Standard-CRUD-Operationen.
### C. Ping Service Hardening (`backend/services/ping/ping-service`)
Mache den Service "Production Ready".
Mache den Service "Production Ready."
* [ ] **Flyway:**
* Erstelle `src/main/resources/db/migration/V1__init_ping.sql`.