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;
|
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
|
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
|
||||||
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
|
* 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
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -3683,6 +4014,7 @@ async function main() {
|
|||||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2470,6 +2470,210 @@ im Bereich `user` — kein eigener Eintrag noetig.
|
|||||||
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
|
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
|
||||||
KEINE Regel.
|
KEINE Regel.
|
||||||
|
|
||||||
|
## Bereich auth
|
||||||
|
|
||||||
|
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `auth`
|
||||||
|
(Quick-Task 260911-fh9) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||||
|
Anders als jeder Bereich davor traegt dieser Bereich die Grenze, an der die
|
||||||
|
gesamte Mandantentrennung haengt: der Anmeldeweg (Benutzer suchen, BEVOR der
|
||||||
|
Mandant bekannt ist) wurde bereits in Etappe 1 (260909-eor) ueber drei enge
|
||||||
|
SECURITY-DEFINER-Funktionen geloest und bleibt in diesem Durchlauf
|
||||||
|
unangetastet; gegenstand dieses Durchlaufs sind ausschliesslich die drei
|
||||||
|
Methoden NACH der Anmeldung (`getMe`, `changePassword`,
|
||||||
|
`adminResetPassword`), die in Etappe 1 bewusst liegen gelassen wurden.
|
||||||
|
|
||||||
|
### (h1) Die Messung
|
||||||
|
|
||||||
|
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen dreizehnten
|
||||||
|
Abschnitt (`runAuthAreaChecks`) erweitert — ausdruecklich GETRENNT vom
|
||||||
|
bestehenden `runAuthLookupChecks`: jener misst den Anmeldeweg VOR bekanntem
|
||||||
|
Mandanten (die drei Funktionen ueber `$queryRaw`), dieser die drei Methoden
|
||||||
|
NACH der Anmeldung (gebundener Modellzugriff ueber den GENERIERTEN Client).
|
||||||
|
Der neue Abschnitt setzt auf der von `runAuthLookupChecks` bereits angelegten
|
||||||
|
Wegwerf-Tabelle `"User"` auf (eingeschalteter und erzwungener Zeilenschutz,
|
||||||
|
wortgleiche Policy, zwei Zeilen in zwei Mandanten; seit `runTenantAreaChecks`
|
||||||
|
zusaetzlich mit Fremdschluessel auf `"Tenant"`) und ruestet sie um die fuenf
|
||||||
|
Spalten nach, die der generierte Client zusaetzlich braucht (`createdAt`,
|
||||||
|
`updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` — Befund G): dafuer
|
||||||
|
liest ein neuer Helfer `readSchemaModelScalarFieldNames('User')` die
|
||||||
|
skalaren Felder aus `schema.prisma` und filtert Relationsfelder ueber ihren
|
||||||
|
Typ heraus (`tenant`, `passwordResetTokens`, `groupMemberships`,
|
||||||
|
`moduleGrants` fallen weg, `role` bleibt — `Role` ist ein `enum`, kein
|
||||||
|
`model`). Diese Spaltenpruefung steht VOR den Client-Pruefungen und bricht
|
||||||
|
den Abschnitt ab, wenn sie durchfaellt (Lehre aus Pruefung 8 im Bereich
|
||||||
|
`calendar`).
|
||||||
|
|
||||||
|
Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||||||
|
`tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||||
|
|
||||||
|
```
|
||||||
|
auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, 15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]
|
||||||
|
auth-anmeldefunktionen-security-definer-unveraendert: bestanden — 3 Funktion(en) unter 'auth_lookup_%' gefunden: ["auth_lookup_reset_token","auth_lookup_user_by_email","auth_lookup_user_by_username"]; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: [{"proname":"auth_lookup_reset_token","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_email","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_username","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true}]
|
||||||
|
auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz: bestanden — auth_lookup_user_by_username('alice') liefert 1 Zeile(n) mit 9 Schluessel(n): ["displayName","id","isActive","ldapDn","mustChangePassword","passwordHash","role","tenantId","username"] — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch
|
||||||
|
auth-getme-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert null — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert
|
||||||
|
auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer: bestanden — gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): {"id":"user-a","username":"alice","displayName":null,"role":"USER","tenantId":"TENANT-A","mustChangePassword":false,"passwordHash":"hash-a","ldapDn":null,"avatarPath":null,"accentColor":null}
|
||||||
|
auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): null — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01
|
||||||
|
auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut: bestanden — ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — die Wartungsrolle liest danach weiterhin passwordHash="hash-a"
|
||||||
|
auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt: bestanden — gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash="hash-a-neu", updatedAt="2026-09-11T09:42:42.165Z" — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt
|
||||||
|
auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut: bestanden — gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash="hash-a-neu"
|
||||||
|
auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf: bestanden — Fan-out ueber 2 Mandant(en): 'user-a' gefunden unter "TENANT-A", tenantId der Zeile="TENANT-A" — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist
|
||||||
|
Alle 120 Pruefungen bestanden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Die tragende Belegzeile ist `auth-getme-generierter-client-ungebunden-liefert-null`: der IDENTISCHE `findUnique`, den `getMe` heute stellt, liefert UNGEBUNDEN `null`, waehrend die Wartungsrolle die Zeile sieht — das ist der Wert, den `GET /auth/me` nach dem Scharfschalten als leeren Rumpf ausliefert. Die `pg_proc`-Messung
|
||||||
|
(`auth-anmeldefunktionen-security-definer-unveraendert`) und der feste
|
||||||
|
Spaltensatz (`auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`)
|
||||||
|
belegen zusammen, dass die drei Anmeldefunktionen unangetastet sind und die
|
||||||
|
Spaltenerweiterung von `"User"` nichts Zusaetzliches preisgibt: alle drei
|
||||||
|
Funktionen sind weiterhin `SECURITY DEFINER`, `STABLE`, mit festem Suchpfad
|
||||||
|
`search_path=public, pg_temp` und `LIMIT 1`, und `auth_lookup_user_by_username`
|
||||||
|
liefert nach der Spaltenerweiterung weiterhin genau neun Spalten — weder
|
||||||
|
`avatarPath` noch `accentColor` noch `email` sind darunter.
|
||||||
|
|
||||||
|
Sechs der zehn Pruefungen laufen ueber den GENERIERTEN CLIENT
|
||||||
|
(`prisma.user.findUnique`/`update`, `bound.user.findUnique`/`update`), nicht
|
||||||
|
ueber Roh-SQL — bewusst, weil `findUnique` ohne `select` (die Form von
|
||||||
|
`changePassword`/`adminResetPassword`) und das `select` von `getMe` Client-
|
||||||
|
Formen sind: Roh-SQL sieht sie strukturell nicht (Fehler 7 des Vorhabens).
|
||||||
|
|
||||||
|
### (h2) Signaltabelle je Pfad
|
||||||
|
|
||||||
|
| Pfad | `@Public()` | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `validateUser` (`POST /auth/login`) | ja | Sucht ueber `auth_lookup_user_by_username` (Funktion, unveraendert) — findet den Benutzer weiterhin, dann gebundener `lastLoginAt`-Schreibzugriff | Anmeldung funktioniert unveraendert | — |
|
||||||
|
| `requestPasswordReset` (`POST /auth/request-reset`) | ja | Sucht ueber `auth_lookup_user_by_email` (Funktion, unveraendert), dann gebundene Token-Anlage | Unveraendert, immer `200` (T-02-12) | — |
|
||||||
|
| `resetPassword` (`POST /auth/reset-password`) | ja | Sucht ueber `auth_lookup_reset_token` (Funktion, unveraendert), dann gebundenes Kennwort-Update und Token-Markierung | Unveraendert | — |
|
||||||
|
| `login`/`logout` | ja/nein | Kein Datenbankzugriff | Keins | — |
|
||||||
|
| `getMe` (`GET /auth/me`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` (`auth-getme-generierter-client-ungebunden-liefert-null`) statt der eigenen Zeile | `200` mit leerem Rumpf statt der Profildaten | Ja — verschluckt, siehe (h3) |
|
||||||
|
| `changePassword` (`POST /auth/change-password`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` | `UnauthorizedException('User not found or has no local password')`, `401` | Ja — als `networkError`, siehe (h3) |
|
||||||
|
| `adminResetPassword`, ADMIN-Zweig (`POST /auth/admin-reset-password/:userId`) | nein | Vor diesem Plan: ungebundener `findUnique` sieht Ziele JEDES Mandanten — Rechteausweitung ueber die Mandantengrenze (T-FH9-01), kein Frontend-Aufrufer | `BadRequestException('User not found')` nur, wenn die Kennung gar nicht existiert; ein fremdmandantiges Ziel wuerde heute GEFUNDEN | Kein UI-Aufrufer (gemessen, Befund D) |
|
||||||
|
| `adminResetPassword`, SUPER_ADMIN-Zweig | nein | Dieselbe Ausweitung; zusaetzlich keine Rollenpruefung des Ziels (T-FH9-04) | Dieselbe Meldung | Kein UI-Aufrufer |
|
||||||
|
|
||||||
|
Nach der Bindung (Aufgabe 2) loest der SUPER_ADMIN-Zweig den Mandanten des
|
||||||
|
Ziels ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
|
||||||
|
auf — denselben Praezedenzfall, den `user.controller.ts` (`resolveTargetUser`,
|
||||||
|
260910-das) fuer seine eigene Rollenverzweigung benutzt; der ADMIN-Zweig
|
||||||
|
bindet dagegen an `currentUser.tenantId` aus dem Sitzungsnachweis.
|
||||||
|
|
||||||
|
### (h3) Welcher Code Leere als Abwesenheit deutet
|
||||||
|
|
||||||
|
Die `getMe`-Kette, alle vier Glieder namentlich: `getMe` liefert `null` (die
|
||||||
|
Belegzeile `auth-getme-generierter-client-ungebunden-liefert-null`) →
|
||||||
|
`AuthController.me` gibt `null` zurueck, ohne zu werfen → NestJS'
|
||||||
|
`ExpressAdapter.reply` sendet bei `isNil(body)` einen LEEREN Rumpf mit
|
||||||
|
Status `200` (`apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js`,
|
||||||
|
Methode `reply`) → `fetchCurrentUser` (`apps/web/src/lib/auth-actions.ts`)
|
||||||
|
laeuft mit `response.json()` auf den leeren Rumpf, faengt im `catch` und
|
||||||
|
liefert `null` → `apps/web/src/components/layout/header.tsx` (`if (u)
|
||||||
|
setUser(...)`) und `apps/web/src/components/settings/account-settings-form.tsx`
|
||||||
|
(`if (u) ...`) tun bei `null` NICHTS. Folge: die Portalhuelle rendert OHNE
|
||||||
|
angemeldeten Benutzer — kein Name, kein Avatar, `isAdmin` falsch, Admin-
|
||||||
|
Navigation weg. Der Auftrag vermutete hier "laut"; die Lesung ergibt
|
||||||
|
**verschluckt**: `fetchCurrentUser` liefert fuer "nicht angemeldet" und
|
||||||
|
"Zeile unsichtbar" denselben Wert `null` — das ist NICHT die Familie eines
|
||||||
|
lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26. Die Seite
|
||||||
|
`apps/web/src/app/(portal)/change-password/page.tsx` haengt an demselben
|
||||||
|
Aufruf; `ForcePasswordChangeInterceptor` laesst `/auth/me` und
|
||||||
|
`/auth/change-password` ausdruecklich durch, weil der erzwungene
|
||||||
|
Kennwortwechsel `getMe` braucht.
|
||||||
|
|
||||||
|
`changePassword`: der gebundene `findUnique` liefert nach der Bindung `null`
|
||||||
|
fuer ein fremdmandantiges Ziel (kommt hier praktisch nicht vor — der
|
||||||
|
angemeldete Benutzer sucht sich selbst) → `UnauthorizedException('User not
|
||||||
|
found or has no local password')`, `401` → `auth-actions.ts` uebersetzt NUR
|
||||||
|
die woertliche Meldung `Current password is incorrect` in
|
||||||
|
`wrongCurrentPassword`, jede andere 401-Meldung in `networkError` — der
|
||||||
|
Nutzer saehe eine Netzwerkfehler-Meldung fuer einen Trennungsfehler. Der
|
||||||
|
Auftrag vermutete "falsches altes Kennwort"; gemessen ist es `networkError`.
|
||||||
|
|
||||||
|
`adminResetPassword`: `BadRequestException('User not found')`, `400`, kein
|
||||||
|
UI-Aufrufer (Befund D) — fuer einen API-Aufrufer bedeutet das "diesen
|
||||||
|
Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das NACH der
|
||||||
|
Bindung dieses Plans die RICHTIGE Antwort: sie macht keine Aussage ueber die
|
||||||
|
Existenz oder den Mandanten eines fremden Halters (dieselbe Form wie
|
||||||
|
T-DAS-08).
|
||||||
|
|
||||||
|
### (h4) Was dieser Durchlauf bewusst nicht löst
|
||||||
|
|
||||||
|
**(a) Etappe 3.** Die Etappe-3-Entscheidung (1) macht `username` (und
|
||||||
|
`email`) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag:
|
||||||
|
`auth_lookup_user_by_username(p_username)` (stuetzt sich heute auf `username
|
||||||
|
@unique` plattformweit; braucht kuenftig `(p_tenant_id, p_username)` — die
|
||||||
|
Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht
|
||||||
|
weiter), gegebenenfalls `auth_lookup_user_by_email`, `local.strategy.ts`
|
||||||
|
(kennt nur `username`/`password`, braucht eine Mandantenangabe VOR der
|
||||||
|
Suche), die `@unique`-Indizes auf `User`, `UserService.findByUsername`,
|
||||||
|
`resolveEmailForWrite` (ldap), die P2002-Uebersetzung in `UserService.create`/
|
||||||
|
`update` (WINDOWS #22). Dieser Plan ist dafuer NEUTRAL: die Bindung der drei
|
||||||
|
Methoden haengt am Claim `tenantId` (das es unabhaengig davon gibt, WIE der
|
||||||
|
Anmeldeweg den Mandanten ermittelt) und an `User.id` (plattformweite UUID,
|
||||||
|
Kette aus 260911-cwh: Schema → `auth.service.ts` `sub: user.id` →
|
||||||
|
`JwtStrategy.validate` → `@CurrentUser().id`) — nicht an `username`/`email`.
|
||||||
|
Unterlassen, damit Etappe 3 nicht schwerer wird: Selbstbedienung ueber
|
||||||
|
`req.tenantId` binden; den Mandanten irgendwo nach der Anmeldung aus
|
||||||
|
`username`/`email` ableiten; die Funktionen um Spalten erweitern.
|
||||||
|
|
||||||
|
**(b) Die Rechteausweitung ADMIN → SUPER_ADMIN im Schwesterweg.**
|
||||||
|
`apps/api/src/user/user.controller.ts`, `update` (Zeilen 172-208) prueft mit
|
||||||
|
T-02-08 nur, ob die Rolle `SUPER_ADMIN` NEU ZUGEWIESEN wird
|
||||||
|
(`dto.role === Role.SUPER_ADMIN`), nicht, ob das ZIEL diese Rolle bereits
|
||||||
|
HAT — `password`/`isActive` im `UpdateUserDto` gehen fuer ein bestehendes
|
||||||
|
`SUPER_ADMIN`-Ziel ungeprueft durch. `remove` (Zeilen 220-244) prueft
|
||||||
|
ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze.
|
||||||
|
Erneut gelesen (260911-fh9, Aufgabe 1): beide Luecken bestehen unveraendert
|
||||||
|
— `grep -n "Role.SUPER_ADMIN" apps/api/src/user/user.controller.ts` findet
|
||||||
|
nur die eine Stelle in `update` (`dto.role === Role.SUPER_ADMIN`), keine im
|
||||||
|
`user`-Objekt selbst. Kein Mandantenproblem, sondern Rechteausweitung
|
||||||
|
INNERHALB des Mandanten — derselbe Fall wie T-FH9-04 in diesem Plan, nur am
|
||||||
|
Schwesterweg. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist
|
||||||
|
`user.role === Role.SUPER_ADMIN` und der Aufrufer nicht `SUPER_ADMIN`,
|
||||||
|
`ForbiddenException`) — die Vorlage steht seit dieser Aufgabe in
|
||||||
|
`AuthService.adminResetPassword`. Ausserhalb der Erlaubnisliste dieses
|
||||||
|
Plans, deshalb Ledger-Eintrag statt Reparatur (Aufgabe 3, T-FH9-05).
|
||||||
|
|
||||||
|
**(c) Das Frontend, das `null` verschluckt.** Nicht angefasst (siehe (h3));
|
||||||
|
Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26.
|
||||||
|
|
||||||
|
**(d) Die fehlende Existenzpruefung des `x-tenant-id`-Werts.** Bekannt aus
|
||||||
|
`(n4)(f)` des Abschnitts `## Bereich tenant`; hier nur genannt, weil sie die
|
||||||
|
Entscheidung gegen `req.tenantId` fuer Selbstbedienung stuetzt — ein
|
||||||
|
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken, sie
|
||||||
|
bindet an einen leeren Kontext, kein Leck. Nicht dieser Auftrag.
|
||||||
|
|
||||||
|
**(e) Die Etappe-4-Vorabpruefung.** Fuer einen bekannten Benutzer die Zeile
|
||||||
|
ueber die Wartungsrolle lesen und den gebundenen `findUnique` unter seinem
|
||||||
|
Claim-Mandanten daneben halten — dieselbe Form wie bei den Bereichen `user`
|
||||||
|
und `tenant`.
|
||||||
|
|
||||||
|
**(f) `changePassword` hat zwei verschiedene 401-Meldungen.**
|
||||||
|
`User not found or has no local password` (kein lokales Kennwort, z. B.
|
||||||
|
LDAP-Konto) vs. `Current password is incorrect` (falsches Kennwort) —
|
||||||
|
bestehendes Verhalten, betrifft nur die eigene Zeile des angemeldeten
|
||||||
|
Benutzers, unveraendert in diesem Plan.
|
||||||
|
|
||||||
|
### (h5) Was dieser Durchlauf bewusst nicht anfasst
|
||||||
|
|
||||||
|
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1
|
||||||
|
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
|
||||||
|
`auth_lookup_reset_token`) und ihre Migration `20260909160000_auth_lookup_functions`
|
||||||
|
— belegt durch `git diff --name-only 6236b30 -- apps/api/prisma` (leer)
|
||||||
|
und die `pg_proc`-Messung aus (h1).
|
||||||
|
- Die drei `$queryRaw`-Aufrufe in `validateUser`, `requestPasswordReset`,
|
||||||
|
`resetPassword` — bleiben auf dem ungebundenen Klienten `this.prisma`.
|
||||||
|
- `local.strategy.ts` und `jwt.strategy.ts` — nur gelesen.
|
||||||
|
- `apps/api/src/auth/dto/admin-reset-password.dto.ts` — nur gelesen, kein
|
||||||
|
Mandantenfeld.
|
||||||
|
- `docs/mandantentrennung-datenbankrolle.md`, Abschnitt 3 — beschreibt die
|
||||||
|
Anordnung des Anmeldewegs korrekt, keine Aenderung noetig.
|
||||||
|
- `docs/anleitung-entwicklung.md` — nennt keine der drei Methoden.
|
||||||
|
- `apps/api/src/user/user.controller.ts` — nur gelesen (siehe (h4)(b)).
|
||||||
|
- Das Frontend (`header.tsx`, `account-settings-form.tsx`, `auth-actions.ts`,
|
||||||
|
`change-password/page.tsx`) — nur beschrieben, nicht geaendert.
|
||||||
|
- Schema und Migrationen.
|
||||||
|
- Die Kopfzeile in `auth.service.spec.ts` (Zeile 4-6), die auf ein Muster in
|
||||||
|
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
|
||||||
|
Befund F genannt.
|
||||||
|
|
||||||
## Verweis
|
## Verweis
|
||||||
|
|
||||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||||
|
|||||||
Reference in New Issue
Block a user