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

7.9 KiB
Raw Blame History

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_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):

@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: 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:

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ä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.