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 {
|
||||
|
||||
Reference in New Issue
Block a user