diff --git a/docs/01_Architecture/adr/0021-tenant-resolution-strategy-de.md b/docs/01_Architecture/adr/0021-tenant-resolution-strategy-de.md new file mode 100644 index 00000000..d20a5b93 --- /dev/null +++ b/docs/01_Architecture/adr/0021-tenant-resolution-strategy-de.md @@ -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 `). + +Auszug Plugin: +```kotlin +val TenantContextKey = AttributeKey("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 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_` 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 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. diff --git a/docs/04_Agents/Roadmaps/Architect_Roadmap.md b/docs/04_Agents/Roadmaps/Architect_Roadmap.md index 11f19101..c5dffd39 100644 --- a/docs/04_Agents/Roadmaps/Architect_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Architect_Roadmap.md @@ -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