feat(datenbank): Datenmodell mit Datenbankregeln und Seed (#003)

Alle Modelle fuer die fuenf Phasen in einer Migration, damit spaetere
Phasen keine Umbauten an Kerntabellen brauchen. Was auch an der
Service-Schicht vorbei gelten muss, steht als CHECK, partieller Index
oder Trigger in der Datenbank: Personen-Keys ohne Private Key, genau
ein Instanz-Deployment-Key, ein vertrauter Host-Key je Algorithmus,
shell-sichere Zielbenutzer und Pfade.

Das Protokoll ist append-only und per Hash-Kette verbunden; den Hash
berechnet ein Trigger, damit keine Zeile ohne Verkettung entstehen
kann (#005 baut darauf auf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:12:06 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit 03c7c45576
15 changed files with 1480 additions and 13 deletions
+225
View File
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { datenbankLeeren } from "./hilfen/db";
/**
* Regeln, die in der Datenbank stehen (#003, #005).
*
* Die Liste ist **namentlich**: eine neue Regel in der Migration ohne Eintrag hier faellt
* im ersten Test auf.
*/
const REGELN = [
"schluessel_umschlag_vollstaendig",
"schluessel_person_ohne_private_key",
"schluessel_instanzstandard_nur_deployment",
"publicverwendung_zielbenutzer_gueltig",
"privateverwendung_zielbenutzer_gueltig",
"zielkonto_benutzername_gueltig",
"host_adresse_gueltig",
"host_port_gueltig",
"privateverwendung_pfad_gueltig",
"publicverwendung_host_oder_gruppe",
"benutzer_notfall_vollstaendig",
].sort();
const INDIZES = [
"schluessel_ein_instanzstandard",
"publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig",
"hostschluessel_ein_vertrauter",
].sort();
let zaehler = 0;
async function schluesselAnlegen(
daten: Partial<Parameters<typeof prisma.schluessel.create>[0]["data"]> = {},
) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name: `Test ${zaehler}`,
slug: `test-${zaehler}`,
typ: "ED25519",
kategorie: "DIENST",
publicKey: "ssh-ed25519 AAAA test",
fingerprint: `SHA256:test${zaehler}`,
kommentar: "test",
...daten,
},
});
}
async function hostAnlegen(name = "srv-test01") {
return prisma.host.create({ data: { name, adresse: "10.0.0.1" } });
}
beforeEach(async () => {
await datenbankLeeren();
});
describe("Datenbankregeln", () => {
it("sind vollstaendig aufgezaehlt", async () => {
const checks = await prisma.$queryRaw<{ conname: string }[]>`
SELECT conname FROM pg_constraint WHERE contype = 'c' AND connamespace = 'public'::regnamespace`;
expect(checks.map((c) => c.conname).sort()).toEqual(REGELN);
const indizes = await prisma.$queryRaw<{ indexname: string }[]>`
SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexdef LIKE '% WHERE %'`;
expect(indizes.map((i) => i.indexname).sort()).toEqual(INDIZES);
});
it("verlangt Chiffrat, Data-Key und Version nur zusammen", async () => {
await expect(schluesselAnlegen({ privatVerschluesselt: Buffer.from("x") })).rejects.toThrow();
await expect(
schluesselAnlegen({
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).resolves.toBeDefined();
});
it("speichert keine Private Keys von Personen", async () => {
await expect(
schluesselAnlegen({
kategorie: "PERSON",
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).rejects.toThrow();
});
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
await expect(schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true })).rejects.toThrow();
// Ein rotierter alter Deployment-Key darf daneben stehen bleiben.
await expect(
schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true, status: "ROTIERT" }),
).resolves.toBeDefined();
});
it("weist Zielbenutzer und Adressen ab, die in einer Shell Schaden anrichten koennten", async () => {
const host = await hostAnlegen();
const schluessel = await schluesselAnlegen();
for (const zielbenutzer of ["root; rm -rf /", "Root", "a b", "$(id)", ""]) {
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer },
}),
).rejects.toThrow();
}
await expect(prisma.host.create({ data: { name: "x", adresse: "host;id" } })).rejects.toThrow();
await expect(
prisma.host.create({ data: { name: "y", adresse: "10.0.0.2", port: 70000 } }),
).rejects.toThrow();
await expect(prisma.host.create({ data: { name: "z", adresse: "fe80::1" } })).resolves.toBeDefined();
});
it("prueft Private-Key-Pfade", async () => {
const host = await hostAnlegen();
const schluessel = await schluesselAnlegen();
const anlegen = (pfad: string) =>
prisma.privateVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad },
});
await expect(anlegen("~/.ssh/id_ed25519_backup")).resolves.toBeDefined();
await expect(anlegen("/root/.ssh/id_x")).resolves.toBeDefined();
for (const pfad of ["relativ/pfad", "~/../etc/shadow", "/etc/../x", "~/a b", "~/x\ny"]) {
await expect(anlegen(pfad)).rejects.toThrow();
}
});
it("verlangt bei einer Public-Verwendung genau Host oder Gruppe", async () => {
const host = await hostAnlegen();
const gruppe = await prisma.hostgruppe.create({ data: { name: "Proxmox" } });
const schluessel = await schluesselAnlegen();
await expect(
prisma.publicVerwendung.create({ data: { schluesselId: schluessel.id, zielbenutzer: "root" } }),
).rejects.toThrow();
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, hostgruppeId: gruppe.id, zielbenutzer: "root" },
}),
).rejects.toThrow();
await prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
});
// Doppelt nur, solange die erste nicht entfernt ist.
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
}),
).rejects.toThrow();
});
it("laesst je Host und Algorithmus nur einen vertrauten Host-Key zu", async () => {
const host = await hostAnlegen();
const basis = { hostId: host.id, algorithmus: "ssh-ed25519", publicKey: "AAAA" };
await prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:a", status: "VERTRAUT" } });
await expect(
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "VERTRAUT" } }),
).rejects.toThrow();
await expect(
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "AUSSTEHEND" } }),
).resolves.toBeDefined();
});
it("verlangt beim Notfall-Admin Anmeldename und Passwort", async () => {
await expect(prisma.benutzer.create({ data: { name: "Notfall", istNotfall: true } })).rejects.toThrow();
});
});
describe("Protokoll", () => {
async function eintragen(aktion: string) {
return prisma.protokoll.create({
data: { akteurName: "Test", aktion, ergebnis: "ERFOLG", details: { a: 1 } },
});
}
it("verkettet jede Zeile mit ihrer Vorgaengerin", async () => {
const erster = await eintragen("eins");
const zweiter = await eintragen("zwei");
expect(erster.vorgaengerHash).toBe("");
expect(erster.hash).toMatch(/^[0-9a-f]{64}$/);
expect(zweiter.vorgaengerHash).toBe(erster.hash);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
it("verweigert Aendern, Loeschen und Leeren", async () => {
const eintrag = await eintragen("eins");
await expect(
prisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "x" } }),
).rejects.toThrow(/unveränderlich/);
await expect(prisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/unveränderlich/);
await expect(prisma.$executeRawUnsafe(`TRUNCATE "Protokoll"`)).rejects.toThrow(/unveränderlich/);
});
it("findet eine manipulierte Zeile", async () => {
await eintragen("eins");
const zweiter = await eintragen("zwei");
await eintragen("drei");
// Ein Angreifer mit Datenbankrechten schaltet den Trigger ab und aendert eine Zeile.
await prisma.$transaction([
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
]);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBe(zweiter.id);
});
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { execFileSync } from "node:child_process";
import "dotenv/config";
/**
* Spielt vor dem Testlauf alle Migrationen in die Testdatenbank ein.
*
* Absichtlich gegen TEST_DATABASE_URL und nie gegen DATABASE_URL: die Hilfen in
* tests/hilfen/db.ts leeren vor jedem Test alle Tabellen.
*/
export default function vorbereiten(): void {
const url = process.env["TEST_DATABASE_URL"];
if (!url) {
throw new Error(
"TEST_DATABASE_URL fehlt (siehe .env.example). Datenbanktests brauchen eine eigene Datenbank.",
);
}
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: url },
stdio: "pipe",
});
}
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from "@/lib/db";
/**
* Leert alle Tabellen der Testdatenbank.
*
* Das Protokoll laesst sich nur leeren, weil die Sitzung `sshkv.test_modus` setzt – der
* Trigger verweigert TRUNCATE sonst (#005). Die Einstellung gilt nur fuer diese Transaktion.
*/
export async function datenbankLeeren(): Promise<void> {
const tabellen = await prisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
if (tabellen.length === 0) return;
const liste = tabellen.map((t) => `"${t.tablename}"`).join(", ");
await prisma.$transaction([
prisma.$executeRawUnsafe(`SET LOCAL sshkv.test_modus = 'ja'`),
prisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`),
]);
}