feat(quick-260910-das): messen die Kette user unsichtbar->frei->Eindeutigkeitsfehler
- runUserAreaChecks in rls-scratch-check.mjs: 12 neue Pruefungen gegen die
ausgelieferte User-Policy (baut auf der vom Anmeldeweg-Abschnitt
angelegten Tabelle auf, legt zusaetzlich Tenant ohne Zeilenschutz an)
- Belegt: ungebundene Suche nach vorhandenem Benutzernamen liefert 0
Zeilen, gebundene Suche nach fremdem Benutzernamen ebenso ("frei"), und
das anschliessende gebundene INSERT scheitert hart an SQLSTATE 23505
(Eindeutigkeitsverletzung), nicht an 42501 (Zeilenschutz)
- SQLSTATE wird aus err.meta.code gelesen, nicht err.code (das bei
$executeRaw-Fehlern immer den generischen Prisma-Code P2010 traegt,
empirisch gegen tessera-ctl-db-1 geprueft)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
"Bereich user" (u1-u5) mit der tatsaechlich beobachteten Ausgabe,
Signaltabelle, der vollstaendigen Kette (Befund L) und den Grenzen zu
auth.service.ts/ldap.service.ts
- Teil 2/3 gemessen: keine Transaktion in apps/api/src/user (Befund B
haelt), findByUsername hat genau einen Treffer, die eigene Definition
(Befund D haelt)
- 789 Tests weiterhin gruen, Typpruefung sauber, Wegwerf-Werkzeug meldet
alle 53 Pruefungen bestanden (41 bisherige + 12 neue)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1391,6 +1391,325 @@ async function runDkvAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht zwei SQL-Fragmente nach Normalisierung von Leerraum und
|
||||
* abschliessendem Semikolon, damit der Vergleich nicht an Formatierung
|
||||
* scheitert (Aufgabe 1, 260910-das).
|
||||
*/
|
||||
function normalizePolicySql(sql) {
|
||||
return sql.trim().replace(/;\s*$/, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte
|
||||
* Policy auf "User" — Wortlaut identisch mit Zeile 285 dieser Datei. Dieser
|
||||
* Abschnitt haelt sie GEGEN die aus der ausgelieferten Migration
|
||||
* `20260618112133_rls_policies` geschnittene Fassung, statt eine dritte
|
||||
* Fassung zu erzeugen (Befund O).
|
||||
*/
|
||||
const AUTH_LOOKUP_USER_POLICY_SQL =
|
||||
'CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());';
|
||||
|
||||
/**
|
||||
* Liest den SQLSTATE-Fehlercode aus einem PrismaClientKnownRequestError,
|
||||
* der aus einem fehlgeschlagenen $executeRaw/$queryRaw stammt. Bei
|
||||
* Rohabfragen setzt Prisma selbst `.code` auf den generischen Wert
|
||||
* `P2010` ("Raw query failed") und legt den tatsaechlichen
|
||||
* PostgreSQL-SQLSTATE-Code unter `.meta.code` ab (empirisch geprueft gegen
|
||||
* `tessera-ctl-db-1`, 2026-09-10: `.code` ist fuer eine Eindeutigkeits-
|
||||
* UND eine Zeilenschutz-Verletzung gleichermassen `P2010`, waehrend
|
||||
* `.meta.code` `23505` bzw. `42501` unterscheidet) — deshalb wird hier
|
||||
* `.meta.code` gelesen, nicht `.code`.
|
||||
*/
|
||||
function sqlStateOf(err) {
|
||||
return err?.meta?.code ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260910-das) — misst die zwoelf im Plan genannten
|
||||
* Verhaltensweisen des Bereichs `user` unter der Rolle ohne BYPASSRLS. Die
|
||||
* Tabelle "User" existiert bereits (vom runAuthLookupChecks()-Abschnitt
|
||||
* angelegt, samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
|
||||
* Eindeutigkeitsbedingungen `username`/`email` und zwei Testzeilen in zwei
|
||||
* Mandanten, Befund O) — dieser Abschnitt legt sie NICHT neu an, sondern
|
||||
* baut darauf auf und ergaenzt, was ihm fehlt: eine Tabelle "Tenant" ohne
|
||||
* Zeilenschutz (die zu messende Eigenschaft selbst, nicht Beiwerk) und je
|
||||
* eine weitere Benutzerzeile pro Mandant.
|
||||
*
|
||||
* Muss NACH runDkvAreaChecks() und VOR runTransactionShapeMeasurement()
|
||||
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
|
||||
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
|
||||
* Abschnitt aendert daran nichts.
|
||||
*/
|
||||
async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const baseMigrationSql = readRlsPoliciesMigrationSql();
|
||||
const extractedUserPolicy = baseMigrationSql
|
||||
? extractPolicySql(baseMigrationSql, 'User')
|
||||
: null;
|
||||
|
||||
if (
|
||||
!extractedUserPolicy ||
|
||||
normalizePolicySql(extractedUserPolicy) !== normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL)
|
||||
) {
|
||||
report(
|
||||
results,
|
||||
'user-policy-aus-migration-wortgleich',
|
||||
false,
|
||||
extractedUserPolicy
|
||||
? `Policy aus 20260618112133_rls_policies weicht von der im Anmeldeweg-Abschnitt getippten Fassung ab: extrahiert=${JSON.stringify(normalizePolicySql(extractedUserPolicy))}, getippt=${JSON.stringify(normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL))} — die getippte Fassung wird durch die geschnittene ERSETZT, die Abweichung gehoert in die Kritikschrift`
|
||||
: 'CREATE POLICY fuer "User" nicht in der ausgelieferten 20260618112133_rls_policies-Migration gefunden',
|
||||
);
|
||||
return;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'user-policy-aus-migration-wortgleich',
|
||||
true,
|
||||
'die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen',
|
||||
);
|
||||
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
// "Tenant" traegt bewusst KEINEN Zeilenschutz — das ist die zu
|
||||
// messende Eigenschaft (Befund F/K), nicht ein Versehen.
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "Tenant" (
|
||||
id text PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE
|
||||
);
|
||||
`);
|
||||
await db.$executeRawUnsafe(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON "Tenant" TO ${SCRATCH_ROLE_NAME}`,
|
||||
);
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "Tenant" (id, slug) VALUES
|
||||
('TENANT-A', 'tenant-a'),
|
||||
('TENANT-B', 'tenant-b');
|
||||
`);
|
||||
|
||||
// Je eine weitere Benutzerzeile pro Mandant, zusaetzlich zu den beiden
|
||||
// bereits vom Anmeldeweg-Abschnitt angelegten (alice/TENANT-A,
|
||||
// bob/TENANT-B) — role braucht einen gueltigen Aufzaehlungswert
|
||||
// (Befund O).
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role)
|
||||
VALUES ('user-a2', 'carol', 'TENANT-A', 'hash-c', true, 'USER'),
|
||||
('user-b2', 'dave', 'TENANT-B', 'hash-d', true, 'USER');
|
||||
`);
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// user-gebunden-nur-eigener-mandant
|
||||
const userRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT "tenantId" FROM "User" ORDER BY id`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'user-gebunden-nur-eigener-mandant',
|
||||
userRowsForA.length === 2 && userRowsForA.every((r) => r.tenantId === 'TENANT-A'),
|
||||
`forTenant(TENANT-A) liefert ${userRowsForA.length} Zeile(n): ${JSON.stringify(userRowsForA.map((r) => r.tenantId))}`,
|
||||
);
|
||||
|
||||
// user-ungebunden-null-zeilen — die Belegzeile, die den gesamten
|
||||
// user-Abschnitt der Kritikschrift traegt: der IDENTISCHE SELECT ohne
|
||||
// vorheriges set_config liefert null Zeilen, nicht die vier
|
||||
// tatsaechlich vorhandenen.
|
||||
const unboundUserRows = await prisma.$queryRaw`SELECT "tenantId" FROM "User"`;
|
||||
report(
|
||||
results,
|
||||
'user-ungebunden-null-zeilen',
|
||||
unboundUserRows.length === 0,
|
||||
`ungebundener SELECT auf "User" liefert ${unboundUserRows.length} Zeile(n)`,
|
||||
);
|
||||
|
||||
// user-ungebundene-suche-nach-benutzername-liefert-keine-zeile — die
|
||||
// Form, die die Erstanlage-Pruefung beim Start heute benutzt: ein
|
||||
// ungebundenes SELECT mit Gleichheitsbedingung auf einen Benutzernamen,
|
||||
// den es GIBT ('bob', TENANT-B).
|
||||
const unboundUsernameLookup =
|
||||
await prisma.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`;
|
||||
report(
|
||||
results,
|
||||
'user-ungebundene-suche-nach-benutzername-liefert-keine-zeile',
|
||||
unboundUsernameLookup.length === 0,
|
||||
`ungebundenes SELECT ... WHERE username = 'bob' liefert ${unboundUsernameLookup.length} Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an`,
|
||||
);
|
||||
|
||||
// user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile —
|
||||
// dieselbe Suche, gebunden an TENANT-A, nach einem Benutzernamen von
|
||||
// TENANT-B ('bob'). Das ist der Moment, in dem eine Kollisionspruefung
|
||||
// faelschlich "frei" meldet.
|
||||
const boundForeignUsernameLookup = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile',
|
||||
boundForeignUsernameLookup.length === 0,
|
||||
`forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) ${boundForeignUsernameLookup.length} Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"`,
|
||||
);
|
||||
|
||||
// user-eindeutigkeit-greift-trotz-unsichtbarkeit — die wichtigste
|
||||
// Messung dieser Aufgabe und der Schluss der Kette: unmittelbar nach
|
||||
// der vorherigen Pruefung ein gebundenes INSERT unter TENANT-A mit
|
||||
// genau diesem, angeblich freien Benutzernamen ('bob'). Bestanden, wenn
|
||||
// es abgewiesen wird UND die Ablehnung nachweislich eine
|
||||
// Eindeutigkeitsverletzung ist (SQLSTATE 23505) und NICHT eine
|
||||
// Zeilenschutz-Ablehnung (SQLSTATE 42501).
|
||||
let uniquenessHoldsDespiteInvisibility = false;
|
||||
let uniquenessDetail = '';
|
||||
try {
|
||||
await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-a-collision', 'bob', 'TENANT-A', 'hash-x', true, 'USER')`,
|
||||
);
|
||||
uniquenessDetail =
|
||||
'gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
const sqlState = sqlStateOf(err);
|
||||
uniquenessHoldsDespiteInvisibility = sqlState === '23505';
|
||||
uniquenessDetail = `gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()}) — ${
|
||||
uniquenessHoldsDespiteInvisibility
|
||||
? 'eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette'
|
||||
: 'KEINE Eindeutigkeitsverletzung, sondern eine andere Fehlerklasse — das waere NICHT die im Auftrag beschriebene Kette'
|
||||
}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'user-eindeutigkeit-greift-trotz-unsichtbarkeit',
|
||||
uniquenessHoldsDespiteInvisibility,
|
||||
uniquenessDetail,
|
||||
);
|
||||
|
||||
// user-gebundenes-einfuegen-fremder-mandant-abgelehnt — die Policy
|
||||
// traegt keine eigene WITH-CHECK-Klausel; was PostgreSQL daraus fuer
|
||||
// ein INSERT ableitet, ist eine Eigenschaft der Datenbank, keine des
|
||||
// Policy-Textes.
|
||||
let foreignTenantInsertRejected = false;
|
||||
let foreignTenantInsertDetail = '';
|
||||
try {
|
||||
await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-foreign-tenant', 'erin', 'TENANT-B', 'hash-e', true, 'USER')`,
|
||||
);
|
||||
foreignTenantInsertDetail =
|
||||
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
foreignTenantInsertRejected = true;
|
||||
foreignTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message.trim()}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'user-gebundenes-einfuegen-fremder-mandant-abgelehnt',
|
||||
foreignTenantInsertRejected,
|
||||
foreignTenantInsertDetail,
|
||||
);
|
||||
|
||||
// user-ungebundenes-einfuegen-abgelehnt — die Zeile, die Befund J
|
||||
// traegt: bliebe die Erstanlage des Administrators ungebunden, koennte
|
||||
// eine frische Installation ihren ersten Administrator nach dem
|
||||
// Scharfschalten nicht anlegen.
|
||||
let unboundInsertRejected = false;
|
||||
let unboundInsertDetail = '';
|
||||
try {
|
||||
await prisma.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-unbound', 'frank', 'TENANT-A', 'hash-f', true, 'USER')`;
|
||||
unboundInsertDetail = 'ungebundenes INSERT mit gueltiger Mandantenkennung ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
unboundInsertRejected = true;
|
||||
unboundInsertDetail = `ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: ${err.message.trim()}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'user-ungebundenes-einfuegen-abgelehnt',
|
||||
unboundInsertRejected,
|
||||
unboundInsertDetail,
|
||||
);
|
||||
|
||||
// user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
|
||||
const foreignUpdateAffected = await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`UPDATE "User" SET "displayName" = 'ueberschrieben' WHERE id = 'user-b'`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
|
||||
foreignUpdateAffected === 0,
|
||||
`gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt`,
|
||||
);
|
||||
|
||||
// user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen
|
||||
const foreignDeleteAffected = await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) => tx.$executeRaw`DELETE FROM "User" WHERE id = 'user-b2'`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen',
|
||||
foreignDeleteAffected === 0,
|
||||
`gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft ${foreignDeleteAffected} Zeile(n)`,
|
||||
);
|
||||
|
||||
// tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar — die Eigenschaft, auf
|
||||
// der sowohl die umgestellte Plattform-Administratorsicht als auch die
|
||||
// Standardgruppen-Reparatur beim Start stehen (Befund F, Befund K).
|
||||
// Zusaetzlich im Systemkatalog geprueft, damit die Aussage nicht allein
|
||||
// daran haengt, dass dieser Abschnitt selbst keinen Zeilenschutz
|
||||
// eingeschaltet hat.
|
||||
const unboundTenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`;
|
||||
const [rlsRow] =
|
||||
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Tenant'`;
|
||||
const tenantReadable = unboundTenantRows.length === 2 && rlsRow?.relrowsecurity === false;
|
||||
report(
|
||||
results,
|
||||
'tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar',
|
||||
tenantReadable,
|
||||
`ungebundenes SELECT auf "Tenant" liefert ${unboundTenantRows.length} Zeile(n): ${JSON.stringify(unboundTenantRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Tenant" = ${JSON.stringify(rlsRow?.relrowsecurity)}`,
|
||||
);
|
||||
|
||||
// user-fan-out-je-mandant-gebunden-liefert-alle-zeilen — die
|
||||
// Nachbildung der umgestellten Plattform-Administratorsicht: erst die
|
||||
// Mandanten ungebunden lesen, dann je Mandant EIN gebundener SELECT,
|
||||
// dann die Ergebnisse vereinigen. Die Gesamtmenge wird ueber die
|
||||
// Wartungsrolle (mit BYPASSRLS) gemessen, nicht angenommen.
|
||||
const tenantIds = unboundTenantRows.map((r) => r.id).sort();
|
||||
let fannedOutUsernames = [];
|
||||
for (const tenantId of tenantIds) {
|
||||
const rows = await forTenantQuery(prisma, tenantId, (tx) =>
|
||||
tx.$queryRaw`SELECT username FROM "User" ORDER BY username`,
|
||||
);
|
||||
fannedOutUsernames.push(...rows.map((r) => r.username));
|
||||
}
|
||||
fannedOutUsernames = fannedOutUsernames.sort();
|
||||
|
||||
const groundTruthUsernames = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT username FROM "User" ORDER BY username`;
|
||||
return rows.map((r) => r.username).sort();
|
||||
},
|
||||
);
|
||||
|
||||
const fanOutMatches =
|
||||
fannedOutUsernames.length === groundTruthUsernames.length &&
|
||||
fannedOutUsernames.every((u, i) => u === groundTruthUsernames[i]);
|
||||
report(
|
||||
results,
|
||||
'user-fan-out-je-mandant-gebunden-liefert-alle-zeilen',
|
||||
fanOutMatches,
|
||||
`Vereinigung der je-Mandant gebundenen SELECTs liefert ${fannedOutUsernames.length} Benutzernamen: ${JSON.stringify(fannedOutUsernames)}; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind ${groundTruthUsernames.length}: ${JSON.stringify(groundTruthUsernames)}`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -1623,6 +1942,7 @@ async function main() {
|
||||
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
@@ -912,6 +912,276 @@ entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
|
||||
Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je
|
||||
Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).
|
||||
|
||||
## Bereich user
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `user`
|
||||
(Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||||
beantwortet sie erneut, für den Bereich, in dem das plattformweite
|
||||
Eindeutigkeitsproblem tatsächlich wohnt: `username` und `email` sind im
|
||||
Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält
|
||||
als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN
|
||||
(Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF
|
||||
(Nachschlagen auf dem plattformweit eindeutigen Schlüssel `username`). Ein
|
||||
Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung
|
||||
über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen
|
||||
Vorhabens.
|
||||
|
||||
### (u1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen siebten
|
||||
Abschnitt (`runUserAreaChecks`) erweitert. Die Tabelle `"User"` wird dabei
|
||||
NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs,
|
||||
samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
|
||||
Eindeutigkeitsbedingungen (`username`, `email`) und zwei Testzeilen in zwei
|
||||
Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im
|
||||
Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der
|
||||
ausgelieferten Migration `20260618112133_rls_policies` geschnittene Fassung
|
||||
(beide sind nach Normalisierung von Leerraum und abschließendem Semikolon
|
||||
wortgleich — keine Ersetzung nötig), legt eine Tabelle `"Tenant"`
|
||||
ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und
|
||||
ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete
|
||||
Ausgabe dieses Laufs (2026-09-10, gegen `tessera-ctl-db-1`, Adresse
|
||||
`172.19.0.2`):
|
||||
|
||||
```
|
||||
user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen
|
||||
user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
|
||||
user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n)
|
||||
user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an
|
||||
user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"
|
||||
user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette
|
||||
user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
|
||||
user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
|
||||
user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt
|
||||
user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n)
|
||||
tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false
|
||||
user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"]
|
||||
Alle 53 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und
|
||||
auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette
|
||||
sind:
|
||||
|
||||
- **Die Belegzeile zur Unsichtbarkeit**, `user-ungebunden-null-zeilen`: der
|
||||
IDENTISCHE `SELECT "tenantId" FROM "User"` ohne vorheriges `set_config`
|
||||
liefert **0 Zeilen**, nicht etwa die 4 tatsächlich vorhandenen — an der
|
||||
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
|
||||
nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die
|
||||
ungebundene Suche nach einem GÜLTIGEN Benutzernamen
|
||||
(`user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`) — die
|
||||
Form, die die Erstanlage-Prüfung beim Start heute benutzt.
|
||||
- **Die Zeile zum falschen „frei"**,
|
||||
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`:
|
||||
dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen `bob`
|
||||
(gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine
|
||||
Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist.
|
||||
- **Die Zeile zum harten Eindeutigkeitsfehler**,
|
||||
`user-eindeutigkeit-greift-trotz-unsichtbarkeit`: unmittelbar danach ein
|
||||
gebundenes `INSERT` unter TENANT-A mit genau diesem, angeblich freien
|
||||
Benutzernamen `bob`. Die Ablehnung trägt SQLSTATE **23505**
|
||||
(Eindeutigkeitsverletzung) — gelesen aus `err.meta.code`, nicht aus
|
||||
`err.code` (das bei einem fehlgeschlagenen `$executeRaw` immer den
|
||||
generischen Prisma-Code `P2010` trägt, empirisch gegen
|
||||
`tessera-ctl-db-1` geprüft, siehe Kopfkommentar von `sqlStateOf()` im
|
||||
Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser
|
||||
Abschnitt in zwei anderen Zeilen ebenfalls misst
|
||||
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`,
|
||||
`user-ungebundenes-einfuegen-abgelehnt`). Diese Unterscheidung ist der
|
||||
Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette,
|
||||
die zweite wäre eine ganz andere Geschichte.
|
||||
|
||||
Zusätzlich gemessen, statt behauptet: `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`
|
||||
hält sowohl das ungebundene `SELECT` (2 von 2 Zeilen sichtbar) als auch den
|
||||
Systemkatalog (`pg_class.relrowsecurity` für `"Tenant"` = `false`)
|
||||
gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser
|
||||
Abschnitt selbst keinen Zeilenschutz eingeschaltet hat.
|
||||
`user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` bildet die in
|
||||
Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten
|
||||
ungebunden lesen, je Mandant EIN gebundener `SELECT`, Ergebnisse
|
||||
vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit
|
||||
`BYPASSRLS`) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide
|
||||
Mengen sind identisch: `["alice","bob","carol","dave"]`.
|
||||
|
||||
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
|
||||
diesem Bereich:
|
||||
|
||||
```
|
||||
$ grep -rn '\$transaction(' apps/api/src/user --include=*.ts | grep -v spec
|
||||
$ echo $?
|
||||
1
|
||||
```
|
||||
|
||||
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
|
||||
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
|
||||
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
|
||||
Aufgabe 2/3 nicht eingeführt.
|
||||
|
||||
**TEIL 3, Beleg statt Behauptung für Befund D** — die Aufrufermessung für
|
||||
`findByUsername`:
|
||||
|
||||
```
|
||||
$ grep -rn "findByUsername" apps/api/src packages
|
||||
apps/api/src/user/user.service.ts:21: async findByUsername(username: string) {
|
||||
```
|
||||
|
||||
Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1
|
||||
(260909-eor) hat den Anmeldeweg auf die drei schmalen
|
||||
SECURITY-DEFINER-Funktionen umgezogen; `auth.service.ts` sucht seither über
|
||||
`auth_lookup_user_by_username` und nicht mehr über diese Methode. Die
|
||||
Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden
|
||||
(Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in
|
||||
Aufgabe 2 richtiggestellt.
|
||||
|
||||
### (u2) Signaltabelle je umgestelltem Pfad
|
||||
|
||||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||||
|---|---|---|
|
||||
| `UserService.findById` | Der gebundene `findUnique` liefert `null` statt des eigenen Benutzers | `GET /users/:id` liefert `404 User not found`, obwohl der Benutzer existiert |
|
||||
| `UserService.create` | Betrifft nicht das Lesen — ein gebundenes `INSERT` mit fremder Mandantenkennung wird von der Policy abgewiesen (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`) | `POST /users` scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil `tenantId` aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt |
|
||||
| `UserService.update` | Der gebundene `update` über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, `user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`), nicht laut | `PATCH /users/:id` würfe ohne die vorgeschaltete Prüfung in `user.controller.ts` keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht |
|
||||
| `UserService.deactivate`/`delete` | Dieselbe stille Form wie `update` | `DELETE /users/:id` bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler |
|
||||
| Neue Methode: Plattform-Administratorsicht, Liste | Der Schleifentreiber (`tenant.findMany`, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen | Die Benutzerliste des `SUPER_ADMIN` ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs |
|
||||
| Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung | Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht | `GET /users/:id`, `PATCH /users/:id`, `DELETE /users/:id` liefern für den `SUPER_ADMIN` `404`, obwohl der Benutzer existiert |
|
||||
| `user.controller.ts`, `findAll` (ADMIN-Zweig) | Der gebundene `findMany` liefert 0 Benutzer statt der tatsächlich vorhandenen | Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus |
|
||||
| Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) | Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen | `GET /users/me/avatar` liefert die vorhandene `404 No avatar set`, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3) |
|
||||
| `UserService.findByUsername` (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung | Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D) |
|
||||
| `AdminSeedService`, Erstanlage-Prüfung (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist | Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs |
|
||||
| `AdminSeedService`, beide Zugriffe auf `tenant` (bewusst UNGEBUNDEN) | Betrifft nicht diese Pfade selbst — `Tenant` trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt | `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` misst die tragende Eigenschaft |
|
||||
|
||||
### (u3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L,
|
||||
namentlich benannt und nach ihrer Wirkung sortiert:
|
||||
|
||||
**Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen
|
||||
Vorhaben):**
|
||||
|
||||
1. **`AdminSeedService.seedAdmin()`, die Erstanlage-Prüfung** — die
|
||||
vollständige, im Auftrag beschriebene Kette in Reinform: eine
|
||||
unsichtbare Zeile wird als Abwesenheit gelesen
|
||||
(`user.findUnique({ where: { username } })` liefert nach dem
|
||||
Scharfschalten `null`, nicht weil der Administrator fehlt, sondern weil
|
||||
ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar
|
||||
ist — `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`),
|
||||
die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt
|
||||
3 entfällt), und das Anlegen scheitert hart an der plattformweiten
|
||||
Eindeutigkeit von `username`
|
||||
(`user-eindeutigkeit-greift-trotz-unsichtbarkeit`, SQLSTATE 23505, KEINE
|
||||
Zeilenschutz-Ablehnung). Weil `seedAdmin()` bewusst NICHT gekapselt ist
|
||||
— der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start
|
||||
weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren
|
||||
Zeile eine **Startsperre**: die Anwendung startet nach dem
|
||||
Scharfschalten nicht mehr, für jede bestehende Installation mit
|
||||
gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern)
|
||||
wird damit ungewollt zu einer Sperre für den Normalfall.
|
||||
|
||||
**Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im
|
||||
Bereich):**
|
||||
|
||||
2. **`user.controller.ts`, `findAll` im ADMIN-Zweig** — eine leere Liste
|
||||
heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine
|
||||
Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen
|
||||
kollidiert auf `username` bzw. `email`. Die Oberfläche zeigt dabei
|
||||
nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen
|
||||
Installation der Normalzustand.
|
||||
3. **`user.controller.ts`, `findAll` im SUPER_ADMIN-Zweig** — dieselbe
|
||||
Leere, eine Ebene höher: die Plattformverwaltung sieht eine
|
||||
Installation ohne jeden Benutzer und würde, bliebe die neue
|
||||
übergreifende Methode ungebunden statt als gebundene Schleife gebaut,
|
||||
ebenfalls zum Neuanlegen verleiten.
|
||||
4. **Eine gebundene Suche nach Benutzername oder Adresse** (Befund D,
|
||||
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`)
|
||||
— meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird
|
||||
zur Kollisionserzeugung; genau deshalb bleibt `findByUsername`
|
||||
ungebunden und übersetzt `UserService.create`/`update` die
|
||||
Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in
|
||||
eine verständliche Meldung (Aufgabe 2).
|
||||
|
||||
**Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand
|
||||
dieses Durchlaufs):**
|
||||
|
||||
5. **`ldap.service.ts`, `upsertMappedUser`** — bereits gebunden, seit
|
||||
260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht
|
||||
anzufassen: die Identitätssuche entscheidet über Anlegen-oder-
|
||||
Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die
|
||||
Kette nicht erst nach dem Scharfschalten existiert — die
|
||||
Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist
|
||||
also bereits heute unsichtbar. Die Übersetzung der
|
||||
Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt
|
||||
in `user.service.ts` (Aufgabe 2), der in diesem Plan ohnehin angefasst
|
||||
wird — dort und nicht in `ldap`.
|
||||
|
||||
**Harmlos (eine Stelle):**
|
||||
|
||||
6. **Die Selbstbedienungswege für Bild und Akzentfarbe** (Befund G) — ein
|
||||
leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der
|
||||
Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten.
|
||||
|
||||
**Was ein GEBUNDENER Nachschlageweg auf `username` mit dem Anmeldeweg
|
||||
machen würde, und warum die Frage hier gegenstandslos ist:** der Anmeldeweg
|
||||
läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen
|
||||
und nicht mehr über `UserService.findByUsername` — belegt durch die
|
||||
Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst),
|
||||
nicht behauptet. Würde `findByUsername` dennoch gebunden, säße das Problem
|
||||
nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern
|
||||
genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe
|
||||
Grund, aus dem `resolveEmailForWrite` im Bereich `ldap` ungebunden bleibt.
|
||||
|
||||
**Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift
|
||||
gehörend:** ein gebundenes Ändern oder Löschen über die Kennung allein
|
||||
trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung,
|
||||
sondern schlicht mit null betroffenen Zeilen
|
||||
(`user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`,
|
||||
`user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen`) — die
|
||||
vorgeschalteten Besitz- und Rollenprüfungen in `user.controller.ts` bleiben
|
||||
deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt.
|
||||
Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen
|
||||
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, SQLSTATE 42501).
|
||||
Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus
|
||||
Alarm.
|
||||
|
||||
### (u4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
Die plattformweite Eindeutigkeit von `username` und `email` selbst — die
|
||||
Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur
|
||||
wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das
|
||||
ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten
|
||||
gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie
|
||||
festgehalten, nicht entschieden, und ausdrücklich nicht durch eine
|
||||
Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2).
|
||||
|
||||
Die Übergabe in den noch nicht umgestellten Bereich `auth`: `auth.service.ts`
|
||||
ist aus demselben Grund `gemischt` (Befund E) und NICHT Gegenstand dieses
|
||||
Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat
|
||||
drei bereits über `forTenant()` gebundene Schreibzugriffe (Anmeldezeitstempel,
|
||||
Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf
|
||||
`user`, die zu `getMe`, `changePassword` und `adminResetPassword` gehören.
|
||||
Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über `$queryRaw` auf die
|
||||
drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst `auth.service.ts` an
|
||||
KEINER Stelle an.
|
||||
|
||||
Die offene Architekturfrage `req.tenantPrisma` — auch der Bereich `user`
|
||||
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
|
||||
`tenders` und `dkv` es vormachen.
|
||||
|
||||
### (u5) Was dieser Durchlauf bewusst NICHT anfasst
|
||||
|
||||
- `auth.service.ts` an keiner Stelle (siehe (u4)).
|
||||
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht —
|
||||
der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus
|
||||
TEIL 3 oben.
|
||||
- Schema und Migrationen nicht.
|
||||
- Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder
|
||||
(`user-files/avatars/`) nicht — anders als bei den Ausfuhrdateien des
|
||||
Bereichs `dkv` (Befund F dort) ist die Dateibenennung hier
|
||||
`{userId}.{ext}` und damit bereits kollisionsfrei über Mandanten hinweg;
|
||||
es ist ohnehin keine Bindungsfrage.
|
||||
|
||||
Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind —
|
||||
nicht übersehen.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
Reference in New Issue
Block a user