feat(quick-260911-nke): current_user_id(), Benutzerdimension in den Regeln, forTenant() mit userId — ein Pfad
- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id() (NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel, SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy, Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert. - forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20), Leerstring ohne Benutzer statt Weglassen. - tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId durch; Detektor-Regex bestaetigt 4 Treffer. - rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten (nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks() mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich), die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer). sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code). - Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
+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.
|
||||||
@@ -143,6 +143,27 @@ async function setupScratchDatabase(adminUrl) {
|
|||||||
await db.$executeRawUnsafe(
|
await db.$executeRawUnsafe(
|
||||||
`INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`,
|
`INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Benutzerdimension (Etappe 3b, 260911-nke): current_user_id() wird aus
|
||||||
|
// der neuen Migration GESCHNITTEN, nicht getippt (Muster ldap,
|
||||||
|
// T-IPC-08) — findet die Extraktion nichts, bricht das Werkzeug hier
|
||||||
|
// ab, statt mit einem geratenen Funktionstext weiterzumessen.
|
||||||
|
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
|
||||||
|
if (!userDimensionMigrationSql) {
|
||||||
|
fail(
|
||||||
|
'Migration "_rls_user_dimension_personal_tables" nicht gefunden — current_user_id() kann nicht geschnitten werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentUserIdFunctionSql = extractCurrentUserIdFunctionSql(userDimensionMigrationSql);
|
||||||
|
if (!currentUserIdFunctionSql) {
|
||||||
|
fail(
|
||||||
|
'CREATE OR REPLACE FUNCTION current_user_id() nicht in der neuen Migration gefunden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.$executeRawUnsafe(currentUserIdFunctionSql);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`GRANT EXECUTE ON FUNCTION current_user_id() TO ${SCRATCH_ROLE_NAME}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,9 +189,9 @@ function report(results, kennung, passed, detail) {
|
|||||||
* eigentliche Abfrage als Array-Form von $transaction, also auf einer
|
* eigentliche Abfrage als Array-Form von $transaction, also auf einer
|
||||||
* gemeinsamen Verbindung.
|
* gemeinsamen Verbindung.
|
||||||
*/
|
*/
|
||||||
async function forTenantQuery(prisma, tenantId, queryFn) {
|
async function forTenantQuery(prisma, tenantId, queryFn, userId) {
|
||||||
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||||
const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]);
|
const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +254,41 @@ async function runForTenantChecks(scratchRoleUrl, results) {
|
|||||||
unbound.length === 0,
|
unbound.length === 0,
|
||||||
`ungebundener SELECT liefert ${unbound.length} Zeile(n)`,
|
`ungebundener SELECT liefert ${unbound.length} Zeile(n)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Benutzerdimension (Etappe 3b, 260911-nke): die drei Funktionsfaelle
|
||||||
|
// von current_user_id(), je innerhalb einer eigenen Transaktion ueber
|
||||||
|
// $queryRaw gemessen.
|
||||||
|
const [ungesetztRow] = await prisma.$transaction([
|
||||||
|
prisma.$queryRaw`SELECT current_user_id() AS v`,
|
||||||
|
]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'current-user-id-ungesetzt-ist-null',
|
||||||
|
ungesetztRow[0].v === null,
|
||||||
|
`current_user_id() ohne gesetzte Variable=${JSON.stringify(ungesetztRow[0].v)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, leerQueryRows] = await prisma.$transaction([
|
||||||
|
prisma.$executeRaw`SELECT set_config('app.current_user', '', true)`,
|
||||||
|
prisma.$queryRaw`SELECT current_user_id() AS v`,
|
||||||
|
]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'current-user-id-leer-ist-null',
|
||||||
|
leerQueryRows[0].v === null,
|
||||||
|
`current_user_id() nach set_config('app.current_user', '', true)=${JSON.stringify(leerQueryRows[0].v)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, gesetztRows] = await prisma.$transaction([
|
||||||
|
prisma.$executeRaw`SELECT set_config('app.current_user', 'user-a1', true)`,
|
||||||
|
prisma.$queryRaw`SELECT current_user_id() AS v`,
|
||||||
|
]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'current-user-id-gesetzt-liefert-wert',
|
||||||
|
gesetztRows[0].v === 'user-a1',
|
||||||
|
`current_user_id() nach set_config('app.current_user', 'user-a1', true)=${JSON.stringify(gesetztRows[0].v)}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -424,6 +480,38 @@ function extractAllPolicySql(migrationSql, tableName) {
|
|||||||
return [...migrationSql.matchAll(re)].map((m) => m[0]);
|
return [...migrationSql.matchAll(re)].map((m) => m[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Migration der Benutzerdimension (Etappe 3b, 260911-nke,
|
||||||
|
* Dateiname endet auf "_rls_user_dimension_personal_tables"). Die Regeln der
|
||||||
|
* zehn persoenlichen Tabellen UND die Funktion `current_user_id()` MUESSEN
|
||||||
|
* ab hier aus dieser Datei geschnitten werden, nicht getippt und nicht aus
|
||||||
|
* einer aelteren Migration gelesen — sonst misst dieses Werkzeug ein
|
||||||
|
* getipptes Wunschbild oder die abgeloeste Regel (Befund D, Praezedenz
|
||||||
|
* 260910-jab).
|
||||||
|
*/
|
||||||
|
function readRlsUserDimensionMigrationSql() {
|
||||||
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.isDirectory() && entry.name.endsWith('_rls_user_dimension_personal_tables'),
|
||||||
|
)
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
if (dirs.length !== 1) return null;
|
||||||
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet die Definition von `current_user_id()` wortgleich aus der neuen
|
||||||
|
* Migration. Liefert `null`, wenn nichts gefunden wird — der Aufrufer bricht
|
||||||
|
* dann mit einer FEHLGESCHLAGENEN Pruefung ab, statt die Funktion im
|
||||||
|
* Werkzeug selbst zu tippen (Muster ldap, T-IPC-08).
|
||||||
|
*/
|
||||||
|
function extractCurrentUserIdFunctionSql(migrationSql) {
|
||||||
|
const re = /CREATE OR REPLACE FUNCTION current_user_id\(\)[\s\S]*?LANGUAGE sql STABLE;/;
|
||||||
|
const match = migrationSql.match(re);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen
|
* Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen
|
||||||
* des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies
|
* des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies
|
||||||
@@ -970,6 +1058,7 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||||
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||||
const widenMigrationSql = readRlsWidenMigrationSql();
|
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||||
|
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
|
||||||
|
|
||||||
const emailConfigPolicy = remainingMigrationSql
|
const emailConfigPolicy = remainingMigrationSql
|
||||||
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
|
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
|
||||||
@@ -980,8 +1069,12 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
const rssFeedPolicies = widenMigrationSql
|
const rssFeedPolicies = widenMigrationSql
|
||||||
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
|
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
|
||||||
: [];
|
: [];
|
||||||
const savedSearchPolicy = remainingMigrationSql
|
// Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird
|
||||||
? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch')
|
// ab der Benutzerdimension-Migration gelesen, nicht mehr aus
|
||||||
|
// *_rls_remaining_tenant_tables — sonst misst dieser Abschnitt die
|
||||||
|
// abgeloeste Regel ohne Benutzerdimension (Befund D, Praezedenz jab).
|
||||||
|
const savedSearchPolicy = userDimensionMigrationSql
|
||||||
|
? extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch')
|
||||||
: null;
|
: null;
|
||||||
const triagePolicy = remainingMigrationSql
|
const triagePolicy = remainingMigrationSql
|
||||||
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
|
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
|
||||||
@@ -1131,21 +1224,36 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
`ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`,
|
`ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund
|
// Umgedreht (260911-nke, Aufgabe 1): die alte Pruefung
|
||||||
// E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das
|
// "tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar" (Befund
|
||||||
// bestandene Ergebnis. Es belegt, dass die Policy keine
|
// E, 260909-laa) mass OHNE Benutzer — nach der Migration
|
||||||
// Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt
|
// 20260911120000 ist das GELINGEN (beide Nutzer sichtbar) weiterhin der
|
||||||
// deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern
|
// Fall, aber jetzt als GEWOLLTE Eigenschaft der IS-NULL-Form fuer einen
|
||||||
// desselben Mandanten und darf bei der Umstellung nicht entfallen.
|
// Aufruf ohne gesetzten Benutzer (Admin, Hintergrunddienst), nicht mehr
|
||||||
|
// als Luecke. Die alte Messung bleibt unter neuem Namen bestehen; die
|
||||||
|
// Umkehrung misst MIT Benutzer und erwartet das GEGENTEIL.
|
||||||
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
|
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
|
||||||
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
|
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
|
||||||
report(
|
report(
|
||||||
results,
|
results,
|
||||||
'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar',
|
'tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
|
||||||
bothUsersVisible,
|
bothUsersVisible,
|
||||||
bothUsersVisible
|
`bis 260911-nke als Befund E unter dem Namen tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst. forTenant(TENANT-A) OHNE Benutzer liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA])}`,
|
||||||
? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen`
|
);
|
||||||
: `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`,
|
|
||||||
|
const savedSearchRowsForA1 = await forTenantQuery(
|
||||||
|
prisma,
|
||||||
|
'TENANT-A',
|
||||||
|
(tx) => tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`,
|
||||||
|
'user-a1',
|
||||||
|
);
|
||||||
|
const usersVisibleForA1 = new Set(savedSearchRowsForA1.map((r) => r.userId));
|
||||||
|
const kollegeNichtSichtbar = usersVisibleForA1.has('user-a1') && !usersVisibleForA1.has('user-a2');
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden',
|
||||||
|
kollegeNichtSichtbar,
|
||||||
|
`forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA1])}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// tenderemailconfig-gebunden-nur-eigener-mandant
|
// tenderemailconfig-gebunden-nur-eigener-mandant
|
||||||
@@ -1744,7 +1852,17 @@ const AUTH_LOOKUP_USER_POLICY_SQL =
|
|||||||
* `.meta.code` gelesen, nicht `.code`.
|
* `.meta.code` gelesen, nicht `.code`.
|
||||||
*/
|
*/
|
||||||
function sqlStateOf(err) {
|
function sqlStateOf(err) {
|
||||||
return err?.meta?.code ?? null;
|
if (err?.meta?.code) return err.meta.code;
|
||||||
|
// 260911-nke, Aufgabe 1: ein RLS-abgewiesenes .create() ueber den
|
||||||
|
// GENERIERTEN Client (Batch-Insert-Pfad) wirft PrismaClientUnknownRequestError
|
||||||
|
// OHNE `.meta.code` — der PostgreSQL-SQLSTATE steckt stattdessen nur im
|
||||||
|
// Fehlertext (`code: "42501"` innerhalb der ConnectorError-Ausgabe).
|
||||||
|
// Empirisch geprueft gegen tessera-ctl-db-1, 2026-09-11:
|
||||||
|
// `tenderSavedSearch.create()` unter einer RLS-Verletzung liefert genau
|
||||||
|
// diese Form. Fallback-Regex, damit die Pruefung den tatsaechlichen
|
||||||
|
// Datenbankfehler misst statt eines falschen "unbekannt".
|
||||||
|
const match = /code:\s*"(\d{5})"/.exec(err?.message ?? '');
|
||||||
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4351,6 +4469,193 @@ async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Innere Tabellenroutine, wiederverwendet fuer alle zehn persoenlichen
|
||||||
|
* Tabellen der Benutzerdimension (Etappe 3b, 260911-nke). Jede Tabelle
|
||||||
|
* bekommt IHRE EIGENE, VOLLSTAENDIGE Wegwerf-Tabelle unter ihrem
|
||||||
|
* Produktionsnamen (`DROP TABLE IF EXISTS` davor — andere Bereichsfunktionen
|
||||||
|
* legen Tabellen desselben Namens mit REDUZIERTEN Spalten fuer reine
|
||||||
|
* Roh-SQL-Messungen an; dieser Abschnitt braucht den VOLLEN Spaltensatz fuer
|
||||||
|
* den GENERIERTEN Client und laeuft deshalb nach allen anderen
|
||||||
|
* Bereichsabschnitten). Misst je Tabelle vier Wahrheiten ueber den
|
||||||
|
* generierten Client: Benutzer A sieht die eigene Zeile; Benutzer A sieht
|
||||||
|
* die Zeile eines Kollegen DESSELBEN Mandanten nicht; ein Aufruf OHNE
|
||||||
|
* Benutzer sieht beide; ein Schreiben als Benutzer A mit der Kennung von B
|
||||||
|
* wird von der Datenbank abgewiesen (SQLSTATE 42501).
|
||||||
|
*/
|
||||||
|
async function runSingleRulePersonalTableCheck(config) {
|
||||||
|
const {
|
||||||
|
adminUrl,
|
||||||
|
scratchRoleUrl,
|
||||||
|
results,
|
||||||
|
slug,
|
||||||
|
tableName,
|
||||||
|
modelName,
|
||||||
|
userDimensionMigrationSql,
|
||||||
|
createTableSql,
|
||||||
|
seedSql,
|
||||||
|
ownRowId,
|
||||||
|
colleagueRowId,
|
||||||
|
createAttempt,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const policySql = extractPolicySql(userDimensionMigrationSql, tableName);
|
||||||
|
if (!policySql) {
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
`${slug}-policy-aus-migration-gefunden`,
|
||||||
|
false,
|
||||||
|
`CREATE POLICY tenant_isolation_policy ON "${tableName}" nicht in der Benutzerdimension-Migration (20260911120000) gefunden`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||||
|
await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`);
|
||||||
|
await db.$executeRawUnsafe(createTableSql);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`);
|
||||||
|
await db.$executeRawUnsafe(policySql);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`,
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(seedSql);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pruefung 1 zuerst — Spaltenvergleich gegen schema.prisma (260910-krx-
|
||||||
|
// Falle): faellt sie durch, sind die Client-Messungen darunter wertlos.
|
||||||
|
const schemaFields = readSchemaModelScalarFieldNames(modelName);
|
||||||
|
const tableColumns = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRawUnsafe(
|
||||||
|
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`,
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.column_name).sort();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const schemaFieldsSorted = [...schemaFields].sort();
|
||||||
|
const columnsMatch =
|
||||||
|
schemaFieldsSorted.length > 0 &&
|
||||||
|
schemaFieldsSorted.length === tableColumns.length &&
|
||||||
|
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
`${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`,
|
||||||
|
columnsMatch,
|
||||||
|
`Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||||
|
);
|
||||||
|
if (!columnsMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
||||||
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||||
|
try {
|
||||||
|
const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1');
|
||||||
|
|
||||||
|
const ownRow = await boundA1[modelAccessor].findUnique({ where: { id: ownRowId } });
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
`${slug}-benutzer-a-sieht-eigene-zeile`,
|
||||||
|
Boolean(ownRow) && ownRow.id === ownRowId,
|
||||||
|
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${ownRowId}' }) liefert ${JSON.stringify(ownRow)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const colleagueRow = await boundA1[modelAccessor].findUnique({ where: { id: colleagueRowId } });
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
`${slug}-benutzer-a-sieht-kollegen-nicht`,
|
||||||
|
colleagueRow === null,
|
||||||
|
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${colleagueRowId}' }) (gehoert user-a2) liefert ${JSON.stringify(colleagueRow)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundNoUser = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||||
|
const bothRows = await boundNoUser[modelAccessor].findMany({
|
||||||
|
where: { id: { in: [ownRowId, colleagueRowId] } },
|
||||||
|
});
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
`${slug}-ohne-benutzer-sieht-beide`,
|
||||||
|
bothRows.length === 2,
|
||||||
|
`bound(TENANT-A, ohne Benutzer).${modelAccessor}.findMany liefert ${bothRows.length} Zeile(n): ${JSON.stringify(bothRows.map((r) => r.id))} — gewollte Eigenschaft der IS-NULL-Form fuer Admin/Hintergrunddienst`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let writeRejected = false;
|
||||||
|
let writeDetail = '';
|
||||||
|
try {
|
||||||
|
await boundA1[modelAccessor].create({
|
||||||
|
data: {
|
||||||
|
id: createAttempt.id,
|
||||||
|
userId: 'user-a2',
|
||||||
|
tenantId: 'TENANT-A',
|
||||||
|
...createAttempt.extraData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' ist NICHT fehlgeschlagen`;
|
||||||
|
} catch (err) {
|
||||||
|
const sqlState = sqlStateOf(err);
|
||||||
|
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||||
|
writeRejected = sqlState === '42501';
|
||||||
|
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim()}`;
|
||||||
|
}
|
||||||
|
report(results, `${slug}-schreiben-als-a-mit-kennung-b-abgelehnt`, writeRejected, writeDetail);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etappe 3b (260911-nke) — misst die Benutzerdimension der zehn
|
||||||
|
* persoenlichen Tabellen ueber den GENERIERTEN Client. Aufgabe 1: nur
|
||||||
|
* "TenderSavedSearch"; die uebrigen neun folgen in Aufgabe 2 ueber
|
||||||
|
* dieselbe innere Routine (`runSingleRulePersonalTableCheck`).
|
||||||
|
*/
|
||||||
|
async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) {
|
||||||
|
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
|
||||||
|
if (!userDimensionMigrationSql) {
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'user-dimension-migration-gefunden',
|
||||||
|
false,
|
||||||
|
'Migration "_rls_user_dimension_personal_tables" nicht gefunden',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runSingleRulePersonalTableCheck({
|
||||||
|
adminUrl,
|
||||||
|
scratchRoleUrl,
|
||||||
|
results,
|
||||||
|
slug: 'tendersavedsearch',
|
||||||
|
tableName: 'TenderSavedSearch',
|
||||||
|
modelName: 'TenderSavedSearch',
|
||||||
|
userDimensionMigrationSql,
|
||||||
|
createTableSql: `
|
||||||
|
CREATE TABLE "TenderSavedSearch" (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
"tenantId" text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
filters jsonb NOT NULL DEFAULT '{}',
|
||||||
|
"instantAlert" boolean NOT NULL DEFAULT false,
|
||||||
|
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE ("userId", name)
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
seedSql: `
|
||||||
|
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) VALUES
|
||||||
|
('ss-a1', 'user-a1', 'TENANT-A', 'Profil A1', '{}'),
|
||||||
|
('ss-a2', 'user-a2', 'TENANT-A', 'Profil A2', '{}'),
|
||||||
|
('ss-b', 'user-b', 'TENANT-B', 'Profil B', '{}');
|
||||||
|
`,
|
||||||
|
ownRowId: 'ss-a1',
|
||||||
|
colleagueRowId: 'ss-a2',
|
||||||
|
createAttempt: { id: 'ss-a-schreibversuch', extraData: { name: 'Schreibversuch', filters: {} } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -4362,12 +4667,19 @@ async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
* Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits
|
* Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits
|
||||||
* angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat.
|
* angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat.
|
||||||
*/
|
*/
|
||||||
function buildInlineExtendedClient(prisma, tenantId) {
|
/**
|
||||||
|
* Spiegelbildlich zu `forTenant()` in apps/api/src/prisma/prisma-tenant.extension.ts
|
||||||
|
* — bei jeder Aenderung dort HIER nachziehen. Seit Etappe 3b (260911-nke)
|
||||||
|
* optionaler dritter Parameter `userId`: sendet dieselbe Ein-Anweisungs-Form
|
||||||
|
* wie der Helfer (beide `set_config` kommasepariert in EINER getaggten
|
||||||
|
* Anweisung, Leerstring ohne Benutzer).
|
||||||
|
*/
|
||||||
|
function buildInlineExtendedClient(prisma, tenantId, userId) {
|
||||||
return prisma.$extends({
|
return prisma.$extends({
|
||||||
query: {
|
query: {
|
||||||
$allOperations({ args, query }) {
|
$allOperations({ args, query }) {
|
||||||
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||||
return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]);
|
return prisma.$transaction([setContext, query(args)]).then((res) => res[1]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -4593,6 +4905,7 @@ async function main() {
|
|||||||
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
|
await runUserDimensionChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
|
||||||
const sql = readMigrationSql('_add_group_internal_name_and_object_guid');
|
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).toMatch(/\$transaction\(\s*\[/);
|
||||||
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
|
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)', () => {
|
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
|
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
|
||||||
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
|
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
|
||||||
* ein anderes Ergebnis liefern.
|
* 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({
|
return prisma.$extends({
|
||||||
query: {
|
query: {
|
||||||
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
||||||
const setTenantContext = (prisma as any)
|
const setContext = (prisma as any)
|
||||||
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||||
|
|
||||||
return (prisma as any)
|
return (prisma as any)
|
||||||
.$transaction([setTenantContext, query(args)])
|
.$transaction([setContext, query(args)])
|
||||||
.then((results: any[]) => results[1]);
|
.then((results: any[]) => results[1]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
|
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
|
||||||
@@ -311,4 +312,48 @@ describe('TenderSavedSearchService', () => {
|
|||||||
expectBoundCall(prisma, 't1', 'delete');
|
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
|
* method call, never shared across methods (same convention as
|
||||||
* `groups.service.ts`).
|
* `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
|
* @@unique([userId, name]) (T-11-14): a second profile with the same name
|
||||||
* for the same user is rejected by Postgres (P2002) — this service
|
* for the same user is rejected by Postgres (P2002) — this service
|
||||||
* translates that into a 409 ConflictException so the frontend can show a
|
* 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.
|
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
|
||||||
*/
|
*/
|
||||||
async list(userId: string, tenantId: string) {
|
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({
|
return tenantPrisma.tenderSavedSearch.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@@ -52,7 +58,7 @@ export class TenderSavedSearchService {
|
|||||||
* users, since the uniqueness is scoped per-user.
|
* users, since the uniqueness is scoped per-user.
|
||||||
*/
|
*/
|
||||||
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
|
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 {
|
try {
|
||||||
return await tenantPrisma.tenderSavedSearch.create({
|
return await tenantPrisma.tenderSavedSearch.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -81,7 +87,7 @@ export class TenderSavedSearchService {
|
|||||||
* leaking whether another user's profile exists).
|
* leaking whether another user's profile exists).
|
||||||
*/
|
*/
|
||||||
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
|
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({
|
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
@@ -118,7 +124,7 @@ export class TenderSavedSearchService {
|
|||||||
* update().
|
* update().
|
||||||
*/
|
*/
|
||||||
async remove(id: string, userId: string, tenantId: string) {
|
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({
|
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user