test(quick-260910-exd): Fehlerrichtung fuer module-registry messen, kein Produktivcode
- rls-scratch-check.mjs: achter Abschnitt runModuleRegistryAreaChecks mit 13
benannten Pruefungen gegen die echten, aus den ausgelieferten Migrationen
geschnittenen Regeln (Group/GroupMembership/ModuleGrant/TenantModuleActivation),
neu angelegt nur: Modulkatalog-Tabelle ohne Zeilenschutz, Eindeutigkeitsindex
auf TenantModuleActivation, zwei Direkt-Freigaben, eine fremde Mitgliedschaft
- Alle 66 Pruefungen bestanden (53 bisherige + 13 neue), 810 Tests gruen,
Typpruefung sauber
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
"Bereich module-registry" mit den fuenf Unterabschnitten (m1-m5), inklusive
Praezisierung aus Befund E (Katalogbindung ist HEUTE wirkungslos, nicht
katastrophal — die Bedingung wird als Bedingung notiert) und der
unbeschoenigten Antwort auf die Signalfrage ("keines")
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1710,6 +1710,361 @@ async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die ausgelieferte Migration, die die Tabellen "Module" und
|
||||
* "TenantModuleActivation" samt dem Eindeutigkeitsindex auf
|
||||
* ("tenantId","moduleId") anlegt (Dateiname endet auf
|
||||
* "_add_module_registry").
|
||||
*/
|
||||
function readModuleRegistryMigrationSql() {
|
||||
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_module_registry'))
|
||||
.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 beiden
|
||||
* partiellen Eindeutigkeitsindizes auf "ModuleGrant" anlegt (Dateiname endet
|
||||
* auf "_add_groups_and_module_grants") — dieselbe Datei, aus der
|
||||
* runGroupsAreaChecks() NICHT liest (jene braucht nur die RLS-Policy-
|
||||
* Migration), deshalb ein eigenes, unabhaengiges Lesehilfsmittel.
|
||||
*/
|
||||
function readAddGroupsAndModuleGrantsMigrationSql() {
|
||||
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_groups_and_module_grants'))
|
||||
.map((entry) => entry.name);
|
||||
if (dirs.length !== 1) return null;
|
||||
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schneidet eine `CREATE UNIQUE INDEX "<indexName>" ...;`-Anweisung
|
||||
* wortgleich aus einem Migrationstext, nach demselben Muster wie
|
||||
* extractPolicySql() oben.
|
||||
*/
|
||||
function extractIndexSql(migrationSql, indexName) {
|
||||
const re = new RegExp(`CREATE UNIQUE INDEX "${indexName}"[\\s\\S]*?;`);
|
||||
const match = migrationSql.match(re);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260910-exd) — misst die dreizehn im Plan genannten
|
||||
* Verhaltensweisen des Bereichs `module-registry` unter der Rolle ohne
|
||||
* BYPASSRLS. Setzt auf den Tabellen "Group", "GroupMembership",
|
||||
* "ModuleGrant" und "TenantModuleActivation" auf, die runGroupsAreaChecks()
|
||||
* bereits angelegt und mit Policies WORTGLEICH aus den ausgelieferten
|
||||
* Migrationen versehen hat — dieser Abschnitt legt sie NICHT neu an. Neu
|
||||
* angelegt werden nur: die Tabelle "Module" OHNE Zeilenschutz (die zu
|
||||
* messende Eigenschaft selbst), der Eindeutigkeitsindex auf
|
||||
* ("tenantId","moduleId") fuer "TenantModuleActivation" (bis hierhin fehlte
|
||||
* er, weil runGroupsAreaChecks() ihn nicht braucht), zwei Direkt-Freigaben
|
||||
* (eine je Mandant) und die Mitgliedschaft (group-b, user-a), die
|
||||
* runGroupsAreaChecks() unter gebundenem Kontext bewusst nicht anlegen
|
||||
* konnte.
|
||||
*
|
||||
* Die bereits von runGroupsAreaChecks() eingefuegte Zeile
|
||||
* `grant-foreign-group` (TENANT-A, mod-1, groupId=group-b) wird
|
||||
* WIEDERVERWENDET, nicht neu erzeugt.
|
||||
*
|
||||
* Muss NACH runUserAreaChecks() und VOR runTransactionShapeMeasurement()
|
||||
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
|
||||
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
|
||||
* Abschnitt aendert daran nichts.
|
||||
*/
|
||||
async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const moduleRegistryMigrationSql = readModuleRegistryMigrationSql();
|
||||
const activationUniqueIndexSql = moduleRegistryMigrationSql
|
||||
? extractIndexSql(
|
||||
moduleRegistryMigrationSql,
|
||||
'TenantModuleActivation_tenantId_moduleId_key',
|
||||
)
|
||||
: null;
|
||||
|
||||
const groupsAndGrantsMigrationSql = readAddGroupsAndModuleGrantsMigrationSql();
|
||||
const grantGroupUniqueIndexSql = groupsAndGrantsMigrationSql
|
||||
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_group_unique')
|
||||
: null;
|
||||
const grantUserUniqueIndexSql = groupsAndGrantsMigrationSql
|
||||
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_user_unique')
|
||||
: null;
|
||||
|
||||
if (!activationUniqueIndexSql || !grantGroupUniqueIndexSql || !grantUserUniqueIndexSql) {
|
||||
report(
|
||||
results,
|
||||
'module-registry-regeln-aus-migration-gefunden',
|
||||
false,
|
||||
'Eindeutigkeitsindex fuer "TenantModuleActivation" und/oder die beiden partiellen Eindeutigkeitsindizes fuer "ModuleGrant" nicht in den ausgelieferten Migrationen gefunden',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
// (a) Katalogtabelle OHNE Zeilenschutz — die zu messende Eigenschaft
|
||||
// selbst (Befund E), nach dem Muster der Tabelle "Tenant" im
|
||||
// Abschnitt des Bereichs `user`.
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "Module" (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL
|
||||
);
|
||||
`);
|
||||
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "Module" TO ${SCRATCH_ROLE_NAME}`);
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "Module" (id, name) VALUES ('mod-1', 'Modul Eins'), ('mod-2', 'Modul Zwei');
|
||||
`);
|
||||
|
||||
// (b) Eindeutigkeitsindex auf ("tenantId","moduleId") fuer
|
||||
// "TenantModuleActivation", wortgleich aus der ausgelieferten Migration
|
||||
// — ohne ihn liesse sich Pruefung 12 nicht messen.
|
||||
await db.$executeRawUnsafe(activationUniqueIndexSql);
|
||||
|
||||
// Zusaetzliche Aktivierungszeile fuer Pruefung 12: (TENANT-B, mod-2),
|
||||
// unter TENANT-A unsichtbar, aber physisch vorhanden.
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES
|
||||
('activation-b2', 'TENANT-B', 'mod-2', true);
|
||||
`);
|
||||
|
||||
// (c) je eine Direkt-Freigabezeile pro Mandant (userId, kein groupId).
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES
|
||||
('grant-direct-a', 'TENANT-A', 'mod-1', NULL, 'user-a'),
|
||||
('grant-direct-b', 'TENANT-B', 'mod-1', NULL, 'user-b');
|
||||
`);
|
||||
|
||||
// (d) die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks()
|
||||
// unter gebundenem Kontext bewusst NICHT anlegen konnte (die Regel wies
|
||||
// sie ab) — hier ueber die Verwaltungsrolle gesetzt, weil Pruefung 6
|
||||
// sonst aus dem falschen Grund bestuende.
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES
|
||||
('membership-cross', 'group-b', 'user-a', 'MANUAL');
|
||||
`);
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// 1: modulegrant-ungebunden-null-zeilen — die Belegzeile dieses
|
||||
// Abschnitts.
|
||||
const actualGrantCount = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "ModuleGrant"`;
|
||||
return row.n;
|
||||
},
|
||||
);
|
||||
const unboundGrantRows = await prisma.$queryRaw`SELECT "tenantId" FROM "ModuleGrant"`;
|
||||
report(
|
||||
results,
|
||||
'modulegrant-ungebunden-null-zeilen',
|
||||
unboundGrantRows.length === 0,
|
||||
`ungebundener SELECT auf "ModuleGrant" liefert ${unboundGrantRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualGrantCount}`,
|
||||
);
|
||||
|
||||
// 2: tenantmoduleactivation-ungebunden-null-zeilen — dasselbe fuer die
|
||||
// Aktivierungstabelle, die der Kurzschluss fuer ADMIN/SUPER_ADMIN als
|
||||
// EINZIGE liest.
|
||||
const actualActivationCount = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "TenantModuleActivation"`;
|
||||
return row.n;
|
||||
},
|
||||
);
|
||||
const unboundActivationRows =
|
||||
await prisma.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation"`;
|
||||
report(
|
||||
results,
|
||||
'tenantmoduleactivation-ungebunden-null-zeilen',
|
||||
unboundActivationRows.length === 0,
|
||||
`ungebundener SELECT auf "TenantModuleActivation" liefert ${unboundActivationRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualActivationCount}`,
|
||||
);
|
||||
|
||||
// 3: admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen
|
||||
const activeActivationsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" WHERE "isActive" = true ORDER BY id`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen',
|
||||
activeActivationsForA.length === 1 && activeActivationsForA[0].tenantId === 'TENANT-A',
|
||||
`forTenant(TENANT-A) liefert ${activeActivationsForA.length} aktive Aktivierung(en): ${JSON.stringify(activeActivationsForA.map((r) => r.tenantId))}`,
|
||||
);
|
||||
|
||||
// 4: direktfreigabe-gebunden-nur-eigene-zeile
|
||||
const directGrantsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT id FROM "ModuleGrant" WHERE "userId" = 'user-a' ORDER BY id`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'direktfreigabe-gebunden-nur-eigene-zeile',
|
||||
directGrantsForA.length === 1 && directGrantsForA[0].id === 'grant-direct-a',
|
||||
`forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) ${directGrantsForA.length} Zeile(n): ${JSON.stringify(directGrantsForA.map((r) => r.id))}`,
|
||||
);
|
||||
|
||||
// Der Drei-Tabellen-Weg (ModuleGrant ueber Group ueber GroupMembership),
|
||||
// Grundlage fuer Pruefung 5 und 6 — EINE Abfrage, zwei Aussagen.
|
||||
const groupPathForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`
|
||||
SELECT mg.id FROM "ModuleGrant" mg
|
||||
JOIN "Group" g ON g.id = mg."groupId"
|
||||
JOIN "GroupMembership" gm ON gm."groupId" = g.id
|
||||
WHERE mg."tenantId" = current_tenant_id() AND gm."userId" = 'user-a'
|
||||
ORDER BY mg.id
|
||||
`,
|
||||
);
|
||||
|
||||
// 5: gruppenpfad-gebunden-folgt-der-gruppenregel
|
||||
report(
|
||||
results,
|
||||
'gruppenpfad-gebunden-folgt-der-gruppenregel',
|
||||
groupPathForA.length === 1 && groupPathForA[0].id === 'grant-a',
|
||||
`forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a ${groupPathForA.length} Zeile(n): ${JSON.stringify(groupPathForA.map((r) => r.id))}`,
|
||||
);
|
||||
|
||||
// 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus
|
||||
const excludesForeignGroup = !groupPathForA.some((r) => r.id === 'grant-foreign-group');
|
||||
report(
|
||||
results,
|
||||
'gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus',
|
||||
excludesForeignGroup,
|
||||
`forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ${JSON.stringify(groupPathForA.map((r) => r.id))}), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten`,
|
||||
);
|
||||
|
||||
// 7: gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit —
|
||||
// die Gegenmessung ueber die Verwaltungsrolle mit BYPASSRLS.
|
||||
const groupPathViaAdmin = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => db.$queryRaw`
|
||||
SELECT mg.id FROM "ModuleGrant" mg
|
||||
JOIN "Group" g ON g.id = mg."groupId"
|
||||
JOIN "GroupMembership" gm ON gm."groupId" = g.id
|
||||
WHERE mg."tenantId" = 'TENANT-A' AND gm."userId" = 'user-a'
|
||||
ORDER BY mg.id
|
||||
`,
|
||||
);
|
||||
const adminIncludesForeignGroup = groupPathViaAdmin.some((r) => r.id === 'grant-foreign-group');
|
||||
report(
|
||||
results,
|
||||
'gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit',
|
||||
adminIncludesForeignGroup,
|
||||
`dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ${JSON.stringify(groupPathViaAdmin.map((r) => r.id))} — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau`,
|
||||
);
|
||||
|
||||
// 8: module-tabelle-traegt-keinen-zeilenschutz
|
||||
const unboundModuleRows = await prisma.$queryRaw`SELECT id FROM "Module" ORDER BY id`;
|
||||
const [moduleRlsRow] =
|
||||
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Module'`;
|
||||
const moduleHasNoRls =
|
||||
unboundModuleRows.length === 2 && moduleRlsRow?.relrowsecurity === false;
|
||||
report(
|
||||
results,
|
||||
'module-tabelle-traegt-keinen-zeilenschutz',
|
||||
moduleHasNoRls,
|
||||
`ungebundenes SELECT auf "Module" liefert ${unboundModuleRows.length} Zeile(n): ${JSON.stringify(unboundModuleRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Module" = ${JSON.stringify(moduleRlsRow?.relrowsecurity)}`,
|
||||
);
|
||||
|
||||
// 9: katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge
|
||||
const boundModuleRows = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT id FROM "Module" ORDER BY id`,
|
||||
);
|
||||
const catalogUnaffectedByBinding =
|
||||
boundModuleRows.length === unboundModuleRows.length &&
|
||||
boundModuleRows.every((r, i) => r.id === unboundModuleRows[i].id);
|
||||
report(
|
||||
results,
|
||||
'katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge',
|
||||
catalogUnaffectedByBinding,
|
||||
`forTenant(TENANT-A) liefert ${JSON.stringify(boundModuleRows.map((r) => r.id))}, ungebunden liefert ${JSON.stringify(unboundModuleRows.map((r) => r.id))} — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)`,
|
||||
);
|
||||
|
||||
// 10: gebundener-join-auf-den-katalog-liefert-den-modulnamen
|
||||
const activationWithModuleName = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`
|
||||
SELECT tma."moduleId", m.name FROM "TenantModuleActivation" tma
|
||||
JOIN "Module" m ON m.id = tma."moduleId"
|
||||
WHERE tma."isActive" = true
|
||||
ORDER BY tma.id
|
||||
`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'gebundener-join-auf-den-katalog-liefert-den-modulnamen',
|
||||
activationWithModuleName.length === 1 &&
|
||||
activationWithModuleName[0].moduleId === 'mod-1' &&
|
||||
activationWithModuleName[0].name === 'Modul Eins',
|
||||
`forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: ${JSON.stringify(activationWithModuleName)}`,
|
||||
);
|
||||
|
||||
// 11: aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt
|
||||
let foreignActivationInsertRejected = false;
|
||||
let foreignActivationInsertDetail = '';
|
||||
try {
|
||||
await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-rejected-foreign', 'TENANT-B', 'mod-2', true)`,
|
||||
);
|
||||
foreignActivationInsertDetail =
|
||||
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
const sqlState = sqlStateOf(err);
|
||||
foreignActivationInsertRejected = sqlState === '42501';
|
||||
foreignActivationInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt',
|
||||
foreignActivationInsertRejected,
|
||||
foreignActivationInsertDetail,
|
||||
);
|
||||
|
||||
// 12: aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision
|
||||
let ownActivationInsertSucceeded = false;
|
||||
let ownActivationInsertDetail = '';
|
||||
try {
|
||||
await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-a2', 'TENANT-A', 'mod-2', true)`,
|
||||
);
|
||||
ownActivationInsertSucceeded = true;
|
||||
ownActivationInsertDetail =
|
||||
'gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)';
|
||||
} catch (err) {
|
||||
ownActivationInsertDetail = `gebundenes INSERT von (TENANT-A, mod-2) unerwartet abgewiesen: ${err.message.trim()}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision',
|
||||
ownActivationInsertSucceeded,
|
||||
ownActivationInsertDetail,
|
||||
);
|
||||
|
||||
// 13: freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung —
|
||||
// Textmessung statt Datenbankmessung.
|
||||
const groupIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"groupId"\)/.test(
|
||||
grantGroupUniqueIndexSql,
|
||||
);
|
||||
const userIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"userId"\)/.test(
|
||||
grantUserUniqueIndexSql,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung',
|
||||
groupIndexLeadsWithTenant && userIndexLeadsWithTenant,
|
||||
`aus 20260804130130_add_groups_and_module_grants extrahiert: ${JSON.stringify(grantGroupUniqueIndexSql)} und ${JSON.stringify(grantUserUniqueIndexSql)} — beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -1943,6 +2298,7 @@ async function main() {
|
||||
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
@@ -1248,6 +1248,215 @@ auf den ungebundenen Basisclient zurückgebaut — genau
|
||||
properties of undefined (reading 'update')"; der Rückbau wurde
|
||||
zurückgenommen, derselbe Testlauf danach wieder grün.
|
||||
|
||||
## Bereich module-registry
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `module-registry`
|
||||
(Quick-Task 260910-exd) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||||
beantwortet sie für den Bereich, der bei JEDER Modulanfrage entscheidet, wer
|
||||
was benutzen darf: die Aktivierungsstufe (`TenantModuleActivation`) und die
|
||||
Freigabestufe (`ModuleGrant`). Bleibt hier eine Abfrage ungebunden, sieht das
|
||||
nach dem Scharfschalten nicht wie ein Fehler aus, sondern wie ein
|
||||
Rechteentzug — die sichtbarste Ausprägung der umgekehrten Fehlerrichtung im
|
||||
gesamten Vorhaben und zugleich die am wenigsten meldungswahrscheinliche.
|
||||
|
||||
### (m1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen achten
|
||||
Abschnitt (`runModuleRegistryAreaChecks`) erweitert. Er setzt auf den vier
|
||||
Tabellen auf, die `runGroupsAreaChecks` bereits mit Policies WORTGLEICH aus
|
||||
den ausgelieferten Migrationen anlegt (`Group`, `GroupMembership`,
|
||||
`ModuleGrant`, `TenantModuleActivation`) und legt selbst nur hinzu: die
|
||||
Tabelle `"Module"` OHNE Zeilenschutz (die zu messende Eigenschaft selbst),
|
||||
den Eindeutigkeitsindex auf `("tenantId","moduleId")` für
|
||||
`"TenantModuleActivation"` (wortgleich aus `20260619103242_add_module_registry`
|
||||
geschnitten), zwei Direkt-Freigabezeilen und die Mitgliedschaft (group-b,
|
||||
user-a), die `runGroupsAreaChecks` unter gebundenem Kontext bewusst nicht
|
||||
anlegen konnte. Die Zeile `grant-foreign-group` aus dem Abschnitt `groups`
|
||||
wird wiederverwendet. Tatsächlich beobachtete Ausgabe dieses Laufs
|
||||
(2026-09-10, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||
|
||||
```
|
||||
modulegrant-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "ModuleGrant" liefert 0 Zeile(n), tatsaechlich vorhanden sind 5
|
||||
tenantmoduleactivation-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenantModuleActivation" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||||
admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen: bestanden — forTenant(TENANT-A) liefert 1 aktive Aktivierung(en): ["TENANT-A"]
|
||||
direktfreigabe-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) 1 Zeile(n): ["grant-direct-a"]
|
||||
gruppenpfad-gebunden-folgt-der-gruppenregel: bestanden — forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a 1 Zeile(n): ["grant-a"]
|
||||
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ["grant-a"]), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten
|
||||
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"] — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau
|
||||
module-tabelle-traegt-keinen-zeilenschutz: bestanden — ungebundenes SELECT auf "Module" liefert 2 Zeile(n): ["mod-1","mod-2"]; pg_class.relrowsecurity fuer "Module" = false
|
||||
katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge: bestanden — forTenant(TENANT-A) liefert ["mod-1","mod-2"], ungebunden liefert ["mod-1","mod-2"] — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)
|
||||
gebundener-join-auf-den-katalog-liefert-den-modulnamen: bestanden — forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: [{"moduleId":"mod-1","name":"Modul Eins"}]
|
||||
aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "TenantModuleActivation")
|
||||
aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision: bestanden — gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)
|
||||
freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung: bestanden — aus 20260804130130_add_groups_and_module_grants extrahiert: beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" ("tenantId","moduleId","groupId") und ("tenantId","moduleId","userId") fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle
|
||||
Alle 66 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Die Belegzeile, die diesen Abschnitt trägt, ist
|
||||
`modulegrant-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
|
||||
"ModuleGrant"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
|
||||
die 5 tatsächlich vorhandenen — an der echten, ausgelieferten Policy
|
||||
gemessen. `tenantmoduleactivation-ungebunden-null-zeilen` misst dieselbe
|
||||
Unsichtbarkeit für die Tabelle, die der Rollen-Kurzschluss für
|
||||
ADMIN/SUPER_ADMIN als EINZIGE liest: auch hier fällt der
|
||||
Verwaltungszugriff nach dem Scharfschalten aus.
|
||||
|
||||
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
|
||||
diesem Bereich:
|
||||
|
||||
```
|
||||
$ grep -rn '\$transaction(' apps/api/src/module-registry --include=*.ts | grep -v spec
|
||||
$ echo $?
|
||||
1
|
||||
```
|
||||
|
||||
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
|
||||
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
|
||||
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
|
||||
Aufgabe 2/3 nicht eingeführt.
|
||||
|
||||
**TEIL 3, Beleg statt Behauptung für Befund G** — die Aufrufermessung für
|
||||
`isModuleActive` und `findActiveForTenant`:
|
||||
|
||||
```
|
||||
$ grep -rn "isModuleActive" apps/api/src apps/web/src packages
|
||||
apps/api/src/module-registry/module-registry.service.ts:133: async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
|
||||
|
||||
$ grep -rn "findActiveForTenant" apps/api/src apps/web/src packages
|
||||
apps/api/src/module-registry/module-registry.service.ts:35: async findActiveForTenant(tenantId: string) {
|
||||
apps/api/src/module-registry/module-registry.controller.ts:51: * uses, not just tenant-wide activation. findActiveForTenant on
|
||||
```
|
||||
|
||||
`isModuleActive` hat genau EINEN Treffer, die Definition selbst — ihr
|
||||
Kopfkommentar behauptet "Used by ModuleGuard to gate access to
|
||||
module-specific endpoints"; der Wächter ruft sie nachweislich nie auf
|
||||
(`module.guard.ts` hält keinen eigenen Datenbankzugriff, siehe Befund D).
|
||||
`findActiveForTenant` hat zwei Treffer, die Definition und eine
|
||||
Prosa-Erwähnung im Kopfkommentar des Controllers ("stays unchanged for Plan
|
||||
15-03's marketplace catalog") — auch sie hat keinen echten Aufrufer, der
|
||||
Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient. Beide
|
||||
Kommentare werden in Aufgabe 3 richtiggestellt, mit Bezug auf diese Messung.
|
||||
|
||||
### (m2) Signaltabelle je umgestelltem Pfad
|
||||
|
||||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||||
|---|---|---|
|
||||
| `ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss (ADMIN/SUPER_ADMIN) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Sidebar und Marktplatz zeigen für JEDEN Administrator des Mandanten keine Module mehr; jede Modulroute antwortet mit 403 |
|
||||
| `ModuleAccessService.getAccessibleModuleIds`, Direktweg (USER) | Der gebundene Freigabe-Lesezugriff über `userId` liefert 0 Zeilen statt der eigenen Direkt-Freigaben | Der Benutzer verliert genau die Module, die ihm direkt zugewiesen waren — ununterscheidbar von einem echten Entzug |
|
||||
| `ModuleAccessService.getAccessibleModuleIds`, Gruppenweg (USER) | Der gebundene, verschachtelte Freigabe-Lesezugriff über die Gruppenmitgliedschaft liefert 0 Zeilen statt der Gruppen-Freigaben | Der Benutzer verliert alle über Gruppen geerbten Module, während seine Direkt-Freigaben unberührt bleiben — ein teilweiser, schwer zu erklärender Verlust |
|
||||
| `ModuleAccessService.getAccessibleModuleIds`, Schnittmengenabfrage | Die gebundene Aktivierungsabfrage über `moduleId: { in: grantedIds }` liefert 0 Zeilen, obwohl Freigaben vorliegen | Ein Benutzer mit vorhandenen Freigaben sieht trotzdem kein Modul — von der leeren Vorgabemenge (kein Freigabe) nicht zu unterscheiden |
|
||||
| `ModuleAccessService.findAccessibleModules` | Der Rückgabepunkt bei leerer `accessibleIds`-Menge liefert `[]`, ohne den Katalog überhaupt anzufragen | `GET /modules/active` liefert eine leere Liste; die Sidebar ist leer |
|
||||
| `ModuleAccessService.getCatalogFlags` | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Der Marktplatz zeigt für JEDES Modul `isActiveForTenant: false` UND `hasAccess: false` — eine ANDERE Unwahrheit als der Sperrhinweis: "nicht aktiviert" statt "nicht freigegeben" |
|
||||
| `ModuleRegistryService.findActiveForTenant` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen | Betrifft heute keinen erreichbaren Pfad — die Falle liegt in einer KÜNFTIGEN Verdrahtung, siehe TEIL 3 |
|
||||
| `ModuleRegistryService.activateForTenant` | Der gebundene Schreibzugriff schlägt fehl bzw. die Existenzprüfung des Katalogs (ungebunden) liefert `null` | `POST /modules/:id/activate` liefert `404 Module with id '...' not found`, obwohl das Modul existiert |
|
||||
| `ModuleRegistryService.deactivateForTenant` | Der gebundene Lesezugriff auf die Aktivierung liefert `null` statt der vorhandenen Zeile | `POST /modules/:id/deactivate` liefert `404 Module '...' is not activated for this tenant` — LAUT, siehe (m3) |
|
||||
| `ModuleRegistryService.isModuleActive` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert `null`, die Methode gibt `false` zurück | Betrifft heute keinen erreichbaren Pfad — die stille Falle für eine KÜNFTIGE Verdrahtung, siehe (m3) |
|
||||
| `ModuleRegistryService.findAll`/`findBySlug`/`seedModule` (bewusst UNGEBUNDEN, Modulkatalog) | Betrifft nicht diese Pfade selbst — sie binden nicht und liefern deshalb weiterhin korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3, sobald `Module` eine Regel bekommt) | Würde man sie binden: der gesamte Modulkatalog verschwände für JEDEN Mandanten — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten |
|
||||
| `ModuleAccessService`, Katalogzugriffe in `findAccessibleModules`/`getCatalogFlags` (bewusst UNGEBUNDEN) | Dieselbe Grenze wie oben, hier für die beiden Katalog-Lesezugriffe des Nachbardienstes | Dieselbe Folge: eine künftige Bindung würde den Katalog mandantenweit unsichtbar machen |
|
||||
|
||||
### (m3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
Nach Wirkung sortiert:
|
||||
|
||||
1. **`ModuleAccessService.getAccessibleModuleIds`, `grantedIds.length === 0`
|
||||
→ `return new Set()`** — der Rückgabepunkt bei leerer Freigabemenge. Der
|
||||
Vorgabezustand ist geschlossen (D-04/T-EXD-04); eine zu leer gebliebene
|
||||
Freigabeabfrage sieht identisch aus wie ein Benutzer ohne jede Freigabe.
|
||||
2. **`ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss** —
|
||||
`if (role === 'ADMIN' || role === 'SUPER_ADMIN')` liest ausschließlich
|
||||
`tenantModuleActivation`; eine leere Aktivierungsliste liefert ein leeres
|
||||
Set, ohne die Freigabestufe überhaupt zu befragen. Betrifft damit JEDEN
|
||||
Administrator des Mandanten gleichzeitig.
|
||||
3. **`ModuleAccessService.findAccessibleModules`, `accessibleIds.size === 0`
|
||||
→ `return []`** — der Rückgabepunkt bei leerer Modulliste, bedient
|
||||
`GET /modules/active` und damit die Sidebar.
|
||||
4. **`ModuleAccessService.getCatalogFlags`** — eine leere Aktivierungsliste
|
||||
lässt die zurückgegebene Map leer; der Controller
|
||||
(`module-registry.controller.ts`, `findCatalog`) mappt einen fehlenden
|
||||
Eintrag auf BEIDE Flags `false`. Das erzeugt eine ANDERE Unwahrheit als
|
||||
der Sperrhinweis der Freigabestufe: "nicht aktiviert" statt "nicht
|
||||
freigegeben" — der Marktplatz zeigt dem Benutzer den falschen Grund für
|
||||
die Sperre.
|
||||
5. **`ModuleGuard.canActivate`, die 403-Stelle** —
|
||||
`if (!accessibleModuleIds.has(module.id)) throw new ForbiddenException(...)`.
|
||||
Dieselbe Meldung für "wirklich keine Freigabe" und "die Aufösung hat
|
||||
nichts gefunden" — siehe die Leitfrage dieses Abschnitts unten.
|
||||
6. **`ModuleRegistryService.isModuleActive`, Vorgabewert `false`** — heute
|
||||
ohne Aufrufer (TEIL 3), aber die stille Falle für morgen: `return
|
||||
activation?.isActive === true` liefert bei `null` (leerer, gebundener
|
||||
Lesezugriff) exakt dasselbe `false` wie eine echte, mandantenweite
|
||||
Deaktivierung. Wird dieser Pfad morgen verdrahtet, ist er von Fall an
|
||||
nicht mehr vom Rest dieses Abschnitts zu unterscheiden.
|
||||
|
||||
**Gegenrichtung, damit dieser Abschnitt nicht nur aus Alarm besteht:**
|
||||
`ModuleRegistryService.deactivateForTenant` wirft bei leerer
|
||||
Aktivierungsabfrage (`if (!activation) throw new NotFoundException(...)`)
|
||||
LAUT — die Ausnahme meldet sich sofort und verständlich, statt ein stilles
|
||||
`false` zu liefern. Dieselbe laute Richtung gilt für `activateForTenant` bei
|
||||
unbekannter `moduleId`.
|
||||
|
||||
**Die Frage, die dieser Bereich vor allen anderen beantworten muss: welches
|
||||
Signal unterscheidet "wirklich keine Freigabe" von "die Abfrage hat nichts
|
||||
gefunden"?** Die Antwort lautet **keines** — schlicht, ohne Beschönigung.
|
||||
Drei Stellen sehen heute identisch aus, ob der Benutzer tatsächlich keine
|
||||
Freigabe hat oder ob eine gebundene Abfrage nach dem Scharfschalten leer
|
||||
lief: dieselbe `ForbiddenException`-Meldung im Wächter
|
||||
("Module '...' is not accessible for this user"), dieselbe leere Modulliste
|
||||
mit Status 200 (`GET /modules/active`), kein einziger Protokolleintrag. Der
|
||||
Zusatz, der diesen Bereich von allen vorherigen unterscheidet: der
|
||||
Betroffene hat eine fertige, FALSCHE Erklärung zur Hand ("mein Administrator
|
||||
hat mir das entzogen") und meldet deshalb keinen Fehler — anders als etwa
|
||||
bei `LdapConfigScheduler`, wo niemand eine plausible Alltagserklärung für
|
||||
ausbleibende Synchronisation hat.
|
||||
|
||||
Die eine Asymmetrie, die sich zu einem Signal machen LIESSE, wird hier
|
||||
benannt und an Etappe 4 übergeben, nicht gelöst: nach dem Scharfschalten ist
|
||||
ein zu kleines Ergebnis in diesem Bereich TOTAL und nicht selektiv — JEDER
|
||||
Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, während die
|
||||
Aktivierungs- und Freigabetabellen weiterhin Zeilen halten. Die
|
||||
Vorabprüfung von Etappe 4 (`rls-preflight.mjs`) kann genau das feststellen:
|
||||
aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen
|
||||
bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den oben
|
||||
genannten Stellen wird ERWOGEN und VERWORFEN, mit derselben Begründung wie
|
||||
bei `getAllActiveConfigs` im Bereich `ldap` und den fünf Stellen im Bereich
|
||||
`tenders`: eine leere Menge ist für einen Benutzer ohne Freigabe und auf
|
||||
einer frischen Installation der Normalzustand, eine Warnung wäre Dauerlärm
|
||||
und verlöre ihr Signal.
|
||||
|
||||
### (m4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
- **T-JTS-02/T-JTS-03 bleiben aufgezeichnet und ungefixt.** Gemessen in
|
||||
Aufgabe 1 (Prüfungen 6/7): die Regel auf `ModuleGrant` prüft nur die
|
||||
Mandantenkennung der Zeile selbst, nicht die referenzierte Gruppe; die
|
||||
Regel auf `GroupMembership` prüft nur die Gruppenseite. Die Bindung fügt
|
||||
auf der LESESEITE eine zweite Verteidigung hinzu (der Drei-Tabellen-Weg
|
||||
schließt die fremde Gruppe gebunden aus), aber erst nach Etappe 4 — die
|
||||
Schreibseiten-Gegenprüfung in `module-grants.service.ts`
|
||||
(`assertTargetBelongsToTenant`) bleibt deshalb der heutige Schutz und wird
|
||||
durch diesen Durchlauf NICHT ersetzt.
|
||||
- **Die Regel für den Modulkatalog gehört zu Etappe 3.** Gemessen in
|
||||
Aufgabe 1 (Prüfung 8/9, Befund E): `"Module"` trägt heute keinen
|
||||
Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal.
|
||||
Katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt
|
||||
— dann verschwände der gesamte Katalog für jeden Mandanten. Diese
|
||||
Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
|
||||
- **Die offene Architekturfrage `req.tenantPrisma`** — auch dieser Bereich
|
||||
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
|
||||
`tenders`, `dkv` und `user` es vormachen.
|
||||
|
||||
### (m5) Was dieser Durchlauf bewusst NICHT anfasst
|
||||
|
||||
- `module.guard.ts` — geprüft (Befund D: hält keinen eigenen
|
||||
Datenbankzugriff, seine Richtigkeit ist vollständig eine Funktion dessen,
|
||||
was `ModuleAccessService` zurückgibt) und bewusst gelassen, keine Bindung
|
||||
nötig.
|
||||
- `module-registry.controller.ts` — geprüft (hält ebenfalls keinen eigenen
|
||||
Datenbankzugriff) und bewusst gelassen.
|
||||
- Das Frontend — geprüft und bewusst gelassen, keine Datei dieses Plans.
|
||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||
Schemaänderung in dieser Etappe.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
Reference in New Issue
Block a user