Zum Inhalt springen

Wie funktioniert die Mandantentrennung?

Kurzantwort: In der Betriebsart MULTI hat jeder Mandant eine eigene Datenbank, dazu kommt eine System-Datenbank für mandantenübergreifende Daten. Welche Datenbank benutzt wird, entscheidet allein die Basisklasse der Entity – nicht ein Request-Header. Der effektive Mandant kommt aus dem signierten Token; ein Wechsel per Header wirkt nur, wenn Rolle und Erlaubnisliste ihn decken. Dateien liegen analog in einem Verzeichnis pro Mandant. In der Betriebsart SINGLE existiert keine technische Trennung.


EbeneBasisklasseSpeicherortFilter
SystemAbstractSystemModelSystem-Datenbankkeiner
MandantAbstractTenantModelMandanten-Datenbanküber die Datenbankwahl
BenutzerAbstractUserModelMandanten-Datenbankzusätzlich _userId

Die Benutzerebene ist keine eigene Datenbank, sondern eine Verfeinerung innerhalb der Mandantendatenbank. Der Owner-Filter läuft im System-Layer.

flowchart TB
    T["JWT (Keycloak)<br/>Attribut 'tenant'<br/>Attribut 'allowedTenants'"] --> TP[TokenParser]
    H["HTTP-Header 'tenant'<br/>(nicht vertrauenswürdig)"] --> JF[JwtSessionFilter]
    JF --> TP
    TP --> RC["RequestContext<br/>userTenant · allowedTenants<br/>userTenantSwitchRequest"]
    RC --> CS{ContextSwitch}
    CS -->|"Realm-Rolle 'allowed-tenant-context-switch'<br/>UND Ziel in allowedTenants"| OK["userTenant := Ziel"]
    CS -->|"sonst"| IGN["Wechsel wird ignoriert"]
    OK --> P[Persistenz]
    IGN --> P
    P --> R{"Entity erbt von<br/>AbstractSystemModel?"}
    R -->|ja| SDB[(System-DB)]
    R -->|nein| G{"Tenant-Katalog:<br/>ACTIVE und gültig?"}
    G -->|nein| ERR["CDMS_TENANT_NOT_SERVED (403)"]
    G -->|ja| TDB[(Mandanten-DB)]

TokenParser liest aus dem signierten Token:

  • das Attribut tenantRequestContext.userTenant
  • das Attribut allowedTenantsRequestContext.allowedTenants (immer inklusive des eigenen Mandanten, ohne Duplikate)
  • Realm-Rollen aus realm_access.roles
  • Fachrollen aus resource_access.<cias_client>.roles
  • alle übrigen Nicht-Protokoll-Claims als Benutzerattribute

Die Header tenant und user landen nur als Wunsch (userTenantSwitchRequest, userIdSwitchRequest) im Kontext.

ContextSwitch setzt den gewünschten Mandanten nur, wenn beides zutrifft:

  1. Der Benutzer trägt die Realm-Rolle allowed-tenant-context-switch („darf überhaupt wechseln”).
  2. Das Ziel steht in allowedTenants („darf dorthin wechseln”).

Beides ist fail-closed: fehlende Rolle, fehlende Liste, unbekanntes Ziel → der Kontext bleibt unverändert, der Wechsel findet still nicht statt. Analog gilt für den Benutzerwechsel die Rolle allowed-user-context-switch.

Die Persistenz prüft den effektiven Mandanten noch einmal defensiv gegen allowedTenants – zwei unabhängige Gates auf demselben Sachverhalt.

private String resolveTenant(boolean isSystemModel) {
if (isSystemModel)
return DEFAULT_TENANT; // vor jeder Kontextprüfung
RequestContext ctx = RequestContextHolder.get();
if (ctx == null) throw CDMS_PERSISTENCE_CONTEXT_MISSING;
boolean single = mode == SINGLE;
String tenant = ctx.getUserTenant();
if (tenant != null && !tenant.isBlank()) {
if (!single && !tenantRegistry.isServed(tenant)) throw CDMS_TENANT_NOT_SERVED;
return tenant;
}
if (single) return DEFAULT_TENANT;
throw CDMS_TENANT_REQUIRED;
}

Drei Eigenschaften sind wichtig:

  1. Die Systemobjekt-Prüfung kommt zuerst. Ein fehlender oder manipulierter Request-Kontext kann Systemobjekte weder umleiten noch blockieren.
  2. Kein Mandant für ein Mandantenobjekt ⇒ Abbruch. Ein leerer oder nur aus Leerzeichen bestehender Mandant gilt als fehlend.
  3. Der Tenant-Katalog entscheidet, ob ein Mandant überhaupt bedient wird.
SystemobjektMandant im TokenHeader-MandantWechsel erlaubtZiel
jabeliebigbeliebigbeliebigSystem-Datenbank
neinT1Mandant T1
neinT1T2jaMandant T2
neinT1T2neinFehler CDMS_TENANT_SWITCH_NOT_AUTHORIZED
neinT2jaMandant T2
neinT2neinFehler CDMS_TENANT_REQUIRED
neinFehler CDMS_TENANT_REQUIRED

Diese Fallbacks gibt es nicht und darf es nicht geben:

  • unbekannter Mandant → System-Datenbank
  • unbekannter Mandant → Datenbank automatisch anlegen
  • fehlender Request-Kontext → System-Datenbank
  • fehlender Mandant → System-Datenbank
  • abgelehnter Wechsel → stillschweigend beim eigenen Mandanten weitermachen
  • Fehler beim Aufbau der Mandanten-DataSource → System-Datenbank

Jeder dieser Fälle scheitert sichtbar und deterministisch. Abgesichert durch TenantRoutingFailureMultiTest und TenantDatabaseProvisioningTest.

Die zweite Verteidigungslinie: getrennte Typmengen

Abschnitt betitelt „Die zweite Verteidigungslinie: getrennte Typmengen“

Neben dem Routing entscheidet eine zweite, unabhängige Quelle, welche Entitäten eine Persistenzeinheit überhaupt kennt – die generierte EntityRegistry:

MULTI, Ziel "system":
AbstractEntityModel, AbstractSystemModel, TenantCatalogEntry
+ alle getSystemEntityClasses()
MULTI, Ziel = ein Mandant:
AbstractEntityModel, AbstractTenantModel, AbstractUserModel
+ alle getTenantEntityClasses() + getUserEntityClasses()
SINGLE:
alles zusammen in einer Einheit

Effekt: Die System-Persistenzeinheit kennt die Mandanten-Entitäten nicht und umgekehrt. Ein falsch geroutetes Objekt scheitert an Hibernate, statt in der falschen Datenbank zu landen. AuditRevisionEntity wird in jede Einheit aufgenommen, damit Envers überall arbeiten kann.

Damit Routing (Basisklasse) und Typmengen (Registry) nicht auseinanderlaufen, prüft EntityClassificationValidator beim Start, dass jede Registry-Gruppe zur Basisklasse passt. Eine inkonsistente Registry lässt die Anwendung nicht starten – statt beim ersten betroffenen Request unerklärlich zu brechen.

TenantCatalogEntry ist ein Systemobjekt und liegt damit immer in der System-Datenbank.

FeldBedeutung
tenantIdMandantenkennung, eindeutig
statusNEW, ACTIVE oder DISABLED
validFrom, validUntiloptionales Gültigkeitsfenster (inklusiv)
  • isServed(tenantId) ist genau dann wahr, wenn status = ACTIVE und das heutige Datum im Fenster liegt. Das Ergebnis wird pro Mandant cdms.database.tenant-cache-ttl-seconds (Standard 3600 s) gecacht.
  • Ein unbekannter Mandant wird beim ersten Kontakt mit NEW eingetragen und nicht bedient. Ein Betreiber muss ihn aktivieren.
  • activate(...) / setStatus(...) sind die Betreiber-API und invalidieren den Cache sofort.

Abgrenzung: Der Katalog beantwortet „ist dieser Mandant technisch aktiv”, nicht „darf dieser Benutzer auf diesen Mandanten” – Letzteres ist Sache der Sicherheitsschicht.

  • Pro Mandant ein eigener HikariCP-Pool (cdms.database.pool-size, Standard 20).
  • Die JDBC-URL entsteht durch Ersetzen des Platzhalters {tenant} in cdms.database.url.
  • Fehlt die Zieldatenbank, entscheidet cdms.database.auto-create-tenant-database (Standard false):
ZustandFreigabeErgebnis
Datenbank existiertbeliebigwird benutzt
Datenbank fehltfalseCDMS_TENANT_DATASOURCE_NOT_FOUND
Datenbank fehlttruewird angelegt
Erreichbarkeit unklarbeliebigCDMS_TENANT_DATASOURCE_UNAVAILABLE, kein Anlegeversuch

Die Unterscheidung „nicht erreichbar” vs. „existiert nicht” verhindert, dass ein kurzer Ausfall eine Provisionierung auslöst. Angelegt wird über MySqlDatabaseCreator (utf8mb4 / utf8mb4_unicode_ci); die Namensprüfung ^[a-z0-9-]+$ ist die letzte Schranke, bevor eine Mandantenkennung in SQL landet, und muss streng bleiben.

EntityManagerFactories werden pro Ziel prozessweit gecacht (LRU, mit Leerlauf-Räumung); EntityManager gibt es pro Thread, nie geteilt über Requests hinweg. Eine geräumte Factory wird erst geschlossen, wenn ihr letzter EntityManager zurückgegeben ist.

Dieselbe Trennung, nur positional im Pfad:

<basePath>/[<tenant>/]<modellpfad>/<fileId>
  • Das Mandantensegment steht nur bei mandantengebundenen Modellen in MULTI.
  • Systemmodelle liegen immer ohne Mandantensegment – global.
  • Die Mandantenkennung wird geprüft, nicht bereinigt ([a-zA-Z0-9._-]+, kein ..). Bereinigen würde zwei verschiedene Mandanten auf dasselbe Verzeichnis abbilden – aus einem Sanitizer würde ein Trennungsleck.

Details in Dateien und Storage.

  • genau eine Datenbank, Cache-Schlüssel "single"
  • System-, Mandanten- und Benutzer-Entitäten liegen zusammen
  • keine technische Mandantentrennung, kein Katalog, kein Pflicht-Mandant
  • gedacht für Einzelmandanten-Installationen sowie Entwicklung und Test

Die Betriebsart wirkt an drei Stellen: Typmengen, Factory-Cache-Schlüssel und DataSource-Auswahl. Sie darf nach der Initialisierung nicht mehr gewechselt werden, weil bereits erzeugte Factories im Cache bleiben.

#Invariante
1Systemobjekte werden nie in einer Mandantendatenbank gespeichert oder daraus gelesen
2Mandantenobjekte werden nie ohne effektiven Mandanten verarbeitet
3Ein fremder Mandant wird nie ohne Autorisierung benutzt
4Routing erfolgt nie allein aufgrund eines nicht vertrauenswürdigen Headers
5Fehler beim Routing führen nie auf die System-Datenbank zurück
6Persistenzkontexte werden nicht zwischen parallelen Requests vermischt
7Ein EntityManager wird nie über Requests hinweg geteilt
  • Keine Atomarität über Datenbankgrenzen. Berührt eine Operation System- und Mandantendatenbank, gibt es ohne verteilte Transaktion keine Atomarität. Regel: eine fachliche Operation schreibt genau ein Ziel. Ist das unvermeidbar, wird die Reihenfolge bewusst gewählt und der Restzustand erkennbar gemacht. Siehe Transaktionen.
  • Direktzugriff umgeht Filter. Wer die Persistenz unter Umgehung des System-Layers aufruft, umgeht Owner- und Attributfilter. Das ist beabsichtigt (Infrastruktur ohne Fachautorisierung), muss bei neuen Aufrufern aber bedacht werden.
  • Gegen In-Memory-H2 ist die Existenzprüfung wirkungslos, weil eine solche Datenbank beim Verbinden entsteht. Die Freigabeprüfung greift dort nicht.