feat(jts-01): messen statt annehmen — groups-Policies und Transaktionsform vor jedem Dienstcode

rls-scratch-check.mjs bekommt einen vierten Abschnitt (Group/GroupMembership/
ModuleGrant/TenantModuleActivation, Policies woertlich aus den ausgelieferten
Migrationen) sowie eine eigene Messung, welche der drei Transaktionsformen
(Array auf gebundenem Client, interaktiv auf gebundenem Client, interaktiv auf
ungebundenem Client mit set_config auf tx) den Mandantenkontext tatsaechlich
auf derselben Verbindung traegt. Ergebnis: Form (i) versagt nachweisbar
(unterschiedliche pg_backend_pid() je Teilschritt); Form (ii) und (iii)
bestehen die Einzelmessung, aber eine zusaetzliche Lastprobe mit 40 parallelen
Aufrufen zeigt, dass Form (ii) unter echter Nebenlaeufigkeit mit P2028
(Transaction API error) abbricht, waehrend Form (iii) 0 Verletzungen zeigt.
Die Kritikschrift bekommt einen eigenen groups-Abschnitt mit den tatsaechlich
beobachteten Werten, der Signaltabelle je Pfad und der Liste der Stellen, die
Leere als Abwesenheit deuten (inkl. der einen Stelle, an der zu wenig Lesen zu
viel Schreiben ausloest). Kein Dienstcode angefasst; 719 Tests und die
Typpruefung bleiben gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 14:48:04 +02:00
parent b532eaa2ad
commit fd0b9f7d21
2 changed files with 593 additions and 0 deletions
+402
View File
@@ -535,6 +535,406 @@ async function runLdapAreaChecks(adminUrl, scratchRoleUrl, results) {
} }
} }
/**
* Liest die ausgelieferte Migration, die die drei Policies fuer "Group",
* "GroupMembership" und "ModuleGrant" enthaelt. Der Dateiname endet auf
* "_groups_rls_policies" — readRlsPoliciesMigrationSql() oben schliesst
* diese Migration ausdruecklich AUS, deshalb ein eigenes, unabhaengiges
* Lesehilfsmittel statt einer Aenderung am bestehenden (Aufgabe 1,
* 260909-jts-PLAN.md).
*/
function readGroupsRlsPoliciesMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_groups_rls_policies'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Liest die ausgelieferte Migration, die (unter anderem) die Policy fuer
* "TenantModuleActivation" enthaelt (Dateiname endet auf
* "_rls_remaining_tenant_tables").
*/
function readRemainingTenantTablesMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_rls_remaining_tenant_tables'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Aufgabe 1 (260909-jts), TEIL 1 — misst die im Plan genannten
* Verhaltensweisen des Bereichs groups unter der Rolle ohne BYPASSRLS, mit
* den vier Policies WORTGLEICH aus den beiden ausgelieferten Migrationen
* (nicht im Werkzeug nachgetippt, vgl. runLdapAreaChecks). Findet die
* Extraktion eine der vier nicht, meldet dieser Abschnitt eine
* FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer geratenen Policy
* weiterzumessen.
*
* Legt die Tabelle "Group" (samt je einer Zeile fuer TENANT-A und
* TENANT-B) an, auf der runTransactionShapeMeasurement() weiter unten
* aufsetzt — diese Funktion muss deshalb VOR jener aufgerufen werden.
*/
async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) {
const groupsMigrationSql = readGroupsRlsPoliciesMigrationSql();
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const groupPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'Group') : null;
const groupMembershipPolicy = groupsMigrationSql
? extractPolicySql(groupsMigrationSql, 'GroupMembership')
: null;
const moduleGrantPolicy = groupsMigrationSql
? extractPolicySql(groupsMigrationSql, 'ModuleGrant')
: null;
const tenantModuleActivationPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenantModuleActivation')
: null;
if (
!groupPolicy ||
!groupMembershipPolicy ||
!moduleGrantPolicy ||
!tenantModuleActivationPolicy
) {
report(
results,
'groups-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "Group", "GroupMembership", "ModuleGrant" und/oder "TenantModuleActivation" nicht in den ausgelieferten Migrationen gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "Group" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
name text NOT NULL,
"isDefault" boolean NOT NULL DEFAULT false
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "GroupMembership" (
id text PRIMARY KEY,
"groupId" text NOT NULL,
"userId" text NOT NULL,
source text NOT NULL DEFAULT 'MANUAL'
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "ModuleGrant" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
"moduleId" text NOT NULL,
"groupId" text,
"userId" text
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenantModuleActivation" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
"moduleId" text NOT NULL,
"isActive" boolean NOT NULL DEFAULT true
);
`);
for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(groupPolicy);
await db.$executeRawUnsafe(groupMembershipPolicy);
await db.$executeRawUnsafe(moduleGrantPolicy);
await db.$executeRawUnsafe(tenantModuleActivationPolicy);
for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
await db.$executeRawUnsafe(`
INSERT INTO "Group" (id, "tenantId", name, "isDefault") VALUES
('group-a', 'TENANT-A', 'Gruppe A', true),
('group-b', 'TENANT-B', 'Gruppe B', true);
`);
await db.$executeRawUnsafe(`
INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES
('membership-a', 'group-a', 'user-a', 'MANUAL'),
('membership-b', 'group-b', 'user-b', 'MANUAL');
`);
await db.$executeRawUnsafe(`
INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES
('grant-a', 'TENANT-A', 'mod-1', 'group-a', NULL),
('grant-b', 'TENANT-B', 'mod-1', 'group-b', NULL);
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES
('activation-a', 'TENANT-A', 'mod-1', true),
('activation-b', 'TENANT-B', 'mod-1', true);
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// group-gebunden-nur-eigene-zeile
const groupRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "Group" ORDER BY id`,
);
report(
results,
'group-gebunden-nur-eigene-zeile',
groupRowsForA.length === 1 && groupRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${groupRowsForA.length} Zeile(n): ${JSON.stringify(groupRowsForA.map((r) => r.tenantId))}`,
);
// group-ungebunden-null-zeilen — die Belegzeile, die die Kritikschrift
// traegt, am echten, ausgelieferten Policy-Text gemessen.
const unboundGroupRows = await prisma.$queryRaw`SELECT "tenantId" FROM "Group"`;
report(
results,
'group-ungebunden-null-zeilen',
unboundGroupRows.length === 0,
`ungebundener SELECT auf "Group" liefert ${unboundGroupRows.length} Zeile(n)`,
);
// groupmembership-folgt-join-auf-group
const membershipRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "groupId" FROM "GroupMembership" ORDER BY id`,
);
report(
results,
'groupmembership-folgt-join-auf-group',
membershipRowsForA.length === 1 && membershipRowsForA[0].groupId === 'group-a',
`forTenant(TENANT-A) liefert ${membershipRowsForA.length} Mitgliedschaft(en): ${JSON.stringify(membershipRowsForA.map((r) => r.groupId))}`,
);
// groupmembership-schreiben-fremde-gruppe-abgelehnt
let foreignGroupInsertRejected = false;
let foreignGroupInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-group', 'group-b', 'user-a', 'MANUAL')`,
);
foreignGroupInsertDetail = 'INSERT mit fremder groupId ist NICHT fehlgeschlagen';
} catch (err) {
foreignGroupInsertRejected = true;
foreignGroupInsertDetail = `INSERT mit fremder groupId abgewiesen: ${err.message}`;
}
report(
results,
'groupmembership-schreiben-fremde-gruppe-abgelehnt',
foreignGroupInsertRejected,
foreignGroupInsertDetail,
);
// groupmembership-schreiben-fremder-benutzer-nicht-verhindert (Befund E):
// das GELINGEN dieses INSERTs ist das bestandene Ergebnis — es belegt,
// dass die Policy nur die Gruppenseite prueft, nicht die Benutzerseite.
let foreignUserInsertSucceeded = false;
let foreignUserInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user', 'group-a', 'user-nicht-in-a', 'MANUAL')`,
);
foreignUserInsertSucceeded = true;
foreignUserInsertDetail =
'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E); die Anwendung muss die Benutzerseite selbst pruefen';
} catch (err) {
foreignUserInsertDetail = `INSERT unerwartet abgewiesen: ${err.message}`;
}
report(
results,
'groupmembership-schreiben-fremder-benutzer-nicht-verhindert',
foreignUserInsertSucceeded,
foreignUserInsertDetail,
);
// modulegrant-gebunden-nur-eigene-zeile
const grantRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "ModuleGrant" ORDER BY id`,
);
report(
results,
'modulegrant-gebunden-nur-eigene-zeile',
grantRowsForA.length === 1 && grantRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${grantRowsForA.length} Zeile(n): ${JSON.stringify(grantRowsForA.map((r) => r.tenantId))}`,
);
// modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt
// (Befund F): auch hier ist das Durchgehen das bestandene Ergebnis.
let foreignGroupGrantSucceeded = false;
let foreignGroupGrantDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`,
);
foreignGroupGrantSucceeded = true;
foreignGroupGrantDetail =
'INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F); assertTargetBelongsToTenant ist der einzige Schutz und darf bei der Umstellung nicht entfallen';
} catch (err) {
foreignGroupGrantDetail = `INSERT unerwartet abgewiesen: ${err.message}`;
}
report(
results,
'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt',
foreignGroupGrantSucceeded,
foreignGroupGrantDetail,
);
// tenantmoduleactivation-gebunden-nur-eigene-zeile
const activationRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" ORDER BY id`,
);
report(
results,
'tenantmoduleactivation-gebunden-nur-eigene-zeile',
activationRowsForA.length === 1 && activationRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${activationRowsForA.length} Zeile(n): ${JSON.stringify(activationRowsForA.map((r) => r.tenantId))}`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
* traegt. Baut die Erweiterungsform aus prisma-tenant.extension.ts
* WORTGLEICH nach ($extends mit $allOperations, Array-Form von
* $transaction darin) statt ueber das vereinfachte forTenantQuery(), denn
* genau diese Erweiterungsschicht ist hier der Gegenstand der Messung.
*
* 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) {
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]);
},
},
});
}
/**
* Druckt die tatsaechlich beobachteten Werte einer Transaktionsform. Fliesst
* NICHT in die Pruefliste ein und beeinflusst den Rueckgabewert nicht — eine
* Form, die abbricht, ist ein Messergebnis und kein Werkzeugfehler.
*/
function beobachte(formName, payload) {
console.log(` [beobachtet] ${formName}: ${JSON.stringify(payload)}`);
}
/**
* Alle drei Bedingungen aus dem Plan: gleiche Verbindungskennung ueber
* beide Teilschritte, gelesener Mandantenkontext gleich TENANT-A in
* beiden Teilschritten, und der Lesezugriff liefert genau die eine Zeile
* von TENANT-A.
*/
function traegtKontextAufDerselbenVerbindung(step1, step2) {
return Boolean(
step1 &&
step2 &&
step1.pid === step2.pid &&
step1.t === 'TENANT-A' &&
step2.t === 'TENANT-A' &&
step2.rows === 1,
);
}
async function measureArrayFormOnBoundClient(scratchRoleUrl) {
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const [step1Rows, step2Rows] = await bound.$transaction([
bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`,
bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`,
]);
return { step1: step1Rows[0], step2: step2Rows[0] };
} finally {
await prisma.$disconnect();
}
}
async function measureInteractiveFormOnBoundClient(scratchRoleUrl) {
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
return await bound.$transaction(async (tx) => {
const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`;
const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`;
return { step1: step1Rows[0], step2: step2Rows[0] };
});
} finally {
await prisma.$disconnect();
}
}
async function measureInteractiveFormOnUnboundClient(scratchRoleUrl) {
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
const tenantId = 'TENANT-A';
try {
return await prisma.$transaction(async (tx) => {
const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', ${tenantId}, true) AS applied, current_tenant_id() AS t`;
const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`;
return { step1: step1Rows[0], step2: step2Rows[0] };
});
} finally {
await prisma.$disconnect();
}
}
async function runTransactionShapeMeasurement(scratchRoleUrl, results) {
const forms = [
{ name: 'Form (i) — Array-Form auf gebundenem Client', fn: measureArrayFormOnBoundClient },
{
name: 'Form (ii) — interaktive Callback-Form auf gebundenem Client',
fn: measureInteractiveFormOnBoundClient,
},
{
name: 'Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx)',
fn: measureInteractiveFormOnUnboundClient,
},
];
const outcomes = [];
for (const form of forms) {
try {
const r = await form.fn(scratchRoleUrl);
beobachte(form.name, r);
outcomes.push({ name: form.name, passed: traegtKontextAufDerselbenVerbindung(r.step1, r.step2) });
} catch (err) {
beobachte(form.name, { abbruch: err.message });
outcomes.push({ name: form.name, passed: false });
}
}
const passedForms = outcomes.filter((o) => o.passed).map((o) => o.name);
const failedForms = outcomes.filter((o) => !o.passed).map((o) => o.name);
report(
results,
'mindestens-eine-transaktionsform-traegt-den-mandantenkontext',
passedForms.length > 0,
`bestanden: [${passedForms.join(' ; ')}] — nicht bestanden: [${failedForms.join(' ; ')}]`,
);
}
async function main() { async function main() {
const adminUrl = parseAdminUrl(); const adminUrl = parseAdminUrl();
const results = []; const results = [];
@@ -551,6 +951,8 @@ async function main() {
await runForTenantChecks(scratchRoleUrlString, results); await runForTenantChecks(scratchRoleUrlString, results);
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results); await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
} finally { } finally {
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
await teardownScratchDatabase(adminUrl); await teardownScratchDatabase(adminUrl);
@@ -157,6 +157,197 @@ interpretieren:
`docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese
Etappe NICHT entscheidet"). Etappe NICHT entscheidet").
## Bereich groups
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `groups`
(Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht
selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches
Modul sehen darf.
### (g1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen vierten
Abschnitt (`runGroupsAreaChecks`) und eine eigene Transaktionsmessung
(`runTransactionShapeMeasurement`) erweitert, beide gegen die Wegwerf-Datenbank
unter der Rolle ohne `BYPASSRLS`, mit den vier Policies für `Group`,
`GroupMembership`, `ModuleGrant` (aus `20260804130918_groups_rls_policies`)
und `TenantModuleActivation` (aus `20260909140000_rls_remaining_tenant_tables`)
WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`):
```
group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E)
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F)
tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
```
Die Belegzeile, die diesen Abschnitt trägt, ist `group-ungebunden-null-zeilen`:
der IDENTISCHE `SELECT "tenantId" FROM "Group"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 2 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle.
**Die Transaktionsmessung — namentliches Ergebnis.** Drei Formen wurden
gegen einen Client beobachtet, der die Erweiterungsform aus
`prisma-tenant.extension.ts` wortgleich nachbaut, jeweils mit
`pg_backend_pid()` und `current_tenant_id()` in jeder Teilabfrage plus einem
echten Lesezugriff auf `"Group"`:
```
[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}}
[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}}
[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}}
mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]
```
Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: `step1`
lief auf Verbindung 276749, `step2` auf Verbindung 276750 — zwei
verschiedene physische Verbindungen, obwohl beide Schritte denselben
Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem
Kopfkommentar von `prisma-tenant.extension.ts`: jede Modell-Operation eines
`$transaction`-Arrays auf dem gebundenen Client dispatcht durch
`$allOperations` und bekommt dadurch ihre EIGENE Ein-Element-Transaktion —
mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt
einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion
zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die
Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem
Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand
inkonsistent.
Form (ii) und Form (iii) bestanden beide die im Plan festgelegte
Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte
Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche,
sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber
durch die Tragweite dieses Bereichs geboten) beide Formen unter echter
Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe, alternierend
TENANT-A/TENANT-B, gegen eine separate Experiment-Datenbank mit derselben
Struktur:
- **Form (ii)** brach unter dieser Last mit
`PrismaClientKnownRequestError: Transaction API error: Unable to start a
transaction in the given time.` (Code `P2028`) ab. Ursache: jede
`tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem
gebundenen Client löst selbst wieder eine VERSCHACHTELTE
Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil
`$allOperations` bei jedem Aufruf erneut feuert) — die äußere
interaktive Transaktion UND jede innere Verschachtelung belegen
gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last
reicht der Pool nicht mehr aus.
- **Form (iii)** bestand dieselbe Belastung mit 0 Verletzungen unter 40
parallelen Aufrufen — sie belegt pro Aufruf genau eine Verbindung, ohne
Verschachtelung.
Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2:
obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,
ist sie unter echter Nebenläufigkeit strukturell fragil und ein
Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die
Startreparatur ruft `ensureDefaultGroup` für mehrere Mandanten auf). Form
(iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als
auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen
Hilfsmittels `withTenantTransaction` in `prisma-tenant.extension.ts`.
### (g2) Signaltabelle je umzustellendem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `GroupsService.listForTenant` | Liefert 0 Gruppen statt der tatsächlich vorhandenen | Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt) |
| `GroupsService.getImpact` | Liefert `{memberCount:0, grantCount:0}` statt der tatsächlichen Zahlen | Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage |
| `GroupsService.listMembers` | Liefert 0 Mitglieder statt der tatsächlich vorhandenen | Die Mitgliederliste im Gruppen-Detail ist leer |
| `ModuleGrantsService.getMatrix` | Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen | Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe |
| `ModuleGrantsService.getUserAccess` | Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen | Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine" |
| `LdapService.syncBoundGroupsForTenant` (Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup`) | Der Zähler `defaultMarkerMoved` im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos | Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06) |
| `GroupsService.addUserToDefaultGroup` | Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts | Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln) |
### (g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups
Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung
beider Dateien:
- **`GroupsService.reassignDefaultBeforeDelete`** — zerstörend und still.
Zwei getrennte Stellen liefern `false`: die Gruppe selbst ist nicht
sichtbar (`findFirst` auf `Group` liefert 0 Zeilen), oder es ist kein
Ersatzkandidat sichtbar (beide `findFirst`-Fallbacks liefern 0 Zeilen).
Der Aufrufer im Verzeichnis-Sync (`syncBoundGroupsForTenant`) löscht die
Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die
Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist
Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist
ein Hauptzweck dieses Durchlaufs.
- **`GroupsService.ensureDefaultGroup`** — die einzige Stelle des Bereichs,
an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist
UMGEKEHRT gepolt: null gelesene Gruppen (`group.count` liefert 0 statt
der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern
"alles neu aufbauen". Bliebe der Zähler ungebunden, während der
Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen
Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme
ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE
aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst
durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex
`Group_one_default_per_tenant` fängt einen Teil der Fälle ab (P2002 beim
`group.create`, abgefangen und in `null` übersetzt) — aber nur, wenn der
Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit
Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab.
Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden,
nie einzeln (T-JTS-05).
- **`GroupsService.getImpact`** — die Zahlen des Löschdialogs. Zwei
Zählungen (`groupMembership.count`, `moduleGrant.count`) ohne
Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator
entscheidet auf dieser Grundlage über eine kaskadierende Löschung und
bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle
Gruppe angezeigt.
- **`GroupsService.addUserToDefaultGroup`** — stilles Zurückkehren ohne
sichtbare Standardgruppe (`group.findFirst` liefert 0 Zeilen statt der
einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER
Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht
zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust.
Gegenrichtung, ebenfalls festgehalten: `ModuleGrantsService.grant` (die
Aktivierungsprüfung: kein aktives `TenantModuleActivation` wirft
`BadRequestException`, statt still zu erteilen) und
`ModuleGrantsService.assertTargetBelongsToTenant` (die
Mandanten-Gegenprüfung: kein Treffer wirft `NotFoundException`) werfen bei
Leere LAUT und sind damit die harmlosen Stellen des Bereichs.
Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend
geschlossen: `apps/web/src/app/(portal)/admin/modules/grants/page.tsx`
schaltet die Freigabe-Matrix je Zelle einzeln (ein `POST` bzw. `DELETE` pro
Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den
gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu
einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied
zum `deleteMany`-mit-`notIn` des ldap-Bereichs.
### (g4) Was dieser Durchlauf bewusst nicht löst
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`groups` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und
`auth.service.ts` es vormachen.
- **Die Policies auf `GroupMembership` und `ModuleGrant` prüfen jeweils nur
eine Seite.** Gemessen in (g1): `GroupMembership` prüft ausschließlich die
Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer
Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die
Policy NICHT. `ModuleGrant` prüft ausschließlich die Mandantenkennung der
Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener
Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy
ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in
`addUserToDefaultGroup`, `assertTargetBelongsToTenant`) bleiben deshalb
der primäre Schutz gegen diese beiden Formen der Rechteausweitung und
werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt.
### (g5) Fortschreibung des ldap-Abschnitts
Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der
Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf
geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt
in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt
tatsächlich vorliegt.
## Verweis ## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang