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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -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() {
|
async function main() {
|
||||||
const adminUrl = parseAdminUrl();
|
const adminUrl = parseAdminUrl();
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -375,6 +550,7 @@ async function main() {
|
|||||||
|
|
||||||
await runForTenantChecks(scratchRoleUrlString, results);
|
await runForTenantChecks(scratchRoleUrlString, results);
|
||||||
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
|
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
||||||
await teardownScratchDatabase(adminUrl);
|
await teardownScratchDatabase(adminUrl);
|
||||||
|
|||||||
@@ -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`.
|
||||||
Reference in New Issue
Block a user