Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die Nummer aus dem Default darf der Trigger aber nicht einfach uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele Transaktion mit groesserer ID koennte zuerst verketten, und die Kette liesse sich in ID-Reihenfolge nicht mehr pruefen. Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe eingeht. Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen genau 20 aufeinanderfolgende IDs bei intakter Kette. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
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",
|
|
"schluessel_slug_gueltig",
|
|
"schluessel_geloescht_ohne_private_key",
|
|
].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 Slugs zu, die in Dateinamen passen (#012)", async () => {
|
|
for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
|
|
await expect(schluesselAnlegen({ slug })).rejects.toThrow();
|
|
}
|
|
await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
|
|
});
|
|
|
|
it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
|
|
await expect(
|
|
schluesselAnlegen({
|
|
privatGeloeschtAm: new Date(),
|
|
privatVerschluesselt: Buffer.from("x"),
|
|
dekVerschluesselt: Buffer.from("y"),
|
|
masterKeyVersion: 1,
|
|
}),
|
|
).rejects.toThrow();
|
|
await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
|
|
});
|
|
|
|
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("vergibt IDs ohne Zweierschritte (#032)", async () => {
|
|
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
|
|
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
|
|
expect(b.id).toBe(a.id + 1n);
|
|
expect(c.id).toBe(a.id + 2n);
|
|
const [pruefung] = await prisma.$queryRaw<
|
|
{ ergebnis: bigint | null }[]
|
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
|
expect(pruefung?.ergebnis).toBeNull();
|
|
});
|
|
|
|
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
|
|
const erster = await eintragen("eins");
|
|
const zweiter = await prisma.protokoll.create({
|
|
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
|
|
});
|
|
expect(zweiter.id).toBe(erster.id + 1n);
|
|
expect(zweiter.vorgaengerHash).toBe(erster.hash);
|
|
});
|
|
|
|
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
|
|
const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
|
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
|
|
expect(ids.at(-1)! - ids[0]!).toBe(19n);
|
|
const [pruefung] = await prisma.$queryRaw<
|
|
{ ergebnis: bigint | null }[]
|
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
|
expect(pruefung?.ergebnis).toBeNull();
|
|
});
|
|
});
|