docs(quick-260911-fh9): Mandantenquelle im auth-Controller festnageln, Klassifikation nachziehen, Ledger-Eintraege anlegen
- auth.controller.spec.ts: NEU. Mandantenquelle je Handler (das Claim fuer me/changePassword; fuer die oberste Rolle der Mandant des Ziels aus dem Fan-out), unbekanntes Ziel, null-Durchreichung von me, Rollen-Metadaten (ROLES_KEY) und Public-Metadaten (IS_PUBLIC_KEY) fuer alle sieben Handler — 23 Faelle; Falsifizierungsnachweis am Fan-out-Zweig durchgefuehrt und zurueckgenommen - docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeile auth auf 3/10 (war 8/5), Summenzeile 78/167, Bestandsaufnahme-Zeile auth.service.ts/user auf gebunden (keine gemischt-Zeile mehr fuer diese Datei), Klassen-Verteilung unveraendert bei 64 Paaren mit Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, Etappe-3-Anmeldeweg-Punkt in "Was diese Etappe NICHT entscheidet"; beide Dokument-Falsifizierungen durchgefuehrt und zurueckgenommen - .planning/WINDOWS.md: zwei neue offene Eintraege (#28 verschluckte Leere im Frontend, Familie #23/#25/#26; #29 Rechteausweitung ADMIN->SUPER_ADMIN im Schwesterweg PATCH /users/:id, T-FH9-05) Baseline wiederhergestellt: 951/951 Tests gruen in 60 Dateien (927+23 neue Faelle plus der in Aufgabe 2 erwartungsgemaess rote Test), Werkzeug 120/120, Typpruefung sauber. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
+29
-3
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 9
|
||||
open_count: 11
|
||||
waived_count: 1
|
||||
fixed_count: 17
|
||||
total_count: 27
|
||||
last_updated: 2026-09-11T09:08:00.435Z
|
||||
total_count: 29
|
||||
last_updated: 2026-09-11T10:00:38.418Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -42,6 +42,8 @@ last_updated: 2026-09-11T09:08:00.435Z
|
||||
| 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | |
|
||||
| 26 | quick-260911-cwh | deviation | apps/web/src/components/dashboard/widgets/calendar-widget.tsx | | Bereich calendar: zu kleines Leseergebnis auf getSources/fetchAndCacheEvents sieht aus wie 'keine Quelle eingerichtet' bzw. 'keine Termine' (calendar-widget.tsx, calendar-settings-panel.tsx); das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand (calendar-widget.tsx catch->setEvents([]), calendar-settings-panel.tsx .catch(()=>{}) auf fetchSources); der Nutzer legt seine Quelle neu an und tippt Exchange-/CalDAV-Zugangsdaten ein zweites Mal in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt unsichtbar liegen und wird nach Behebung zur Dublette; Vorabpruefung fuer Etappe 4: physisch vorhandene CalendarSource-Zeilen je Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung vergleichen (docs/mandantentrennung-etappe2-fehlerrichtung.md (k4)(e)); an dieselbe Bedingung gebunden wie WINDOWS #18; Familie mit #23 (module-registry) und #25 (dashboard); das Frontend wird von 260911-cwh NICHT geaendert. | open | | 2026-09-11T07:57:36.769Z | |
|
||||
| 27 | 2 | unmet-truth | apps/api/src/prisma/rls-access-inventory.spec.ts | | Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma.<Modell> bzw. <gebundener Client>.<Modell>. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht. | open | | 2026-09-11T09:08:00.435Z | |
|
||||
| 28 | quick-260911-fh9 | deviation | apps/web/src/components/layout/header.tsx | | Bereich auth: getMe liefert nach dem Scharfschalten null, der Controller antwortet 200 mit leerem Rumpf, fetchCurrentUser (auth-actions.ts) macht daraus null, header.tsx und account-settings-form.tsx tun bei null nichts — die Portalhuelle rendert ohne angemeldeten Benutzer; changePassword liest sich als networkError (nicht als falsches Kennwort); adminResetPassword als 'User not found' ohne UI-Aufrufer. 'nicht angemeldet' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert — an dieselbe Bedingung gebunden wie WINDOWS #18; Familie #23/#25/#26; Etappe-4-Vorabpruefung: bekannten Benutzer ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten. Das Frontend wird von 260911-fh9 NICHT geaendert. | open | | 2026-09-11T10:00:29.558Z | |
|
||||
| 29 | quick-260911-fh9 | unmet-truth | apps/api/src/user/user.controller.ts | | Bereich auth: adminResetPassword schliesst die Rechteausweitung ADMIN -> SUPER_ADMIN im eigenen Handler (T-FH9-04), der Schwesterweg PATCH /users/:id tut das nicht. UserController.update (T-02-08) prueft nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht ob das ZIEL diese Rolle bereits HAT — password/isActive gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch; remove prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten. 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 260911-fh9 in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen. | open | | 2026-09-11T10:00:38.418Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -368,6 +370,30 @@ last_updated: 2026-09-11T09:08:00.435Z
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T09:08:00.435Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-fh9",
|
||||
"file": "apps/web/src/components/layout/header.tsx",
|
||||
"line": null,
|
||||
"description": "Bereich auth: getMe liefert nach dem Scharfschalten null, der Controller antwortet 200 mit leerem Rumpf, fetchCurrentUser (auth-actions.ts) macht daraus null, header.tsx und account-settings-form.tsx tun bei null nichts — die Portalhuelle rendert ohne angemeldeten Benutzer; changePassword liest sich als networkError (nicht als falsches Kennwort); adminResetPassword als 'User not found' ohne UI-Aufrufer. 'nicht angemeldet' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert — an dieselbe Bedingung gebunden wie WINDOWS #18; Familie #23/#25/#26; Etappe-4-Vorabpruefung: bekannten Benutzer ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten. Das Frontend wird von 260911-fh9 NICHT geaendert.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T10:00:29.558Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "quick-260911-fh9",
|
||||
"file": "apps/api/src/user/user.controller.ts",
|
||||
"line": null,
|
||||
"description": "Bereich auth: adminResetPassword schliesst die Rechteausweitung ADMIN -> SUPER_ADMIN im eigenen Handler (T-FH9-04), der Schwesterweg PATCH /users/:id tut das nicht. UserController.update (T-02-08) prueft nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht ob das ZIEL diese Rolle bereits HAT — password/isActive gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch; remove prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten. 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 260911-fh9 in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T10:00:38.418Z",
|
||||
"resolved_at": null
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'reflect-metadata';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
|
||||
import { ROLES_KEY } from './decorators/roles.decorator';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
/**
|
||||
* auth.controller.spec.ts — NEU (260911-fh9, Aufgabe 3). Nagelt die
|
||||
* Mandantenquelle je Handler fest: `me`/`changePassword` reichen
|
||||
* ausschliesslich `user.tenantId` aus dem Sitzungsnachweis (`@CurrentUser()`)
|
||||
* durch; `adminResetPassword` verzweigt nach Rolle des AUFRUFERS — ADMIN
|
||||
* bindet an den eigenen Mandanten, SUPER_ADMIN loest den Mandanten des
|
||||
* ZIELS ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
|
||||
* auf. Form: `tenant.controller.spec.ts` (Dienst-Attrappen, Rollen-Metadaten
|
||||
* ueber `Reflect.getMetadata`, `reflect-metadata`).
|
||||
*/
|
||||
function makeFakeAuthService() {
|
||||
return {
|
||||
getMe: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
adminResetPassword: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeFakeUserService() {
|
||||
return {
|
||||
findByIdForPlatformAdmin: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('AuthController.me', () => {
|
||||
it('reicht den Mandanten des Aufrufers und dessen Kennung GENAU durch (das Claim, nicht die Guard-Kennung)', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
||||
|
||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN, dessen Claim t1 traegt: ebenfalls (\'t1\', \'u1\') — keine Kopfzeile und keine Guard-Kennung koennten das aendern, weil der Handler nur @CurrentUser() liest', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN });
|
||||
|
||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('liefert null, wenn der Dienst null liefert — wirft NICHT (das ist der Beginn des leeren Rumpfs, (h3))', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
authService.getMe.mockResolvedValue(null);
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.changePassword', () => {
|
||||
it('reicht Mandant, Kennung, beide Kennwoerter und die Antwort durch, liefert die Erfolgsmeldung', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
const res = {} as any;
|
||||
|
||||
const result = await controller.changePassword(
|
||||
{ id: 'u1', tenantId: 't1', role: 'USER' },
|
||||
{ currentPassword: 'old', newPassword: 'new' } as any,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(authService.changePassword).toHaveBeenCalledWith('t1', 'u1', 'old', 'new', res);
|
||||
expect(result).toEqual({ message: 'Password changed successfully.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.adminResetPassword', () => {
|
||||
it('Aufrufer ADMIN (tenantId t1), Ziel "target": Dienst mit (\'t1\', \'ADMIN\', \'target\', \'new-password\', true) aufgerufen; findByIdForPlatformAdmin NICHT aufgerufen; Erfolgsmeldung', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
const userService = makeFakeUserService();
|
||||
const controller = new AuthController(authService, userService);
|
||||
|
||||
const result = await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
||||
);
|
||||
|
||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||
't1',
|
||||
Role.ADMIN,
|
||||
'target',
|
||||
'new-password',
|
||||
true,
|
||||
);
|
||||
expect(userService.findByIdForPlatformAdmin).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ message: 'User password has been reset.' });
|
||||
});
|
||||
|
||||
it('Aufrufer ADMIN, mustChangePassword: false im Rumpf: false wird durchgereicht', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password', mustChangePassword: false } as any,
|
||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
||||
);
|
||||
|
||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||
't1',
|
||||
Role.ADMIN,
|
||||
'target',
|
||||
'new-password',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('Aufrufer SUPER_ADMIN (tenantId t1), Fan-out liefert { id: "target", tenantId: "t9", role: "USER" }: Dienst mit (\'t9\', \'SUPER_ADMIN\', \'target\', ...) — der Mandant des ZIELS, nicht der des Aufrufers; findByIdForPlatformAdmin genau einmal mit "target"', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
const userService = makeFakeUserService();
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
||||
id: 'target',
|
||||
tenantId: 't9',
|
||||
role: 'USER',
|
||||
});
|
||||
const controller = new AuthController(authService, userService);
|
||||
|
||||
await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
||||
);
|
||||
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('target');
|
||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||
't9',
|
||||
Role.SUPER_ADMIN,
|
||||
'target',
|
||||
'new-password',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('Aufrufer SUPER_ADMIN, Fan-out liefert null: BadRequestException mit Meldung "User not found", Dienst NICHT aufgerufen', async () => {
|
||||
const authService = makeFakeAuthService();
|
||||
const userService = makeFakeUserService();
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue(null);
|
||||
const controller = new AuthController(authService, userService);
|
||||
|
||||
await expect(
|
||||
controller.adminResetPassword(
|
||||
'unknown',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('User not found'));
|
||||
expect(authService.adminResetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController — Rollen-Metadaten (T-FH9)', () => {
|
||||
it('adminResetPassword traegt genau [Role.ADMIN, Role.SUPER_ADMIN]', () => {
|
||||
const roles = Reflect.getMetadata(ROLES_KEY, AuthController.prototype.adminResetPassword);
|
||||
expect(roles).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
|
||||
});
|
||||
|
||||
it.each(['me', 'changePassword', 'logout', 'login', 'requestReset', 'resetPassword'] as const)(
|
||||
'Handler %s traegt KEINE Rollenmetadaten',
|
||||
(handlerName) => {
|
||||
const handlerRoles = Reflect.getMetadata(
|
||||
ROLES_KEY,
|
||||
(AuthController.prototype as any)[handlerName],
|
||||
);
|
||||
expect(handlerRoles).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('die Klasse selbst traegt KEINE Rollenmetadaten (die Grenze aus Befund B liegt je Handler, nicht klassenweit)', () => {
|
||||
const classRoles = Reflect.getMetadata(ROLES_KEY, AuthController);
|
||||
expect(classRoles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController — Public-Metadaten (die Grenze aus Befund B als Metadaten-Test)', () => {
|
||||
it.each(['login', 'requestReset', 'resetPassword'] as const)(
|
||||
'Handler %s (Anmeldeweg) ist @Public()',
|
||||
(handlerName) => {
|
||||
const isPublic = Reflect.getMetadata(
|
||||
IS_PUBLIC_KEY,
|
||||
(AuthController.prototype as any)[handlerName],
|
||||
);
|
||||
expect(isPublic).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['me', 'changePassword', 'adminResetPassword', 'logout'] as const)(
|
||||
'Handler %s (Nach-Anmeldung) ist NICHT @Public() — waere er es, liefe die Bindung an das Claim ins Leere',
|
||||
(handlerName) => {
|
||||
const isPublic = Reflect.getMetadata(
|
||||
IS_PUBLIC_KEY,
|
||||
(AuthController.prototype as any)[handlerName],
|
||||
);
|
||||
expect(isPublic).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -140,12 +140,12 @@ autoritative Quelle.
|
||||
| user | 8 | 14 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||
| module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
||||
| auth | 3 | 10 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 7 | 0 | unverändert |
|
||||
| settings | 4 | 0 | unverändert |
|
||||
| **Summe** | **83** | **162** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0) und unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern — nur die Gebunden-Spalte änderte sich). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), jetzt 162 nach 260911-e2s (zusätzlich 3 in `tenant`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| **Summe** | **78** | **167** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), jetzt 78 nach 260911-fh9 (`auth` 8→3). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), jetzt 167 nach 260911-fh9 (zusätzlich 5 in `auth`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare)
|
||||
|
||||
@@ -214,6 +214,18 @@ bestehende Klasse verschiebt sich — die beiden `tenant`-Paare
|
||||
`keine-mandantengebundene-tabelle`/`ungebunden`, nur ihre Begründung wird
|
||||
fortgeschrieben (siehe Fundstellentabelle unten).
|
||||
|
||||
**Stand 260911-fh9 (Aufgabe 3): unveraendert, ausdruecklich festgehalten
|
||||
statt uebersprungen.** Weiterhin 64 Paare, keine Klasse verschiebt sich. Das
|
||||
Paar `apps/api/src/auth/auth.service.ts`/`user` war bereits vor diesem
|
||||
Durchlauf korrekt klassifiziert (`muss-mandantengebunden`) — Aufgabe 2
|
||||
(260911-fh9) aendert nur seine `Stand`-Spalte (`gemischt` auf `gebunden`),
|
||||
nicht seine Klasse. Das Paar `apps/api/src/auth/auth.service.ts`/
|
||||
`passwordResetToken` bleibt `muss-mandantengebunden`/`gebunden`,
|
||||
unveraendert. Eine unveraenderte Tabelle ohne diesen Vermerk waere von
|
||||
einer vergessenen Nachziehung nicht zu unterscheiden — deshalb steht die
|
||||
Abwesenheit einer Aenderung hier ausdruecklich, statt stillschweigend
|
||||
uebersprungen zu werden.
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 32 |
|
||||
@@ -371,6 +383,20 @@ Bereichs `groups` (`forTenant()`/`withTenantTransaction()`, siehe
|
||||
Fundstellentabelle unten, `groups.service.ts`/`group`, Stand `gebunden`) —
|
||||
nichts an dieser Übergabe musste in 260911-e2s umgestellt werden.
|
||||
|
||||
**Stand 260911-fh9 — auch der Bereich `auth` fügt diesem Abschnitt keinen
|
||||
sechsten Fall hinzu, gemessen statt angenommen (Befund J).** Anweisung:
|
||||
`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/auth --include=*.ts`
|
||||
und `grep -rn '\$transaction(' apps/api/src/auth --include=*.ts` liefern je
|
||||
null Treffer außerhalb von Testdateien — kein Hintergrunddienst, keine
|
||||
Transaktion in diesem Bereich. `grep -rn "include:\|_count\|select:"
|
||||
apps/api/src/auth --include=*.ts | grep -v spec` findet genau EINEN
|
||||
Treffer, das `select` in `getMe` — ausschließlich skalare Felder von
|
||||
`User`, keine Relation (WINDOWS #27 hier ohne Ausprägung). Die einzige
|
||||
Stelle, an der dieser Bereich ohne Mandantenkontext liest, ist der
|
||||
Anmeldeweg (`validateUser`, `requestPasswordReset`, `resetPassword`) —
|
||||
geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform
|
||||
"übergreifend lesen, dann je Mandant binden".
|
||||
|
||||
## Bestandsaufnahme
|
||||
|
||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||
@@ -393,7 +419,7 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||
| Datei | Modell | Klasse | Stand | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
@@ -494,3 +520,16 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||
- Die Reihenfolge und Zuschnitt der Etappe-2-Pläne — dafür ist die
|
||||
Klassen-Verteilung oben der Arbeitsvorrat, siehe `<next_stages>` im
|
||||
Plan `260909-eor-PLAN.md`.
|
||||
- **Wie der Anmeldeweg unter je Mandant eindeutigen Anmeldenamen (Etappe-3-
|
||||
Entscheidung (1)) den Mandanten VOR der Benutzersuche erfaehrt (260911-fh9).**
|
||||
`auth_lookup_user_by_username(p_username)` stuetzt sich heute auf
|
||||
`username @unique` plattformweit und braucht kuenftig
|
||||
`(p_tenant_id, p_username)` — die drei SECURITY-DEFINER-Funktionen aus
|
||||
Etappe 1 werden dabei ENGER (zwei Gleichheitsbedingungen statt einer),
|
||||
nicht weiter; `local.strategy.ts` braucht dann eine Mandantenangabe VOR
|
||||
der Suche. Die Bindung der drei Nach-Anmeldungs-Methoden dieses Bereichs
|
||||
(`getMe`, `changePassword`, `adminResetPassword`) ist davon NEUTRAL — sie
|
||||
haengt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht
|
||||
an `username`/`email`. Siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth",
|
||||
(h4)(a).
|
||||
|
||||
Reference in New Issue
Block a user