Document Tenant-Resolution strategy: finalize ADR-0021 with schema-per-tenant approach, add detailed implementation guidelines, and update Architect Roadmap.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
---
|
||||
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 1–2 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.
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
## 🔴 Sprint A — Sofort (diese Woche)
|
||||
|
||||
- [ ] **A-1** | ADR-0021 schreiben: Tenant-Resolution-Strategie
|
||||
- [ ] Optionen analysieren: Schema-per-Tenant vs. Tenant-ID in allen Tabellen
|
||||
- [ ] Entscheidung treffen und begründen
|
||||
- [ ] ADR-0021 in `docs/01_Architecture/ADRs/` ablegen
|
||||
- [ ] Backend Developer informieren (A-3 ist Blocker)
|
||||
- [x] **A-1** | ADR-0021 schreiben: Tenant-Resolution-Strategie
|
||||
- [x] Optionen analysieren: Schema-per-Tenant vs. Tenant-ID in allen Tabellen
|
||||
- [x] Entscheidung treffen und begründen
|
||||
- [x] ADR-0021 in `docs/01_Architecture/adr/` ablegen
|
||||
- [x] Backend Developer informieren (A-3 ist Blocker)
|
||||
|
||||
- [ ] **A-2** | Domänen-Modell formal präzisieren
|
||||
- [ ] Hierarchie `Veranstaltung → Turnier → Bewerb → Abteilung` als offizielles Modell festschreiben
|
||||
|
||||
Reference in New Issue
Block a user