Compare commits
4 Commits
8829999e70
...
b62a905adb
| Author | SHA1 | Date | |
|---|---|---|---|
| b62a905adb | |||
| 07fc653f52 | |||
| f0b531b712 | |||
| 3e57d916a1 |
+16
-3
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 14
|
||||
open_count: 15
|
||||
waived_count: 1
|
||||
fixed_count: 18
|
||||
total_count: 33
|
||||
last_updated: 2026-09-11T14:48:15.447Z
|
||||
total_count: 34
|
||||
last_updated: 2026-09-11T15:46:08.295Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -48,6 +48,7 @@ last_updated: 2026-09-11T14:48:15.447Z
|
||||
| 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | |
|
||||
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
|
||||
| 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | |
|
||||
| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -446,6 +447,18 @@ last_updated: 2026-09-11T14:48:15.447Z
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T14:48:09.723Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-nke",
|
||||
"file": "apps/api/src/prisma/prisma-tenant.extension.ts",
|
||||
"line": null,
|
||||
"description": "Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T15:46:08.295Z",
|
||||
"resolved_at": null
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
+255
File diff suppressed because one or more lines are too long
+221
@@ -0,0 +1,221 @@
|
||||
-- 260911-nke, Etappe 3b — die Benutzerdimension in den Regeln der zehn
|
||||
-- persoenlichen Tabellen. Schliesst die Klasse von Befunden, die in sieben
|
||||
-- Bereichs-Kritiken als "Policy hat keine Benutzerdimension" festgehalten
|
||||
-- wurde (docs/mandantentrennung-etappe2-fehlerrichtung.md).
|
||||
--
|
||||
-- Die betroffenen Dateien (20260618112133_rls_policies fuer die urspruengliche
|
||||
-- Tabellenform der acht Ein-Regel-Tabellen, 20260909140000_rls_remaining_
|
||||
-- tenant_tables fuer deren zuletzt ausgelieferte Fassung, 20260910120000_rls_
|
||||
-- widen_membership_grant_and_platform_read fuer TenderRssFeedSource) bleiben
|
||||
-- UNVERAENDERT stehen — Prisma fuehrt ihre Pruefsumme, eine Aenderung braechte
|
||||
-- "prisma migrate deploy" zum Abbruch. Praezedenzfall und Kopfform:
|
||||
-- 20260910120000_rls_widen_membership_grant_and_platform_read.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (siehe
|
||||
-- 20260909130000_rls_app_role und docs/mandantentrennung-datenbankrolle.md).
|
||||
-- Die Verbindung ist zum Zeitpunkt dieser Migration weiterhin NICHT
|
||||
-- umgestellt — `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`
|
||||
-- (BYPASSRLS). Der Schalter bleibt AUS: diese Regeln sind fuer jeden
|
||||
-- heutigen Aufrufer wirkungslos, bis Etappe 4 scharfschaltet.
|
||||
|
||||
-- Zweite Sitzungsvariable `app.current_user`, Funktion nach dem Muster von
|
||||
-- `current_tenant_id()` (20260618112133). NULLIF ist Pflicht: `forTenant()`
|
||||
-- sendet "kein Benutzer" ausdruecklich als Leerstring (nicht als
|
||||
-- weggelassene Variable) — ohne NULLIF wuerde current_user_id() bei einem
|
||||
-- Aufruf ohne Benutzer den Leerstring statt NULL liefern, und
|
||||
-- "userId" = '' waere fuer jede Zeile falsch, nicht gleichbedeutend mit
|
||||
-- "kein Benutzer gesetzt". Kein GRANT EXECUTE noetig — wie bei
|
||||
-- current_tenant_id() (20260909130000_rls_app_role vergibt dafuer keines):
|
||||
-- PostgreSQL vergibt EXECUTE auf Funktionen standardmaessig an PUBLIC.
|
||||
CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$
|
||||
SELECT NULLIF(current_setting('app.current_user', true), '');
|
||||
$$ LANGUAGE sql STABLE;
|
||||
|
||||
-- Vierzehn Tabellen tragen eine `userId`-Spalte, zehn davon sind
|
||||
-- persoenliche Daten und bekommen unten eine Regel. Die vier Ausnahmen
|
||||
-- bekommen KEINE Anweisung in dieser Migration:
|
||||
--
|
||||
-- - GroupMembership: Verwaltungsobjekt — ein Admin muss Mitgliedschaften
|
||||
-- anderer Nutzer sehen und pflegen koennen, das ist keine persoenliche
|
||||
-- Zeile des referenzierten Benutzers.
|
||||
-- - ModuleGrant: Verwaltungsobjekt — dieselbe Begruendung, ein Admin
|
||||
-- vergibt und sieht Freigaben fuer andere.
|
||||
-- - PasswordResetToken: Anmelde-Artefakt — wird gelesen, BEVOR ein
|
||||
-- Benutzer im Sinne von `app.current_user` bekannt ist (der Token IST
|
||||
-- der Weg, den Benutzer erst zu ermitteln); eine Benutzerdimension hier
|
||||
-- waere zirkulaer.
|
||||
-- - TenderMatch: wird vom Hintergrunddienst (tender-matching.service.ts)
|
||||
-- je Treffer geschrieben, nicht von einem eingeloggten Benutzer direkt;
|
||||
-- Etappe 3c behandelt Hintergrunddienste gesondert (Systemkontext).
|
||||
|
||||
-- Acht Tabellen mit NOT-NULL-`userId`: ein einzelner USING-Ausdruck genuegt,
|
||||
-- weil Lesen und Schreiben dieselbe Bedingung haben sollen — WITH CHECK
|
||||
-- folgt USING bei einer Policy ohne FOR-Klausel, ein Einfuegen/Aendern als
|
||||
-- Benutzer A mit fremder Kennung B faellt damit durch. Die `IS NULL OR`-Form
|
||||
-- macht die Aenderung fuer jeden Aufruf OHNE gesetzten Benutzer (Admin,
|
||||
-- Hintergrunddienst) wirkungslos: der sieht weiterhin den ganzen Mandanten,
|
||||
-- exakt wie vor dieser Migration (bewusste, offene Flanke — siehe
|
||||
-- .planning/WINDOWS.md). Die Regelnamen bleiben `tenant_isolation_policy`
|
||||
-- (wie jab bei GroupMembership/ModuleGrant): `extractPolicySql` und
|
||||
-- `pg_policies` behalten je Tabelle genau eine Regel.
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "CalendarSource";
|
||||
CREATE POLICY tenant_isolation_policy ON "CalendarSource"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "DashboardLayout";
|
||||
CREATE POLICY tenant_isolation_policy ON "DashboardLayout"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "FavoriteLink";
|
||||
CREATE POLICY tenant_isolation_policy ON "FavoriteLink"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "TenderEmailConfig";
|
||||
CREATE POLICY tenant_isolation_policy ON "TenderEmailConfig"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "TenderNotificationPref";
|
||||
CREATE POLICY tenant_isolation_policy ON "TenderNotificationPref"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "TenderSavedSearch";
|
||||
CREATE POLICY tenant_isolation_policy ON "TenderSavedSearch"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "TenderTriage";
|
||||
CREATE POLICY tenant_isolation_policy ON "TenderTriage"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "WidgetInstance";
|
||||
CREATE POLICY tenant_isolation_policy ON "WidgetInstance"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
-- SearchProvider — `userId` ist NULL-faehig (eine gemeinsame, mandanten-
|
||||
-- gebundene Zeile ohne Besitzer ist erlaubt), die Mandantenhaelfte ist NICHT
|
||||
-- gelockert: `SearchProvider` bleibt mandantenstreng (260910-jab (4),
|
||||
-- widerlegte Praemisse aus WINDOWS #19 — es gibt keinen Codeweg, der eine
|
||||
-- mandantenlose Zeile erzeugt). Vier nach Befehl getrennte Regeln
|
||||
-- (Praezedenz 260910-jab (3)): ein einzelner USING-Ausdruck, der die
|
||||
-- gemeinsame Zeile (`userId IS NULL`) zum Lesen einschliesst, wuerde sie
|
||||
-- ohne Trennung auch zum Aendern/Entfernen freigeben.
|
||||
DROP POLICY tenant_isolation_policy ON "SearchProvider";
|
||||
|
||||
CREATE POLICY tenant_user_read_policy ON "SearchProvider"
|
||||
FOR SELECT
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (
|
||||
current_user_id() IS NULL
|
||||
OR "userId" IS NULL
|
||||
OR "userId" = current_user_id()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_insert_policy ON "SearchProvider"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_update_policy ON "SearchProvider"
|
||||
FOR UPDATE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
)
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_delete_policy ON "SearchProvider"
|
||||
FOR DELETE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
-- TenderRssFeedSource — loest die vier Regeln aus 20260910120000 ab (WINDOWS
|
||||
-- #19), unter DENSELBEN NAMEN neu angelegt. Die plattformweite Lesezulassung
|
||||
-- (`tenantId IS NULL`) und die Mandantenpflicht beim Schreiben aus jener
|
||||
-- Migration bleiben unveraendert bestehen — hier kommt ausschliesslich die
|
||||
-- Benutzerdimension hinzu. WINDOWS #24 (Admin-Erstellung/-Entfernen
|
||||
-- plattformweiter Zeilen bleibt ungebunden) ist von dieser Migration
|
||||
-- UNBERUEHRT.
|
||||
DROP POLICY tenant_platform_read_policy ON "TenderRssFeedSource";
|
||||
DROP POLICY tenant_insert_policy ON "TenderRssFeedSource";
|
||||
DROP POLICY tenant_update_policy ON "TenderRssFeedSource";
|
||||
DROP POLICY tenant_delete_policy ON "TenderRssFeedSource";
|
||||
|
||||
CREATE POLICY tenant_platform_read_policy ON "TenderRssFeedSource"
|
||||
FOR SELECT
|
||||
USING (
|
||||
("tenantId" = current_tenant_id() OR "tenantId" IS NULL)
|
||||
AND (
|
||||
current_user_id() IS NULL
|
||||
OR "userId" IS NULL
|
||||
OR "userId" = current_user_id()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_insert_policy ON "TenderRssFeedSource"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_update_policy ON "TenderRssFeedSource"
|
||||
FOR UPDATE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
)
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_delete_policy ON "TenderRssFeedSource"
|
||||
FOR DELETE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
-- Was diese Migration bewusst NICHT tut:
|
||||
-- - Kein Systemkontext fuer Hintergrunddienste (Etappe 3c) — die `IS NULL
|
||||
-- OR`-Form macht das fuer heutige Aufrufer unnoetig.
|
||||
-- - Keine SECURITY-DEFINER-Funktion (Etappe 3a, Anmeldenamen pro Mandant).
|
||||
-- - Ein Aufrufer, der den Benutzer vergisst (drittes Argument an
|
||||
-- `forTenant()` nicht setzt), sieht den ganzen Mandanten — heute exakt
|
||||
-- der Stand VOR dieser Migration, also keine Verschlechterung, aber auch
|
||||
-- kein Netz dagegen. Siehe .planning/WINDOWS.md fuer den Nachweis, dass
|
||||
-- dieser Zustand aufgezeichnet, nicht uebersehen wurde.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -238,6 +238,8 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hasCredentials).toBe(true);
|
||||
expect((result[0] as any).encryptedPassword).toBeUndefined();
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
|
||||
});
|
||||
|
||||
it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => {
|
||||
|
||||
@@ -108,13 +108,19 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
* The three ownership checks (`updateSource`/`deleteSource`/
|
||||
* `testConnection`, comparing `existing.userId` against the calling user)
|
||||
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS
|
||||
* policy on `CalendarSource` carries no user dimension (measured
|
||||
* 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
|
||||
* policy on `CalendarSource` carried no user dimension when measured
|
||||
* 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
|
||||
* so a colleague of the SAME tenant would otherwise see and modify a
|
||||
* fellow user's encrypted Exchange/CalDAV credentials. Until the RLS
|
||||
* policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these
|
||||
* application-level checks remain the only protection between users of the
|
||||
* same tenant.
|
||||
* fellow user's encrypted Exchange/CalDAV credentials.
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die
|
||||
* `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension
|
||||
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder
|
||||
* `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die
|
||||
* drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT
|
||||
* bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und
|
||||
* ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten
|
||||
* (siehe .planning/WINDOWS.md).
|
||||
*
|
||||
* Credentials encrypted at rest via CryptoService (T-05-10).
|
||||
*/
|
||||
@@ -155,7 +161,7 @@ export class CalendarService {
|
||||
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
|
||||
*/
|
||||
async getSources(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const sources = await tenantPrisma.calendarSource.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
@@ -195,7 +201,7 @@ export class CalendarService {
|
||||
data.encryptedPassword = this.crypto.encrypt(dto.password);
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const created = await tenantPrisma.calendarSource.create({
|
||||
data: data as any,
|
||||
select: SOURCE_SAFE_SELECT,
|
||||
@@ -209,7 +215,7 @@ export class CalendarService {
|
||||
* Re-encrypts password if provided; T-05-12 ownership enforcement.
|
||||
*/
|
||||
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, type: true },
|
||||
@@ -261,7 +267,7 @@ export class CalendarService {
|
||||
* Deletes a calendar source. Ownership check enforced (T-05-12).
|
||||
*/
|
||||
async deleteSource(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
@@ -283,7 +289,7 @@ export class CalendarService {
|
||||
* Updates lastSyncAt/lastSyncError on the source record.
|
||||
*/
|
||||
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
|
||||
if (!source) throw new NotFoundException('Calendar source not found');
|
||||
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
|
||||
@@ -399,7 +405,7 @@ export class CalendarService {
|
||||
to: Date,
|
||||
cacheKey: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const sources = await tenantPrisma.calendarSource.findMany({
|
||||
where: { userId, isVisible: true },
|
||||
});
|
||||
|
||||
@@ -388,6 +388,8 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
|
||||
expect(result).toEqual({ lg: [{ i: 'w1' }] });
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
|
||||
});
|
||||
|
||||
it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => {
|
||||
|
||||
@@ -58,12 +58,27 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
||||
* three ownership checks in this file (`updateWidgetConfig`, `removeWidget`,
|
||||
* `removeSearchProvider`) compare against the user id from the session proof
|
||||
* and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance`
|
||||
* and `SearchProvider` know only the tenant dimension, not the user dimension
|
||||
* (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped
|
||||
* and `SearchProvider` knew only the tenant dimension, not the user dimension,
|
||||
* when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped
|
||||
* (WINDOWS #18) they remain the only actually effective protection against
|
||||
* cross-reading/cross-deleting between two users of the SAME tenant, and the
|
||||
* `forTenant()` binding below ADDS a tenant boundary on top of them, it never
|
||||
* replaces them.
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die
|
||||
* Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die
|
||||
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`,
|
||||
* fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder
|
||||
* `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die
|
||||
* drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz,
|
||||
* kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen
|
||||
* Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift:
|
||||
* `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile
|
||||
* per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach
|
||||
* dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen
|
||||
* bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann
|
||||
* NotFoundException statt der heutigen Forbidden-Form — beides eine
|
||||
* Abweisung, nur die Fehlerart aendert sich.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
@@ -77,7 +92,7 @@ export class DashboardService {
|
||||
* with all breakpoint arrays initialized.
|
||||
*/
|
||||
async getLayout(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -108,7 +123,7 @@ export class DashboardService {
|
||||
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
|
||||
*/
|
||||
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
try {
|
||||
return await tenantPrisma.dashboardLayout.upsert({
|
||||
where: { userId },
|
||||
@@ -144,7 +159,7 @@ export class DashboardService {
|
||||
* betroffene Widget entfernt (Fail-Closed).
|
||||
*/
|
||||
async getWidgets(userId: string, tenantId: string, role: Role) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const widgets = await tenantPrisma.widgetInstance.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -195,7 +210,7 @@ export class DashboardService {
|
||||
* Creates a new widget instance for the user.
|
||||
*/
|
||||
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.widgetInstance.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -223,7 +238,7 @@ export class DashboardService {
|
||||
tenantId: string,
|
||||
dto: UpdateWidgetConfigDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -253,7 +268,7 @@ export class DashboardService {
|
||||
* queries run over the SAME bound client and tenant id.
|
||||
*/
|
||||
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -279,7 +294,7 @@ export class DashboardService {
|
||||
* below and are always prepended unchanged.
|
||||
*/
|
||||
async getSearchProviders(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const custom = await tenantPrisma.searchProvider.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -300,7 +315,7 @@ export class DashboardService {
|
||||
tenantId: string,
|
||||
dto: CreateSearchProviderDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.searchProvider.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -319,7 +334,7 @@ export class DashboardService {
|
||||
* above: both queries run over the SAME bound client and tenant id.
|
||||
*/
|
||||
async removeSearchProvider(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
// Default providers have hardcoded IDs that won't exist in DB
|
||||
const provider = await tenantPrisma.searchProvider.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -203,6 +203,8 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
|
||||
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
|
||||
});
|
||||
|
||||
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
|
||||
|
||||
@@ -23,11 +23,15 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
|
||||
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
|
||||
*
|
||||
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh,
|
||||
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/
|
||||
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben
|
||||
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN
|
||||
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach).
|
||||
* Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
|
||||
* Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
|
||||
* `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
|
||||
* Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
|
||||
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
|
||||
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
|
||||
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
|
||||
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
|
||||
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
|
||||
*
|
||||
* Access control (T-08-06 / Pitfall 3):
|
||||
* - Every query is scoped by userId (prevents cross-user access).
|
||||
@@ -58,7 +62,7 @@ export class FavoritesService {
|
||||
async list(tenantId: string, userId: string, widgetId: string) {
|
||||
if (!widgetId) throw new BadRequestException('widgetId is required');
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.favoriteLink.findMany({
|
||||
where: { userId, widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
@@ -72,7 +76,7 @@ export class FavoritesService {
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
|
||||
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
||||
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
||||
@@ -116,7 +120,7 @@ export class FavoritesService {
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
@@ -157,7 +161,7 @@ export class FavoritesService {
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
*/
|
||||
async remove(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
@@ -183,7 +187,7 @@ export class FavoritesService {
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
|
||||
@@ -173,6 +173,88 @@ describe('rls_widen_membership_grant_and_platform_read migration.sql (T-JTS-02,
|
||||
});
|
||||
});
|
||||
|
||||
describe('rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nke)', () => {
|
||||
const sql = readMigrationSql('_rls_user_dimension_personal_tables');
|
||||
const PERSONAL_TABLES = [
|
||||
'CalendarSource',
|
||||
'DashboardLayout',
|
||||
'FavoriteLink',
|
||||
'SearchProvider',
|
||||
'TenderEmailConfig',
|
||||
'TenderNotificationPref',
|
||||
'TenderRssFeedSource',
|
||||
'TenderSavedSearch',
|
||||
'TenderTriage',
|
||||
'WidgetInstance',
|
||||
];
|
||||
const EXCLUDED_TABLES = ['GroupMembership', 'ModuleGrant', 'PasswordResetToken', 'TenderMatch'];
|
||||
|
||||
function nonCommentLines(source: string): string {
|
||||
return source
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
it('legt current_user_id() mit NULLIF an', () => {
|
||||
expect(sql).toContain('CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$');
|
||||
expect(sql).toContain("NULLIF(current_setting('app.current_user', true), '')");
|
||||
});
|
||||
|
||||
it('nennt fuer jede der zehn persoenlichen Tabellen mindestens eine CREATE POLICY-Anweisung', () => {
|
||||
for (const table of PERSONAL_TABLES) {
|
||||
expect(sql).toMatch(new RegExp(`CREATE POLICY [\\w]+ ON "${table}"`));
|
||||
}
|
||||
});
|
||||
|
||||
it('jede CREATE-POLICY-Anweisung der zehn Tabellen enthaelt current_user_id() IS NULL OR', () => {
|
||||
for (const table of PERSONAL_TABLES) {
|
||||
const re = /CREATE POLICY [\w]+[\s\S]*?ON "([A-Za-z]+)"[\s\S]*?;/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let found = 0;
|
||||
while ((match = re.exec(sql)) !== null) {
|
||||
if (match[1] !== table) continue;
|
||||
found += 1;
|
||||
expect(match[0].replace(/\s+/g, ' ')).toContain('current_user_id() IS NULL OR');
|
||||
}
|
||||
expect(found).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('legt genau 8 DROP POLICY tenant_isolation_policy auf den NOT-NULL-Tabellen, einen weiteren auf SearchProvider, und 4 DROPs auf TenderRssFeedSource an', () => {
|
||||
const NOT_NULL_TABLES = [
|
||||
'CalendarSource',
|
||||
'DashboardLayout',
|
||||
'FavoriteLink',
|
||||
'TenderEmailConfig',
|
||||
'TenderNotificationPref',
|
||||
'TenderSavedSearch',
|
||||
'TenderTriage',
|
||||
'WidgetInstance',
|
||||
];
|
||||
const dropIsolationOnNotNullTables = NOT_NULL_TABLES.filter((table) =>
|
||||
sql.includes(`DROP POLICY tenant_isolation_policy ON "${table}"`),
|
||||
).length;
|
||||
expect(dropIsolationOnNotNullTables).toBe(8);
|
||||
expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "SearchProvider"');
|
||||
const dropRssFeed = (sql.match(/DROP POLICY \w+ ON "TenderRssFeedSource"/g) ?? []).length;
|
||||
expect(dropRssFeed).toBe(4);
|
||||
});
|
||||
|
||||
it('nennt die vier Ausnahmen namentlich im Kopf', () => {
|
||||
for (const table of EXCLUDED_TABLES) {
|
||||
expect(sql).toContain(table);
|
||||
}
|
||||
});
|
||||
|
||||
it('enthaelt KEINE Anweisung auf GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch (ausserhalb von Kommentaren)', () => {
|
||||
const codeOnly = nonCommentLines(sql);
|
||||
for (const table of EXCLUDED_TABLES) {
|
||||
expect(codeOnly).not.toMatch(new RegExp(`(DROP|CREATE) POLICY [\\w ]*ON "${table}"`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
|
||||
const sql = readMigrationSql('_add_group_internal_name_and_object_guid');
|
||||
|
||||
|
||||
@@ -132,6 +132,73 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
|
||||
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
|
||||
});
|
||||
|
||||
// Benutzerdimension (Etappe 3b, 260911-nke): drei neue Tests fuer den
|
||||
// optionalen dritten Parameter `userId`.
|
||||
it('ohne userId: die Parameterliste des Templates enthaelt den Leerstring an zweiter Stelle, der Template-Text nennt app.current_user', async () => {
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
|
||||
$extends: (config: any) => ({
|
||||
async __invoke(args: unknown, query: (args: unknown) => unknown) {
|
||||
return config.query.$allOperations({ args, query });
|
||||
},
|
||||
}),
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
expect(strings.join('')).toContain('app.current_user');
|
||||
expect(values[1]).toBe('');
|
||||
return 'set-config-promise';
|
||||
}),
|
||||
};
|
||||
|
||||
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
|
||||
await scoped.__invoke({}, () => 'query-result');
|
||||
|
||||
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mit userId: der Wert geht als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt gewahrt)', async () => {
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
|
||||
$extends: (config: any) => ({
|
||||
async __invoke(args: unknown, query: (args: unknown) => unknown) {
|
||||
return config.query.$allOperations({ args, query });
|
||||
},
|
||||
}),
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
expect(Array.isArray(strings)).toBe(true);
|
||||
expect(strings.join('')).not.toContain("user-with-quote-' OR 1=1");
|
||||
expect(values).toContain("user-with-quote-' OR 1=1");
|
||||
return 'set-config-promise';
|
||||
}),
|
||||
};
|
||||
|
||||
const scoped = forTenant(fakePrisma, 'tenant-a', "user-with-quote-' OR 1=1") as any;
|
||||
await scoped.__invoke({}, () => 'query-result');
|
||||
|
||||
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mit userId: das $transaction-Feld behaelt weiterhin genau zwei Eintraege', async () => {
|
||||
const transactionCalls: unknown[] = [];
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((arg: unknown) => {
|
||||
transactionCalls.push(arg);
|
||||
return Promise.resolve(['set-config-result', 'query-result']);
|
||||
}),
|
||||
$extends: (config: any) => ({
|
||||
async __invoke(args: unknown, query: (args: unknown) => unknown) {
|
||||
return config.query.$allOperations({ args, query });
|
||||
},
|
||||
}),
|
||||
$executeRaw: vi.fn(() => 'set-config-promise'),
|
||||
};
|
||||
|
||||
const scoped = forTenant(fakePrisma, 'tenant-a', 'user-a') as any;
|
||||
await scoped.__invoke({}, () => 'query-result');
|
||||
|
||||
expect(transactionCalls).toHaveLength(1);
|
||||
expect((transactionCalls[0] as unknown[]).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
|
||||
|
||||
@@ -107,16 +107,55 @@ import { PrismaClient } from '@prisma/client';
|
||||
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
|
||||
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
|
||||
* ein anderes Ergebnis liefern.
|
||||
*
|
||||
* BENUTZERDIMENSION (Etappe 3b, 260911-nke):
|
||||
*
|
||||
* `forTenant()` bekommt einen OPTIONALEN dritten Parameter `userId` statt
|
||||
* eines Schwesterhelfers (`forTenantAndUser()`). Grund: der Detektor der
|
||||
* Bestandsaufnahme (`rls-access-inventory.spec.ts`) erkennt gebundene
|
||||
* Aufrufstellen ueber den Regex `const X = forTenant(` — ein anders
|
||||
* benannter Schwesterhelfer waere fuer ihn UNSICHTBAR, jeder damit
|
||||
* gebundene Zugriff wuerde faelschlich als ungebunden gezaehlt. Ein
|
||||
* dritter Parameter aendert am Match des Regex nichts, weil er nur den
|
||||
* Funktionsnamen und das oeffnende `(` prueft. Praezedenz fuer "Helfer
|
||||
* erweitern statt zweiten bauen": `withTenantTransaction()` oben, das
|
||||
* ebenfalls keinen Zwilling bekam.
|
||||
*
|
||||
* Ohne `userId` wird `app.current_user` auf den LEERSTRING gesetzt, nicht
|
||||
* weggelassen. Grund: `set_config(..., true)` gilt nur transaktionslokal
|
||||
* (siehe WINDOWS-#20-Herleitung oben) — ein Aufruf ohne Benutzer koennte
|
||||
* sonst theoretisch einen Benutzer aus einer fruaheren Transaktion
|
||||
* DERSELBEN Verbindung erben, sollte spaeter jemand `local=false`
|
||||
* einfuehren. Der Leerstring schliesst das aus. `current_user_id()`
|
||||
* (neue Migration 20260911120000) faltet den Leerstring per `NULLIF` auf
|
||||
* NULL — die Regeln der zehn persoenlichen Tabellen behandeln "ungesetzt"
|
||||
* und "leer" dadurch gleich.
|
||||
*
|
||||
* Beide `set_config`-Aufrufe stehen in EINER getaggten Anweisung
|
||||
* (kommasepariert) — das `$transaction`-Array behaelt weiterhin GENAU ZWEI
|
||||
* Eintraege (Kontext-Anweisung, eigentliche Abfrage), das WINDOWS-#20-Muster
|
||||
* bleibt unangetastet.
|
||||
*
|
||||
* Wer den Benutzer setzt: NUR Nutzer-CRUD-Aufrufer (die zehn persoenlichen
|
||||
* Tabellen betreffende Methoden in calendar/dashboard/favorites/tenders).
|
||||
* Hintergrunddienste (`tender-digest.scheduler.ts`) und Verwaltungswege
|
||||
* (ldap, groups, user, tenant, auth, dkv, module-registry) rufen weiterhin
|
||||
* OHNE Benutzer — die `IS NULL OR`-Form der Regeln macht das zu einer
|
||||
* bewussten Eigenschaft (Admin/Hintergrunddienst sieht den ganzen
|
||||
* Mandanten), nicht zu einer Luecke. `withTenantTransaction()` bekommt
|
||||
* KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion
|
||||
* (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere
|
||||
* Spekulation ohne heutigen Aufrufer.
|
||||
*/
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
||||
const setTenantContext = (prisma as any)
|
||||
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
||||
const setContext = (prisma as any)
|
||||
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||
|
||||
return (prisma as any)
|
||||
.$transaction([setTenantContext, query(args)])
|
||||
.$transaction([setContext, query(args)])
|
||||
.then((results: any[]) => results[1]);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -132,6 +132,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
|
||||
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
|
||||
// Client fuer alle Zugriffe dieses Schleifendurchlaufs.
|
||||
//
|
||||
// Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist
|
||||
// ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND
|
||||
// schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL
|
||||
// OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne
|
||||
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
|
||||
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
|
||||
// Etappe 3c, nicht Teil dieser Aenderung.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
|
||||
@@ -375,6 +376,8 @@ describe('TenderEmailConfigService', () => {
|
||||
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
|
||||
);
|
||||
expect(findUniqueCalls.length).toBe(2);
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-k');
|
||||
});
|
||||
|
||||
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
|
||||
|
||||
@@ -54,6 +54,12 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
* uniqueness constraint on `userId` and surfaces as a translated
|
||||
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
|
||||
*
|
||||
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
|
||||
* `forTenant()` call above also passes `userId` as the third argument, so
|
||||
* the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces
|
||||
* `userId = current_user_id()` — a second net, not a replacement for the
|
||||
* `userId @unique` ownership model above.
|
||||
*
|
||||
* Security:
|
||||
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
||||
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
||||
@@ -96,7 +102,7 @@ export class TenderEmailConfigService {
|
||||
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
||||
*/
|
||||
async getConfigForApi(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
|
||||
where: { userId },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
@@ -146,7 +152,7 @@ export class TenderEmailConfigService {
|
||||
*/
|
||||
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
||||
const { userId, tenantId } = ctx;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged =
|
||||
@@ -234,7 +240,7 @@ export class TenderEmailConfigService {
|
||||
|
||||
if (!username || !password) {
|
||||
try {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
|
||||
@@ -144,6 +145,8 @@ describe('TenderNotificationPrefService', () => {
|
||||
await service.getForUser('u1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'findUnique');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => {
|
||||
|
||||
@@ -26,6 +26,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
* the failure is a P2002 unique-constraint violation, not an RLS
|
||||
* rejection. Translated below into a German message, same pattern as
|
||||
* `tender-saved-search.service.ts`, instead of surfacing as a raw 500.
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
|
||||
* die Regel auf TenderNotificationPref die Benutzerdimension
|
||||
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide
|
||||
* `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch.
|
||||
* Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderNotificationPrefService {
|
||||
@@ -39,7 +45,7 @@ export class TenderNotificationPrefService {
|
||||
* autowrite needed to represent "using the default".
|
||||
*/
|
||||
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -57,7 +63,7 @@ export class TenderNotificationPrefService {
|
||||
* than creating a new one.
|
||||
*/
|
||||
async setForUser(userId: string, tenantId: string, digestInterval: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderNotificationPref.upsert({
|
||||
where: { userId },
|
||||
|
||||
@@ -509,6 +509,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
|
||||
expectBoundCall(prisma, 'tenant-a', 'count');
|
||||
expectBoundCall(prisma, 'tenant-a', 'create');
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'user-a');
|
||||
});
|
||||
|
||||
// Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit
|
||||
@@ -530,7 +531,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
|
||||
await service.listForUser('u-anyone', 'tenant-a');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a');
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'u-anyone');
|
||||
expectBoundCall(prisma, 'tenant-a', 'findMany');
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
|
||||
* Bindung nicht überflüssig, sondern das zweite Netz.
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderRssFeedSource.findMany({
|
||||
where: { OR: [{ userId: null }, { userId }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
|
||||
) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
|
||||
const existingCount = await tenantPrisma.tenderRssFeedSource.count({
|
||||
where: { userId: ctx.userId },
|
||||
});
|
||||
@@ -130,7 +130,10 @@ export class TenderRssFeedSourceService {
|
||||
* Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht
|
||||
* anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein
|
||||
* Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen
|
||||
* offenen Punkt fest, der NICHT mit #19 verschwindet.
|
||||
* offenen Punkt fest, der NICHT mit #19 verschwindet. Nachtrag (260911-nke,
|
||||
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
|
||||
* (20260911120000) — `createPlatform` bleibt bewusst ungebunden, WINDOWS #24
|
||||
* unveraendert offen.
|
||||
*/
|
||||
async createPlatform(dto: TenderRssFeedDto) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
@@ -173,7 +176,10 @@ export class TenderRssFeedSourceService {
|
||||
* Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden,
|
||||
* wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser
|
||||
* Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode
|
||||
* ungebunden, nicht nur ihre plattformweite Haelfte.
|
||||
* ungebunden, nicht nur ihre plattformweite Haelfte. Nachtrag (260911-nke,
|
||||
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
|
||||
* (20260911120000) — `remove` bleibt bewusst ungebunden, WINDOWS #24
|
||||
* unveraendert offen.
|
||||
*/
|
||||
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
|
||||
const { userId, isAdmin } = ctx;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
|
||||
@@ -311,4 +312,48 @@ describe('TenderSavedSearchService', () => {
|
||||
expectBoundCall(prisma, 't1', 'delete');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Benutzerdimension (Etappe 3b, 260911-nke): forTenant() bekommt den
|
||||
// Benutzer als drittes Argument — je Methode mindestens ein dreistelliger
|
||||
// Aufruf festgenagelt, damit ein vergessenes drittes Argument den Test
|
||||
// bricht statt still zu verschwinden.
|
||||
describe('Benutzerdimension: forTenant() bekommt userId als drittes Argument (260911-nke)', () => {
|
||||
it('list() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
|
||||
await service.list('u1', 't1');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('create() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
|
||||
await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('update() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
await service.update(created.id, 'u1', 't1', { name: 'B' });
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('remove() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
await service.remove(created.id, 'u1', 't1');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,12 @@ import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.d
|
||||
* method call, never shared across methods (same convention as
|
||||
* `groups.service.ts`).
|
||||
*
|
||||
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
|
||||
* `forTenant()` call above also passes `userId` as the third argument, so
|
||||
* the database-level `tenant_isolation_policy` on TenderSavedSearch now
|
||||
* ALSO enforces `userId = current_user_id()` — a second net alongside the
|
||||
* application-level scoping above, which stays exactly as it was.
|
||||
*
|
||||
* @@unique([userId, name]) (T-11-14): a second profile with the same name
|
||||
* for the same user is rejected by Postgres (P2002) — this service
|
||||
* translates that into a 409 ConflictException so the frontend can show a
|
||||
@@ -38,7 +44,7 @@ export class TenderSavedSearchService {
|
||||
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
|
||||
*/
|
||||
async list(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderSavedSearch.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -52,7 +58,7 @@ export class TenderSavedSearchService {
|
||||
* users, since the uniqueness is scoped per-user.
|
||||
*/
|
||||
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderSavedSearch.create({
|
||||
data: {
|
||||
@@ -81,7 +87,7 @@ export class TenderSavedSearchService {
|
||||
* leaking whether another user's profile exists).
|
||||
*/
|
||||
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -118,7 +124,7 @@ export class TenderSavedSearchService {
|
||||
* update().
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderTriageService } from './tender-triage.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/
|
||||
@@ -188,6 +189,8 @@ describe('TenderTriageService', () => {
|
||||
await service.setTriage('u1', 't1', 'tender-x', { isRead: true });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'upsert');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface SetTriageInput {
|
||||
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area
|
||||
* share the identical `"tenantId" = current_tenant_id()` text).
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
|
||||
* die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS
|
||||
* NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe
|
||||
* unten reichen `userId` als drittes Argument durch. Die anwendungsseitige
|
||||
* userId-Filterung bleibt zweites Netz, kein Ersatz.
|
||||
*
|
||||
* Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete:
|
||||
* Cascade)` removes a tender's triage rows automatically when Phase 10's
|
||||
* retention job deletes the tender — no manual cleanup needed here.
|
||||
@@ -69,7 +75,7 @@ export class TenderTriageService {
|
||||
update.favoritedAt = dto.isFavorite ? now : null;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderTriage.upsert({
|
||||
where: { userId_tenderId: { userId, tenderId } },
|
||||
@@ -105,7 +111,7 @@ export class TenderTriageService {
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
|
||||
if (!tenderIds.length) return [];
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, tenderId: { in: tenderIds } },
|
||||
});
|
||||
@@ -117,7 +123,7 @@ export class TenderTriageService {
|
||||
* tender-query.builder.ts's buildTenderWhere.
|
||||
*/
|
||||
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const rows = await tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, isFavorite: true },
|
||||
select: { tenderId: true },
|
||||
|
||||
@@ -320,8 +320,20 @@ die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten
|
||||
|
||||
**Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft
|
||||
dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma`
|
||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) — die zusätzlichen `where`-Filter über
|
||||
`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (siehe
|
||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)`
|
||||
trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
|
||||
260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen
|
||||
(CalendarSource, DashboardLayout, FavoriteLink, SearchProvider,
|
||||
TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource,
|
||||
TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension
|
||||
in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`),
|
||||
vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten
|
||||
(GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur
|
||||
Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege
|
||||
rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie
|
||||
wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über
|
||||
`userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz,
|
||||
kein Ersatz (siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId`
|
||||
(oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B.
|
||||
plattformweiter Katalog, kein Mandantenfilter nötig); siehe
|
||||
|
||||
@@ -67,6 +67,24 @@ noetig sind:
|
||||
`20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang
|
||||
fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine
|
||||
Regel.
|
||||
- **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben
|
||||
`app.current_tenant` (Migration `20260618112133_rls_policies`,
|
||||
`current_tenant_id()`) setzt die Rolle seit Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
|
||||
260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion
|
||||
`current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der
|
||||
Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring,
|
||||
nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen
|
||||
Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann.
|
||||
Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt
|
||||
PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln
|
||||
der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout,
|
||||
FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref,
|
||||
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance)
|
||||
pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` —
|
||||
ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht
|
||||
weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige
|
||||
Aufrufer wirkungslos.
|
||||
|
||||
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
|
||||
|
||||
|
||||
@@ -460,6 +460,14 @@ Bereich brauchte:
|
||||
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
|
||||
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
|
||||
Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
|
||||
|
||||
**Nachtrag (260911-nke):** seit Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
trägt die Regel auf `TenderSavedSearch` die Benutzerdimension — die alte
|
||||
Messung bleibt unter dem Namen `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
|
||||
als die gewollte Eigenschaft für Admin/Hintergrunddienst bestehen, die
|
||||
Umkehrung `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden`
|
||||
misst MIT Benutzer und erwartet das Gegenteil. Siehe Abschnitt
|
||||
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
|
||||
- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die
|
||||
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
|
||||
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
|
||||
@@ -654,6 +662,15 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
|
||||
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
|
||||
Aufgabe.
|
||||
|
||||
**Nachtrag (260911-nke):** die Benutzerdimension der `TenderRssFeedSource`-Regeln
|
||||
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
Teil der vier befehlsgetrennten Regeln (Lesen schließt gemeinsame/eigene
|
||||
Zeilen ein, Schreiben verlangt weiterhin `userId = current_user_id()`),
|
||||
gemessen in `tenderrssfeed-gemeinsame-zeile-*`. Befund G (Admin eines
|
||||
beliebigen Mandanten entfernt eine plattformweite Zeile) bleibt
|
||||
unverändert — WINDOWS #24 hält das offen, siehe Abschnitt "Regelschluss
|
||||
Benutzerdimension (Etappe 3b, 260911-nke)" unten.
|
||||
|
||||
### (t5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
|
||||
@@ -1749,6 +1766,13 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F:
|
||||
eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche
|
||||
Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die
|
||||
anwendungsseitige `userId`-Filterung bleibt der einzige Schutz.
|
||||
|
||||
**Nachtrag (260911-nke): ÜBERHOLT — die zweite Sitzungsvariable ist
|
||||
gebaut.** Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
führt `app.current_user`/`current_user_id()` ein und trägt die
|
||||
Benutzerdimension in die Regeln der zehn persönlichen Tabellen, darunter
|
||||
`TenderSavedSearch`. Siehe Abschnitt "Regelschluss Benutzerdimension
|
||||
(Etappe 3b, 260911-nke)" unten.
|
||||
- **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS
|
||||
#22)** — unverändert, nicht Gegenstand dieses Plans.
|
||||
|
||||
@@ -1809,6 +1833,21 @@ searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebund
|
||||
Alle 87 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
**Nachtrag (260911-nke):** die drei oben zitierten Ausgabezeilen
|
||||
(`dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
|
||||
`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
|
||||
`searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
|
||||
bleiben als historische Messung stehen — sie sind seit Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables` UMGEDREHT, nicht
|
||||
gelöscht: die alte Messung lebt unter den neuen Namen
|
||||
`dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`,
|
||||
`widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` und
|
||||
`searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
|
||||
weiter (jetzt als gewollte Eigenschaft für Admin/Hintergrunddienst), dazu je
|
||||
eine neue Umkehrung `<tabelle>-benutzer-a-sieht-kollegen-nicht-gebunden` MIT
|
||||
Benutzer. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe 3b,
|
||||
260911-nke)" unten.
|
||||
|
||||
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
|
||||
namentlich vorgezeichnet — die dreizehnte
|
||||
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
|
||||
@@ -1984,6 +2023,15 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
|
||||
gebunden wie #18 — er wird erst
|
||||
nach dem Scharfschalten beobachtbar.
|
||||
|
||||
**Nachtrag (260911-nke):** die Benutzerdimension der Regeln auf
|
||||
`DashboardLayout`/`WidgetInstance`/`SearchProvider` (Lesen UND Schreiben
|
||||
je Kollegenzeile) ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
geschlossen — siehe (w1) oben und Abschnitt "Regelschluss
|
||||
Benutzerdimension (Etappe 3b, 260911-nke)" unten. Die plattformweite
|
||||
Eindeutigkeit von `DashboardLayout.userId` (Befund K, dieser Punkt) ist
|
||||
davon UNBERÜHRT und bleibt für Etappe 3 vorgemerkt — 3b ändert keine
|
||||
Unique-Constraints.
|
||||
|
||||
### (w5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
|
||||
@@ -2086,6 +2134,16 @@ calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: be
|
||||
Alle 101 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
**Nachtrag (260911-nke):** `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`
|
||||
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
UMGEDREHT — die alte Messung lebt unter dem Namen
|
||||
`calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
|
||||
weiter (gewollte Eigenschaft ohne Benutzer), die neue Umkehrung
|
||||
`calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` misst MIT
|
||||
Benutzer und bestätigt, dass `encryptedPassword` eines Kollegen jetzt NICHT
|
||||
mehr lesbar ist. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe
|
||||
3b, 260911-nke)" unten.
|
||||
|
||||
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
|
||||
des Plans namentlich vorgezeichnet — die dreizehnte
|
||||
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
|
||||
@@ -2235,6 +2293,14 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
||||
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
|
||||
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
|
||||
und die drei Besitzprüfungen der EINZIGE Schutz.
|
||||
|
||||
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
|
||||
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
|
||||
`CalendarSource`-Regel; `calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden`
|
||||
bestätigt, dass die verschlüsselten Zugangsdaten eines Kollegen jetzt
|
||||
NICHT mehr lesbar sind. Der `userId`-Filter und die Besitzprüfungen
|
||||
bleiben zusätzlich bestehen (zweites Netz, kein Ersatz).
|
||||
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
|
||||
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
|
||||
Besitz `ForbiddenException('Not your calendar source')` (403), bei
|
||||
@@ -2749,6 +2815,17 @@ favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.fav
|
||||
Alle 137 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
**Nachtrag (260911-nke):** die Doppelaussage in
|
||||
`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`
|
||||
ist GETRENNT — die Prüfung behält nur die erste Hälfte (die eigenen Zeilen
|
||||
kommen). Die zweite Hälfte (Kollege sichtbar) lebt jetzt als eigene Prüfung
|
||||
`favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` (alte
|
||||
Messung, gewollte Eigenschaft ohne Benutzer) plus die Umkehrung
|
||||
`favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden` (MIT Benutzer,
|
||||
Kollege NICHT mehr sichtbar) — seit Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables`. Siehe Abschnitt
|
||||
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
|
||||
|
||||
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
|
||||
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
|
||||
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
|
||||
@@ -2817,6 +2894,14 @@ Scharfschalten neu entsteht.
|
||||
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
|
||||
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
|
||||
Mandanten.
|
||||
|
||||
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
|
||||
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
|
||||
`FavoriteLink`-Regel; `favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden`
|
||||
bestätigt, dass die Kollegenzeile über die `widgetId` jetzt NICHT mehr
|
||||
sichtbar ist. Die anwendungsseitige `userId`-Filterung bleibt zusätzlich
|
||||
bestehen (zweites Netz, kein Ersatz).
|
||||
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
|
||||
Ledger-Eintrag in Aufgabe 3.
|
||||
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
|
||||
@@ -3029,6 +3114,118 @@ unverändert und steht nicht in der Erlaubnisliste.
|
||||
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per
|
||||
`vi.mock('nodemailer')` ersetzt.
|
||||
|
||||
## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)
|
||||
|
||||
Reiner Datenbank- und Helfer-Umbau: Migration `20260911120000_rls_user_dimension_personal_tables`
|
||||
bringt die Funktion `current_user_id()` und die Benutzerdimension in die
|
||||
Regeln der zehn persönlichen Tabellen. `forTenant(prisma, tenantId, userId?)`
|
||||
bekommt einen optionalen dritten Parameter; 34 Nutzer-CRUD-Aufrufstellen in
|
||||
acht Diensten reichen ihn durch. Der Schalter bleibt AUS — nichts hiervon
|
||||
wirkt, bis Etappe 4 scharfschaltet.
|
||||
|
||||
### (b1) Die Messung
|
||||
|
||||
Wörtliche Werkzeugausgabe der drei Funktionsfälle:
|
||||
|
||||
```
|
||||
current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null
|
||||
current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null
|
||||
current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1"
|
||||
```
|
||||
|
||||
Wörtliche Werkzeugausgabe zweier der sechs Umkehrungen (eine Ein-Regel-Tabelle,
|
||||
eine der beiden vier-Regel-Tabellen):
|
||||
|
||||
```
|
||||
tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"]
|
||||
calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar
|
||||
searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern
|
||||
tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch
|
||||
Alle 203 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
`pg_policies` der lebenden Datenbank (`tessera-ctl-db-1`) für die zehn
|
||||
umgestellten und die vier bewusst unveränderten Tabellen, gemessen nach dem
|
||||
Anwenden von `20260911120000` (Tabelle#Regelname#Befehl#USING#WITH CHECK):
|
||||
|
||||
```
|
||||
CalendarSource#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
DashboardLayout#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
FavoriteLink#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))#
|
||||
ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))#
|
||||
PasswordResetToken#tenant_isolation_policy#ALL#("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))#
|
||||
SearchProvider#tenant_user_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
SearchProvider#tenant_user_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
|
||||
SearchProvider#tenant_user_read_policy#SELECT#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
|
||||
SearchProvider#tenant_user_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
|
||||
TenderEmailConfig#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
TenderMatch#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
|
||||
TenderNotificationPref#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
TenderRssFeedSource#tenant_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
TenderRssFeedSource#tenant_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
|
||||
TenderRssFeedSource#tenant_platform_read_policy#SELECT#((("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL)) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
|
||||
TenderRssFeedSource#tenant_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
|
||||
TenderSavedSearch#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
TenderTriage#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
WidgetInstance#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
|
||||
```
|
||||
|
||||
Werkzeug-Endstand nach Aufgabe 2: `Alle 203 Pruefungen bestanden.` (Baseline vor
|
||||
diesem Lauf: 137). Tests am Ende von Aufgabe 2: 1020 bestanden / 62 Dateien,
|
||||
Typprüfung sauber.
|
||||
|
||||
### (b2) Signaltabelle — beide Fehlerrichtungen je Regel
|
||||
|
||||
| Fehlerrichtung | Erwartung | Gemessen | Befund |
|
||||
|---|---|---|---|
|
||||
| Zu streng: ein Aufruf OHNE Benutzer (Admin, Hintergrunddienst) sähe nur EINEN Nutzer statt des ganzen Mandanten | Regel müsste beide Nutzer weiterhin liefern | `<tabelle>-ohne-benutzer-sieht-beide` (zehn Tabellen) — JEDE Tabelle liefert beide Nutzer | NICHT der Fall — die `IS NULL OR`-Form wirkt wie entworfen |
|
||||
| Zu locker: ein Aufruf MIT Benutzer sähe die Zeile eines Kollegen DESSELBEN Mandanten | Regel müsste die Kollegenzeile ausblenden | `<tabelle>-benutzer-a-sieht-kollegen-nicht(-gebunden)` (zehn Tabellen) — JEDE Tabelle blendet aus; `<tabelle>-schreiben-als-a-mit-kennung-b-abgelehnt` (SQLSTATE 42501) für alle zehn | NICHT der Fall — Lesen UND Schreiben sind durchgesetzt |
|
||||
| Bewusst offene Flanke: ein Nutzer-CRUD-Aufrufer, der `userId` VERGISST | Sähe den ganzen Mandanten, keine Fehlermeldung | Nicht durch einen Test erzwungen — kein Wächter über das dritte Argument gebaut | Akzeptiert, aufgezeichnet (T-NKE-02, WINDOWS-Eintrag unten) — heute exakt der Stand vor dieser Migration |
|
||||
|
||||
### (b3) Welcher Code Leere anders deutet als vorher
|
||||
|
||||
Erst wirksam NACH dem Scharfschalten (Etappe 4) — heute mit BYPASSRLS ohne
|
||||
Wirkung, hier vorab aufgezeichnet, weil der Codepfad schon jetzt geschrieben
|
||||
ist. Methoden, die eine Zeile per `findUnique({ where: { id } })` holen und
|
||||
danach `userId` gegen den Aufrufer vergleichen, sehen nach dem Scharfschalten
|
||||
die Kollegenzeile bereits als `null` (die Regel blendet sie aus, BEVOR die
|
||||
Anwendung überhaupt vergleicht) — die Anwendung meldet dann `NotFoundException`
|
||||
statt der heutigen `Forbidden`-artigen Abweisung über den `userId`-Vergleich.
|
||||
Betroffen: `dashboard.service.ts` (`removeWidget`, `updateWidgetConfig`,
|
||||
`removeSearchProvider`), `calendar.service.ts` (`updateSource`,
|
||||
`deleteSource`), `favorites.service.ts` (`update`, `remove`). Beides ist eine
|
||||
Abweisung — kein Informationsleck entsteht, nur die Fehlerart ändert sich von
|
||||
Forbidden zu NotFound.
|
||||
|
||||
### (b4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
- Systemkontext für Hintergrunddienste (Etappe 3c) — `tender-digest.scheduler.ts`
|
||||
bleibt bewusst zweistellig, mit Kommentar.
|
||||
- Anmeldenamen pro Mandant (Etappe 3a) — nicht Teil dieses Laufs.
|
||||
- Kein Wächter, der jede Nutzer-CRUD-Aufrufstelle auf das dritte Argument von
|
||||
`forTenant()` prüft. Die Bestandsaufnahme (`rls-access-inventory.spec.ts`)
|
||||
unterscheidet heute nur mandanten-gebunden/ungebunden, nicht
|
||||
benutzer-gebunden — ein Aufrufer, der `userId` vergisst, ist für sie
|
||||
unsichtbar. Netz bis dahin: die dreistelligen Spec-Zusicherungen je Dienst.
|
||||
Siehe der neue WINDOWS-Eintrag unten.
|
||||
- WINDOWS #24 (Admin-Erstellung/-Entfernen plattformweiter `TenderRssFeedSource`-Zeilen
|
||||
bleibt ungebunden) bleibt unverändert offen — diese Migration ändert daran
|
||||
nichts, `tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`
|
||||
bestätigt das lediglich erneut.
|
||||
|
||||
### (b5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- Die vier Tabellen mit `userId`-Spalte, die KEINE persönlichen Daten tragen
|
||||
(`GroupMembership`, `ModuleGrant`, `PasswordResetToken`, `TenderMatch`) —
|
||||
Verwaltungsobjekte, Anmelde-Artefakt, Hintergrunddienst-Schreibweg,
|
||||
begründet im Kopf der Migration.
|
||||
- Die drei SECURITY-DEFINER-Anmeldefunktionen (`auth_lookup_user_by_username`,
|
||||
`auth_lookup_user_by_email`, `auth_lookup_reset_token`) — unangetastet.
|
||||
- `schema.prisma` — unverändert, Gate gegen `8829999` in jeder Aufgabe.
|
||||
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS.
|
||||
- Compose-/Umgebungsdateien — unangetastet.
|
||||
|
||||
## Etappe 2 — Abschluss
|
||||
|
||||
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
|
||||
@@ -3111,6 +3308,15 @@ dieser Aufgabe.
|
||||
Etappe-3-Entscheidung (1)) — siehe (h4)(a).
|
||||
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
|
||||
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
|
||||
**Nachtrag (260911-nke): ERLEDIGT.** Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) trägt die
|
||||
Benutzerdimension in die Regeln aller zehn persönlichen Tabellen; 34
|
||||
Nutzer-CRUD-Aufrufstellen reichen `userId` an `forTenant()` durch. Siehe
|
||||
Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" oben.
|
||||
Was davon bewusst offenbleibt: ein Aufrufer, der `userId` vergisst, sieht
|
||||
weiterhin den ganzen Mandanten (kein Wächter gebaut, siehe
|
||||
`.planning/WINDOWS.md`); Systemkontext für Hintergrunddienste (Etappe 3c)
|
||||
und Anmeldenamen pro Mandant (Etappe 3a) bleiben offen.
|
||||
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
|
||||
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
|
||||
Katalogzugriffe nachgezogen werden.
|
||||
|
||||
@@ -38,6 +38,19 @@ der Grundlagen beginnen koennen. Alles hier ist gemessen, nicht erinnert.
|
||||
|
||||
### 3b zuerst: Benutzerdimension in den Regeln
|
||||
|
||||
**Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser
|
||||
Aufgabe):** Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables` bringt `current_user_id()`
|
||||
und die Benutzerdimension in die Regeln der zehn persoenlichen Tabellen;
|
||||
`forTenant(prisma, tenantId, userId?)` bekommt den optionalen dritten
|
||||
Parameter, 34 Nutzer-CRUD-Aufrufstellen in acht Diensten reichen ihn durch.
|
||||
Gemessene Zahl der umgedrehten Loch-Pruefungen: SECHS, nicht drei wie unten
|
||||
noch angenommen. Endzahlen: Tests 1020/62 Dateien, Werkzeug
|
||||
`rls-scratch-check.mjs` 203/203 bestanden (Baseline vor diesem Lauf: 137).
|
||||
Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Der ursprüngliche
|
||||
Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage).
|
||||
|
||||
Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg
|
||||
NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als
|
||||
"Policy hat keine Benutzerdimension" festgehalten wurde.
|
||||
|
||||
@@ -278,6 +278,15 @@ Unterabfrage-Form, die WINDOWS #27 aufgedeckt hat.
|
||||
Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen,
|
||||
nicht geschaetzt.
|
||||
|
||||
**Stand 260911-nke:** die Paarzahl (72) und die Klassen-Verteilung sind
|
||||
UNVERÄNDERT — Etappe 3b (Benutzerdimension in den Regeln, `forTenant()`
|
||||
bekommt ein drittes Argument) fügt keine neue Fundstelle hinzu und ändert
|
||||
keine bestehende von mandanten-gebunden auf mandanten-ungebunden oder
|
||||
umgekehrt; benutzer-gebunden ist keine eigene Klasse in diesem Schema. Die
|
||||
Benutzerdimension steht stattdessen in der Begründungsspalte der drei
|
||||
betroffenen Bestandsaufnahme-Zeilen (`calendarSource`, `widgetInstance`,
|
||||
`favoriteLink`) oben und im Abschnitt "Was diese Etappe NICHT entscheidet".
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 35 |
|
||||
@@ -572,15 +581,15 @@ werden.
|
||||
|---|---|---|---|---|
|
||||
| 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 | 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/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. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| 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. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
|
||||
@@ -693,6 +702,19 @@ werden.
|
||||
an `username`/`email`. Siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth",
|
||||
(h4)(a).
|
||||
- **Wie die Benutzerdimension in die Regeln der zehn persönlichen Tabellen
|
||||
kommt (Etappe-3-Entscheidung (2)).** **Aufgelöst (260911-nke):** Migration
|
||||
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) bringt
|
||||
`app.current_user`/`current_user_id()` und die `IS NULL OR`-Form in die
|
||||
Regeln aller zehn persönlichen Tabellen; `forTenant(prisma, tenantId,
|
||||
userId?)` bekommt den optionalen dritten Parameter, 34 Nutzer-CRUD-
|
||||
Aufrufstellen reichen ihn durch. Siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Was weiterhin
|
||||
offen ist: ein Aufrufer, der `userId` vergisst, sieht den ganzen
|
||||
Mandanten (kein Wächter gebaut, siehe `.planning/WINDOWS.md`);
|
||||
Systemkontext (Etappe 3c) und Anmeldenamen pro Mandant (Etappe 3a) bleiben
|
||||
offen.
|
||||
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der
|
||||
Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst
|
||||
ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe
|
||||
|
||||
Reference in New Issue
Block a user