docs(quick-260911-fh9): Fehlerrichtung fuer Bereich auth messen und aufschreiben
- rls-scratch-check.mjs: dreizehnter Abschnitt runAuthAreaChecks, getrennt von runAuthLookupChecks — misst die Grenze zwischen Anmeldeweg (drei SECURITY-DEFINER-Funktionen, unveraendert) und Nach-Anmeldung (getMe, changePassword, adminResetPassword) ueber den generierten Client; neuer Helfer readSchemaModelScalarFieldNames() filtert Relationsfelder ueber ihren Typ heraus - zehn neue, namentlich benannte Pruefungen (120/120 insgesamt), sechs davon ueber den generierten Client an der auf 15 Spalten erweiterten Wegwerf-Tabelle "User"; pg_proc bestaetigt SECURITY DEFINER/STABLE/festen Suchpfad/LIMIT 1 fuer alle drei Anmeldefunktionen - docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt "## Bereich auth" (h1)-(h5) vor "## Verweis" — Signaltabelle je Pfad, getMe-Kette als "verschluckt" statt "laut", Etappe-3-Vorbehalt, Schwesterweg-Luecke in user.controller.ts Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -2794,6 +2794,41 @@ function readSchemaModelFieldNames(modelName) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-fh9) — wie `readSchemaModelFieldNames`, aber laesst
|
||||
* jedes Feld weg, dessen TYP (zweites Wort der Zeile, `?` und `[]`
|
||||
* abgestreift) selbst der Name eines anderen `model` im Schema ist —
|
||||
* Relationsfelder haben keine Spalte (Befund M aus 260911-e2s, dort fuer
|
||||
* "Tenant" ueber die Migration umgangen; bei "User" ist die Spaltenmenge
|
||||
* ueber drei Migrationen verteilt, deshalb hier der Weg ueber das Schema
|
||||
* mit Relationsfilter). `Role` ist ein `enum`, kein `model`, und bleibt
|
||||
* deshalb ein skalares Feld.
|
||||
*/
|
||||
function readSchemaModelScalarFieldNames(modelName) {
|
||||
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
|
||||
const modelNames = new Set(
|
||||
[...schemaSource.matchAll(/^model\s+(\w+)\s*\{/gm)].map((m) => m[1]),
|
||||
);
|
||||
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
|
||||
const match = schemaSource.match(re);
|
||||
if (!match) return [];
|
||||
const fields = [];
|
||||
for (const rawLine of match[1].split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('@@')) continue;
|
||||
if (line.startsWith('//')) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const fieldName = parts[0];
|
||||
if (!fieldName) continue;
|
||||
const rawType = parts[1] ?? '';
|
||||
const fieldType = rawType.replace(/\?$/, '').replace(/\[\]$/, '');
|
||||
if (modelNames.has(fieldType)) continue; // Relationsfeld, keine Spalte
|
||||
fields.push(fieldName);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
|
||||
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
|
||||
@@ -3445,6 +3480,302 @@ async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-fh9) — misst die Grenze zwischen Anmeldeweg (die drei
|
||||
* SECURITY-DEFINER-Funktionen, gemessen in `runAuthLookupChecks`) und
|
||||
* Nach-Anmeldung (dieser Abschnitt, gebundener Modellzugriff ueber den
|
||||
* GENERIERTEN Client) fuer die drei Methoden `getMe`, `changePassword`,
|
||||
* `adminResetPassword`. Ausdruecklich GETRENNT von `runAuthLookupChecks`:
|
||||
* jener misst den Anmeldeweg VOR bekanntem Mandanten (Funktionen), dieser
|
||||
* die drei Methoden NACH der Anmeldung (gebundener Modellzugriff) — die
|
||||
* Grenze, die dieser Plan festnagelt.
|
||||
*
|
||||
* Setzt auf der vorhandenen Wegwerf-Tabelle "User" auf (aus
|
||||
* `runAuthLookupChecks`, mit eingeschaltetem und erzwungenem Zeilenschutz,
|
||||
* wortgleicher Policy, zwei Zeilen in zwei Mandanten; seit
|
||||
* `runTenantAreaChecks` zusaetzlich mit Fremdschluessel auf "Tenant") und
|
||||
* auf der bereits eingespielten Funktions-Migration; erweitert "User" um die
|
||||
* fuenf im generierten Client fehlenden Spalten (Befund G). Keine spaetere
|
||||
* Pruefung setzt auf diesen Erweiterungen auf (`runTransactionShapeMeasurement`/
|
||||
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an, Befund M aus
|
||||
* 260911-e2s) — dieser Abschnitt ist ein Blatt in der Aufrufkette und muss
|
||||
* deshalb NACH `runTenantAreaChecks()` und VOR
|
||||
* `runTransactionShapeMeasurement()` laufen (siehe Aufrufkette in `main()`).
|
||||
*/
|
||||
async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
// Vorbereitung ueber die Wartungsrolle: die fuenf im generierten Client
|
||||
// fehlenden Spalten nachruesten (Befund G). Typen aus den drei
|
||||
// ausgelieferten Migrationen (20260618112124_auth_multi_tenancy,
|
||||
// 20260630095533_add_user_avatar, 20260702000000_add_user_accent_color).
|
||||
// ABWEICHUNG: "updatedAt" bekommt fuer die zwei bereits vorhandenen
|
||||
// Wegwerf-Zeilen (user-a/user-b aus runAuthLookupChecks) eine Vorgabe
|
||||
// CURRENT_TIMESTAMP, die die ausgelieferte Migration nicht hat (dort NOT
|
||||
// NULL ohne DEFAULT — Prisma setzt den Wert clientseitig ueber
|
||||
// `@updatedAt`) — betrifft nur dieses Nachruesten hier, keine Aussage
|
||||
// ueber den ausgelieferten Stand.
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
await db.$executeRawUnsafe(
|
||||
`ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`ALTER TABLE "User" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||
);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3);`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT;`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "accentColor" TEXT;`);
|
||||
});
|
||||
|
||||
// Pruefung — steht VOR den Client-Pruefungen (4-10 unten); faellt sie
|
||||
// durch, bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich
|
||||
// `calendar`/Pruefung 3 im Bereich `tenant`).
|
||||
const schemaFields = readSchemaModelScalarFieldNames('User');
|
||||
const tableColumns = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'User'
|
||||
`;
|
||||
return rows.map((r) => r.column_name).sort();
|
||||
},
|
||||
);
|
||||
const schemaFieldsSorted = [...schemaFields].sort();
|
||||
const columnsMatch =
|
||||
schemaFieldsSorted.length > 0 &&
|
||||
schemaFieldsSorted.length === tableColumns.length &&
|
||||
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||
report(
|
||||
results,
|
||||
'auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients',
|
||||
columnsMatch,
|
||||
`Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||
);
|
||||
if (!columnsMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pruefung 1: pg_proc-Messung der drei Anmeldefunktionen — die
|
||||
// Datenbankseite von "nichts an der Anordnung angefasst" (Befund H).
|
||||
const functionRows = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) =>
|
||||
db.$queryRaw`
|
||||
SELECT proname, prosecdef, provolatile, proconfig, pg_get_functiondef(oid) AS def
|
||||
FROM pg_proc WHERE proname LIKE 'auth_lookup_%' ORDER BY proname
|
||||
`,
|
||||
);
|
||||
const expectedFunctionNames = [
|
||||
'auth_lookup_reset_token',
|
||||
'auth_lookup_user_by_email',
|
||||
'auth_lookup_user_by_username',
|
||||
];
|
||||
const actualFunctionNames = functionRows.map((r) => r.proname).sort();
|
||||
const perFunctionProps = functionRows.map((r) => ({
|
||||
proname: r.proname,
|
||||
prosecdef: r.prosecdef,
|
||||
provolatile: r.provolatile,
|
||||
proconfig: r.proconfig,
|
||||
hatLimit1: typeof r.def === 'string' && r.def.includes('LIMIT 1'),
|
||||
}));
|
||||
const allSecurityDefinerStableFixedSearchPathLimit1 = perFunctionProps.every(
|
||||
(p) =>
|
||||
p.prosecdef === true &&
|
||||
p.provolatile === 's' &&
|
||||
Array.isArray(p.proconfig) &&
|
||||
p.proconfig.some((c) => String(c).replace(/\s+/g, '') === 'search_path=public,pg_temp') &&
|
||||
p.hatLimit1,
|
||||
);
|
||||
const functionsOk =
|
||||
functionRows.length === 3 &&
|
||||
JSON.stringify(actualFunctionNames) === JSON.stringify(expectedFunctionNames) &&
|
||||
allSecurityDefinerStableFixedSearchPathLimit1;
|
||||
report(
|
||||
results,
|
||||
'auth-anmeldefunktionen-security-definer-unveraendert',
|
||||
functionsOk,
|
||||
`${functionRows.length} Funktion(en) unter 'auth_lookup_%' gefunden: ${JSON.stringify(actualFunctionNames)}; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: ${JSON.stringify(perFunctionProps)}`,
|
||||
);
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// Pruefung 3: die Anmeldesuche findet den Benutzer weiterhin, mit dem
|
||||
// FESTEN Spaltensatz der Funktion — die Spaltenerweiterung oben laesst
|
||||
// weder avatarPath noch accentColor noch email durch.
|
||||
const lookupRows = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`;
|
||||
const lookupKeys = lookupRows.length === 1 ? Object.keys(lookupRows[0]).sort() : [];
|
||||
const lookupOk =
|
||||
lookupRows.length === 1 &&
|
||||
lookupKeys.length === 9 &&
|
||||
!lookupKeys.includes('avatarPath') &&
|
||||
!lookupKeys.includes('accentColor') &&
|
||||
!lookupKeys.includes('email');
|
||||
report(
|
||||
results,
|
||||
'auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz',
|
||||
lookupOk,
|
||||
`auth_lookup_user_by_username('alice') liefert ${lookupRows.length} Zeile(n) mit ${lookupKeys.length} Schluessel(n): ${JSON.stringify(lookupKeys)} — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch`,
|
||||
);
|
||||
|
||||
// Die tragende Belegzeile: getMe() als Client-Form, UNGEBUNDEN.
|
||||
const getMeSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
mustChangePassword: true,
|
||||
passwordHash: true,
|
||||
ldapDn: true,
|
||||
avatarPath: true,
|
||||
accentColor: true,
|
||||
};
|
||||
const unboundGetMe = await prisma.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-ungebunden-liefert-null',
|
||||
unboundGetMe === null,
|
||||
`ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert ${JSON.stringify(unboundGetMe)} — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert`,
|
||||
);
|
||||
|
||||
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||
const boundGetMeOwn = await boundA.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
const ownTenantOk =
|
||||
Boolean(boundGetMeOwn) &&
|
||||
boundGetMeOwn.tenantId === 'TENANT-A' &&
|
||||
Object.keys(boundGetMeOwn).sort().length === 10;
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer',
|
||||
ownTenantOk,
|
||||
`gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): ${JSON.stringify(boundGetMeOwn)}`,
|
||||
);
|
||||
|
||||
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
|
||||
const boundGetMeForeign = await boundB.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null',
|
||||
boundGetMeForeign === null,
|
||||
`gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): ${JSON.stringify(boundGetMeForeign)} — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01`,
|
||||
);
|
||||
|
||||
// Die Schreibform, die changePassword heute stellt: UNGEBUNDEN.
|
||||
let ungebundenesUpdateWarf = false;
|
||||
let ungebundenesUpdateCtor = 'unbekannt';
|
||||
let ungebundenesUpdateCode;
|
||||
let ungebundenesUpdateMessage = '';
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-neu-ungebunden', mustChangePassword: false },
|
||||
});
|
||||
} catch (err) {
|
||||
ungebundenesUpdateWarf = true;
|
||||
ungebundenesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
|
||||
ungebundenesUpdateCode = err?.code;
|
||||
ungebundenesUpdateMessage = (err.message ?? '').toString().trim();
|
||||
}
|
||||
const passwordHashNachUngebundenemVersuch = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0]?.passwordHash;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut',
|
||||
ungebundenesUpdateWarf && passwordHashNachUngebundenemVersuch === 'hash-a',
|
||||
`ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft ${ungebundenesUpdateCtor}${ungebundenesUpdateCode ? ` (code ${ungebundenesUpdateCode})` : ''}: ${ungebundenesUpdateMessage} — die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachUngebundenemVersuch)}`,
|
||||
);
|
||||
|
||||
// Dasselbe update, GEBUNDEN unter TENANT-A (eigener Mandant): gelingt.
|
||||
await boundA.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-neu', mustChangePassword: false },
|
||||
});
|
||||
const nachGebundenemUpdate = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows =
|
||||
await db.$queryRaw`SELECT "passwordHash", "updatedAt" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0];
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt',
|
||||
nachGebundenemUpdate?.passwordHash === 'hash-a-neu' && nachGebundenemUpdate?.updatedAt != null,
|
||||
`gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash=${JSON.stringify(nachGebundenemUpdate?.passwordHash)}, updatedAt=${JSON.stringify(nachGebundenemUpdate?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt`,
|
||||
);
|
||||
|
||||
// Dieselbe Schreibform, GEBUNDEN unter TENANT-B (fremder Mandant): die
|
||||
// Form, die adminResetPassword fuer ein fremdmandantiges Ziel stellt.
|
||||
let fremdesUpdateWarf = false;
|
||||
let fremdesUpdateCtor = 'unbekannt';
|
||||
let fremdesUpdateCode;
|
||||
let fremdesUpdateMessage = '';
|
||||
try {
|
||||
await boundB.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-fremd' },
|
||||
});
|
||||
} catch (err) {
|
||||
fremdesUpdateWarf = true;
|
||||
fremdesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
|
||||
fremdesUpdateCode = err?.code;
|
||||
fremdesUpdateMessage = (err.message ?? '').toString().trim();
|
||||
}
|
||||
const passwordHashNachFremdemVersuch = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0]?.passwordHash;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut',
|
||||
fremdesUpdateWarf && passwordHashNachFremdemVersuch === 'hash-a-neu',
|
||||
`gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) ${fremdesUpdateCtor}${fremdesUpdateCode ? ` (code ${fremdesUpdateCode})` : ''}: ${fremdesUpdateMessage} — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachFremdemVersuch)}`,
|
||||
);
|
||||
|
||||
// Fan-out je Mandant, gebunden: die Form, die Aufgabe 2/3 fuer die
|
||||
// oberste Rolle (SUPER_ADMIN) benutzt — Tenant ungebunden als Treiber
|
||||
// (Tenant ohne Regel, gemessen in runTenantAreaChecks), je Mandant EIN
|
||||
// gebundener findUnique.
|
||||
const tenantsForFanOut = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
|
||||
let fanOutHit = null;
|
||||
let fanOutTenantId = null;
|
||||
for (const t of tenantsForFanOut) {
|
||||
const boundForT = buildInlineExtendedClient(prisma, t.id);
|
||||
const hit = await boundForT.user.findUnique({ where: { id: 'user-a' } });
|
||||
if (hit) {
|
||||
fanOutHit = hit;
|
||||
fanOutTenantId = t.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf',
|
||||
Boolean(fanOutHit) && fanOutTenantId === 'TENANT-A' && fanOutHit.tenantId === 'TENANT-A',
|
||||
`Fan-out ueber ${tenantsForFanOut.length} Mandant(en): 'user-a' gefunden unter ${JSON.stringify(fanOutTenantId)}, tenantId der Zeile=${JSON.stringify(fanOutHit?.tenantId)} — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -3683,6 +4014,7 @@ async function main() {
|
||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user