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:
@@ -143,6 +143,27 @@ async function setupScratchDatabase(adminUrl) {
|
||||
await db.$executeRawUnsafe(
|
||||
`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
|
||||
* gemeinsamen Verbindung.
|
||||
*/
|
||||
async function forTenantQuery(prisma, tenantId, queryFn) {
|
||||
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
||||
const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]);
|
||||
async function forTenantQuery(prisma, tenantId, queryFn, userId) {
|
||||
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||
const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -233,6 +254,41 @@ async function runForTenantChecks(scratchRoleUrl, results) {
|
||||
unbound.length === 0,
|
||||
`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 {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -424,6 +480,38 @@ function extractAllPolicySql(migrationSql, tableName) {
|
||||
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
|
||||
* 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) {
|
||||
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
|
||||
|
||||
const emailConfigPolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
|
||||
@@ -980,8 +1069,12 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const rssFeedPolicies = widenMigrationSql
|
||||
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
|
||||
: [];
|
||||
const savedSearchPolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch')
|
||||
// Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird
|
||||
// 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;
|
||||
const triagePolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
|
||||
@@ -1131,21 +1224,36 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
`ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`,
|
||||
);
|
||||
|
||||
// tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund
|
||||
// E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das
|
||||
// bestandene Ergebnis. Es belegt, dass die Policy keine
|
||||
// Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt
|
||||
// deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern
|
||||
// desselben Mandanten und darf bei der Umstellung nicht entfallen.
|
||||
// Umgedreht (260911-nke, Aufgabe 1): die alte Pruefung
|
||||
// "tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar" (Befund
|
||||
// E, 260909-laa) mass OHNE Benutzer — nach der Migration
|
||||
// 20260911120000 ist das GELINGEN (beide Nutzer sichtbar) weiterhin der
|
||||
// Fall, aber jetzt als GEWOLLTE Eigenschaft der IS-NULL-Form fuer einen
|
||||
// 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 bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
|
||||
report(
|
||||
results,
|
||||
'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar',
|
||||
'tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
|
||||
bothUsersVisible,
|
||||
bothUsersVisible
|
||||
? `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])}`,
|
||||
`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])}`,
|
||||
);
|
||||
|
||||
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
|
||||
@@ -1744,7 +1852,17 @@ const AUTH_LOOKUP_USER_POLICY_SQL =
|
||||
* `.meta.code` gelesen, nicht `.code`.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* 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({
|
||||
query: {
|
||||
$allOperations({ args, query }) {
|
||||
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
||||
return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]);
|
||||
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
|
||||
return prisma.$transaction([setContext, query(args)]).then((res) => res[1]);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4593,6 +4905,7 @@ async function main() {
|
||||
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runUserDimensionChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
||||
|
||||
Reference in New Issue
Block a user