From a0c9ef070ff4b5caabc575a4607cc6c9b966da57 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 13:53:56 +0200 Subject: [PATCH] feat(quick-260909-ipc): Fehlerrichtung des Bereichs ldap messen und schriftlich festhalten Aufgabe 1 der Etappe 2: erweitert das Wegwerf-Werkzeug rls-scratch-check.mjs um fuenf Messungen des forTenant()-Musters gegen die echte, aus der ausgelieferten Migration geschnittene LdapConfig/LdapFieldMapping-Policy unter einer Rolle ohne BYPASSRLS. Belegt insbesondere, dass ein ungebundener Zugriff nach dem Scharfschalten 0 Zeilen liefert, nicht alle -- die Fehlerrichtung dreht sich um. Die neue Kritikschrift docs/mandantentrennung-etappe2-fehlerrichtung.md haelt das schriftlich fest, mit Signaltabelle je Pfad und den vier Stellen, die Leere als Abwesenheit deuten. Kein Dienstcode angefasst. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/scripts/rls-scratch-check.mjs | 176 ++++++++++++++++++ ...andantentrennung-etappe2-fehlerrichtung.md | 164 ++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 docs/mandantentrennung-etappe2-fehlerrichtung.md diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 91b607e..2b36c03 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -360,6 +360,181 @@ async function runAuthLookupChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Liest die ausgelieferte RLS-Basismigration und schneidet die beiden + * `CREATE POLICY`-Anweisungen fuer "LdapConfig" und "LdapFieldMapping" bis + * zum abschliessenden Semikolon heraus (Vorbild: readAuthLookupMigrationSql). + * Der Dateiname wird ueber ein Suffix gesucht, nicht hartkodiert — aber die + * Groups-Migration endet ebenfalls auf "_rls_policies" und wird deshalb + * ausdruecklich ausgeschlossen, sonst faende der Filter zwei Verzeichnisse. + */ +function readRlsPoliciesMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.endsWith('_rls_policies') && + !entry.name.endsWith('_groups_rls_policies'), + ) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +function extractPolicySql(migrationSql, tableName) { + const re = new RegExp( + `CREATE POLICY tenant_isolation_policy ON "${tableName}"[\\s\\S]*?;`, + ); + const match = migrationSql.match(re); + return match ? match[0] : null; +} + +/** + * Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen + * des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies + * WORTGLEICH aus der ausgelieferten Migration statt im Werkzeug neu getippt + * (T-IPC-08). Findet die Extraktion eine der beiden Policies nicht, meldet + * dieser Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit + * einer geratenen Policy weiterzumessen. + */ +async function runLdapAreaChecks(adminUrl, scratchRoleUrl, results) { + const migrationSql = readRlsPoliciesMigrationSql(); + const ldapConfigPolicy = migrationSql + ? extractPolicySql(migrationSql, 'LdapConfig') + : null; + const ldapFieldMappingPolicy = migrationSql + ? extractPolicySql(migrationSql, 'LdapFieldMapping') + : null; + + if (!ldapConfigPolicy || !ldapFieldMappingPolicy) { + report( + results, + 'ldap-policies-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "LdapConfig" und/oder "LdapFieldMapping" nicht in der ausgelieferten *_rls_policies-Migration gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "LdapConfig" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL, + "serverUrl" text NOT NULL + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "LdapFieldMapping" ( + id text PRIMARY KEY, + "ldapConfigId" text NOT NULL REFERENCES "LdapConfig"(id), + "ldapField" text NOT NULL, + "tesseraField" text NOT NULL + ); + `); + await db.$executeRawUnsafe(`ALTER TABLE "LdapConfig" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "LdapConfig" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "LdapFieldMapping" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "LdapFieldMapping" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(ldapConfigPolicy); + await db.$executeRawUnsafe(ldapFieldMappingPolicy); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "LdapConfig" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "LdapFieldMapping" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(` + INSERT INTO "LdapConfig" (id, "tenantId", "serverUrl") VALUES + ('cfg-a', 'TENANT-A', 'ldap://a.example'), + ('cfg-b', 'TENANT-B', 'ldap://b.example'); + `); + await db.$executeRawUnsafe(` + INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES + ('map-a', 'cfg-a', 'sAMAccountName', 'username'), + ('map-b', 'cfg-b', 'sAMAccountName', 'username'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 1: forTenant(TENANT-A) sieht genau die LdapConfig-Zeile von A. + const configRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "LdapConfig" ORDER BY id`, + ); + report( + results, + 'ldapconfig-gebunden-nur-eigene-zeile', + configRowsForA.length === 1 && configRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${configRowsForA.length} Zeile(n): ${JSON.stringify(configRowsForA.map((r) => r.tenantId))}`, + ); + + // 2: derselbe SELECT ohne Bindung liefert 0 Zeilen — die Fehlerrichtung, + // an der echten Policy gemessen statt an der Hilfstabelle "probe". + const unboundConfigRows = await prisma.$queryRaw`SELECT "tenantId" FROM "LdapConfig"`; + report( + results, + 'ldapconfig-ungebunden-null-zeilen', + unboundConfigRows.length === 0, + `ungebundener SELECT auf "LdapConfig" liefert ${unboundConfigRows.length} Zeile(n)`, + ); + + // 3: forTenant(TENANT-A) sieht ueber den Join genau die Feldzuordnung, + // die an A's Konfiguration haengt. + const mappingRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "ldapConfigId" FROM "LdapFieldMapping" ORDER BY id`, + ); + report( + results, + 'fieldmapping-folgt-join-auf-ldapconfig', + mappingRowsForA.length === 1 && mappingRowsForA[0].ldapConfigId === 'cfg-a', + `forTenant(TENANT-A) liefert ${mappingRowsForA.length} Feldzuordnung(en): ${JSON.stringify(mappingRowsForA.map((r) => r.ldapConfigId))}`, + ); + + // 4: gebundenes INSERT mit A's eigener ldapConfigId gelingt. + let ownInsertOk = false; + let ownInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES ('map-a-2', 'cfg-a', 'mail', 'email')`, + ); + ownInsertOk = true; + ownInsertDetail = 'INSERT mit eigener ldapConfigId erfolgreich'; + } catch (err) { + ownInsertDetail = `INSERT mit eigener ldapConfigId fehlgeschlagen: ${err.message}`; + } + report(results, 'fieldmapping-schreiben-eigene-konfiguration-erlaubt', ownInsertOk, ownInsertDetail); + + // 5: gebundenes INSERT unter TENANT-A mit B's ldapConfigId wird + // abgewiesen — die Abweisung IST das bestandene Ergebnis. + let foreignInsertRejected = false; + let foreignInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES ('map-foreign', 'cfg-b', 'mail', 'email')`, + ); + foreignInsertDetail = 'INSERT mit fremder ldapConfigId ist NICHT fehlgeschlagen'; + } catch (err) { + foreignInsertRejected = true; + foreignInsertDetail = `INSERT mit fremder ldapConfigId abgewiesen: ${err.message}`; + } + report( + results, + 'fieldmapping-schreiben-fremde-konfiguration-abgelehnt', + foreignInsertRejected, + foreignInsertDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + async function main() { const adminUrl = parseAdminUrl(); const results = []; @@ -375,6 +550,7 @@ async function main() { await runForTenantChecks(scratchRoleUrlString, results); await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results); + await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); await teardownScratchDatabase(adminUrl); diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md new file mode 100644 index 0000000..55b2def --- /dev/null +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -0,0 +1,164 @@ +# Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um + +Dieses Dokument gehört zusammen mit +`docs/mandantentrennung-zugriffsklassifikation.md` zur Vorbereitung der +Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält, +**welche** Fundstelle umgestellt wird, hält dieses Dokument fest, **woran** +man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten +(Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend +nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich `ldap` zum +Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc). + +## (a) Die Leitfrage + +Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die +Rolle `tessera` läuft mit `BYPASSRLS`, jede Abfrage sieht alle Zeilen aller +Mandanten, unabhängig davon, ob sie an `forTenant()` gebunden ist oder nicht. +Ein vergessener `forTenant()`-Aufruf blieb bisher unsichtbar, weil die Policy +gar nicht griff. + +Nach dem Scharfschalten (Etappe 4, wenn `DATABASE_URL` auf eine Rolle ohne +`BYPASSRLS` zeigt) kehrt sich das um. Jede Abfrage, die **nicht** gebunden +ist, sieht nicht mehr alle Zeilen, sondern **keine** — die Policy vergleicht +gegen `current_tenant_id()`, und ohne vorheriges `set_config` ist dieser Wert +`NULL`. `NULL = "tenantId"` ist in SQL nie wahr, auch wenn `"tenantId"` +selbst nicht `NULL` ist. Der Fehlerfall ist damit nicht mehr "ein +Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der +Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut +so, als sei nichts zu tun." + +Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim +Scharfschalten entdeckt: **Woran würde ich merken, dass eine umgestellte +Abfrage jetzt zu WENIG liefert statt zu viel?** + +## (b) Die Messung + +Task 1 dieses Plans hat `apps/api/scripts/rls-scratch-check.mjs` um einen +dritten Abschnitt erweitert, der exakt das im Bereich `ldap` verwendete +Muster (Tabellen `LdapConfig`/`LdapFieldMapping`, Policies wortgleich aus der +ausgelieferten Migration `20260618112133_rls_policies/migration.sql` +herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle **ohne** +`BYPASSRLS` prüft. Tatsächlich beobachtete Ausgabe dieses Laufs +(2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n) +fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"] +fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich +fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping" +Alle 13 Pruefungen bestanden. +``` + +Der Beleg, der diese Kritikschrift trägt, ist die Zeile +`ldapconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM +"LdapConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa +alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen, +nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung +"sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine +beobachtete Tatsache. + +## (c) Signaltabelle je umgestelltem Pfad des Bereichs `ldap` + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal | +|---|---|---| +| `LdapConfigService.getConfig/createConfig/updateConfig` | `forTenant()` liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration | `GET /ldap/config` liefert `null`; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat | +| `LdapConfigService.addFieldMapping/removeFieldMapping` | Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung | `POST`/`DELETE /ldap/config/mappings` scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert | +| `LdapService.listGroups`/`searchUsers` (Markierung "bereits importiert") | Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen | Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen `ldapObjectGuid`-Eindeutigkeit scheitern) | +| `LdapService.upsertMappedUser`/`importUsersByDn` (Identitätssuche über `ldapDn`/`username`) | Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos | Der Abgleich-Bericht zählt das Konto unter `created` statt `updated` — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts | +| `LdapService.syncUsersForTenant` (Deaktivierungs-Kandidatenliste) | Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen | Der Zähler `deactivated` bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel | +| `LdapService.syncUsersForTenant` (`lastSyncAt`-Fortschreibung) | Das `UPDATE` trifft 0 Zeilen statt der eigenen Konfiguration | Das Feld `lastSyncAt` der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb | +| `LdapService.importGroupsByDn` (Idempotenzprüfung über `ldapObjectGuid`) | Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe | Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil `group.create` an der eigenen `ldapObjectGuid`-Unique-Bedingung ohnehin scheitert | +| `LdapConfigScheduler` (Planer, `getAllActiveConfigs()`) | Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten | Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E | + +## (d) Welcher Code deutet Leere als Abwesenheit + +Diese vier Stellen sind die still gefährlichsten Orte des Bereichs `ldap`, +weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als +gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr") +interpretieren: + +1. **`syncGroupMembershipsForTenant`, `deleteMany` mit `notIn`** — gefährlich, + zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach + dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließende + `deleteMany({ where: { NOT: { userId: { in: [...] } } } })`-Aufruf ALLE + LDAP-Mitgliedschaften der Gruppe, weil die Vergleichsliste leer ist und + jede vorhandene Mitgliedschaft als "nicht mehr in der Liste" gilt. Dieser + Pfad ist bereits gebunden (`tenantPrisma` seit Etappe 1, Zeile 1179 vor + dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals + entfernt würde. + +2. **Die Deaktivierungsschleife in `syncUsersForTenant`** — harmlose + Richtung, trotzdem festgehalten. Liefert die Kandidatenliste + (`localLdapUsers`) zu wenig, wird zu WENIG deaktiviert, nie zu viel: ein + Konto, das eigentlich deaktiviert werden müsste, bleibt aktiv. Das ist + unerwünscht, aber nicht destruktiv — es verliert keine Daten und sperrt + niemanden fälschlich aus. + +3. **Der Löschzweig in `syncBoundGroupsForTenant`** — die eigentliche + Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr für + `objectGUID`"), nicht an der Datenbank; ein zu kleines Datenbankergebnis + führt hier zu WENIGER Löschungen, nicht zu mehr. Gefährlich ist + stattdessen die Übergabe unmittelbar davor: + `this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und + `ensureDefaultGroup(tenantId)` liegen in `groups.service.ts` und sind + NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefert + `reassignDefaultBeforeDelete` still `false` (kein Ersatzkandidat + sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird + trotzdem gelöscht — der Mandant bleibt ohne Standardgruppe zurück + (Befund D, siehe (e)). + +4. **`getAllActiveConfigs`** — die stillste Stelle im gesamten Bereich. + Liefert diese Abfrage nach dem Scharfschalten 0 Zeilen (sie ist bewusst + übergreifend und bleibt ungebunden, siehe (e)), stellt der LDAP-Abgleich + für JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne + sichtbare Änderung die Arbeit ein (Befund E). Eine Laufzeitwarnung bei + "0 aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer läuft + jede Minute, und auf einer frischen Installation ohne LDAP ist 0 der + Normalfall — eine Warnung wäre Dauerlärm, der nach kurzer Zeit ignoriert + wird und sein Signal verliert. Das Signal gehört deshalb hierhin und in + die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), nicht in den + Minutentakt des Planers. + +## (e) Was dieser Durchlauf bewusst nicht löst + +- **Befund A — `resolveEmailForWrite` muss übergreifend bleiben.** `email` + und `username` sind in `prisma/schema.prisma` plattformweit eindeutig + (`@unique`), nicht je Mandant. Würde diese Abfrage mitgebunden, sähe sie + einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und + der anschließende Schreibvorgang liefe in die plattformweite + Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten + Kollision (WINDOWS #15/T-Q3-01) würde ein P2002-Abbruch des gesamten + Sync-Laufs. Nach dem Scharfschalten liefert diese ungebundene Abfrage + IMMER "frei" (0 Zeilen unter jedem Mandantenkontext außer dem der + Adresse selbst) — ein bekannter, hier bewusst offen gelassener Punkt. + Die Lösung gehört nach Etappe 3, vermutlich als vierte + SECURITY-DEFINER-Funktion nach dem Muster der drei Funktionen des + Anmeldewegs (`auth_lookup_user_by_username`, + `auth_lookup_reset_token`, plus die dritte aus Etappe 1). + +- **Befund D — die Standardgruppen-Übergabe an den Bereich `groups`.** Wie + in (d.3) beschrieben, ist der Löschzweig selbst bereits gebunden, aber + die Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup` in + `groups.service.ts` ist es nicht. Das ist eine Reihenfolgebedingung für + Etappe 4: der Bereich `groups` muss umgestellt sein, bevor scharf + geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung + ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der + Etappe 2 vorgesehen. + +- **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts` + und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber + kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT, + ob Controller künftig darüber gehen sollten statt eines erneuten + `forTenant()`-Aufrufs im Service — der Bereich `ldap` bindet weiterhin + dienst-intern, wie die vier Bestandsstellen in `ldap.service.ts` und die + drei in `auth.service.ts` es vormachen. Die Frage bleibt für die übrigen + Bereiche der Etappe 2 offen (siehe + `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese + Etappe NICHT entscheidet"). + +## Verweis + +Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang +bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`), +steht in `docs/mandantentrennung-zugriffsklassifikation.md`.