feat(quick-260910-das): user-Dienst binden, Linie ziehen, Startsperre entschaerfen
- user.service.ts: findById/update/deactivate/delete bekommen einen Pflicht-Mandanten und laufen ueber forTenant(); create/update uebersetzen die plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen; zwei neue Methoden (findAllForPlatformAdmin, findByIdForPlatformAdmin) bilden die Plattform-Administratorsicht als Schleife ueber alle Mandanten mit je einem gebundenen Lesezugriff nach; findByUsername bleibt bewusst ungebunden, Kopfkommentar richtiggestellt (Anmeldeweg laeuft seit Etappe 1 ueber SECURITY-DEFINER-Funktionen, kein Aufrufer mehr) - admin-seed.service.ts: Erstanlage-Pruefung bleibt ungebunden (mit Begruendung), Erstanlage des Administrators bindet an den unmittelbar zuvor angelegten Mandanten (Befund J-Korrektur); P2002 bei der Anlage wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen -- jeder andere Fehler bricht weiterhin ab - user.controller.ts: die vier Aufrufstellen der geaenderten Signaturen auf currentUser.tenantId umgestellt (Signatur-Minimalanpassung; die Rollenlogik inkl. Plattform-Administratorsicht folgt in Aufgabe 3) - Zwei-Klienten-Nachweis in beiden Testdateien (Muster groups.service.spec.ts), Falsifizierungsnachweis fuer beide Bereiche durchgefuehrt und zurueckgenommen (siehe SUMMARY) - docs/mandantentrennung-zugriffsklassifikation.md: Zwischenstand fuer (user.service.ts, user) und (admin-seed.service.ts, user) auf gemischt korrigiert, neue Zeile (user.service.ts, tenant) ergaenzt -- volle Klassenkorrektur mit Begruendung sowie die vier handgepflegten Uebersichtstabellen folgen in Aufgabe 3 - .planning/WINDOWS.md: offener Eintrag fuer die plattformweite Eindeutigkeit von username/email (Produktentscheidung fuer Etappe 3) - 802 Tests gruen (13 neue in user.service.spec.ts, 4 neue in admin-seed.service.spec.ts), Typpruefung sauber, Wegwerf-Werkzeug meldet weiterhin alle 53 Pruefungen bestanden Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
+16
-3
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
open_count: 4
|
open_count: 5
|
||||||
waived_count: 1
|
waived_count: 1
|
||||||
fixed_count: 16
|
fixed_count: 16
|
||||||
total_count: 21
|
total_count: 22
|
||||||
last_updated: 2026-09-09T14:41:07.256Z
|
last_updated: 2026-09-10T08:17:20.009Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Broken Windows Ledger
|
# Broken Windows Ledger
|
||||||
@@ -36,6 +36,7 @@ last_updated: 2026-09-09T14:41:07.256Z
|
|||||||
| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). | open | | 2026-09-09T08:08:19.293Z | |
|
| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). | open | | 2026-09-09T08:08:19.293Z | |
|
||||||
| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | |
|
| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | |
|
||||||
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | |
|
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | |
|
||||||
|
| 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | |
|
||||||
|
|
||||||
````json
|
````json
|
||||||
[
|
[
|
||||||
@@ -290,6 +291,18 @@ last_updated: 2026-09-09T14:41:07.256Z
|
|||||||
"reason": "",
|
"reason": "",
|
||||||
"recorded_at": "2026-09-09T14:41:07.256Z",
|
"recorded_at": "2026-09-09T14:41:07.256Z",
|
||||||
"resolved_at": null
|
"resolved_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"kind": "deviation",
|
||||||
|
"phase": "quick-260910-das",
|
||||||
|
"file": "apps/api/src/user/user.service.ts",
|
||||||
|
"line": null,
|
||||||
|
"description": "Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4).",
|
||||||
|
"status": "open",
|
||||||
|
"reason": "",
|
||||||
|
"recorded_at": "2026-09-10T08:17:20.009Z",
|
||||||
|
"resolved_at": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
````
|
````
|
||||||
|
|||||||
@@ -2,26 +2,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { AdminSeedService } from './admin-seed.service';
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage und
|
* AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage, Startup-
|
||||||
* Startup-Reparatur (quick-260805-fok).
|
* Reparatur (quick-260805-fok) UND Bindungsnachweis/Startsperren-
|
||||||
|
* Entschärfung (260910-das, Aufgabe 2, Befund C/I/J).
|
||||||
*
|
*
|
||||||
* Deckt ab: seedAdmin() ruft ensureDefaultGroup NACH tenant.upsert und VOR
|
* Diese Datei hatte bisher KEINE Attrappe für das Bindungshilfsmittel — die
|
||||||
* user.create auf; beide frühen Rückkehrpfade (fehlende ENV, Admin existiert
|
* Erstanlage des Administrators lief nach der Umstellung über
|
||||||
* bereits) überspringen den Benutzer, lassen aber die Reparatur über ALLE
|
* `forTenant(this.prisma, tenant.id)`, ohne dass ein Test das bemerken
|
||||||
* Mandanten laufen; die Reparatur ist fehlerisoliert je Mandant; ein zweiter
|
* konnte. Zwei-Klienten-Nachweis nach dem Muster aus
|
||||||
* Bootstrap-Lauf löst keinen zusätzlichen user.create aus.
|
* `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an
|
||||||
|
* `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper.
|
||||||
*
|
*
|
||||||
* Die Reihenfolgeprüfung läuft über ein gemeinsames Aufruf-Log-Array, in das
|
* Die Reihenfolgeprüfung läuft weiterhin über ein gemeinsames
|
||||||
* jeder Mock beim Aufruf seinen Namen schiebt (nicht über bloße
|
* Aufruf-Log-Array (`callLog`), in das jeder Mock beim Aufruf seinen Namen
|
||||||
* Aufrufzähler) — Mock-invocationCallOrder ist über drei unabhängige vi.fn's
|
* schiebt — die Ordering-Assertions der ursprünglichen Datei bleiben
|
||||||
* (tenant.upsert / ensureDefaultGroup / user.create) weniger lesbar als ein
|
* inhaltlich erhalten.
|
||||||
* geteiltes Log.
|
|
||||||
*/
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AdminSeedService', () => {
|
describe('AdminSeedService', () => {
|
||||||
let prisma: any;
|
let prisma: any;
|
||||||
let configService: any;
|
let configService: any;
|
||||||
let groupsService: any;
|
let groupsService: any;
|
||||||
let callLog: string[];
|
let callLog: string[];
|
||||||
|
let boundCallLog: { tenantId: string; model: string; method: string }[];
|
||||||
|
let userCreateImpl: (args: any) => Promise<any>;
|
||||||
let service: AdminSeedService;
|
let service: AdminSeedService;
|
||||||
|
|
||||||
const tenant = { id: 't-default', slug: 'default' };
|
const tenant = { id: 't-default', slug: 'default' };
|
||||||
@@ -34,6 +41,11 @@ describe('AdminSeedService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
callLog = [];
|
callLog = [];
|
||||||
|
boundCallLog = [];
|
||||||
|
userCreateImpl = async () => {
|
||||||
|
callLog.push('user.create');
|
||||||
|
return { id: 'u1' };
|
||||||
|
};
|
||||||
|
|
||||||
configService = {
|
configService = {
|
||||||
get: vi.fn((key: string) => envValues[key]),
|
get: vi.fn((key: string) => envValues[key]),
|
||||||
@@ -45,10 +57,6 @@ describe('AdminSeedService', () => {
|
|||||||
callLog.push('user.findUnique');
|
callLog.push('user.findUnique');
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
create: vi.fn(async () => {
|
|
||||||
callLog.push('user.create');
|
|
||||||
return { id: 'u1' };
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
tenant: {
|
tenant: {
|
||||||
upsert: vi.fn(async () => {
|
upsert: vi.fn(async () => {
|
||||||
@@ -60,6 +68,21 @@ describe('AdminSeedService', () => {
|
|||||||
return [tenant];
|
return [tenant];
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
__setUserCreateImpl(fn: (args: any) => Promise<any>) {
|
||||||
|
userCreateImpl = fn;
|
||||||
|
},
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
return {
|
||||||
|
__isBoundClient: true,
|
||||||
|
__tenantId: tenantId,
|
||||||
|
user: {
|
||||||
|
create: async (args: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'user', method: 'create' });
|
||||||
|
return userCreateImpl(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
groupsService = {
|
groupsService = {
|
||||||
@@ -93,7 +116,7 @@ describe('AdminSeedService', () => {
|
|||||||
await service.onApplicationBootstrap();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
|
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
expect(callLog).not.toContain('user.create');
|
||||||
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
||||||
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
||||||
});
|
});
|
||||||
@@ -104,7 +127,7 @@ describe('AdminSeedService', () => {
|
|||||||
await service.onApplicationBootstrap();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
|
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
expect(callLog).not.toContain('user.create');
|
||||||
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
||||||
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
||||||
});
|
});
|
||||||
@@ -141,7 +164,7 @@ describe('AdminSeedService', () => {
|
|||||||
|
|
||||||
it('ein zweiter onApplicationBootstrap-Lauf ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus', async () => {
|
it('ein zweiter onApplicationBootstrap-Lauf ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus', async () => {
|
||||||
await service.onApplicationBootstrap();
|
await service.onApplicationBootstrap();
|
||||||
expect(prisma.user.create).toHaveBeenCalledTimes(1);
|
expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1);
|
||||||
// Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor
|
// Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor
|
||||||
// user.create auf, die Reparatur einmal danach fuer denselben Mandanten.
|
// user.create auf, die Reparatur einmal danach fuer denselben Mandanten.
|
||||||
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2);
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2);
|
||||||
@@ -152,7 +175,54 @@ describe('AdminSeedService', () => {
|
|||||||
prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' }));
|
prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' }));
|
||||||
await service.onApplicationBootstrap();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(prisma.user.create).toHaveBeenCalledTimes(1);
|
expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1);
|
||||||
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3);
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Test 9: die Erstanlage-Pruefung steht NICHT im Bindungsprotokoll, die Erstanlage des Administrators dagegen steht dort mit der Kennung des unmittelbar zuvor angelegten Mandanten', async () => {
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
boundCallLog.some((c) => c.model === 'user' && c.method === 'findUnique'),
|
||||||
|
).toBe(false);
|
||||||
|
expect(boundCallLog).toContainEqual({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
model: 'user',
|
||||||
|
method: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 10: liefert die Erstanlage-Pruefung nichts, waehrend das Anlegen an der plattformweiten Eindeutigkeit scheitert, entsteht KEIN Startabbruch — der Dienst behandelt das wie "Administrator existiert bereits", protokolliert und laeuft weiter', async () => {
|
||||||
|
prisma.__setUserCreateImpl(async () => {
|
||||||
|
const err: any = new Error('Unique constraint failed on the fields: (`username`)');
|
||||||
|
err.code = 'P2002';
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||||
|
// Die Reparatur laeuft trotz der abgefangenen Kollision weiter:
|
||||||
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11: jeder ANDERE Fehler beim Anlegen bricht den Start weiterhin ab — die Absicht des Dateikopfs bleibt erhalten', async () => {
|
||||||
|
prisma.__setUserCreateImpl(async () => {
|
||||||
|
throw new Error('connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.onApplicationBootstrap()).rejects.toThrow('connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 12: die beiden Zugriffe auf die Mandantentabelle stehen NICHT im Bindungsprotokoll, und die Reparaturschleife ruft die Standardgruppen-Sicherung weiterhin je Mandant mit dessen Kennung auf', async () => {
|
||||||
|
const otherTenant = { id: 't-other', slug: 'other' };
|
||||||
|
prisma.tenant.findMany = vi.fn(async () => {
|
||||||
|
callLog.push('tenant.findMany');
|
||||||
|
return [tenant, otherTenant];
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(boundCallLog.some((c) => c.model === 'tenant')).toBe(false);
|
||||||
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
|
||||||
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(otherTenant.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { GroupsService } from '../groups/groups.service';
|
import { GroupsService } from '../groups/groups.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +55,21 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if admin already exists
|
// Erstanlage-Pruefung beim Start (260910-das, Befund I/Aufgabe 2):
|
||||||
|
// bleibt bewusst UNGEBUNDEN. HEUTE arbeitet sie richtig, weil zu diesem
|
||||||
|
// Zeitpunkt noch kein Mandant existiert und `username` plattformweit
|
||||||
|
// eindeutig ist -- eine gebundene Suche waere hier ohnehin nicht
|
||||||
|
// formulierbar (es gibt noch keinen Mandanten, an den zu binden waere).
|
||||||
|
// NACH DEM SCHARFSCHALTEN (RLS scharf, WINDOWS #18) liefert dieselbe
|
||||||
|
// Abfrage fuer JEDEN Administrator `null`, weil ohne gesetzten
|
||||||
|
// Mandantenkontext keine Zeile der Benutzertabelle sichtbar ist
|
||||||
|
// (260910-das, Aufgabe 1, `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`).
|
||||||
|
// Leere wird dann als Abwesenheit gedeutet, die natuerliche
|
||||||
|
// Folgehandlung ist Anlegen (Schritt weiter unten laeuft), und das
|
||||||
|
// Anlegen trifft die plattformweite Eindeutigkeit von `username` --
|
||||||
|
// siehe die Entschaerfung direkt an der Erstanlage unten. Details:
|
||||||
|
// docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt
|
||||||
|
// "Bereich user", (u3) Punkt 1.
|
||||||
const exists = await this.prisma.user.findUnique({
|
const exists = await this.prisma.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
});
|
});
|
||||||
@@ -64,7 +79,9 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert default tenant
|
// Upsert default tenant. `Tenant` traegt keinen Zeilenschutz (Aufgabe 1,
|
||||||
|
// `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`) -- ungebunden lesen
|
||||||
|
// und schreiben ist hier korrekt, nicht uebersehen.
|
||||||
const tenant = await this.prisma.tenant.upsert({
|
const tenant = await this.prisma.tenant.upsert({
|
||||||
where: { slug: 'default' },
|
where: { slug: 'default' },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -77,19 +94,49 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
|||||||
// repair below to backfill the membership afterwards.
|
// repair below to backfill the membership afterwards.
|
||||||
await this.groupsService.ensureDefaultGroup(tenant.id);
|
await this.groupsService.ensureDefaultGroup(tenant.id);
|
||||||
|
|
||||||
// Create Super-Admin user
|
// Erstanlage des Administrators (260910-das, Befund J-Korrektur,
|
||||||
|
// Aufgabe 2): GEBUNDEN an die Kennung des unmittelbar zuvor angelegten
|
||||||
|
// bzw. geholten Mandanten. Die bisherige Klassifikationsbegruendung
|
||||||
|
// ("es gibt strukturell keinen Mandanten zum Binden") war FALSCH -- der
|
||||||
|
// Mandant ist an dieser Stelle bereits bekannt (`tenant.id` oben).
|
||||||
|
// Ungebunden waere dieses Einfuegen nach dem Scharfschalten von der
|
||||||
|
// Policy abgewiesen worden (Aufgabe 1,
|
||||||
|
// `user-ungebundenes-einfuegen-abgelehnt`): eine FRISCHE Installation
|
||||||
|
// haette ihren allerersten Administrator gar nicht anlegen koennen.
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
await this.prisma.user.create({
|
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||||
data: {
|
try {
|
||||||
username,
|
await tenantPrisma.user.create({
|
||||||
email,
|
data: {
|
||||||
passwordHash,
|
username,
|
||||||
role: 'SUPER_ADMIN',
|
email,
|
||||||
tenantId: tenant.id,
|
passwordHash,
|
||||||
mustChangePassword: forceChange,
|
role: 'SUPER_ADMIN',
|
||||||
isActive: true,
|
tenantId: tenant.id,
|
||||||
},
|
mustChangePassword: forceChange,
|
||||||
});
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
// Entschaerfung der Startsperre (260910-das, Befund I): trifft die
|
||||||
|
// Erstanlage die plattformweite Eindeutigkeit von username/email
|
||||||
|
// (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein
|
||||||
|
// Treffer der vorgeschalteten Pruefung oben ("Administrator existiert
|
||||||
|
// bereits") -- die Pruefung hat ihn nur wegen der Unsichtbarkeit
|
||||||
|
// nicht gefunden. Dieser eine Fehlerfall wird deshalb wie der bereits
|
||||||
|
// vorhandene "existiert bereits"-Zweig behandelt: protokollieren,
|
||||||
|
// NICHT abbrechen. Das ist KEINE Aufweichung der im Dateikopf
|
||||||
|
// festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) --
|
||||||
|
// JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine,
|
||||||
|
// an dieser Stelle gleichbedeutende Fall wird ergaenzt.
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
this.logger.log(
|
||||||
|
`Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`,
|
`Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`,
|
||||||
@@ -105,6 +152,20 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
|||||||
* own try/catch and is additionally wrapped as a whole, so a failing
|
* own try/catch and is additionally wrapped as a whole, so a failing
|
||||||
* tenant.findMany or a single tenant's ensureDefaultGroup call can never
|
* tenant.findMany or a single tenant's ensureDefaultGroup call can never
|
||||||
* block the API from starting.
|
* block the API from starting.
|
||||||
|
*
|
||||||
|
* 260910-das, Befund K: dies ist der FUENFTE Fall der
|
||||||
|
* Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md,
|
||||||
|
* Abschnitt "Der Hintergrunddienst als Falle") und der bislang EINZIGE,
|
||||||
|
* der auf BEIDEN Haelften bereits richtig ist -- uebergreifender Treiber
|
||||||
|
* (`this.prisma.tenant.findMany`, UNGEBUNDEN, korrekt weil `Tenant`
|
||||||
|
* keinen Zeilenschutz traegt), gebundener Rumpf
|
||||||
|
* (`groupsService.ensureDefaultGroup(tenant.id)`, seit 260909-jts
|
||||||
|
* vollstaendig ueber `forTenant()`/`withTenantTransaction()`). Bewusst
|
||||||
|
* NICHT verschwiegen: der aeussere `try/catch` unten verschluckt jeden
|
||||||
|
* Fehler des Treibers in eine Protokollzeile -- laeuft die Mandantenliste
|
||||||
|
* nach dem Scharfschalten aus irgendeinem Grund leer, entsteht keine
|
||||||
|
* Fehlermeldung, sondern gar keine Ausgabe. Die Reparatur meldet nur,
|
||||||
|
* wenn sie etwas GETAN hat.
|
||||||
*/
|
*/
|
||||||
private async ensureDefaultGroupsForAllTenants() {
|
private async ensureDefaultGroupsForAllTenants() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -97,7 +97,13 @@ export class UserController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||||
const user = await this.userService.findById(id);
|
// 260910-das, Aufgabe 2: UserService.findById() bekommt einen
|
||||||
|
// Pflicht-Mandanten (siehe user.service.ts). Nur die Signatur wird hier
|
||||||
|
// nachgezogen, damit die Typpruefung sauber bleibt -- die Rollenlogik
|
||||||
|
// (insbesondere die uebergreifende SUPER_ADMIN-Sicht ueber
|
||||||
|
// findByIdForPlatformAdmin()) wird erst in Aufgabe 3 vollstaendig
|
||||||
|
// verdrahtet.
|
||||||
|
const user = await this.userService.findById(currentUser.tenantId, id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@@ -156,7 +162,8 @@ export class UserController {
|
|||||||
@Body() dto: UpdateUserDto,
|
@Body() dto: UpdateUserDto,
|
||||||
@CurrentUser() currentUser: any,
|
@CurrentUser() currentUser: any,
|
||||||
) {
|
) {
|
||||||
const user = await this.userService.findById(id);
|
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
|
||||||
|
const user = await this.userService.findById(currentUser.tenantId, id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@@ -174,7 +181,7 @@ export class UserController {
|
|||||||
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
|
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.userService.update(id, {
|
const updated = await this.userService.update(currentUser.tenantId, id, {
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
password: dto.password,
|
password: dto.password,
|
||||||
@@ -194,7 +201,8 @@ export class UserController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||||
const user = await this.userService.findById(id);
|
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
|
||||||
|
const user = await this.userService.findById(currentUser.tenantId, id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@@ -212,7 +220,7 @@ export class UserController {
|
|||||||
throw new ForbiddenException('Cannot delete users from other tenants');
|
throw new ForbiddenException('Cannot delete users from other tenants');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userService.delete(id);
|
await this.userService.delete(currentUser.tenantId, id);
|
||||||
return { message: 'User deleted' };
|
return { message: 'User deleted' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,369 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12, PERM-06).
|
* UserService — Zwei-Klienten-Nachweis und Linie je Methode (260910-das,
|
||||||
|
* Aufgabe 2, Befund C).
|
||||||
*
|
*
|
||||||
* UserService.create ist der einzige Erzeugungspunkt für Benutzer im
|
* Diese Datei hatte bisher KEINE Attrappe fuer das Bindungshilfsmittel: der
|
||||||
* Backend (auch LdapService.upsertMappedUser/importUsersByDn rufen
|
* Prisma-Ersatz war ein nacktes Objekt mit genau einem Eintrag
|
||||||
* ausschließlich hierüber auf, unverändert in diesem Plan). Diese Tests
|
* (`user.create`), es gab keine `forTenant`-Attrappe. Nach der Umstellung
|
||||||
* decken ausschließlich die neue Standardgruppen-Anbindung ab, mit
|
* waeren die vier bestehenden Testfaelle rot geworden, aber aus dem
|
||||||
* gemocktem PrismaService und gemocktem GroupsService.
|
* FALSCHEN Grund (das Hilfsmittel bekaeme einen unbrauchbaren Klienten),
|
||||||
|
* nicht wegen einer vergessenen Bindung. Muster nach
|
||||||
|
* `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an
|
||||||
|
* `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper um
|
||||||
|
* DIESELBEN Maps wie der ungebundene Zugriff — der ungebundene Ersatz
|
||||||
|
* protokolliert nicht, der gebundene schon.
|
||||||
|
*
|
||||||
|
* Der Speicher bildet zusaetzlich die plattformweite Eindeutigkeit von
|
||||||
|
* `username` nach: ein Einfuegen mit einem bereits vergebenen Benutzernamen
|
||||||
|
* wirft einen Fehler mit dem Prisma-Fehlercode fuer Eindeutigkeitsverletzungen
|
||||||
|
* (P2002), UNABHAENGIG davon, welchem Mandanten der bestehende Halter
|
||||||
|
* gehoert — ohne diese Nachbildung ist die zentrale Aussage dieses Bereichs
|
||||||
|
* nicht pruefbar.
|
||||||
*/
|
*/
|
||||||
describe('UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12)', () => {
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
let prisma: any;
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
let groupsService: any;
|
}));
|
||||||
let service: UserService;
|
|
||||||
|
|
||||||
const createdUser = { id: 'u1', tenantId: 't1', username: 'alice' };
|
function makeFakePrisma() {
|
||||||
const baseData = {
|
const users = new Map<string, any>();
|
||||||
username: 'Alice',
|
const tenants = new Map<string, any>();
|
||||||
email: 'alice@example.com',
|
let userCounter = 0;
|
||||||
tenantId: 't1',
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||||
|
|
||||||
|
function throwUnique(): never {
|
||||||
|
const err: any = new Error('Unique constraint failed on the fields: (`username`)');
|
||||||
|
err.code = 'P2002';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwNotFound(): never {
|
||||||
|
const err: any = new Error('Record to update/delete not found');
|
||||||
|
err.code = 'P2025';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByUsernameGlobal(username: string, excludeId?: string) {
|
||||||
|
return Array.from(users.values()).find(
|
||||||
|
(u) => u.username === username && u.id !== excludeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungebundene (RLS-freie) Grundoperationen — fuer findByUsername() und
|
||||||
|
// jede andere direkte this.prisma.user.*-Nutzung.
|
||||||
|
const rawUser = {
|
||||||
|
findUnique: async ({ where }: any) => {
|
||||||
|
if (where.username !== undefined) {
|
||||||
|
return findByUsernameGlobal(where.username) ?? null;
|
||||||
|
}
|
||||||
|
if (where.id !== undefined) {
|
||||||
|
return users.get(where.id) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
findMany: async ({ where }: any = {}) => {
|
||||||
|
let rows = Array.from(users.values());
|
||||||
|
if (where?.tenantId !== undefined) {
|
||||||
|
rows = rows.filter((u) => u.tenantId === where.tenantId);
|
||||||
|
}
|
||||||
|
return [...rows].sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
},
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
if (findByUsernameGlobal(data.username)) throwUnique();
|
||||||
|
userCounter += 1;
|
||||||
|
const record = { id: `u-${userCounter}`, createdAt: new Date(), ...data };
|
||||||
|
users.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
update: async ({ where, data }: any) => {
|
||||||
|
const existing = users.get(where.id);
|
||||||
|
if (!existing) throwNotFound();
|
||||||
|
if (data.username && findByUsernameGlobal(data.username, existing.id)) throwUnique();
|
||||||
|
const record = { ...existing, ...data };
|
||||||
|
users.set(where.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
delete: async ({ where }: any) => {
|
||||||
|
const existing = users.get(where.id);
|
||||||
|
if (!existing) throwNotFound();
|
||||||
|
users.delete(where.id);
|
||||||
|
return existing;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
// Gebundene (RLS-simulierende) Fassung: jede Methode filtert zusaetzlich
|
||||||
vi.clearAllMocks();
|
// auf den Mandantenkontext, GENAU wie die ausgelieferte Policy es tut —
|
||||||
prisma = {
|
// unabhaengig davon, ob der Aufrufer selbst ein explizites tenantId ins
|
||||||
user: {
|
// where schreibt (UserService tut das bewusst NICHT fuer findById/
|
||||||
create: vi.fn().mockResolvedValue(createdUser),
|
// update/deactivate/delete, siehe Befund G).
|
||||||
|
function makeScopedUser(tenantId: string) {
|
||||||
|
return {
|
||||||
|
findUnique: async (args: any) => {
|
||||||
|
const row = await rawUser.findUnique(args);
|
||||||
|
return row && row.tenantId === tenantId ? row : null;
|
||||||
|
},
|
||||||
|
findMany: async (args: any) => {
|
||||||
|
const rows = await rawUser.findMany(args);
|
||||||
|
return rows.filter((r: any) => r.tenantId === tenantId);
|
||||||
|
},
|
||||||
|
create: async (args: any) => {
|
||||||
|
if (args.data.tenantId !== tenantId) {
|
||||||
|
const err: any = new Error(
|
||||||
|
'new row violates row-level security policy for table "User"',
|
||||||
|
);
|
||||||
|
err.code = '42501';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return rawUser.create(args);
|
||||||
|
},
|
||||||
|
update: async (args: any) => {
|
||||||
|
const existing = users.get(args.where.id);
|
||||||
|
if (!existing || existing.tenantId !== tenantId) throwNotFound();
|
||||||
|
return rawUser.update(args);
|
||||||
|
},
|
||||||
|
delete: async (args: any) => {
|
||||||
|
const existing = users.get(args.where.id);
|
||||||
|
if (!existing || existing.tenantId !== tenantId) throwNotFound();
|
||||||
|
return rawUser.delete(args);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
groupsService = {
|
}
|
||||||
addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
service = new UserService(prisma, groupsService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ruft nach der Benutzeranlage genau einmal GroupsService.addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => {
|
const fake: any = {
|
||||||
const result = await service.create(baseData);
|
__seedUser(user: any) {
|
||||||
|
users.set(user.id, user);
|
||||||
|
},
|
||||||
|
__seedTenant(tenant: any) {
|
||||||
|
tenants.set(tenant.id, tenant);
|
||||||
|
},
|
||||||
|
user: rawUser,
|
||||||
|
tenant: {
|
||||||
|
findMany: async () => Array.from(tenants.values()),
|
||||||
|
},
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
const scoped = makeScopedUser(tenantId);
|
||||||
|
const wrap = (method: string, fn: (args: any) => any) => {
|
||||||
|
return async (args: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'user', method });
|
||||||
|
return fn(args);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__isBoundClient: true,
|
||||||
|
__tenantId: tenantId,
|
||||||
|
user: {
|
||||||
|
findUnique: wrap('findUnique', scoped.findUnique),
|
||||||
|
findMany: wrap('findMany', scoped.findMany),
|
||||||
|
create: wrap('create', scoped.create),
|
||||||
|
update: wrap('update', scoped.update),
|
||||||
|
delete: wrap('delete', scoped.delete),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
expect(result).toEqual(createdUser);
|
return fake;
|
||||||
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
}
|
||||||
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', 'u1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => {
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||||
groupsService.addUserToDefaultGroup.mockResolvedValue(undefined);
|
const found = prisma.__boundCallLog.some(
|
||||||
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await service.create(baseData);
|
function expectNotBoundCall(prisma: any, model: string, method: string) {
|
||||||
|
const found = prisma.__boundCallLog.some((c: any) => c.model === model && c.method === method);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`unerwarteter gebundener Aufruf ${model}.${method} im Protokoll — diese Methode soll UNGEBUNDEN bleiben: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
expect(result).toEqual(createdUser);
|
describe('UserService', () => {
|
||||||
expect(prisma.user.create).toHaveBeenCalledTimes(1);
|
describe('create — Standardgruppen-Mitgliedschaft (D-11/D-12) und Bindung', () => {
|
||||||
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
let prisma: any;
|
||||||
});
|
let groupsService: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => {
|
const baseData = { username: 'Alice', email: 'alice@example.com', tenantId: 't1' };
|
||||||
groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom'));
|
|
||||||
|
|
||||||
await expect(service.create(baseData)).resolves.toEqual(createdUser);
|
beforeEach(() => {
|
||||||
});
|
prisma = makeFakePrisma();
|
||||||
|
groupsService = { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new UserService(prisma, groupsService);
|
||||||
|
});
|
||||||
|
|
||||||
it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => {
|
it('Test 1: steht mit der uebergebenen Mandantenkennung im Bindungsprotokoll, und ruft nach der Anlage genau einmal addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => {
|
||||||
const result = await service.create(baseData);
|
const result = await service.create(baseData);
|
||||||
|
|
||||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
expectBoundCall(prisma, 't1', 'user', 'create');
|
||||||
data: expect.objectContaining({
|
expect(result.tenantId).toBe('t1');
|
||||||
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', result.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => {
|
||||||
|
const result = await service.create(baseData);
|
||||||
|
|
||||||
|
expect(result.username).toBe('alice');
|
||||||
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => {
|
||||||
|
groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
await expect(service.create(baseData)).resolves.toHaveProperty('username', 'alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => {
|
||||||
|
const result = await service.create(baseData);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('id');
|
||||||
|
expect(result).toMatchObject({
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
email: 'alice@example.com',
|
email: 'alice@example.com',
|
||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
}),
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: wirft bei einem Benutzernamen, den ein Benutzer eines ANDEREN Mandanten bereits hält, eine verständliche deutsche Konfliktmeldung statt eines durchgereichten Datenbankfehlers — und nennt weder den Halter noch dessen Mandanten', async () => {
|
||||||
|
prisma.__seedUser({ id: 'existing', username: 'bob', tenantId: 't2' });
|
||||||
|
|
||||||
|
let caught: any;
|
||||||
|
try {
|
||||||
|
await service.create({ username: 'Bob', email: 'bob2@example.com', tenantId: 't1' });
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(ConflictException);
|
||||||
|
expect(caught.message).not.toContain('t2');
|
||||||
|
expect(caught.message).not.toContain('existing');
|
||||||
|
expect(caught.message.toLowerCase()).toMatch(/benutzername|adresse/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makeFakePrisma();
|
||||||
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' });
|
||||||
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: steht gebunden im Protokoll und liefert einen Benutzer eines anderen Mandanten NICHT', async () => {
|
||||||
|
const own = await service.findById('t1', 'u-a');
|
||||||
|
expect(own).toMatchObject({ id: 'u-a' });
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
||||||
|
|
||||||
|
const foreign = await service.findById('t1', 'u-b');
|
||||||
|
expect(foreign).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makeFakePrisma();
|
||||||
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' });
|
||||||
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: steht gebunden im Protokoll, und ein Namenswechsel auf einen fremd gehaltenen Benutzernamen wirft dieselbe Art von Konfliktmeldung', async () => {
|
||||||
|
await expect(service.update('t1', 'u-a', { username: 'Bob' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate/delete', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makeFakePrisma();
|
||||||
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
||||||
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: deactivate steht gebunden im Protokoll', async () => {
|
||||||
|
await service.deactivate('t1', 'u-a');
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: delete steht gebunden im Protokoll', async () => {
|
||||||
|
await service.delete('t1', 'u-a');
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plattform-Administratorsicht (Befund F)', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makeFakePrisma();
|
||||||
|
prisma.__seedTenant({ id: 't1' });
|
||||||
|
prisma.__seedTenant({ id: 't2' });
|
||||||
|
prisma.__seedUser({ id: 'u-carol', username: 'carol', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u-alice', username: 'alice', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u-bob', username: 'bob', tenantId: 't2' });
|
||||||
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6: liest die Mandanten UNGEBUNDEN und danach je Mandant GEBUNDEN — im Protokoll steht je Mandant genau ein Eintrag, das Ergebnis enthält die Benutzer beider Mandanten in der bisherigen Sortierung nach Benutzername', async () => {
|
||||||
|
const result = await service.findAllForPlatformAdmin();
|
||||||
|
|
||||||
|
expect(result.map((u: any) => u.username)).toEqual(['alice', 'bob', 'carol']);
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||||
|
expectBoundCall(prisma, 't2', 'user', 'findMany');
|
||||||
|
|
||||||
|
const entriesFor = (tenantId: string) =>
|
||||||
|
prisma.__boundCallLog.filter(
|
||||||
|
(c: any) => c.tenantId === tenantId && c.model === 'user' && c.method === 'findMany',
|
||||||
|
).length;
|
||||||
|
expect(entriesFor('t1')).toBe(1);
|
||||||
|
expect(entriesFor('t2')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7: findet einen Benutzer eines fremden Mandanten über einen GEBUNDENEN Lesezugriff je Mandant — im Protokoll nachweisbar, nicht nur am Ergebnis', async () => {
|
||||||
|
const found = await service.findByIdForPlatformAdmin('u-bob');
|
||||||
|
|
||||||
|
expect(found).toMatchObject({ id: 'u-bob', tenantId: 't2' });
|
||||||
|
expectBoundCall(prisma, 't2', 'user', 'findUnique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert null, wenn keine Mandant die Kennung besitzt', async () => {
|
||||||
|
const found = await service.findByIdForPlatformAdmin('unbekannt');
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByUsername (bewusst UNGEBUNDEN)', () => {
|
||||||
|
it('Test 8: steht NICHT im Bindungsprotokoll — das Fehlen der Bindung ist hier die bestandene Erwartung, damit niemand sie später als vergessene Bindung "repariert"', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
||||||
|
const service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
||||||
|
|
||||||
|
const found = await service.findByUsername('Alice');
|
||||||
|
|
||||||
|
expect(found).toMatchObject({ id: 'u-a' });
|
||||||
|
expectNotBoundCall(prisma, 'user', 'findUnique');
|
||||||
});
|
});
|
||||||
expect(result).toHaveProperty('id', 'u1');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { GroupsService } from '../groups/groups.service';
|
import { GroupsService } from '../groups/groups.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindung an forTenant() (WINDOWS #20 Etappe 2, 260910-das): dieser Bereich
|
||||||
|
* enthaelt als einziger BEIDE Formen gleichzeitig -- Wege, die binden
|
||||||
|
* MUESSEN (Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT
|
||||||
|
* DARF (Nachschlagen auf dem plattformweit eindeutigen Schluessel
|
||||||
|
* `username`). Der gebundene Klient heisst in jeder Methode `tenantPrisma`
|
||||||
|
* (Konvention aus `ldap`, `groups`, `dkv`, `auth`).
|
||||||
|
*
|
||||||
|
* `findById`/`update`/`deactivate`/`delete` bekommen einen PFLICHT-Mandanten
|
||||||
|
* als ersten Parameter -- die Steuerungsschicht (`user.controller.ts`) wird
|
||||||
|
* im selben Commit auf die neue Signatur umgestellt (260910-das, Aufgabe 3),
|
||||||
|
* damit die Typpruefung nach jeder Aufgabe sauber bleibt.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
private readonly logger = new Logger(UserService.name);
|
private readonly logger = new Logger(UserService.name);
|
||||||
@@ -13,8 +27,24 @@ export class UserService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find user by username. Uses UNSCOPED Prisma (not tenant-scoped)
|
* Find user by username. Bleibt bewusst UNGEBUNDEN.
|
||||||
* because login must work across all tenants.
|
*
|
||||||
|
* Der Anmeldeweg laeuft seit Etappe 1 (260909-eor) ueber die drei
|
||||||
|
* SECURITY-DEFINER-Funktionen (`auth_lookup_user_by_username` u.a.) und
|
||||||
|
* beruehrt diese Methode nicht mehr -- gemessen zum Zeitpunkt der
|
||||||
|
* Umstellung (260910-das, Aufgabe 1, Teil 3): `findByUsername` hatte
|
||||||
|
* genau EINEN Treffer im gesamten Quelltext, die eigene Definition, kein
|
||||||
|
* Aufrufer. Sie darf trotzdem NICHT gebunden werden: `username` ist
|
||||||
|
* plattformweit eindeutig (`@unique`, nicht je Mandant), eine gebundene
|
||||||
|
* Suche saehe einen fremden Halter nicht mehr, meldete faelschlich
|
||||||
|
* "frei", und die naechste Handlung des (hypothetischen) Aufrufers liefe
|
||||||
|
* in einen harten Eindeutigkeitsfehler (Aufgabe 1,
|
||||||
|
* `user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`
|
||||||
|
* und `user-eindeutigkeit-greift-trotz-unsichtbarkeit`). Derselbe Fall
|
||||||
|
* wie `resolveEmailForWrite` im Bereich `ldap` (260909-ipc, T-IPC-04).
|
||||||
|
* Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt
|
||||||
|
* "Bereich user".
|
||||||
|
*
|
||||||
* Usernames are stored lowercase (case-insensitive login) -- normalize
|
* Usernames are stored lowercase (case-insensitive login) -- normalize
|
||||||
* the lookup input to match regardless of how it was typed.
|
* the lookup input to match regardless of how it was typed.
|
||||||
*/
|
*/
|
||||||
@@ -25,14 +55,20 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find user by ID.
|
* Find user by ID, gebunden an den uebergebenen Mandanten. Ein Benutzer
|
||||||
|
* eines anderen Mandanten liefert `null` -- nicht laut, sondern still,
|
||||||
|
* weil die Policy keine eigene Fehlermeldung fuer "unsichtbar" kennt
|
||||||
|
* (Aufgabe 1, `user-gebunden-nur-eigener-mandant`).
|
||||||
*/
|
*/
|
||||||
async findById(id: string) {
|
async findById(tenantId: string, id: string) {
|
||||||
return this.prisma.user.findUnique({ where: { id } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.user.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new user with hashed password.
|
* Create a new user with hashed password. Bindet an die im Datensatz
|
||||||
|
* uebergebene Mandantenkennung.
|
||||||
|
*
|
||||||
* Username is normalized to lowercase so login is case-insensitive.
|
* Username is normalized to lowercase so login is case-insensitive.
|
||||||
*
|
*
|
||||||
* D-11/D-12 (PERM-06): dies ist der EINZIGE Erzeugungspunkt für Benutzer
|
* D-11/D-12 (PERM-06): dies ist der EINZIGE Erzeugungspunkt für Benutzer
|
||||||
@@ -45,6 +81,18 @@ export class UserService {
|
|||||||
* (T-15-14): eine gescheiterte Gruppenzuordnung darf weder die
|
* (T-15-14): eine gescheiterte Gruppenzuordnung darf weder die
|
||||||
* Benutzeranlage noch einen LDAP-Sync-Lauf über hunderte Benutzer
|
* Benutzeranlage noch einen LDAP-Sync-Lauf über hunderte Benutzer
|
||||||
* abbrechen.
|
* abbrechen.
|
||||||
|
*
|
||||||
|
* Eindeutigkeitsverletzung (260910-das, Aufgabe 2): `username`/`email`
|
||||||
|
* sind plattformweit eindeutig, nicht je Mandant (Schema, keine
|
||||||
|
* Aenderung in diesem Plan). Ein P2002 wird deshalb HIER uebersetzt,
|
||||||
|
* nicht bei den Aufrufern -- dies ist der einzige Erzeugungspunkt fuer
|
||||||
|
* Benutzer im Backend, der AD-Abgleich (`ldap.service.ts`,
|
||||||
|
* `upsertMappedUser`) laeuft ebenfalls hierueber, und dessen
|
||||||
|
* Identitaetssuche ist bereits gebunden (seit 260909-ipc). Die Kette aus
|
||||||
|
* unsichtbarer Zeile, falschem "frei" und hartem Eindeutigkeitsfehler
|
||||||
|
* endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung
|
||||||
|
* nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine
|
||||||
|
* Aussage ueber einen fremden Mandanten waere (T-DAS-08).
|
||||||
*/
|
*/
|
||||||
async create(data: {
|
async create(data: {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -57,13 +105,25 @@ export class UserService {
|
|||||||
ldapDn?: string;
|
ldapDn?: string;
|
||||||
}) {
|
}) {
|
||||||
const { password, ...rest } = data;
|
const { password, ...rest } = data;
|
||||||
const created = await this.prisma.user.create({
|
const tenantPrisma = forTenant(this.prisma, data.tenantId) as any;
|
||||||
data: {
|
|
||||||
...rest,
|
let created: any;
|
||||||
username: rest.username.toLowerCase(),
|
try {
|
||||||
passwordHash: password ? await argon2.hash(password) : null,
|
created = await tenantPrisma.user.create({
|
||||||
},
|
data: {
|
||||||
});
|
...rest,
|
||||||
|
username: rest.username.toLowerCase(),
|
||||||
|
passwordHash: password ? await argon2.hash(password) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.groupsService.addUserToDefaultGroup(created.tenantId, created.id);
|
await this.groupsService.addUserToDefaultGroup(created.tenantId, created.id);
|
||||||
@@ -79,9 +139,13 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user. If password is provided, hash it.
|
* Update user, gebunden an den uebergebenen Mandanten. If password is
|
||||||
|
* provided, hash it. Dieselbe Eindeutigkeitsuebersetzung wie `create()`,
|
||||||
|
* weil auch ein Namens- oder Adresswechsel auf denselben plattformweiten
|
||||||
|
* Schluessel treffen kann.
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
|
tenantId: string,
|
||||||
id: string,
|
id: string,
|
||||||
data: {
|
data: {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -104,26 +168,110 @@ export class UserService {
|
|||||||
updateData.passwordHash = await argon2.hash(password);
|
updateData.passwordHash = await argon2.hash(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.user.update({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
where: { id },
|
try {
|
||||||
data: updateData,
|
return await tenantPrisma.user.update({
|
||||||
});
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deactivate a user (soft delete).
|
* Deactivate a user (soft delete), gebunden an den uebergebenen
|
||||||
|
* Mandanten.
|
||||||
*/
|
*/
|
||||||
async deactivate(id: string) {
|
async deactivate(tenantId: string, id: string) {
|
||||||
return this.prisma.user.update({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isActive: false },
|
data: { isActive: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard delete a user.
|
* Hard delete a user, gebunden an den uebergebenen Mandanten.
|
||||||
*/
|
*/
|
||||||
async delete(id: string) {
|
async delete(tenantId: string, id: string) {
|
||||||
return this.prisma.user.delete({ where: { id } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.user.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plattform-Administratorsicht (Befund F, 260910-das): liest die
|
||||||
|
* Benutzer ALLER Mandanten als Schleife mit je EINEM gebundenen
|
||||||
|
* Lesezugriff im Rumpf -- dieselbe Form, die
|
||||||
|
* `AdminSeedService.ensureDefaultGroupsForAllTenants()` beim Start
|
||||||
|
* bereits benutzt (der fuenfte, bislang einzige bereits vollstaendig
|
||||||
|
* richtige Fall der Hintergrunddienst-Falle). Diese uebergreifende Sicht
|
||||||
|
* ist die bestehende, GEWOLLTE Funktion der obersten Rolle
|
||||||
|
* (`SUPER_ADMIN`) und darf deshalb NICHT an den Mandanten des Aufrufers
|
||||||
|
* gebunden werden -- das waere eine stille Funktionsminderung. Sie darf
|
||||||
|
* aber auch nicht ungebunden bleiben, weil sie nach dem Scharfschalten
|
||||||
|
* (RLS scharf) sonst gar nichts mehr liefert (Aufgabe 1,
|
||||||
|
* `user-ungebunden-null-zeilen`).
|
||||||
|
*
|
||||||
|
* Der Schleifentreiber (`this.prisma.tenant.findMany`) liest UNGEBUNDEN
|
||||||
|
* und DARF DAS: `Tenant` traegt keinen Zeilenschutz (Aufgabe 1,
|
||||||
|
* `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`, gemessen, nicht
|
||||||
|
* behauptet).
|
||||||
|
*
|
||||||
|
* Die Sortierung nach Benutzername wird NACH dem Zusammenfuehren
|
||||||
|
* hergestellt, weil je Mandant sortierte Teilmengen aneinandergehaengt
|
||||||
|
* nicht sortiert sind -- die eine Stelle, an der die Umstellung das
|
||||||
|
* Ergebnis verfaelschen koennte.
|
||||||
|
*/
|
||||||
|
async findAllForPlatformAdmin() {
|
||||||
|
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||||
|
const users = await tenantPrisma.user.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
tenantId: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push(...users);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest eine Benutzerkennung fuer den Plattform-Administrator auf, indem
|
||||||
|
* je Mandant GEBUNDEN gesucht wird und beim ersten Treffer zurueckgekehrt
|
||||||
|
* wird. Derselbe Kopfkommentar-Grund wie `findAllForPlatformAdmin()`
|
||||||
|
* oben -- gehoert zusammen, weil beide die uebergreifende Sicht der
|
||||||
|
* obersten Rolle tragen.
|
||||||
|
*/
|
||||||
|
async findByIdForPlatformAdmin(id: string) {
|
||||||
|
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||||
|
const user = await tenantPrisma.user.findUnique({ where: { id } });
|
||||||
|
if (user) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,9 +288,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
||||||
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
||||||
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. |
|
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. |
|
||||||
| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | ungebunden | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. |
|
| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | gemischt | ZWISCHENSTAND nach Aufgabe 2 (260910-das): die Erstanlage-Pruefung bleibt bewusst ungebunden, die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten (Befund J). Die Klassenkorrektur auf `beides` samt Begruendung folgt in Aufgabe 3. |
|
||||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. |
|
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. Wird in Aufgabe 3 (260910-das) gebunden. |
|
||||||
| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | ungebunden | Dieselbe Begründung. |
|
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
||||||
|
| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | gemischt | ZWISCHENSTAND nach Aufgabe 2 (260910-das): `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen seither ueber `forTenant()`; ausschliesslich `findByUsername` bleibt bewusst ungebunden (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`). Die Klassenkorrektur auf `beides` samt Begruendung folgt in Aufgabe 3. |
|
||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user