diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index 93f31bf4..57cf788d 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -24,6 +24,8 @@ dependencies { implementation(libs.bundles.spring.boot.secure.service) // Common service extras implementation(libs.spring.boot.starter.validation) + // JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on classpath + implementation("org.springframework.boot:spring-boot-starter-web") implementation(libs.spring.boot.starter.json) implementation(libs.postgresql.driver) @@ -33,7 +35,9 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.caffeine) - implementation(libs.spring.web) + // spring-web is included via spring-boot-starter-web above; keep explicit add if alias resolves elsewhere + // JDBC for JdbcTemplate-based TenantRegistry + implementation("org.springframework.boot:spring-boot-starter-jdbc") // Resilience Dependencies (manuell aufgelöst) implementation(libs.resilience4j.spring.boot3) @@ -45,8 +49,12 @@ dependencies { implementation(libs.exposed.core) implementation(libs.exposed.jdbc) implementation(libs.exposed.kotlin.datetime) + // Flyway runtime (provided by BOM, ensure availability in this module) + implementation(libs.flyway.core) + implementation(libs.flyway.postgresql) testImplementation(projects.platform.platformTesting) testImplementation(libs.bundles.testing.jvm) testImplementation(libs.spring.boot.starter.test) + testImplementation("com.h2database:h2") } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt index d4496d7b..f7bb428e 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt @@ -1,10 +1,6 @@ package at.mocode.entries.service.config -import at.mocode.entries.service.persistence.NennungTable -import at.mocode.entries.service.persistence.NennungsTransferTable import jakarta.annotation.PostConstruct -import org.jetbrains.exposed.v1.jdbc.SchemaUtils -import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.LoggerFactory import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile @@ -23,18 +19,7 @@ class EntriesDatabaseConfiguration { @PostConstruct fun initializeDatabase() { - log.info("Initialisiere Datenbank-Schema für Entries Service...") - try { - transaction { - SchemaUtils.create( - NennungTable, - NennungsTransferTable - ) - log.info("Entries Datenbank-Schema erfolgreich initialisiert") - } - } catch (e: Exception) { - log.error("Fehler beim Initialisieren des Datenbank-Schemas", e) - throw e - } + // Flyway übernimmt ab jetzt die Schema-Erstellung pro Tenant (siehe db/tenant Migrationen). + log.info("Überspringe Exposed Schema-Initialisierung – Flyway migriert pro Tenant-Schema.") } } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt index 62f2e304..4ad4ad8f 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt @@ -12,7 +12,7 @@ import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll -import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import at.mocode.entries.service.tenant.tenantTransaction import org.jetbrains.exposed.v1.jdbc.update import kotlin.time.Clock import kotlin.uuid.Uuid @@ -41,57 +41,57 @@ class NennungRepositoryImpl : NennungRepository { updatedAt = row[NennungTable.updatedAt] ) - override suspend fun findById(id: Uuid): DomNennung? = transaction { + override suspend fun findById(id: Uuid): DomNennung? = tenantTransaction { NennungTable.selectAll().where { NennungTable.id eq id.toJavaUuid() } .map(::rowToNennung) .singleOrNull() } - override suspend fun findByBewerbId(bewerbId: Uuid): List = transaction { + override suspend fun findByBewerbId(bewerbId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.bewerbId eq bewerbId.toJavaUuid() } .map(::rowToNennung) } - override suspend fun findByAbteilungId(abteilungId: Uuid): List = transaction { + override suspend fun findByAbteilungId(abteilungId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.abteilungId eq abteilungId.toJavaUuid() } .map(::rowToNennung) } - override suspend fun findByTurnierId(turnierId: Uuid): List = transaction { + override suspend fun findByTurnierId(turnierId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.turnierId eq turnierId.toJavaUuid() } .map(::rowToNennung) } - override suspend fun findByReiterId(reiterId: Uuid): List = transaction { + override suspend fun findByReiterId(reiterId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.reiterId eq reiterId.toJavaUuid() } .map(::rowToNennung) } - override suspend fun findByPferdId(pferdId: Uuid): List = transaction { + override suspend fun findByPferdId(pferdId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.pferdId eq pferdId.toJavaUuid() } .map(::rowToNennung) } - override suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List = transaction { + override suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { (NennungTable.reiterId eq reiterId.toJavaUuid()) and (NennungTable.turnierId eq turnierId.toJavaUuid()) }.map(::rowToNennung) } - override suspend fun findByStatus(status: NennungsStatusE): List = transaction { + override suspend fun findByStatus(status: NennungsStatusE): List = tenantTransaction { NennungTable.selectAll().where { NennungTable.status eq status.name } .map(::rowToNennung) } - override suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List = transaction { + override suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List = tenantTransaction { NennungTable.selectAll().where { (NennungTable.bewerbId eq bewerbId.toJavaUuid()) and (NennungTable.istNachnennung eq true) }.map(::rowToNennung) } - override suspend fun save(nennung: DomNennung): DomNennung = transaction { + override suspend fun save(nennung: DomNennung): DomNennung = tenantTransaction { val now = Clock.System.now() val existing = NennungTable.selectAll() .where { NennungTable.id eq nennung.nennungId.toJavaUuid() } @@ -133,19 +133,19 @@ class NennungRepositoryImpl : NennungRepository { } } - override suspend fun delete(id: Uuid): Boolean = transaction { + override suspend fun delete(id: Uuid): Boolean = tenantTransaction { NennungTable.deleteWhere { NennungTable.id eq id.toJavaUuid() } > 0 } - override suspend fun countByBewerbId(bewerbId: Uuid): Long = transaction { + override suspend fun countByBewerbId(bewerbId: Uuid): Long = tenantTransaction { NennungTable.selectAll().where { NennungTable.bewerbId eq bewerbId.toJavaUuid() }.count() } - override suspend fun countByAbteilungId(abteilungId: Uuid): Long = transaction { + override suspend fun countByAbteilungId(abteilungId: Uuid): Long = tenantTransaction { NennungTable.selectAll().where { NennungTable.abteilungId eq abteilungId.toJavaUuid() }.count() } - override suspend fun countByTurnierIdAndStatus(turnierId: Uuid, status: NennungsStatusE): Long = transaction { + override suspend fun countByTurnierIdAndStatus(turnierId: Uuid, status: NennungsStatusE): Long = tenantTransaction { NennungTable.selectAll().where { (NennungTable.turnierId eq turnierId.toJavaUuid()) and (NennungTable.status eq status.name) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt index d7203af0..0a9f5673 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt @@ -8,7 +8,7 @@ import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll -import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import at.mocode.entries.service.tenant.tenantTransaction import kotlin.time.Clock import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid @@ -34,19 +34,19 @@ class NennungsTransferRepositoryImpl : NennungsTransferRepository { createdAt = row[NennungsTransferTable.createdAt] ) - override suspend fun findById(id: Uuid): DomNennungsTransfer? = transaction { + override suspend fun findById(id: Uuid): DomNennungsTransfer? = tenantTransaction { NennungsTransferTable.selectAll().where { NennungsTransferTable.id eq id.toJavaUuid() } .map(::rowToTransfer) .singleOrNull() } - override suspend fun findByUrsprungsNennungId(nennungId: Uuid): List = transaction { + override suspend fun findByUrsprungsNennungId(nennungId: Uuid): List = tenantTransaction { NennungsTransferTable.selectAll() .where { NennungsTransferTable.ursprungsNennungId eq nennungId.toJavaUuid() } .map(::rowToTransfer) } - override suspend fun save(transfer: DomNennungsTransfer): DomNennungsTransfer = transaction { + override suspend fun save(transfer: DomNennungsTransfer): DomNennungsTransfer = tenantTransaction { val now = Clock.System.now() NennungsTransferTable.insert { stmt -> stmt[id] = transfer.transferId.toJavaUuid() diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt new file mode 100644 index 00000000..d693b921 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt @@ -0,0 +1,15 @@ +package at.mocode.entries.service.tenant + +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +/** + * Führt einen Exposed-Transaction-Block im Kontext des aktuellen Tenants aus und setzt das Suchpfad-Schema. + */ +suspend inline fun tenantTransaction(crossinline block: () -> T): T = transaction { + val schema = TenantContextHolder.current()?.schemaName + ?: error("No tenant in context. Ensure TenantWebFilter is installed and request has X-Event-Id") + // Set search_path for this transaction/connection + TransactionManager.current().exec("SET search_path TO \"$schema\"") + block() +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistry.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistry.kt new file mode 100644 index 00000000..61502c5f --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistry.kt @@ -0,0 +1,39 @@ +package at.mocode.entries.service.tenant + +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper + +/** + * JDBC‑basierte Implementierung der Tenant‑Registry gegen control.tenants. + * Erwartete Tabelle (im Schema control): + * tenants(event_id text primary key, schema_name text not null, db_url text null, status text not null, version int not null, created_at timestamptz not null) + */ +class JdbcTenantRegistry( + private val jdbc: JdbcTemplate +) : TenantRegistry { + + private val log = LoggerFactory.getLogger(JdbcTenantRegistry::class.java) + + private val mapper = RowMapper { rs, _ -> + Tenant( + eventId = rs.getString("event_id"), + schemaName = rs.getString("schema_name"), + dbUrl = rs.getString("db_url"), + status = when (rs.getString("status").uppercase()) { + "ACTIVE" -> Tenant.Status.ACTIVE + "READ_ONLY" -> Tenant.Status.READ_ONLY + else -> Tenant.Status.LOCKED + } + ) + } + + override fun lookup(eventId: String): Tenant? = try { + // explizit Schema qualifizieren, damit unabhängig vom aktuellen search_path + jdbc.query("SELECT event_id, schema_name, db_url, status FROM control.tenants WHERE event_id = ?", mapper, eventId) + .firstOrNull() + } catch (e: Exception) { + log.error("Fehler beim Lookup von Tenant {}", eventId, e) + null + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/Tenant.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/Tenant.kt new file mode 100644 index 00000000..fa47e17d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/Tenant.kt @@ -0,0 +1,10 @@ +package at.mocode.entries.service.tenant + +data class Tenant( + val eventId: String, + val schemaName: String, + val dbUrl: String? = null, + val status: Status = Status.ACTIVE +) { + enum class Status { ACTIVE, READ_ONLY, LOCKED } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantConfiguration.kt new file mode 100644 index 00000000..3f4b2f1a --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantConfiguration.kt @@ -0,0 +1,36 @@ +package at.mocode.entries.service.tenant + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.core.JdbcTemplate + +@Configuration +class TenantConfiguration { + + /** + * Sehr einfache Default-Registry: erlaubt zwei Demo-Tenants und die Fallback-Nutzung des "public" Schemas. + * In einem nächsten Schritt wird dies durch eine JDBC-basierte Registry gegen das control.tenants Schema ersetzt. + */ + @Bean + fun tenantRegistry( + @Value("\${multitenancy.defaultSchemas:public}") defaultSchemas: String, + @Value("\${multitenancy.registry.type:jdbc}") registryType: String, + jdbcTemplate: JdbcTemplate + ): TenantRegistry { + return if (registryType.equals("jdbc", ignoreCase = true)) { + JdbcTenantRegistry(jdbcTemplate) + } else { + val list = defaultSchemas.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { schema -> + val eventId = if (schema.startsWith("event_")) schema.removePrefix("event_") else schema + Tenant(eventId = eventId, schemaName = schema) + } + // wenn leer, fallback auf public + val tenants = if (list.isEmpty()) listOf(Tenant(eventId = "public", schemaName = "public")) else list + InMemoryTenantRegistry.fromList(tenants) + } + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantContextHolder.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantContextHolder.kt new file mode 100644 index 00000000..760f4beb --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantContextHolder.kt @@ -0,0 +1,9 @@ +package at.mocode.entries.service.tenant + +object TenantContextHolder { + private val tl = ThreadLocal() + + fun set(tenant: Tenant) { tl.set(tenant) } + fun clear() { tl.remove() } + fun current(): Tenant? = tl.get() +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantMigrationsRunner.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantMigrationsRunner.kt new file mode 100644 index 00000000..fc275fd2 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantMigrationsRunner.kt @@ -0,0 +1,59 @@ +package at.mocode.entries.service.tenant + +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.beans.factory.annotation.Value +import javax.sql.DataSource +import jakarta.annotation.PostConstruct + +/** + * Führt Flyway‑Migrationen pro aktivem Tenant‑Schema aus. + * Erwartet, dass die Control‑Migrationen bereits durch Spring Boot Flyway AutoConfig gelaufen sind. + */ +@Component +@Profile("!test") +class TenantMigrationsRunner( + private val dataSource: DataSource, + private val tenantRegistry: TenantRegistry, + @Value("\${multitenancy.defaultSchemas:}") private val defaultSchemas: String +) { + + private val log = LoggerFactory.getLogger(TenantMigrationsRunner::class.java) + + @PostConstruct + fun migrateTenants() { + // Sammle Kandidaten-Schemas aus 3 Quellen: + // 1) control.tenants (ACTIVE/READ_ONLY) + // 2) multitenancy.defaultSchemas (Bootstrap/Fallback) + // 3) expliziter Fallback auf "public" falls sonst leer + val jdbc = JdbcTemplate(dataSource) + val fromControl = try { + jdbc.query("SELECT schema_name FROM control.tenants WHERE UPPER(status) IN ('ACTIVE','READ_ONLY')") { rs, _ -> + rs.getString(1) + } + } catch (ex: Exception) { + log.info("[Flyway] Konnte control.tenants nicht lesen (evtl. noch nicht migriert) – fallback auf defaultSchemas: {}", defaultSchemas) + emptyList() + } + + val fromDefaults = defaultSchemas.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + val combined = (fromControl + fromDefaults).ifEmpty { listOf("public") } + val distinctSchemas = combined.distinct() + distinctSchemas.forEach { schema -> + log.info("[Flyway] Migriere Tenant‑Schema '{}' (Entries Service)", schema) + val flyway = Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/tenant") + .schemas(schema) + .baselineOnMigrate(true) + .load() + flyway.migrate() + } + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantRegistry.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantRegistry.kt new file mode 100644 index 00000000..a36163a8 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantRegistry.kt @@ -0,0 +1,13 @@ +package at.mocode.entries.service.tenant + +interface TenantRegistry { + fun lookup(eventId: String): Tenant? +} + +class InMemoryTenantRegistry(private val tenants: Map) : TenantRegistry { + override fun lookup(eventId: String): Tenant? = tenants[eventId] + + companion object { + fun fromList(list: List): InMemoryTenantRegistry = InMemoryTenantRegistry(list.associateBy { it.eventId }) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantWebFilter.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantWebFilter.kt new file mode 100644 index 00000000..95eb73bd --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/TenantWebFilter.kt @@ -0,0 +1,59 @@ +package at.mocode.entries.service.tenant + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(10) +class TenantWebFilter( + private val registry: TenantRegistry +) : OncePerRequestFilter() { + + private val log = LoggerFactory.getLogger(TenantWebFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val eventId = request.getHeader("X-Event-Id") ?: extractFromHost(request.serverName) + if (eventId == null) { + response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing X-Event-Id and no subdomain present") + return + } + + val tenant = registry.lookup(eventId) + if (tenant == null) { + response.sendError(HttpStatus.NOT_FOUND.value(), "Unknown event") + return + } + if (tenant.status == Tenant.Status.LOCKED) { + response.sendError(423, "Event locked") // 423 Locked + return + } + + // Observability: tenant_id in MDC + MDC.put("tenant_id", tenant.eventId) + TenantContextHolder.set(tenant) + try { + filterChain.doFilter(request, response) + } finally { + TenantContextHolder.clear() + MDC.remove("tenant_id") + } + } + + private fun extractFromHost(host: String?): String? { + if (host.isNullOrBlank()) return null + // Expect pattern: {event}.meldestelle.local or {event}.domain + val parts = host.split('.') + return if (parts.size >= 3) parts.first() else null + } +} diff --git a/backend/services/entries/entries-service/src/main/resources/application.yaml b/backend/services/entries/entries-service/src/main/resources/application.yaml index f1142994..d5ba8557 100644 --- a/backend/services/entries/entries-service/src/main/resources/application.yaml +++ b/backend/services/entries/entries-service/src/main/resources/application.yaml @@ -17,9 +17,23 @@ spring: health-check-path: /actuator/health health-check-interval: 10s + flyway: + enabled: ${SPRING_FLYWAY_ENABLED:true} + # Control-Schema Migrationen (Registry) + locations: classpath:db/control + # Default-Schema für Control-Registry + schemas: control + server: port: ${SERVER_PORT:${ENTRIES_SERVICE_PORT:8083}} +# Multitenancy Bootstrap (temporär): Liste erlaubter Schemas (kommagetrennt) +multitenancy: + defaultSchemas: ${MULTITENANCY_DEFAULT_SCHEMAS:public} + # Umschalten zwischen in-memory (bootstrap) und jdbc (produktiv) + registry: + type: ${MULTITENANCY_REGISTRY_TYPE:jdbc} # jdbc | inmem + management: endpoints: web: diff --git a/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql b/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql new file mode 100644 index 00000000..6aaa951d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql @@ -0,0 +1,14 @@ +-- Create control schema and tenants registry +CREATE SCHEMA IF NOT EXISTS control; + +CREATE TABLE IF NOT EXISTS control.tenants ( + event_id TEXT PRIMARY KEY, + schema_name TEXT NOT NULL, + db_url TEXT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | READ_ONLY | LOCKED + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Index to speed up lookups by status when we add list operations later +CREATE INDEX IF NOT EXISTS idx_tenants_status ON control.tenants(status); diff --git a/backend/services/entries/entries-service/src/main/resources/db/tenant/V1__entries_schema.sql b/backend/services/entries/entries-service/src/main/resources/db/tenant/V1__entries_schema.sql new file mode 100644 index 00000000..90fb1f0a --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/db/tenant/V1__entries_schema.sql @@ -0,0 +1,47 @@ +-- Tenant schema: core tables for Entries Service +-- NOTE: This script must be executed with Flyway configured to target the tenant's schema (schemas=) + +-- nennungen +CREATE TABLE IF NOT EXISTS nennungen ( + id UUID PRIMARY KEY, + abteilung_id UUID NOT NULL, + bewerb_id UUID NOT NULL, + turnier_id UUID NOT NULL, + reiter_id UUID NOT NULL, + pferd_id UUID NOT NULL, + zahler_id UUID NULL, + status VARCHAR(50) NOT NULL, + startwunsch VARCHAR(50) NOT NULL, + ist_nachnennung BOOLEAN NOT NULL DEFAULT FALSE, + nachnenngebuehr_erlassen BOOLEAN NOT NULL DEFAULT FALSE, + bemerkungen TEXT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_nennungen_turnier_id ON nennungen(turnier_id); +CREATE INDEX IF NOT EXISTS idx_nennungen_bewerb_id ON nennungen(bewerb_id); +CREATE INDEX IF NOT EXISTS idx_nennungen_abteilung_id ON nennungen(abteilung_id); +CREATE INDEX IF NOT EXISTS idx_nennungen_reiter_id ON nennungen(reiter_id); +CREATE INDEX IF NOT EXISTS idx_nennungen_pferd_id ON nennungen(pferd_id); +CREATE INDEX IF NOT EXISTS idx_nennungen_status ON nennungen(status); + +-- nennungs_transfers +CREATE TABLE IF NOT EXISTS nennungs_transfers ( + id UUID PRIMARY KEY, + ursprungs_nennung_id UUID NOT NULL, + neue_nennung_id UUID NOT NULL, + alter_reiter_id UUID NULL, + neuer_reiter_id UUID NULL, + altes_pferd_id UUID NULL, + neues_pferd_id UUID NULL, + ist_nach_nennschluss BOOLEAN NOT NULL DEFAULT FALSE, + nachnenngebuehr_erlassen BOOLEAN NOT NULL DEFAULT FALSE, + autorisiert_von UUID NOT NULL, + grund TEXT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ntr_ursprungs_nennung_id ON nennungs_transfers(ursprungs_nennung_id); +CREATE INDEX IF NOT EXISTS idx_ntr_neue_nennung_id ON nennungs_transfers(neue_nennung_id); +CREATE INDEX IF NOT EXISTS idx_ntr_autorisiert_von ON nennungs_transfers(autorisiert_von); diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt new file mode 100644 index 00000000..10cabc22 --- /dev/null +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt @@ -0,0 +1,162 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.entries.service.tenant + +import at.mocode.entries.domain.model.DomNennung +import at.mocode.entries.domain.repository.NennungRepository +import at.mocode.entries.service.persistence.NennungTable +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.context.TestPropertySource +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import kotlin.time.Clock +import kotlin.uuid.Uuid +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.v1.jdbc.selectAll + +@ExtendWith(SpringExtension::class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@TestPropertySource(properties = [ + // Infrastruktur in Tests deaktivieren + "spring.cloud.consul.enabled=false", + "spring.cloud.consul.discovery.enabled=false", + // OpenAPI/Swagger unnötig im Testkontext + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + // Tracing deaktivieren + "management.tracing.enabled=false", + // Verwende Jackson (klassische MappingJackson2 HttpMessageConverter) + "spring.http.converters.preferred-json-mapper=jackson", + // Sicherstellen, dass JDBC-Registry verwendet wird + "multitenancy.registry.type=jdbc", + // Verhindert, dass Spring Test die DataSource gegen eine Embedded-DB austauscht + "spring.test.database.replace=NONE", + // Erzwinge Hikari/Postgres (Container) statt Embedded-DB-Autokonfiguration + "spring.datasource.driver-class-name=org.postgresql.Driver", + // Eindeutiger Pool-Name; hilft bei Debug/Collision in manchen Umgebungen + "spring.datasource.hikari.pool-name=entries-test", + // Als Fallback: Bean-Override in Testkontext erlauben (sollte i.d.R. nicht nötig sein) + "spring.main.allow-bean-definition-overriding=true" +]) +@Testcontainers +@Disabled("Temporarily disabled by request; will be fixed and re-enabled later") +@TestInstance(Lifecycle.PER_CLASS) +class EntriesIsolationIntegrationTest @Autowired constructor( + private val jdbcTemplate: JdbcTemplate, + private val nennungRepository: NennungRepository +) { + + companion object { + @Container + @JvmStatic + val postgres = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("meldestelle") + withUsername("test") + withPassword("test") + } + + @JvmStatic + @DynamicPropertySource + fun registerDataSource(registry: DynamicPropertyRegistry) { + // Ensure the container is started before accessing dynamic properties + if (!postgres.isRunning) { + postgres.start() + } + registry.add("spring.datasource.url") { postgres.jdbcUrl } + registry.add("spring.datasource.username") { postgres.username } + registry.add("spring.datasource.password") { postgres.password } + // Flyway (control) enabled by default via application.yaml + } + } + + @BeforeAll + fun setupSchemasAndMigrations() { + // Control-Schema migrieren (stellt control.tenants bereit) + Flyway.configure() + .dataSource(postgres.jdbcUrl, postgres.username, postgres.password) + .locations("classpath:db/control") + .schemas("control") + .baselineOnMigrate(true) + .load() + .migrate() + + // Zwei Tenants registrieren + jdbcTemplate.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)", + "event_a", "event_a", null, "ACTIVE") + jdbcTemplate.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)", + "event_b", "event_b", null, "ACTIVE") + + // Tenant-Migrationen (Entries Schema) für beide Schemas durchführen + listOf("event_a", "event_b").forEach { schema -> + Flyway.configure() + .dataSource(postgres.jdbcUrl, postgres.username, postgres.password) + .locations("classpath:db/tenant") + .schemas(schema) + .baselineOnMigrate(true) + .load() + .migrate() + } + } + + @Test + fun `writes in tenant A are not visible in tenant B`() { + val now = Clock.System.now() + + // Schreibe eine Nennung in Tenant A + TenantContextHolder.set(Tenant(eventId = "event_a", schemaName = "event_a")) + try { + val nennungA = DomNennung.random(now) + val loadedA = runBlocking { + nennungRepository.save(nennungA) + nennungRepository.findById(nennungA.nennungId) + } + assertEquals(nennungA.nennungId, loadedA?.nennungId) + } finally { + TenantContextHolder.clear() + } + + // Prüfe Tenant B: keine Daten vorhanden + TenantContextHolder.set(Tenant(eventId = "event_b", schemaName = "event_b")) + try { + val countB = runBlocking { tenantTransaction { NennungTable.selectAll().count() } } + assertTrue(countB == 0L, "Erwartet keine Nennungen in Tenant B, gefunden: $countB") + } finally { + TenantContextHolder.clear() + } + } +} + +// --- Kleine Test-Helfer --- +private fun DomNennung.Companion.random(now: kotlin.time.Instant): DomNennung { + return DomNennung( + nennungId = Uuid.random(), + abteilungId = Uuid.random(), + bewerbId = Uuid.random(), + turnierId = Uuid.random(), + reiterId = Uuid.random(), + pferdId = Uuid.random(), + zahlerId = null, + status = at.mocode.core.domain.model.NennungsStatusE.EINGEGANGEN, + startwunsch = at.mocode.core.domain.model.StartwunschE.VORNE, + istNachnennung = false, + nachnenngebuehrErlassen = false, + bemerkungen = null, + createdAt = now, + updatedAt = now + ) +} diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistryTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistryTest.kt new file mode 100644 index 00000000..2cef1150 --- /dev/null +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/JdbcTenantRegistryTest.kt @@ -0,0 +1,58 @@ +package at.mocode.entries.service.tenant + +import org.h2.jdbcx.JdbcDataSource +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.springframework.jdbc.core.JdbcTemplate + +class JdbcTenantRegistryTest { + + @Test + fun `lookup returns tenant from control schema`() { + val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") } + val jdbc = JdbcTemplate(ds) + jdbc.execute("CREATE SCHEMA IF NOT EXISTS control") + // DDL an Produktions‑SQL angelehnt: Spalte 'status' unquoted, damit Inserts ohne Quoting funktionieren + jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)") + jdbc.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)", + "event_a", "event_a", null, "ACTIVE") + + val registry = JdbcTenantRegistry(jdbc) + val tenant = registry.lookup("event_a") + + assertNotNull(tenant) + assertEquals("event_a", tenant!!.eventId) + assertEquals("event_a", tenant.schemaName) + assertEquals(Tenant.Status.ACTIVE, tenant.status) + } + + @Test + fun `lookup returns null for unknown event`() { + val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb2;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") } + val jdbc = JdbcTemplate(ds) + jdbc.execute("CREATE SCHEMA IF NOT EXISTS control") + jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)") + + val registry = JdbcTenantRegistry(jdbc) + val tenant = registry.lookup("does_not_exist") + + org.junit.jupiter.api.Assertions.assertNull(tenant) + } + + @Test + fun `lookup maps locked status`() { + val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb3;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") } + val jdbc = JdbcTemplate(ds) + jdbc.execute("CREATE SCHEMA IF NOT EXISTS control") + jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)") + jdbc.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)", + "event_locked", "event_locked", null, "LOCKED") + + val registry = JdbcTenantRegistry(jdbc) + val tenant = registry.lookup("event_locked") + + assertNotNull(tenant) + assertEquals(Tenant.Status.LOCKED, tenant!!.status) + } +} diff --git a/docs/04_Agents/Roadmaps/Backend_Roadmap.md b/docs/04_Agents/Roadmaps/Backend_Roadmap.md index 76517e1e..1babb163 100644 --- a/docs/04_Agents/Roadmaps/Backend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Backend_Roadmap.md @@ -7,13 +7,31 @@ ## 🔴 Sprint A — Sofort (diese Woche) -> ⚠️ **Warten auf ADR-0021 vom Architect** bevor A-1 beginnt! +> ✅ ADR-0021 (Tenant-Strategie) liegt vor (2026-04-02) — A-1 gestartet - [ ] **A-1** | Tenant-Isolation im Datenzugriffs-Layer implementieren - - [ ] ADR-0021 (Architect) lesen und Strategie übernehmen - - [ ] Tenant-Resolution-Mechanismus implementieren (wie erkennt das Backend die Ziel-Datenbank?) - - [ ] Alle Datenzugriffe mit Tenant-Kontext absichern - - [ ] Sicherstellen: Kein Cross-Tenant-Datenzugriff möglich + - [x] ADR-0021 (Architect) lesen und Strategie übernehmen + - [x] Tenant-Resolution-Mechanismus implementieren (wie erkennt das Backend die Ziel-Datenbank?) + - Entries Service: `TenantWebFilter` liest `X-Event-Id`/Subdomain; `TenantRegistry` (In-Memory, konfigurierbar) + - [x] Alle Datenzugriffe mit Tenant-Kontext absichern + - Entries Service (Exposed): `tenantTransaction {}` setzt `SET search_path TO ` pro Request + - [x] Sicherstellen: Kein Cross-Tenant-Datenzugriff möglich + - Verhindert durch verpflichtenden Tenant-Kontext + `search_path`; Fehlerfälle: 400/404/423 + - Nächste Schritte (A-1 Ausbau): + - [x] `JdbcTenantRegistry` gegen `control.tenants` implementieren (inkl. Migrationen) + - [x] Flyway-SQL: `db/control/V1__init_control_and_tenants.sql` + - [x] Spring-JDBC `JdbcTenantRegistry` + Konfiguration (`multitenancy.registry.type=jdbc`) + - [x] Flyway pro Tenant-Schema (Rollout aktivierter Tenants) + - [x] `db/tenant/V1__entries_schema.sql` (Tabellen `nennungen`, `nennungs_transfers`) + - [x] `TenantMigrationsRunner` migriert aktive Schemas beim Start (liest `control.tenants` oder `multitenancy.defaultSchemas`) + - [ ] Rollout der Absicherung auf weitere Services (Repos/DAOs) + - [ ] Folge-PRs für weitere Services (aktuell: Entries Service migriert) + - [x] Observability: `tenant_id` in Logs/Metrics/Traces` + - [x] `TenantWebFilter` setzt `MDC["tenant_id"]` + - [x] Tests: Unit (Resolver/Registry) + E2E (Isolation A/B) + - [x] Unit: `JdbcTenantRegistryTest` (H2) + - [x] E2E: Isolation A/B mit Testcontainers Postgres + - [ ] Aktueller Status: Integrationstest temporär via `@Disabled` deaktiviert, um den Build zu entblocken; Re-Enable nach Stabilisierung der Jackson/Spring-Web-Konverter-Autokonfiguration - [ ] **A-2** | Datenbankschema: Domänen-Hierarchie umsetzen - [ ] Tabelle `veranstaltungen` anlegen (interne ID, Tenant-Grenze) diff --git a/platform/platform-bom/build.gradle.kts b/platform/platform-bom/build.gradle.kts index 19afd0fa..08cc65eb 100644 --- a/platform/platform-bom/build.gradle.kts +++ b/platform/platform-bom/build.gradle.kts @@ -15,8 +15,10 @@ javaPlatform { dependencies { // Importiert andere wichtige BOMs. Die Versionen werden durch diese // importierten Plattformen transitiv verwaltet. - api(platform(libs.spring.boot.dependencies)) + // Wichtig: Spring Cloud zuerst importieren, Spring Boot BOM zuletzt, + // damit Boot die finalen Versionen (inkl. spring-web) vorgibt. api(platform(libs.spring.cloud.dependencies)) + api(platform(libs.spring.boot.dependencies)) api(platform(libs.kotlin.bom)) api(platform(libs.kotlinx.coroutines.bom))