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.
Die drei Ebenen
Abschnitt betitelt „Die drei Ebenen“| Ebene | Basisklasse | Speicherort | Filter |
|---|---|---|---|
| System | AbstractSystemModel | System-Datenbank | keiner |
| Mandant | AbstractTenantModel | Mandanten-Datenbank | über die Datenbankwahl |
| Benutzer | AbstractUserModel | Mandanten-Datenbank | zusätzlich _userId |
Die Benutzerebene ist keine eigene Datenbank, sondern eine Verfeinerung innerhalb der Mandantendatenbank. Der Owner-Filter läuft im System-Layer.
Der Weg des Mandanten durch den Request
Abschnitt betitelt „Der Weg des Mandanten durch den Request“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)]
Schritt 1 – Woher der Mandant kommt
Abschnitt betitelt „Schritt 1 – Woher der Mandant kommt“TokenParser liest aus dem signierten Token:
- das Attribut
tenant→RequestContext.userTenant - das Attribut
allowedTenants→RequestContext.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.
Schritt 2 – Wechsel nur mit doppelter Erlaubnis
Abschnitt betitelt „Schritt 2 – Wechsel nur mit doppelter Erlaubnis“ContextSwitch setzt den gewünschten Mandanten nur, wenn beides zutrifft:
- Der Benutzer trägt die Realm-Rolle
allowed-tenant-context-switch(„darf überhaupt wechseln”). - 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.
Schritt 3 – Auflösung des Persistenzziels
Abschnitt betitelt „Schritt 3 – Auflösung des Persistenzziels“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:
- Die Systemobjekt-Prüfung kommt zuerst. Ein fehlender oder manipulierter Request-Kontext kann Systemobjekte weder umleiten noch blockieren.
- Kein Mandant für ein Mandantenobjekt ⇒ Abbruch. Ein leerer oder nur aus Leerzeichen bestehender Mandant gilt als fehlend.
- Der Tenant-Katalog entscheidet, ob ein Mandant überhaupt bedient wird.
Die Entscheidungstabelle
Abschnitt betitelt „Die Entscheidungstabelle“| Systemobjekt | Mandant im Token | Header-Mandant | Wechsel erlaubt | Ziel |
|---|---|---|---|---|
| ja | beliebig | beliebig | beliebig | System-Datenbank |
| nein | T1 | – | – | Mandant T1 |
| nein | T1 | T2 | ja | Mandant T2 |
| nein | T1 | T2 | nein | Fehler CDMS_TENANT_SWITCH_NOT_AUTHORIZED |
| nein | – | T2 | ja | Mandant T2 |
| nein | – | T2 | nein | Fehler CDMS_TENANT_REQUIRED |
| nein | – | – | – | Fehler CDMS_TENANT_REQUIRED |
Verbotene Rückfallebenen
Abschnitt betitelt „Verbotene Rückfallebenen“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 EinheitEffekt: 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.
Der Tenant-Katalog
Abschnitt betitelt „Der Tenant-Katalog“TenantCatalogEntry ist ein Systemobjekt und liegt damit immer in der
System-Datenbank.
| Feld | Bedeutung |
|---|---|
tenantId | Mandantenkennung, eindeutig |
status | NEW, ACTIVE oder DISABLED |
validFrom, validUntil | optionales Gültigkeitsfenster (inklusiv) |
isServed(tenantId)ist genau dann wahr, wennstatus = ACTIVEund das heutige Datum im Fenster liegt. Das Ergebnis wird pro Mandantcdms.database.tenant-cache-ttl-seconds(Standard 3600 s) gecacht.- Ein unbekannter Mandant wird beim ersten Kontakt mit
NEWeingetragen 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.
Datenbanken und Pools
Abschnitt betitelt „Datenbanken und Pools“- Pro Mandant ein eigener HikariCP-Pool (
cdms.database.pool-size, Standard 20). - Die JDBC-URL entsteht durch Ersetzen des Platzhalters
{tenant}incdms.database.url. - Fehlt die Zieldatenbank, entscheidet
cdms.database.auto-create-tenant-database(Standardfalse):
| Zustand | Freigabe | Ergebnis |
|---|---|---|
| Datenbank existiert | beliebig | wird benutzt |
| Datenbank fehlt | false | CDMS_TENANT_DATASOURCE_NOT_FOUND |
| Datenbank fehlt | true | wird angelegt |
| Erreichbarkeit unklar | beliebig | CDMS_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.
Dateien
Abschnitt betitelt „Dateien“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.
Betriebsart SINGLE
Abschnitt betitelt „Betriebsart SINGLE“- 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.
Sicherheitsinvarianten
Abschnitt betitelt „Sicherheitsinvarianten“| # | Invariante |
|---|---|
| 1 | Systemobjekte werden nie in einer Mandantendatenbank gespeichert oder daraus gelesen |
| 2 | Mandantenobjekte werden nie ohne effektiven Mandanten verarbeitet |
| 3 | Ein fremder Mandant wird nie ohne Autorisierung benutzt |
| 4 | Routing erfolgt nie allein aufgrund eines nicht vertrauenswürdigen Headers |
| 5 | Fehler beim Routing führen nie auf die System-Datenbank zurück |
| 6 | Persistenzkontexte werden nicht zwischen parallelen Requests vermischt |
| 7 | Ein EntityManager wird nie über Requests hinweg geteilt |
Grenzen
Abschnitt betitelt „Grenzen“- 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.