meldestelle/docs/01_Architecture/adr/0021-tenant-resolution-strategy-de.md

166 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
type: ADR
status: PROPOSED
owner: Lead Architect
last_update: 2026-04-02
---
# ADR-0021: Tenant-Resolution-Strategie (Schema-per-Tenant)
## Status
Proposed — 2026-04-02
## Kontext
Das Frontend verfolgt ein Event-first/Offline-first-Modell mit einer lokalen Datenbank pro Veranstaltung. Zur Vermeidung von Datenleckagen und zur Vereinfachung von Archivierung/Löschung müssen wir dieselbe Isolation im Backend abbilden. Der Monolith (Kotlin, Ktor und/oder Spring Boot, Hibernate/JPA optional, Flyway, HikariCP) benötigt eine robuste Tenant-Resolution und -Isolation.
## Entscheidung
Wir führen Schema-per-Tenant ein (je Veranstaltung ein eigenes Schema in derselben physischen Datenbank). Optional unterstützen wir selektiv Database-per-Tenant über ein Feature-Flag in der Registry (wenn `db_url != null`). Die Tenant-Resolution erfolgt primär über den HTTP-Header `X-Event-Id`, alternativ über Subdomain/Host-Auflösung. Alle Zugriffe werden gegen eine zentrale `tenants`-Registry (im `control`-Schema) validiert.
## Konsequenzen
- Pro Tenant (Event) eigenes Schema; keine `tenant_id`-Spalten in Fachtabellen erforderlich.
- Migrationen je Schema via Flyway; separate `flyway_schema_history` pro Schema.
- Request-Pipeline erhält einen Tenant-Filter/Plugin; Data-Layer schaltet das Schema pro Request (`SCHEMA`-Multitenancy oder `SET search_path`).
- Logging/Metrics/Tracing enthalten `tenant_id`/`event_id` als Tag/Attribut.
- Optional: Database-per-Tenant bei Bedarf (Performance/Compliance) aktivierbar.
## Alternativen
Tenant-ID in allen Tabellen (verworfen): Erfordert permanentes Row-Filtering (`WHERE tenant_id = ?`), erhöht das Leckage-Risiko, erschwert Unique-Constraints und kompliziert Löschung/Archivierung pro Event.
## Tenant-Resolution (fachlich)
1. Identifikation pro Request (Priorität):
- `X-Event-Id` Header (kanonisch), z. B. `X-Event-Id: 2026-moc-open`.
- Subdomain/Authority: `{event}.meldestelle.local` → Auflösung zu `event_id`.
- Fallback: expliziter `eventId`-Pfadparameter nur für Admin-/Sync-Endpunkte.
2. Validierung & Lookup:
- Registry `control.tenants(event_id, schema_name, db_url, status, version, created_at)`.
- Nur `active` Tenants sind schreibbar; archivierte Events sind read-only/gesperrt.
3. Autorisierung:
- Token-Claims (`events: [event_id...]`) werden gegen angeforderten `event_id` geprüft.
- Service-zu-Service (Sync) via mTLS + Service-Claims.
## Technische Umsetzung
### Spring Boot + Hibernate (SCHEMA-Strategie)
- `hibernate.multiTenancy=SCHEMA` aktivieren.
- `CurrentTenantIdentifierResolver` implementieren (liest `schemaName` aus Request-Kontext).
- `MultiTenantConnectionProvider` implementieren (Schema-Umschaltung bzw. DataSource-Auswahl bei `db_url != null`).
Beispiel (verkürzt):
```kotlin
@Configuration
class MultiTenantConfig {
@Bean
fun sessionFactory(
dataSource: DataSource,
props: JpaProperties,
provider: MultiTenantConnectionProvider,
resolver: CurrentTenantIdentifierResolver
): LocalContainerEntityManagerFactoryBean {
val jpaProps = HashMap(props.properties)
jpaProps["hibernate.multiTenancy"] = "SCHEMA"
jpaProps["hibernate.tenant_identifier_resolver"] = resolver
jpaProps["hibernate.multi_tenant_connection_provider"] = provider
return LocalContainerEntityManagerFactoryBean().apply {
setPackagesToScan("at.mocode")
setJpaPropertyMap(jpaProps)
setDataSource(dataSource)
jpaVendorAdapter = HibernateJpaVendorAdapter()
}
}
}
```
`CurrentTenantIdentifierResolver` (Auszug):
```kotlin
class EventTenantResolver(private val ctx: TenantContextHolder): CurrentTenantIdentifierResolver {
override fun resolveCurrentTenantIdentifier(): String =
ctx.current()?.schemaName ?: DEFAULT_SCHEMA
override fun validateExistingCurrentSessions() = true
}
```
`TenantContext` per Filter:
```kotlin
@Component
class TenantFilter: OncePerRequestFilter() {
override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
val eventId = req.getHeader("X-Event-Id") ?: extractFromHost(req.serverName)
val tenant = TenantRegistry.lookup(eventId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown event")
TenantContextHolder.set(tenant)
try { chain.doFilter(req, res) } finally { TenantContextHolder.clear() }
}
}
```
### Ktor (Plugin-basierter Ansatz)
- `TenantContextPlugin`: liest `X-Event-Id`, validiert gegen Registry, setzt `TenantContext` in `call.attributes`.
- Repository-/DAO-Layer wählt Schema je Request (z. B. `SET search_path TO <schema>`).
Auszug Plugin:
```kotlin
val TenantContextKey = AttributeKey<Tenant>("TenantContext")
fun Application.installTenantContext(registry: TenantRegistry) {
install(createApplicationPlugin(name = "TenantContext") {
onCall { call ->
val eventId = call.request.headers["X-Event-Id"] ?: extractFromHost(call.request.host())
val tenant = registry.lookup(eventId) ?: throw NotFoundException("Unknown event")
call.attributes.put(TenantContextKey, tenant)
}
})
}
```
Connection Management (Ktor, Exposed/JDBC):
```kotlin
suspend fun <T> withTenantConnection(tenant: Tenant, block: (Connection) -> T): T {
val ds = dataSourceFor(tenant) // pooled or shared
ds.connection.use { conn ->
conn.createStatement().use { st -> st.execute("SET search_path TO ${'$'}{tenant.schemaName}") }
return block(conn)
}
}
```
## Datenbank- und Migrationsstrategie
- `control`-Schema: enthält `tenants` und `flyway_schema_history_control`.
- Tenant-Schema: identische Struktur je Event, eigene `flyway_schema_history`.
- Bootstrapping:
1. `control` migrieren (einmalig).
2. Bei Event-Anlage: Schema `event_<slug>` anlegen, Flyway `migrate` gegen dieses Schema ausführen, Eintrag in `tenants` schreiben.
3. Rollout neuer Version: aktive Tenants enumerieren, Flyway parallelisiert mit Backoff ausführen.
## Connection-Pooling-Strategie
- Standard: Shared `DataSource` je physische DB + `SET search_path` pro Anfrage (ein Pool, geringere Verbindungszahl).
- Alternative: `DataSource` je Tenant (nur bei wenigen gleichzeitigen Events sinnvoll) bzw. bei Database-per-Tenant.
- Schutz: Circuit-Breaker, Pool-Limits, Rate-Limits pro Tenant; Liveness/Readiness Checks tenant-tolerant.
## Sicherheit & Compliance
- Harte Trennung pro Event reduziert Datenleck-Risiken.
- Autorisierung per Token-Claims je Event; Admin-/Sync-Endpunkte whitelisten.
- Auditing: Access-Logs enthalten `event_id`, `subject`, `scope`, `trace_id`.
- DSGVO-konformes Löschen: `DROP SCHEMA <event_schema> CASCADE` + Entfernung aus Registry.
## Teststrategie
- Unit: Resolver, Registry-Lookup, Fehlerpfade (unknown/archived tenant).
- Integration: E2E mit 2 Tenants (A/B), Isolation sicherstellen, Migrationslauf gegen beide.
- Load: Concurrency mit gemischten Tenants, Latenz `SET search_path` messen.
- Chaos: Tenant zur Laufzeit deaktivieren → Requests erhalten `404 Unknown event` oder `423 Locked`.
## Migrations-/Einführungsplan
1. `control`-Schema einführen inkl. `tenants`-Tabelle und Admin-API (`POST /admin/events`).
2. Tenant-Resolution in Web-Layer integrieren (Filter/Plugin) + globale Durchsetzung.
3. Datenzugriffs-Layer auf Schema-per-Tenant umbauen (Entfernen globaler `tenant_id`-Abhängigkeiten, falls vorhanden).
4. Flyway-Migration je Schema implementieren + Rollout-Pipeline anpassen.
5. Observability: `tenant_id` als Label/Tag in Logs/Metrics/Traces.
6. Sicherheits- und Lasttests; Go-Live schrittweise mit 12 Pilot-Events.
## Risiken und Gegenmaßnahmen
- Viele Tenants gleichzeitig → Migrationsdauer: Parallelisierung + Wartungsfenster + Blue/Green.
- Fehlerhafte Resolution → Fail-Fast, Telemetrie, Canary.
- SQL mit Schema-Interpolation → Nur whitelisten/quoten aus Registry, niemals ungeprüft; Prepared Statements wo möglich.
## Offene Punkte
- Selektives Database-per-Tenant via `db_url` in Registry für große/sensible Events.
- Gemeinsame Referenzdaten global im `control`-Schema; Event-Overrides bei Bedarf im Event-Schema.