feat(quick-260909-dgj): Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung (Task 3/4)
- apps/api/scripts/rls-preflight.mjs misst fuenf benannte Eigenschaften (rollenrechte, kontext-setzbar, ohne-kontext-leer, mit-kontext-sichtbar, schreibrechte) jeweils in einer eigenen Transaktion gegen eine per TESSERA_PREFLIGHT_DATABASE_URL angegebene Verbindung; --print-plan verbindet nicht, das Werkzeug schreibt in keiner Betriebsart - 5 Tests in rls-preflight.spec.ts (rot vor dem Werkzeug, jetzt gruen) - docs/mandantentrennung-datenbankrolle.md: Befund, Sperrgrund (182 unskalierte Zugriffe, Anmeldeweg), Handgriffe des Betreibers samt Kennwortsetzung, Freigabebedingung und Rueckweg; docs/README.md verweist darauf Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// WINDOWS #18 — misst, statt zu behaupten, ob die Mandantentrennung unter
|
||||
// einer angegebenen Datenbankrolle tatsaechlich greift (Task 3).
|
||||
//
|
||||
// Verbindet mit der ueber TESSERA_PREFLIGHT_DATABASE_URL angegebenen Rolle
|
||||
// (new PrismaClient({ datasourceUrl: url })), damit gegen eine andere Rolle
|
||||
// gemessen werden kann als die, mit der die Anwendung laeuft. Kein neues
|
||||
// Paket noetig — Prisma ist bereits Abhaengigkeit der API.
|
||||
//
|
||||
// Jede Pruefung laeuft in einer eigenen interaktiven Transaktion
|
||||
// (prisma.$transaction), und jedes Setzen des Mandantenkontexts geschieht
|
||||
// transaktionslokal (set_config(..., true)) — genau wie
|
||||
// apps/api/src/prisma/prisma-tenant.extension.ts es tut. Ausserhalb einer
|
||||
// Transaktion kann Prismas Verbindungspool die Folgeabfrage auf eine andere
|
||||
// physische Verbindung legen, auf der die Einstellung nie gesetzt wurde;
|
||||
// die Messung waere dann wertlos.
|
||||
//
|
||||
// Das Werkzeug schreibt nichts. Es liest ausschliesslich.
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const CHECKS = [
|
||||
['rollenrechte', 'current_user traegt weder rolsuper noch rolbypassrls (WINDOWS #18, der eigentliche Kern dieser Pruefung)'],
|
||||
['kontext-setzbar', 'set_config(app.current_tenant, ...) laesst sich ohne besonderes Recht setzen und lesen'],
|
||||
['ohne-kontext-leer', 'ohne gesetzten Mandantenkontext liefert jede Tabelle mit Policy null Zeilen'],
|
||||
['mit-kontext-sichtbar', 'mit gesetztem Mandantenkontext ist mindestens eine Tabelle wieder sichtbar'],
|
||||
['schreibrechte', 'current_user hat auf jede Tabelle im Schema public alle vier Zugriffsarten'],
|
||||
];
|
||||
|
||||
const ENV_VAR = 'TESSERA_PREFLIGHT_DATABASE_URL';
|
||||
|
||||
function printPlan() {
|
||||
console.log('Pruefplan (verbindet nicht):');
|
||||
for (const [kennung, beschreibung] of CHECKS) {
|
||||
console.log(`- ${kennung}: ${beschreibung}`);
|
||||
}
|
||||
console.log(`Verbindung wird aus der Umgebungsvariablen ${ENV_VAR} gelesen.`);
|
||||
}
|
||||
|
||||
async function checkRollenrechte(tx) {
|
||||
const rows = await tx.$queryRaw`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
|
||||
const row = rows[0];
|
||||
const passed = Boolean(row) && row.rolsuper === false && row.rolbypassrls === false;
|
||||
return {
|
||||
kennung: 'rollenrechte',
|
||||
passed,
|
||||
detail: row
|
||||
? `Rolle ${row.rolname}: rolsuper=${row.rolsuper}, rolbypassrls=${row.rolbypassrls}`
|
||||
: 'current_user nicht in pg_roles gefunden',
|
||||
};
|
||||
}
|
||||
|
||||
async function checkKontextSetzbar(tx) {
|
||||
await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, 'probe');
|
||||
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS tenant`;
|
||||
const value = rows[0]?.tenant;
|
||||
return {
|
||||
kennung: 'kontext-setzbar',
|
||||
passed: value === 'probe',
|
||||
detail: `current_tenant_id() lieferte: ${JSON.stringify(value)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPolicyTables(prisma) {
|
||||
const rows = await prisma.$queryRaw`SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename`;
|
||||
return rows.map((r) => r.tablename);
|
||||
}
|
||||
|
||||
async function countRows(tx, table) {
|
||||
const rows = await tx.$queryRawUnsafe(`SELECT count(*)::int AS count FROM "${table}"`);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async function checkOhneKontextLeer(tx, policyTables) {
|
||||
const nonZero = [];
|
||||
for (const table of policyTables) {
|
||||
const count = await countRows(tx, table);
|
||||
if (count !== 0) nonZero.push(`${table}=${count}`);
|
||||
}
|
||||
return {
|
||||
kennung: 'ohne-kontext-leer',
|
||||
passed: nonZero.length === 0,
|
||||
detail:
|
||||
nonZero.length === 0
|
||||
? `alle ${policyTables.length} Tabellen mit Policy liefern 0 Zeilen`
|
||||
: `sichtbar ohne Kontext (Beweis fuer wirkungslose Trennung): ${nonZero.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkMitKontextSichtbar(prisma, tx, policyTables) {
|
||||
const tenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" LIMIT 1`;
|
||||
const tenantId = tenantRows[0]?.id;
|
||||
if (!tenantId) {
|
||||
return {
|
||||
kennung: 'mit-kontext-sichtbar',
|
||||
passed: null,
|
||||
detail: 'nicht durchfuehrbar — kein Mandant in der Tabelle Tenant gefunden',
|
||||
};
|
||||
}
|
||||
|
||||
await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
|
||||
const visible = [];
|
||||
for (const table of policyTables) {
|
||||
const count = await countRows(tx, table);
|
||||
if (count > 0) visible.push(`${table}=${count}`);
|
||||
}
|
||||
return {
|
||||
kennung: 'mit-kontext-sichtbar',
|
||||
passed: visible.length > 0,
|
||||
detail:
|
||||
visible.length > 0
|
||||
? `mit Mandant ${tenantId} sichtbar: ${visible.join(', ')}`
|
||||
: `mit Mandant ${tenantId} liefert weiterhin keine Tabelle Zeilen`,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkSchreibrechte(tx) {
|
||||
const tableRows = await tx.$queryRaw`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`;
|
||||
const privileges = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
||||
const gaps = [];
|
||||
|
||||
for (const { table_name: table } of tableRows) {
|
||||
const missing = [];
|
||||
for (const priv of privileges) {
|
||||
const rows = await tx.$queryRawUnsafe(
|
||||
`SELECT has_table_privilege(current_user, $1, $2) AS allowed`,
|
||||
table,
|
||||
priv,
|
||||
);
|
||||
if (!rows[0]?.allowed) missing.push(priv);
|
||||
}
|
||||
if (missing.length > 0) gaps.push(`${table} fehlt ${missing.join('/')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
kennung: 'schreibrechte',
|
||||
passed: gaps.length === 0,
|
||||
detail: gaps.length === 0 ? `alle ${tableRows.length} Tabellen vollstaendig` : gaps.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
async function runChecks(url) {
|
||||
const prisma = new PrismaClient({ datasourceUrl: url });
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results.push(await prisma.$transaction((tx) => checkRollenrechte(tx)));
|
||||
results.push(await prisma.$transaction((tx) => checkKontextSetzbar(tx)));
|
||||
|
||||
const policyTables = await getPolicyTables(prisma);
|
||||
|
||||
results.push(await prisma.$transaction((tx) => checkOhneKontextLeer(tx, policyTables)));
|
||||
results.push(await prisma.$transaction((tx) => checkMitKontextSichtbar(prisma, tx, policyTables)));
|
||||
results.push(await prisma.$transaction((tx) => checkSchreibrechte(tx)));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args[0] === '--print-plan') {
|
||||
printPlan();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const url = process.env[ENV_VAR];
|
||||
if (!url) {
|
||||
console.error(`FEHLER: ${ENV_VAR} ist nicht gesetzt.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = await runChecks(url);
|
||||
|
||||
console.log('Ergebnis der Mandantentrennungs-Pruefung:');
|
||||
let allPassed = true;
|
||||
for (const { kennung, passed, detail } of results) {
|
||||
const status = passed === true ? 'bestanden' : passed === false ? 'FEHLGESCHLAGEN' : 'nicht durchfuehrbar';
|
||||
if (passed === false) allPassed = false;
|
||||
console.log(`${kennung}: ${status} — ${detail}`);
|
||||
}
|
||||
|
||||
process.exit(allPassed ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FEHLER beim Ausfuehren der Pruefung:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Prueft apps/api/scripts/rls-preflight.mjs ausschliesslich in der
|
||||
* Pruefplan-Betriebsart (--print-plan) — es wird keine Datenbankverbindung
|
||||
* aufgebaut (Task 3, WINDOWS #18).
|
||||
*/
|
||||
|
||||
const SCRIPT_PATH = join(__dirname, '../../scripts/rls-preflight.mjs');
|
||||
|
||||
describe('rls-preflight.mjs --print-plan', () => {
|
||||
it('Test 1: endet mit Ende-Code 0, obwohl keine Verbindungsangabe in der Umgebung steht', () => {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
|
||||
env: { PATH: process.env.PATH ?? '' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(stdout).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Test 2: nennt alle fuenf Pruefkennungen', () => {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
|
||||
env: { PATH: process.env.PATH ?? '' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
for (const kennung of [
|
||||
'rollenrechte',
|
||||
'kontext-setzbar',
|
||||
'ohne-kontext-leer',
|
||||
'mit-kontext-sichtbar',
|
||||
'schreibrechte',
|
||||
]) {
|
||||
expect(stdout).toContain(kennung);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 3: nennt TESSERA_PREFLIGHT_DATABASE_URL als Quelle der zu pruefenden Verbindung', () => {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
|
||||
env: { PATH: process.env.PATH ?? '' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(stdout).toContain('TESSERA_PREFLIGHT_DATABASE_URL');
|
||||
});
|
||||
|
||||
it('Test 4: jede Pruefung laeuft innerhalb einer Transaktion ($transaction) — ausserhalb kann der Verbindungspool die Folgeabfrage auf eine andere Verbindung legen als die, auf der set_config gesetzt wurde, und die Messung waere wertlos', () => {
|
||||
const source = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
expect(source).toContain('$transaction');
|
||||
});
|
||||
|
||||
it('Test 5: das Werkzeug schreibt nicht — keines der Schluesselwoerter INSERT, UPDATE, DELETE, DROP, ALTER in einer SQL-Zeichenkette', () => {
|
||||
const source = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
const sqlStrings = source.match(/`[^`]*`/g) ?? [];
|
||||
for (const str of sqlStrings) {
|
||||
for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER']) {
|
||||
expect(str.toUpperCase()).not.toContain(verb);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ Daneben liegt das [CI/CD-Runbook](ci-cd-setup.md), das die Einrichtung der
|
||||
Bau-Pipeline in Gitea beschreibt. Es richtet sich an dieselben Leute wie die
|
||||
Betriebsanleitung, deckt aber nur den Weg vom Quelltext zum fertigen Abbild ab.
|
||||
|
||||
Ebenfalls dabei: [Mandantentrennung auf Datenbankebene](mandantentrennung-datenbankrolle.md),
|
||||
das sich an dieselben Leute wie die Betriebsanleitung richtet und ausschliesslich
|
||||
die Datenbankrolle behandelt, mit der Tessera verbindet (WINDOWS #18).
|
||||
|
||||
---
|
||||
|
||||
## Die drei Dinge, die am häufigsten Zeit kosten
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<!-- generated-by: gsd-doc-writer -->
|
||||
# Tessera – Mandantentrennung auf Datenbankebene (WINDOWS #18)
|
||||
|
||||
Diese Anleitung richtet sich an dieselben Kolleg:innen wie das
|
||||
[Betriebshandbuch](./anleitung-betrieb.md) und behandelt nur einen einzigen
|
||||
Punkt: die Datenbankrolle, mit der Tessera verbindet, und warum die
|
||||
bestehenden Sicherheitsregeln (Row-Level Security) heute keine Wirkung
|
||||
haben.
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Der Befund](#1-der-befund)
|
||||
2. [Was bereits vorbereitet ist](#2-was-bereits-vorbereitet-ist)
|
||||
3. [Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist](#3-der-sperrgrund--warum-die-umstellung-noch-nicht-erfolgt-ist)
|
||||
4. [Die Umstellung, Schritt fuer Schritt](#4-die-umstellung-schritt-fuer-schritt)
|
||||
5. [Die Vorher-Pruefung](#5-die-vorher-pruefung)
|
||||
6. [Der Rueckweg](#6-der-rueckweg)
|
||||
|
||||
---
|
||||
|
||||
## 1. Der Befund
|
||||
|
||||
Tessera trennt die Daten mehrerer Mandanten auf zwei Ebenen: im Anwendungscode
|
||||
(`where: { tenantId }`) und zusaetzlich auf Datenbankebene ueber PostgreSQL
|
||||
Row-Level Security (RLS) — ein „zweites Sicherheitsnetz" fuer den Fall, dass
|
||||
im Code einmal ein `tenantId`-Filter vergessen wird.
|
||||
|
||||
Dieses zweite Netz existiert seit Juni 2026 auf sieben Tabellen und wurde am
|
||||
2026-09-09 mit einer wichtigen Einschraenkung nachgemessen: **es greift
|
||||
derzeit nicht.** Die API verbindet laut `docker-compose.yml` als Rolle
|
||||
`tessera`. Diese Rolle entsteht aus der Umgebungsvariable `POSTGRES_USER` und
|
||||
ist damit automatisch Superuser des PostgreSQL-Clusters. PostgreSQL wendet
|
||||
Row-Level Security auf Superuser-Rollen grundsaetzlich nicht an — auch nicht
|
||||
auf Rollen mit dem Recht `BYPASSRLS`, das Superuser automatisch mitbringen.
|
||||
Der Zusatz `FORCE ROW LEVEL SECURITY` in den bestehenden Migrationen aendert
|
||||
daran nichts: er erzwingt Policies nur gegenueber dem *Tabelleneigentuemer*,
|
||||
nicht gegenueber Rollen mit Umgehungsrecht — und die Rolle `tessera` ist
|
||||
beides zugleich.
|
||||
|
||||
Praktisch nachgemessen am 2026-09-09: ein `SELECT count(*) FROM "Group"` ohne
|
||||
gesetzten Mandantenkontext liefert zwei Zeilen, obwohl die vorhandene Policy
|
||||
`USING ("tenantId" = current_tenant_id())` bei fehlendem Kontext null Zeilen
|
||||
liefern muesste. Die Isolation zwischen Mandanten haengt heute vollstaendig
|
||||
am `where`-Filter im Anwendungscode — nicht an der Datenbank.
|
||||
|
||||
Dieser Befund ist als [WINDOWS #18](../.planning/WINDOWS.md) im
|
||||
Fehler-Ledger festgehalten. Kein akutes Risiko, solange Tessera nur intern
|
||||
und einmandantig laeuft — aber zwingend zu beheben, bevor Tessera an einen
|
||||
zweiten, externen Mandanten geht.
|
||||
|
||||
## 2. Was bereits vorbereitet ist
|
||||
|
||||
Diese Aenderung liefert die vier Bausteine, die fuer eine wirksame Trennung
|
||||
noetig sind:
|
||||
|
||||
- **Eine eigene Datenbankrolle** `tessera_app` ohne Superuser- und ohne
|
||||
BYPASSRLS-Recht (Migration `20260909130000_rls_app_role`).
|
||||
- **Getrennte Verbindungen fuer Migration und Laufzeit.** `prisma migrate
|
||||
deploy` braucht die Rechte des Tabelleneigentuemers, die Anwendung soll
|
||||
sie nicht haben — `apps/api/scripts/migrate-and-start.sh` trennt beide
|
||||
Schritte ueber die neue Variable `TESSERA_MIGRATE_DATABASE_URL`.
|
||||
- **Ein Pruefwerkzeug**, das den Nachweis fuehrt statt ihn zu behaupten:
|
||||
`apps/api/scripts/rls-preflight.mjs` misst gegen eine angegebene
|
||||
Verbindung Rollenrechte, Setzbarkeit des Mandantenkontexts, sichtbare
|
||||
Zeilen mit und ohne Kontext sowie vorhandene Zugriffsrechte.
|
||||
- **Vollstaendige Policy-Abdeckung.** Migration
|
||||
`20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang
|
||||
fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine
|
||||
Regel.
|
||||
|
||||
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
|
||||
|
||||
**Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht
|
||||
umgestellt werden.**
|
||||
|
||||
Am 2026-09-09 wurde im Quelltext von `apps/api/src` gezaehlt, wie oft die
|
||||
Anwendung ueber den unskalierten Prisma-Client zugreift (also ohne den
|
||||
Mandantenkontext zu setzen) gegenueber der Anzahl der Verwendungen des
|
||||
mandantengebundenen `tenantPrisma`: **182 unskalierte Zugriffe** — 84 auf die
|
||||
sieben bereits mit Policies versehenen Tabellen, 98 auf die 16 neu
|
||||
hinzugekommenen — gegenueber lediglich **19 Verwendungen** von `tenantPrisma`
|
||||
im gesamten API-Quelltext.
|
||||
|
||||
Darunter ist der Anmeldeweg selbst, und der kann strukturell nicht anders
|
||||
funktionieren: `apps/api/src/auth/auth.service.ts` sucht den Benutzer anhand
|
||||
des Benutzernamens, **bevor** der Mandant bekannt ist — der Mandant wird ja
|
||||
erst aus dem gefundenen Benutzer bestimmt. Unter der Rolle `tessera_app`
|
||||
liefert genau diese Suche null Zeilen. **Niemand koennte sich mehr
|
||||
anmelden.**
|
||||
|
||||
Ebenso betroffen sind Hintergrunddienste, die von Natur aus ohne
|
||||
Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die
|
||||
Modulzugriffspruefung, die Treffersuche im Ausschreibungs-Radar und die
|
||||
Erstanlage des Administrators beim ersten Start.
|
||||
|
||||
Diese 182 Stellen brauchen einen ausdruecklichen, benannten Systemkontext
|
||||
(oder eine gezielte Umstellung auf `tenantPrisma`/`forTenant()`), bevor der
|
||||
Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser
|
||||
Aenderung.** Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt
|
||||
ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese
|
||||
182 Zugriffe aus.
|
||||
|
||||
## 4. Die Umstellung, Schritt fuer Schritt
|
||||
|
||||
Erst durchfuehren, wenn Abschnitt 3 abgearbeitet ist. Reihenfolge einhalten
|
||||
— die Migration muss weiterhin als Tabelleneigentuemer laufen.
|
||||
|
||||
1. **Kennwort der Rolle einmalig setzen**, als Datenbank-Superuser:
|
||||
|
||||
```sql
|
||||
ALTER ROLE tessera_app WITH PASSWORD '<hier ein starkes, generiertes Kennwort einsetzen>';
|
||||
```
|
||||
|
||||
Dieser Befehl gehoert in keine Datei und in kein Protokoll — er wird
|
||||
direkt an `psql` uebergeben und danach aus der Shell-Historie entfernt.
|
||||
|
||||
2. **In der `.env` des Servers** `TESSERA_MIGRATE_DATABASE_URL` auf die
|
||||
**bisherige** Verbindung (Rolle `tessera`, Tabelleneigentuemer) setzen und
|
||||
`DATABASE_URL` auf die **neue** Rolle `tessera_app` umbiegen — in dieser
|
||||
Reihenfolge, denn `prisma migrate deploy` muss weiterhin mit
|
||||
Eigentuemerrechten laufen, waehrend die Anwendung selbst die eingeschraenkte
|
||||
Rolle bekommt.
|
||||
|
||||
3. Vor dem Neustart die Vorher-Pruefung aus Abschnitt 5 gegen die neue Rolle
|
||||
ausfuehren.
|
||||
|
||||
4. API-Dienst neu erstellen (`docker compose up -d --build --force-recreate
|
||||
api`, siehe Betriebshandbuch Abschnitt 4).
|
||||
|
||||
## 5. Die Vorher-Pruefung
|
||||
|
||||
```bash
|
||||
TESSERA_PREFLIGHT_DATABASE_URL="postgresql://tessera_app:<kennwort>@<host>:5432/tessera" \
|
||||
node apps/api/scripts/rls-preflight.mjs
|
||||
```
|
||||
|
||||
Das Werkzeug meldet fuenf Pruefungen: Rollenrechte, Setzbarkeit des
|
||||
Mandantenkontexts, keine sichtbaren Zeilen ohne Kontext, sichtbare Zeilen mit
|
||||
Kontext, vollstaendige Zugriffsrechte. Es veraendert nichts — es liest
|
||||
ausschliesslich.
|
||||
|
||||
**Ein gruener Bericht ist die Freigabebedingung fuer Schritt 4 aus Abschnitt
|
||||
4 — aber er ist keine Freigabe fuer die Umstellung insgesamt.** Er bestaetigt
|
||||
nur, dass die Datenbankseite stimmt. Die 182 unskalierten Zugriffe aus
|
||||
Abschnitt 3 misst er nicht und kann sie nicht messen — dafuer muesste er den
|
||||
Anwendungscode lesen, nicht die Datenbank.
|
||||
|
||||
## 6. Der Rueckweg
|
||||
|
||||
**Wenn die API nach einer Umstellung nicht mehr hochkommt:**
|
||||
|
||||
Das sichtbare Bild: der `api`-Container bleibt ungesund (Healthcheck
|
||||
schlaegt fehl), und das Protokoll (`docker compose logs api`) zeigt einen
|
||||
Authentifizierungs- oder Rechtefehler von PostgreSQL — etwa eine
|
||||
fehlgeschlagene Anmeldung oder eine verweigerte Berechtigung.
|
||||
|
||||
Weg zurueck, zwei Schritte:
|
||||
|
||||
1. `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung (Rolle
|
||||
`tessera`) setzen.
|
||||
2. `docker compose up -d --build --force-recreate api` ausfuehren.
|
||||
|
||||
Die Rolle `tessera_app` darf dabei unangetastet bestehen bleiben — sie
|
||||
schadet nicht, solange niemand mit ihr verbindet.
|
||||
|
||||
**Wichtiger Hinweis fuer den Server:** `/opt/tessera/docker-compose.yml` ist
|
||||
keine Arbeitskopie dieses Repositorys. Aenderungen an `docker-compose.yml`
|
||||
oder `docker-compose.prod.yml` in diesem Repository erreichen den Server
|
||||
nicht automatisch. Wer `TESSERA_MIGRATE_DATABASE_URL` dort verfuegbar haben
|
||||
will, traegt die Zeile selbst in `/opt/tessera/docker-compose.yml` ein
|
||||
(vorher sichern) — genau wie es bereits fuer andere nachtraegliche
|
||||
Variablen dokumentiert ist (Betriebshandbuch, Abschnitt zur
|
||||
Server-Konfiguration).
|
||||
Reference in New Issue
Block a user