Phase 11: Freigabereife (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Die Sperren der Anwendungsrolle — nach einer Wiederherstellung.
|
||||
*
|
||||
* Hintergrund, und er ist unerwartet:
|
||||
*
|
||||
* Beim ersten Start richtet `docker/postgres/10-init-app-role.sh` eine
|
||||
* Vorgabeprivilegierung ein — jede künftig angelegte Tabelle bekommt
|
||||
* automatisch alle Rechte für `florian_app`. Das ist im Betrieb genau richtig:
|
||||
* eine neue Migration soll nicht daran scheitern, dass jemand vergessen hat,
|
||||
* Rechte zu vergeben.
|
||||
*
|
||||
* `pg_restore` legt die Tabellen aber ebenfalls neu an. Die Vorgabe greift
|
||||
* also auch dort — und die `GRANT`-Anweisungen aus dem Dump können nur
|
||||
* hinzufügen, nie wegnehmen. Nach einer Wiederherstellung stünde die
|
||||
* Anwendungsrolle damit wieder mit allen Rechten da: sie dürfte das Audit-Log
|
||||
* ändern, Wiki-Fassungen löschen, Kiosk-Vorgänge umschreiben.
|
||||
*
|
||||
* Nichts an der Anwendung würde darauf hinweisen. Die Unveränderlichkeit, auf
|
||||
* der jeder Nachweis gegenüber der Unfallkasse beruht, wäre stillschweigend
|
||||
* weg — bis jemand danach fragt.
|
||||
*
|
||||
* Gefunden haben das die Datenbanktests nach der ersten Rückspielprobe.
|
||||
* Deshalb gibt es diese Datei: **eine** Liste, aus der sowohl das
|
||||
* Wiederherstellen als auch das Prüfen der Sperren entsteht. Zwei Listen
|
||||
* wären zwei Wahrheiten.
|
||||
*/
|
||||
|
||||
/** Tabelle → Rechte, die `florian_app` NICHT haben darf, mit Begründung. */
|
||||
export const SPERREN: Array<{ tabelle: string; rechte: string[]; grund: string }> = [
|
||||
{
|
||||
tabelle: "berechtigung",
|
||||
rechte: ["INSERT", "UPDATE", "DELETE"],
|
||||
grund: "systemweiter Rechtekatalog — nur lesbar",
|
||||
},
|
||||
{
|
||||
tabelle: "audit_log",
|
||||
rechte: ["UPDATE", "DELETE"],
|
||||
grund: "append-only; das Löschen geht nur im Aufbewahrungslauf",
|
||||
},
|
||||
{
|
||||
tabelle: "kiosk_vorgang",
|
||||
rechte: ["UPDATE", "DELETE"],
|
||||
grund: "übermittelte Terminalvorgänge sind unveränderlich",
|
||||
},
|
||||
{
|
||||
tabelle: "druckmessung",
|
||||
rechte: ["UPDATE", "DELETE"],
|
||||
grund: "Atemschutzüberwachung — Druckwerte werden nie korrigiert",
|
||||
},
|
||||
{
|
||||
tabelle: "atemschutz_ereignis",
|
||||
rechte: ["UPDATE", "DELETE"],
|
||||
grund: "wie oben: der Verlauf einer Überwachung bleibt, wie er war",
|
||||
},
|
||||
{
|
||||
tabelle: "benachrichtigung",
|
||||
rechte: ["DELETE"],
|
||||
grund: "was zugestellt wurde, bleibt nachweisbar",
|
||||
},
|
||||
{
|
||||
tabelle: "ki_vorgang",
|
||||
rechte: ["DELETE"],
|
||||
grund: "das KI-Protokoll belegt, was die Maschine getan hat",
|
||||
},
|
||||
{
|
||||
tabelle: "wikiversion",
|
||||
rechte: ["UPDATE", "DELETE"],
|
||||
grund: "jede Fassung einer Wiki-Seite bleibt erhalten",
|
||||
},
|
||||
{
|
||||
tabelle: "kassenabschluss",
|
||||
rechte: ["DELETE"],
|
||||
grund: "ein festgeschriebener Abschluss lässt sich nicht wegräumen",
|
||||
},
|
||||
{
|
||||
tabelle: "benutzer",
|
||||
rechte: ["INSERT", "DELETE"],
|
||||
grund: "Konten entstehen und vergehen über den Systemzugang",
|
||||
},
|
||||
];
|
||||
|
||||
/** Die SQL-Anweisungen, die die Sperren wiederherstellen. */
|
||||
export function sperrenSql(): string[] {
|
||||
return SPERREN.map(
|
||||
({ tabelle, rechte }) => `REVOKE ${rechte.join(", ")} ON "${tabelle}" FROM florian_app`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle Kombinationen, die nach der Wiederherstellung fehlen müssen. */
|
||||
export function verboteneRechte(): Array<{ tabelle: string; recht: string }> {
|
||||
return SPERREN.flatMap(({ tabelle, rechte }) =>
|
||||
rechte.map((recht) => ({ tabelle, recht })),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Die Verbindungsangaben für `pg_dump` und `pg_restore`.
|
||||
*
|
||||
* Eigene Datei, weil beide Skripte sie brauchen — und weil ein falsch
|
||||
* zerlegter Zugang der unangenehmste Fehler in einem Sicherungsskript wäre:
|
||||
* es liefe durch, schriebe eine Datei, und die enthielte die falsche
|
||||
* Datenbank. Auffallen würde es beim Zurückspielen, also genau dann, wenn man
|
||||
* die Sicherung braucht.
|
||||
*/
|
||||
|
||||
export interface Zugang {
|
||||
benutzer: string;
|
||||
passwort: string;
|
||||
datenbank: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Verbindungszeichenkette in ihre Teile.
|
||||
*
|
||||
* Die URL lässt sich nicht als Ganzes an `pg_dump` durchreichen: Prisma hängt
|
||||
* eigene Parameter an (`?schema=public`), die es mit "invalid URI query
|
||||
* parameter" ablehnt. Und der Port darin gilt vom Rechner aus — im Container,
|
||||
* wo die Werkzeuge laufen, liegt Postgres auf 5432.
|
||||
*/
|
||||
export function zugangAus(roh: string | undefined): Zugang {
|
||||
if (!roh) {
|
||||
throw new Error("DIRECT_DATABASE_URL fehlt.");
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(roh);
|
||||
} catch {
|
||||
throw new Error(`Die Verbindungszeichenkette ist keine gültige URL: ${roh}`);
|
||||
}
|
||||
|
||||
if (!url.protocol.startsWith("postgres")) {
|
||||
throw new Error(`Erwartet wird eine postgresql://-Adresse, bekommen: ${url.protocol}//`);
|
||||
}
|
||||
|
||||
const datenbank = url.pathname.replace(/^\//, "");
|
||||
if (!datenbank) {
|
||||
throw new Error("In der Verbindungszeichenkette fehlt der Datenbankname.");
|
||||
}
|
||||
|
||||
return {
|
||||
benutzer: decodeURIComponent(url.username),
|
||||
passwort: decodeURIComponent(url.password),
|
||||
datenbank,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Sicherung: Datenbank und Dateien in ein Verzeichnis.
|
||||
*
|
||||
* npm run sicherung
|
||||
* npm run sicherung -- --ziel D:\backups
|
||||
*
|
||||
* Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne
|
||||
* Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht.
|
||||
* Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine
|
||||
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.
|
||||
*
|
||||
* Gesichert wird beides, und zwar zusammen:
|
||||
*
|
||||
* * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen
|
||||
* Format (`--format=custom`), weil sich daraus einzelne Tabellen
|
||||
* zurückholen lassen und es kleiner ist.
|
||||
* * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos,
|
||||
* Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere.
|
||||
*
|
||||
* Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit
|
||||
* Dateien von gestern ergibt Datensätze, die auf nichts zeigen.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Client as MinioClient } from "minio";
|
||||
import { zugangAus } from "./sicherung-zugang";
|
||||
|
||||
const ZIEL_VORGABE = "sicherungen";
|
||||
const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres";
|
||||
|
||||
function log(nachricht: string): void {
|
||||
console.info(`[sicherung] ${nachricht}`);
|
||||
}
|
||||
|
||||
/** Ein Zeitstempel, der sich als Verzeichnisname eignet. */
|
||||
function stempel(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
}
|
||||
|
||||
function argument(name: string): string | undefined {
|
||||
const stelle = process.argv.indexOf(`--${name}`);
|
||||
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Datenbank sichern.
|
||||
*
|
||||
* Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version
|
||||
* des Clients muss zur Serverversion passen, und im Container ist sie das
|
||||
* garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit
|
||||
* einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt.
|
||||
*/
|
||||
function datenbankSichern(ziel: string): { datei: string; bytes: number } {
|
||||
const { benutzer, passwort, datenbank } = zugangAus(
|
||||
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
|
||||
);
|
||||
|
||||
const datei = join(ziel, "datenbank.dump");
|
||||
log(`Datenbank aus Container "${CONTAINER}" ...`);
|
||||
|
||||
const lauf = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"postgres",
|
||||
"pg_dump",
|
||||
"--format=custom",
|
||||
// Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server
|
||||
// anders), die RECHTE dagegen schon. Ohne sie waeren nach dem
|
||||
// Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle
|
||||
// duerfte das Audit-Log aendern, Wiki-Fassungen loeschen,
|
||||
// Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen.
|
||||
"--no-owner",
|
||||
"--username",
|
||||
benutzer,
|
||||
"--dbname",
|
||||
datenbank,
|
||||
],
|
||||
{
|
||||
encoding: "buffer",
|
||||
maxBuffer: 2_000_000_000,
|
||||
shell: true,
|
||||
env: { ...process.env, PGPASSWORD: passwort },
|
||||
},
|
||||
);
|
||||
|
||||
if (lauf.status !== 0) {
|
||||
const fehler = lauf.stderr?.toString() ?? "unbekannt";
|
||||
throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`);
|
||||
}
|
||||
|
||||
writeFileSync(datei, lauf.stdout);
|
||||
return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength };
|
||||
}
|
||||
|
||||
/** Alle Objekte aus MinIO holen. */
|
||||
async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> {
|
||||
const bucket = process.env.S3_BUCKET ?? "florian";
|
||||
|
||||
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))) {
|
||||
log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`);
|
||||
return { anzahl: 0, bytes: 0 };
|
||||
}
|
||||
|
||||
const ordner = join(ziel, "dateien");
|
||||
mkdirSync(ordner, { recursive: true });
|
||||
|
||||
let anzahl = 0;
|
||||
let bytes = 0;
|
||||
|
||||
const strom = client.listObjectsV2(bucket, "", true);
|
||||
|
||||
for await (const objekt of strom) {
|
||||
if (!objekt.name) continue;
|
||||
|
||||
const pfad = join(ordner, objekt.name);
|
||||
mkdirSync(dirname(pfad), { recursive: true });
|
||||
|
||||
const quelle = await client.getObject(bucket, objekt.name);
|
||||
await pipeline(quelle, createWriteStream(pfad));
|
||||
|
||||
anzahl += 1;
|
||||
bytes += objekt.size ?? 0;
|
||||
|
||||
if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`);
|
||||
}
|
||||
|
||||
return { anzahl, bytes };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const wurzel = argument("ziel") ?? ZIEL_VORGABE;
|
||||
const ziel = join(wurzel, stempel());
|
||||
mkdirSync(ziel, { recursive: true });
|
||||
|
||||
log(`Ziel: ${ziel}`);
|
||||
|
||||
const datenbank = datenbankSichern(ziel);
|
||||
log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
const dateien = await dateienSichern(ziel);
|
||||
log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
/**
|
||||
* Das Manifest.
|
||||
*
|
||||
* Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim
|
||||
* Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich
|
||||
* eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis
|
||||
* ähnlich hieß.
|
||||
*/
|
||||
const manifest = {
|
||||
erstelltAm: new Date().toISOString(),
|
||||
version: process.env.npm_package_version ?? "unbekannt",
|
||||
datenbank,
|
||||
dateien,
|
||||
pruefsumme: createHash("sha256")
|
||||
.update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`)
|
||||
.digest("hex"),
|
||||
};
|
||||
|
||||
writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
|
||||
log("");
|
||||
log("Fertig. Zum Prüfen der Sicherung:");
|
||||
log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`);
|
||||
log("");
|
||||
log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung.");
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error("[sicherung] Abbruch:", fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user