7.9 KiB
| type | status | owner | last_update |
|---|---|---|---|
| ADR | PROPOSED | Lead Architect | 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_historypro Schema. - Request-Pipeline erhält einen Tenant-Filter/Plugin; Data-Layer schaltet das Schema pro Request (
SCHEMA-Multitenancy oderSET search_path). - Logging/Metrics/Tracing enthalten
tenant_id/event_idals 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)
- Identifikation pro Request (Priorität):
X-Event-IdHeader (kanonisch), z. B.X-Event-Id: 2026-moc-open.- Subdomain/Authority:
{event}.meldestelle.local→ Auflösung zuevent_id. - Fallback: expliziter
eventId-Pfadparameter nur für Admin-/Sync-Endpunkte.
- Validierung & Lookup:
- Registry
control.tenants(event_id, schema_name, db_url, status, version, created_at). - Nur
activeTenants sind schreibbar; archivierte Events sind read-only/gesperrt.
- Registry
- Autorisierung:
- Token-Claims (
events: [event_id...]) werden gegen angefordertenevent_idgeprüft. - Service-zu-Service (Sync) via mTLS + Service-Claims.
- Token-Claims (
Technische Umsetzung
Spring Boot + Hibernate (SCHEMA-Strategie)
hibernate.multiTenancy=SCHEMAaktivieren.CurrentTenantIdentifierResolverimplementieren (liestschemaNameaus Request-Kontext).MultiTenantConnectionProviderimplementieren (Schema-Umschaltung bzw. DataSource-Auswahl beidb_url != null).
Beispiel (verkürzt):
@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):
class EventTenantResolver(private val ctx: TenantContextHolder): CurrentTenantIdentifierResolver {
override fun resolveCurrentTenantIdentifier(): String =
ctx.current()?.schemaName ?: DEFAULT_SCHEMA
override fun validateExistingCurrentSessions() = true
}
TenantContext per Filter:
@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: liestX-Event-Id, validiert gegen Registry, setztTenantContextincall.attributes.- Repository-/DAO-Layer wählt Schema je Request (z. B.
SET search_path TO <schema>).
Auszug Plugin:
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):
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älttenantsundflyway_schema_history_control.- Tenant-Schema: identische Struktur je Event, eigene
flyway_schema_history. - Bootstrapping:
controlmigrieren (einmalig).- Bei Event-Anlage: Schema
event_<slug>anlegen, Flywaymigrategegen dieses Schema ausführen, Eintrag intenantsschreiben. - Rollout neuer Version: aktive Tenants enumerieren, Flyway parallelisiert mit Backoff ausführen.
Connection-Pooling-Strategie
- Standard: Shared
DataSourceje physische DB +SET search_pathpro Anfrage (ein Pool, geringere Verbindungszahl). - Alternative:
DataSourceje 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_pathmessen. - Chaos: Tenant zur Laufzeit deaktivieren → Requests erhalten
404 Unknown eventoder423 Locked.
Migrations-/Einführungsplan
control-Schema einführen inkl.tenants-Tabelle und Admin-API (POST /admin/events).- Tenant-Resolution in Web-Layer integrieren (Filter/Plugin) + globale Durchsetzung.
- Datenzugriffs-Layer auf Schema-per-Tenant umbauen (Entfernen globaler
tenant_id-Abhängigkeiten, falls vorhanden). - Flyway-Migration je Schema implementieren + Rollout-Pipeline anpassen.
- Observability:
tenant_idals Label/Tag in Logs/Metrics/Traces. - 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_urlin Registry für große/sensible Events. - Gemeinsame Referenzdaten global im
control-Schema; Event-Overrides bei Bedarf im Event-Schema.