Phase 11: Freigabereife
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s

Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0:
Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine
Pipeline.

Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export`
stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen,
implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht
abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person,
mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern
auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht.
Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20.

Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person
gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie
getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum
Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber
eine Selbstauskunft an die Geraeteakten.

Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch
lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien
der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt
jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT
kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log
aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts
haette darauf hingewiesen. Gefunden haben es vier Datenbanktests
unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie
verlangt.

Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein
act_runner registriert. Das steht so im README und im Devlog, damit sich
niemand auf eine Pruefung verlaesst, die nicht stattfindet.

601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig
durchgefuehrt und die Tests danach erneut gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 17:16:08 +02:00
co-authored by Claude Opus 5
parent 91742a0dd7
commit 9156dcc144
21 changed files with 2495 additions and 8 deletions
+270
View File
@@ -0,0 +1,270 @@
/**
* Wiederherstellung aus einer Sicherung.
*
* npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00
* npm run wiederherstellen -- --quelle ... --wirklich
*
* Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine
* Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es
* gibt keinen Weg zurück außer einer weiteren Sicherung.
*
* Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner
* kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es
* an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf
* einmal durchgeführt und im Sicherungskonzept vermerkt.
*/
import "dotenv/config";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs";
import { join, relative, sep } from "node:path";
import { Client as MinioClient } from "minio";
import { zugangAus } from "./sicherung-zugang";
import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren";
function log(nachricht: string): void {
console.info(`[wiederherstellen] ${nachricht}`);
}
function argument(name: string): string | undefined {
const stelle = process.argv.indexOf(`--${name}`);
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
}
const wirklich = process.argv.includes("--wirklich");
/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */
function alleDateien(wurzel: string): string[] {
if (!existsSync(wurzel)) return [];
const gefunden: string[] = [];
for (const eintrag of readdirSync(wurzel)) {
const pfad = join(wurzel, eintrag);
if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad));
else gefunden.push(pfad);
}
return gefunden;
}
function datenbankZurueck(quelle: string): void {
const { benutzer, passwort, datenbank } = zugangAus(
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
);
const dump = join(quelle, "datenbank.dump");
if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`);
log("Spiele die Datenbank zurück ...");
/**
* `--clean --if-exists` räumt vorher auf.
*
* Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein
* Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer
* Wiederherstellung herauskommen kann, weil es auf den ersten Blick
* funktioniert.
*/
const lauf = spawnSync(
"docker",
[
"compose",
"exec",
"-T",
"postgres",
"pg_restore",
"--clean",
"--if-exists",
"--no-owner",
"--username",
benutzer,
"--dbname",
datenbank,
],
{
input: readFileSync(dump),
encoding: "buffer",
maxBuffer: 2_000_000_000,
shell: true,
env: { ...process.env, PGPASSWORD: passwort },
},
);
// pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP-
// Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb
// wird die Ausgabe angesehen statt nur der Rückgabewert.
const ausgabe = lauf.stderr?.toString() ?? "";
const echteFehler = ausgabe
.split("\n")
.filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist"));
if (echteFehler.length > 0) {
throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`);
}
log("Datenbank zurückgespielt.");
}
async function dateienZurueck(quelle: string): Promise<number> {
const bucket = process.env.S3_BUCKET ?? "florian";
const ordner = join(quelle, "dateien");
const client = new MinioClient({
endPoint: process.env.S3_ENDPOINT ?? "localhost",
port: Number(process.env.S3_PORT ?? 9000),
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
accessKey: process.env.S3_ACCESS_KEY ?? "",
secretKey: process.env.S3_SECRET_KEY ?? "",
});
if (!(await client.bucketExists(bucket))) {
await client.makeBucket(bucket);
log(`Bucket "${bucket}" angelegt.`);
}
const dateien = alleDateien(ordner);
let hochgeladen = 0;
for (const pfad of dateien) {
// Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit
// Schrägstrichen, auch unter Windows.
const schluessel = relative(ordner, pfad).split(sep).join("/");
await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size);
hochgeladen += 1;
if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`);
}
return hochgeladen;
}
/**
* Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat.
*
* Warum das nötig ist, steht in `sicherung-sperren.ts`: die
* Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu
* angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump
* können nur hinzufügen, nie wegnehmen.
*
* Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein
* bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den
* ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung
* zurückspielt.
*/
async function sperrenSetzen(): Promise<void> {
const { PrismaClient } = await import("@prisma/client");
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } },
});
try {
const rolleDa = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
);
if (!rolleDa[0]?.da) {
log("Die Rolle florian_app gibt es nicht — Sperren übersprungen.");
return;
}
for (const anweisung of sperrenSql()) {
await prisma.$executeRawUnsafe(anweisung);
}
log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`);
// Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab.
const zeilen = await prisma.$queryRawUnsafe<
Array<{ table_name: string; privilege_type: string }>
>(
`SELECT table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'florian_app'`,
);
const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
const befunde = verboteneRechte()
.filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`))
.map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`);
if (befunde.length > 0) {
log("");
log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:");
befunde.forEach((zeile) => log(zeile));
log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben.");
process.exitCode = 1;
return;
}
log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen.");
} finally {
await prisma.$disconnect();
}
}
async function main(): Promise<void> {
const quelle = argument("quelle");
if (!quelle) {
console.error("[wiederherstellen] --quelle <Verzeichnis> fehlt.");
process.exit(1);
}
const manifestPfad = join(quelle, "manifest.json");
if (!existsSync(manifestPfad)) {
console.error(
`[wiederherstellen] In ${quelle} liegt keine manifest.json. ` +
"Ist das wirklich ein Sicherungsverzeichnis?",
);
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as {
erstelltAm: string;
version: string;
datenbank: { bytes: number };
dateien: { anzahl: number; bytes: number };
};
const alter = Math.round(
(Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24,
);
log("");
log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`);
log(`Version: ${manifest.version}`);
log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
log(`Dateien: ${manifest.dateien.anzahl} Stück`);
log("");
if (!wirklich) {
log("PROBELAUF — es wird nichts geändert.");
log("");
log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende.");
log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben.");
return;
}
if (alter > 30) {
log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`);
}
log("Spiele zurück — der vorhandene Bestand wird überschrieben.");
datenbankZurueck(quelle);
const dateien = await dateienZurueck(quelle);
log(`Dateien: ${dateien} zurückgespielt.`);
await sperrenSetzen();
log("");
log("Fertig. Bitte jetzt von Hand prüfen:");
log(" 1. Anmelden — kommt jemand hinein?");
log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?");
log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen.");
log("");
log("Erst danach gilt die Sicherung als geprüft.");
}
main().catch((fehler) => {
console.error("[wiederherstellen] Abbruch:", fehler);
process.exit(1);
});