Merge branch 'phase/003-datenmodell'

This commit is contained in:
2026-10-12 09:12:06 +02:00
15 changed files with 1480 additions and 13 deletions
+3
View File
@@ -30,3 +30,6 @@ playwright-report/
.DS_Store
.vscode/*
!.vscode/extensions.json
# Worktrees der Sub-Agenten
.claude/worktrees/
+1
View File
@@ -6,3 +6,4 @@ docs
pnpm-lock.yaml
src/generated
prisma/migrations
.claude
+13 -8
View File
@@ -1,23 +1,28 @@
# #003 Datenmodell und Migrationen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** datenbank
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 10 min
## Ziel
Prisma-Schema für alle Phasen (Hosts, Gruppen, Personen, Schlüssel, Verwendungen, Host-Keys, Vorgänge, Protokoll), erste Migration, Seed.
## Aufgaben
- [ ] Schema mit deutschen Bezeichnern (siehe `CLAUDE.md`)
- [ ] Datenbankregeln (CHECK, Trigger) für das, was auch an der Service-Schicht vorbei gelten muss
- [ ] Seed: Notfall-Admin, eingebaute Vorlagen, optional Demo-Daten
- [ ] Test-Datenbank-Setup für Vitest
- [x] Schema mit deutschen Bezeichnern (siehe `CLAUDE.md`)
- [x] Datenbankregeln (CHECK, Trigger) für das, was auch an der Service-Schicht vorbei gelten muss
- [x] Seed: Notfall-Admin, eingebaute Vorlagen (Demo-Daten folgen mit #008)
- [x] Test-Datenbank-Setup für Vitest
## Akzeptanzkriterien
- [ ] Migration läuft auf leerer Datenbank
- [ ] Tests für die Datenbankregeln
- [x] Migration läuft auf leerer Datenbank
- [x] Tests für die Datenbankregeln
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- **Protokoll-Hash in der Datenbank berechnet** (Trigger), nicht in der Anwendung: so kann niemand eine Zeile ohne Verkettung einfügen. Die ID wird erst unter einer Advisory-Sperre vergeben, sonst bricht die Kette bei gleichzeitigem Schreiben (Test mit 20 parallelen Einträgen).
- **TRUNCATE des Protokolls** nur mit `SET LOCAL sshkv.test_modus = 'ja'` – ausschließlich für die Testhilfe. Ein Datenbank-Superuser kann Trigger immer abschalten; dagegen hilft die Hash-Kette (Prüfung findet die manipulierte Zeile) und später der Syslog-Export (#027).
- **Zielbenutzer, Adresse und Private-Key-Pfad per CHECK eingeschränkt:** Diese Werte landen in Shell-Skripten auf den Hosts. Quotierung bleibt Pflicht, aber was gar nicht erst in die Datenbank kommt, kann nichts anrichten.
- **TOTP-Geheimnis mit `SSHKV_APP_SECRET` verschlüsselt,** nicht mit dem Master-Key: geprüft wird es im Webprozess, und der Master-Key bleibt im Worker.
- Ein Host-Key-Wechsel ist eine neue Zeile `AUSSTEHEND` neben der vertrauten; je Host und Algorithmus höchstens ein vertrauter Key (partieller Index).
+2
View File
@@ -37,6 +37,8 @@ export default defineConfig([
"build/**",
"coverage/**",
"next-env.d.ts",
// Worktrees der Sub-Agenten liegen im Projektverzeichnis.
".claude/**",
// Erzeugter Prisma-Client.
"src/generated/**",
"test-results/**",
@@ -0,0 +1,598 @@
-- CreateEnum
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'OPERATOR', 'LESER');
-- CreateEnum
CREATE TYPE "SchluesselTyp" AS ENUM ('ED25519', 'RSA4096', 'ANDERER');
-- CreateEnum
CREATE TYPE "SchluesselKategorie" AS ENUM ('PERSON', 'DIENST', 'DEPLOYMENT');
-- CreateEnum
CREATE TYPE "SchluesselStatus" AS ENUM ('AKTIV', 'ROTIERT', 'WIDERRUFEN', 'ABGELAUFEN');
-- CreateEnum
CREATE TYPE "Anmeldeart" AS ENUM ('DIENSTBENUTZER', 'ROOT');
-- CreateEnum
CREATE TYPE "DriftZustand" AS ENUM ('UNBEKANNT', 'SYNCHRON', 'DRIFT');
-- CreateEnum
CREATE TYPE "HostSchluesselStatus" AS ENUM ('AUSSTEHEND', 'VERTRAUT', 'ABGELEHNT', 'ERSETZT');
-- CreateEnum
CREATE TYPE "VerwendungsZustand" AS ENUM ('GEPLANT', 'AUSGEROLLT', 'ABWEICHUNG', 'ENTFERNEN', 'ENTFERNT', 'FEHLGESCHLAGEN');
-- CreateEnum
CREATE TYPE "WiderrufArt" AS ENUM ('LOESCHEN', 'UMBENENNEN');
-- CreateEnum
CREATE TYPE "SudoModus" AS ENUM ('KEINER', 'BEFEHLE', 'VOLL');
-- CreateEnum
CREATE TYPE "AbweichungsArt" AS ENUM ('UNBEKANNTER_SCHLUESSEL', 'FEHLENDER_SCHLUESSEL', 'ZUSAETZLICH_IM_BLOCK', 'DATEI_GEAENDERT', 'RECHTE', 'HOSTKEY_GEAENDERT', 'PRIVATE_KEY_FEHLT');
-- CreateEnum
CREATE TYPE "VorgangsArt" AS ENUM ('HOSTKEY_ABRUFEN', 'ERREICHBARKEIT', 'BOOTSTRAP', 'TROCKENLAUF', 'AUSROLLEN', 'ABGLEICH', 'VERBINDUNGSTEST', 'ROTATION', 'WIDERRUF', 'PRIVATE_KEY_ABHOLEN');
-- CreateEnum
CREATE TYPE "VorgangsStatus" AS ENUM ('WARTEND', 'LAEUFT', 'WARTET_AUF_BESTAETIGUNG', 'ERLEDIGT', 'FEHLGESCHLAGEN', 'ABGEBROCHEN');
-- CreateEnum
CREATE TYPE "ProtokollErgebnis" AS ENUM ('ERFOLG', 'FEHLER', 'VERWEIGERT');
-- CreateTable
CREATE TABLE "Benutzer" (
"id" TEXT NOT NULL,
"oidcSub" TEXT,
"email" TEXT,
"anmeldename" TEXT,
"name" TEXT NOT NULL,
"rolle" "Rolle" NOT NULL DEFAULT 'LESER',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"istNotfall" BOOLEAN NOT NULL DEFAULT false,
"passwortHash" TEXT,
"totpGeheimnis" BYTEA,
"totpEingerichtetAm" TIMESTAMP(3),
"totpLetzterSchritt" BIGINT,
"sitzungenGueltigAb" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bestaetigtAm" TIMESTAMP(3),
"letzteAnmeldungAm" TIMESTAMP(3),
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Person" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"organisation" TEXT,
"notiz" TEXT,
"benutzerId" TEXT,
"archiviertAm" TIMESTAMP(3),
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Kunde" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Host" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"adresse" TEXT NOT NULL,
"port" INTEGER NOT NULL DEFAULT 22,
"beschreibung" TEXT,
"tags" TEXT[],
"kundeId" TEXT,
"anmeldeart" "Anmeldeart" NOT NULL DEFAULT 'DIENSTBENUTZER',
"deploymentSchluesselId" TEXT,
"bootstrapAm" TIMESTAMP(3),
"erreichbar" BOOLEAN,
"zuletztGeprueftAm" TIMESTAMP(3),
"letzterAbgleichAm" TIMESTAMP(3),
"driftZustand" "DriftZustand" NOT NULL DEFAULT 'UNBEKANNT',
"sshdBefund" JSONB,
"archiviertAm" TIMESTAMP(3),
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Host_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Hostgruppe" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"deploymentSchluesselId" TEXT,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Hostgruppe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HostgruppeMitglied" (
"hostId" TEXT NOT NULL,
"hostgruppeId" TEXT NOT NULL,
CONSTRAINT "HostgruppeMitglied_pkey" PRIMARY KEY ("hostId","hostgruppeId")
);
-- CreateTable
CREATE TABLE "HostSchluessel" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"algorithmus" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"status" "HostSchluesselStatus" NOT NULL DEFAULT 'AUSSTEHEND',
"erstmalsGesehen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztGesehen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"entschiedenVon" TEXT,
"entschiedenAm" TIMESTAMP(3),
CONSTRAINT "HostSchluessel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Schluessel" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"typ" "SchluesselTyp" NOT NULL,
"kategorie" "SchluesselKategorie" NOT NULL,
"status" "SchluesselStatus" NOT NULL DEFAULT 'AKTIV',
"publicKey" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"kommentar" TEXT NOT NULL,
"privatVerschluesselt" BYTEA,
"dekVerschluesselt" BYTEA,
"masterKeyVersion" INTEGER,
"privatGeloeschtAm" TIMESTAMP(3),
"passphraseGeschuetzt" BOOLEAN NOT NULL DEFAULT false,
"instanzStandard" BOOLEAN NOT NULL DEFAULT false,
"personId" TEXT,
"vorlageId" TEXT,
"tags" TEXT[],
"ablaufAm" TIMESTAMP(3),
"zuletztVerwendetAm" TIMESTAMP(3),
"vorgaengerId" TEXT,
"erstelltVonId" TEXT,
"selbstHinterlegt" BOOLEAN NOT NULL DEFAULT false,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Schluessel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PublicVerwendung" (
"id" TEXT NOT NULL,
"schluesselId" TEXT NOT NULL,
"hostId" TEXT,
"hostgruppeId" TEXT,
"zielbenutzer" TEXT NOT NULL,
"optionen" TEXT,
"zustand" "VerwendungsZustand" NOT NULL DEFAULT 'GEPLANT',
"zuletztAngewendetAm" TIMESTAMP(3),
"angelegtVonId" TEXT,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PublicVerwendung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PrivateVerwendung" (
"id" TEXT NOT NULL,
"schluesselId" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"zielbenutzer" TEXT NOT NULL,
"pfad" TEXT NOT NULL,
"sshConfig" JSONB,
"knownHostsFuer" TEXT[],
"beimWiderruf" "WiderrufArt" NOT NULL DEFAULT 'LOESCHEN',
"zustand" "VerwendungsZustand" NOT NULL DEFAULT 'GEPLANT',
"zuletztAngewendetAm" TIMESTAMP(3),
"angelegtVonId" TEXT,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PrivateVerwendung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Zielkonto" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"benutzername" TEXT NOT NULL,
"vonSshkvAngelegt" BOOLEAN NOT NULL DEFAULT true,
"sudoModus" "SudoModus" NOT NULL DEFAULT 'KEINER',
"sudoBefehle" TEXT[],
"sudoersDatei" TEXT,
"sudoersInhalt" TEXT,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"entferntAm" TIMESTAMP(3),
CONSTRAINT "Zielkonto_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vorlage" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"eingebaut" BOOLEAN NOT NULL DEFAULT false,
"schluesselTyp" "SchluesselTyp" NOT NULL DEFAULT 'ED25519',
"kategorie" "SchluesselKategorie" NOT NULL DEFAULT 'DIENST',
"standardLaufzeitTage" INTEGER,
"zielbenutzer" TEXT NOT NULL,
"zielbenutzerAnlegen" BOOLEAN NOT NULL DEFAULT false,
"sudoModus" "SudoModus" NOT NULL DEFAULT 'KEINER',
"sudoBefehle" TEXT[],
"authorizedKeysOptionen" TEXT,
"sshConfigAlias" TEXT,
"knownHostsEintragen" BOOLEAN NOT NULL DEFAULT true,
"privatNachAusrollenLoeschen" BOOLEAN NOT NULL DEFAULT false,
"archiviertAm" TIMESTAMP(3),
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Beobachtung" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"zielbenutzer" TEXT NOT NULL,
"datei" TEXT NOT NULL,
"sha256" TEXT NOT NULL,
"eintraege" JSONB NOT NULL,
"beobachtetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Beobachtung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Abweichung" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"zielbenutzer" TEXT,
"art" "AbweichungsArt" NOT NULL,
"fingerprint" TEXT,
"details" JSONB,
"eroeffnetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"behobenAm" TIMESTAMP(3),
CONSTRAINT "Abweichung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vorgang" (
"id" TEXT NOT NULL,
"art" "VorgangsArt" NOT NULL,
"status" "VorgangsStatus" NOT NULL DEFAULT 'WARTEND',
"angefordertVonId" TEXT,
"hostId" TEXT,
"schluesselId" TEXT,
"eingabe" JSONB,
"plan" JSONB,
"ergebnis" JSONB,
"fehler" TEXT,
"jobId" TEXT,
"angelegtAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gestartetAm" TIMESTAMP(3),
"beendetAm" TIMESTAMP(3),
CONSTRAINT "Vorgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Protokoll" (
"id" BIGSERIAL NOT NULL,
"zeitpunkt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"akteurId" TEXT,
"akteurName" TEXT NOT NULL,
"aktion" TEXT NOT NULL,
"hostName" TEXT,
"zielbenutzer" TEXT,
"fingerprint" TEXT,
"diff" TEXT,
"ergebnis" "ProtokollErgebnis" NOT NULL,
"details" JSONB,
"vorgaengerHash" TEXT NOT NULL DEFAULT '',
"hash" TEXT NOT NULL DEFAULT '',
CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Einstellung" (
"schluessel" TEXT NOT NULL,
"wert" JSONB NOT NULL,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("schluessel")
);
-- CreateTable
CREATE TABLE "Ratenbegrenzung" (
"schluessel" TEXT NOT NULL,
"fensterStart" TIMESTAMP(3) NOT NULL,
"anzahl" INTEGER NOT NULL,
CONSTRAINT "Ratenbegrenzung_pkey" PRIMARY KEY ("schluessel")
);
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_oidcSub_key" ON "Benutzer"("oidcSub");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_anmeldename_key" ON "Benutzer"("anmeldename");
-- CreateIndex
CREATE UNIQUE INDEX "Person_benutzerId_key" ON "Person"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "Kunde_name_key" ON "Kunde"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Hostgruppe_name_key" ON "Hostgruppe"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HostSchluessel_hostId_fingerprint_key" ON "HostSchluessel"("hostId", "fingerprint");
-- CreateIndex
CREATE UNIQUE INDEX "Schluessel_slug_key" ON "Schluessel"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Schluessel_fingerprint_key" ON "Schluessel"("fingerprint");
-- CreateIndex
CREATE UNIQUE INDEX "Schluessel_vorgaengerId_key" ON "Schluessel"("vorgaengerId");
-- CreateIndex
CREATE UNIQUE INDEX "Zielkonto_hostId_benutzername_key" ON "Zielkonto"("hostId", "benutzername");
-- CreateIndex
CREATE UNIQUE INDEX "Vorlage_name_key" ON "Vorlage"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Beobachtung_hostId_zielbenutzer_datei_key" ON "Beobachtung"("hostId", "zielbenutzer", "datei");
-- CreateIndex
CREATE INDEX "Abweichung_hostId_behobenAm_idx" ON "Abweichung"("hostId", "behobenAm");
-- CreateIndex
CREATE INDEX "Vorgang_status_angelegtAm_idx" ON "Vorgang"("status", "angelegtAm");
-- CreateIndex
CREATE INDEX "Protokoll_zeitpunkt_idx" ON "Protokoll"("zeitpunkt");
-- CreateIndex
CREATE INDEX "Protokoll_aktion_idx" ON "Protokoll"("aktion");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_deploymentSchluesselId_fkey" FOREIGN KEY ("deploymentSchluesselId") REFERENCES "Schluessel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Hostgruppe" ADD CONSTRAINT "Hostgruppe_deploymentSchluesselId_fkey" FOREIGN KEY ("deploymentSchluesselId") REFERENCES "Schluessel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HostgruppeMitglied" ADD CONSTRAINT "HostgruppeMitglied_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HostgruppeMitglied" ADD CONSTRAINT "HostgruppeMitglied_hostgruppeId_fkey" FOREIGN KEY ("hostgruppeId") REFERENCES "Hostgruppe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HostSchluessel" ADD CONSTRAINT "HostSchluessel_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schluessel" ADD CONSTRAINT "Schluessel_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schluessel" ADD CONSTRAINT "Schluessel_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schluessel" ADD CONSTRAINT "Schluessel_vorgaengerId_fkey" FOREIGN KEY ("vorgaengerId") REFERENCES "Schluessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schluessel" ADD CONSTRAINT "Schluessel_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicVerwendung" ADD CONSTRAINT "PublicVerwendung_schluesselId_fkey" FOREIGN KEY ("schluesselId") REFERENCES "Schluessel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicVerwendung" ADD CONSTRAINT "PublicVerwendung_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicVerwendung" ADD CONSTRAINT "PublicVerwendung_hostgruppeId_fkey" FOREIGN KEY ("hostgruppeId") REFERENCES "Hostgruppe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PrivateVerwendung" ADD CONSTRAINT "PrivateVerwendung_schluesselId_fkey" FOREIGN KEY ("schluesselId") REFERENCES "Schluessel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PrivateVerwendung" ADD CONSTRAINT "PrivateVerwendung_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Zielkonto" ADD CONSTRAINT "Zielkonto_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Beobachtung" ADD CONSTRAINT "Beobachtung_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Abweichung" ADD CONSTRAINT "Abweichung_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vorgang" ADD CONSTRAINT "Vorgang_angefordertVonId_fkey" FOREIGN KEY ("angefordertVonId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vorgang" ADD CONSTRAINT "Vorgang_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vorgang" ADD CONSTRAINT "Vorgang_schluesselId_fkey" FOREIGN KEY ("schluesselId") REFERENCES "Schluessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- =====================================================================================
-- Datenbankregeln (#003, #005). Sie gelten auch, wenn jemand an der Service-Schicht
-- vorbei schreibt. tests/datenbankregeln.test.ts zaehlt sie namentlich auf.
-- =====================================================================================
-- Private Key, Data-Key und Master-Key-Version gibt es nur zusammen.
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_umschlag_vollstaendig" CHECK (
("privatVerschluesselt" IS NULL AND "dekVerschluesselt" IS NULL AND "masterKeyVersion" IS NULL)
OR ("privatVerschluesselt" IS NOT NULL AND "dekVerschluesselt" IS NOT NULL AND "masterKeyVersion" IS NOT NULL)
);
-- Private Keys von Personen werden nie gespeichert (Plan, Abschnitt Schluessel).
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_person_ohne_private_key" CHECK (
"kategorie" <> 'PERSON' OR "privatVerschluesselt" IS NULL
);
-- Nur ein Deployment-Key kann Instanz-Standard sein, und davon nur einer zugleich.
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_instanzstandard_nur_deployment" CHECK (
NOT "instanzStandard" OR "kategorie" = 'DEPLOYMENT'
);
CREATE UNIQUE INDEX "schluessel_ein_instanzstandard" ON "Schluessel" ("instanzStandard")
WHERE "instanzStandard" AND "status" = 'AKTIV';
-- Zielbenutzer und Adressen landen in Shell-Skripten auf den Hosts. Was hier nicht passt,
-- kommt gar nicht erst bis zur Quotierung.
ALTER TABLE "PublicVerwendung" ADD CONSTRAINT "publicverwendung_zielbenutzer_gueltig" CHECK (
"zielbenutzer" ~ '^[a-z_][a-z0-9_-]{0,31}$'
);
ALTER TABLE "PrivateVerwendung" ADD CONSTRAINT "privateverwendung_zielbenutzer_gueltig" CHECK (
"zielbenutzer" ~ '^[a-z_][a-z0-9_-]{0,31}$'
);
ALTER TABLE "Zielkonto" ADD CONSTRAINT "zielkonto_benutzername_gueltig" CHECK (
"benutzername" ~ '^[a-z_][a-z0-9_-]{0,31}$'
);
ALTER TABLE "Host" ADD CONSTRAINT "host_adresse_gueltig" CHECK (
"adresse" ~ '^[A-Za-z0-9._:-]{1,253}$'
);
ALTER TABLE "Host" ADD CONSTRAINT "host_port_gueltig" CHECK ("port" BETWEEN 1 AND 65535);
-- Pfad eines Private Keys: absolut oder ~/, ohne Steuerzeichen und ohne '..'-Segmente.
ALTER TABLE "PrivateVerwendung" ADD CONSTRAINT "privateverwendung_pfad_gueltig" CHECK (
"pfad" ~ '^(~/|/)[A-Za-z0-9._/@+-]+$' AND "pfad" !~ '(^|/)\.\.(/|$)'
);
-- Eine Verwendung zielt entweder auf einen Host oder auf eine Hostgruppe.
ALTER TABLE "PublicVerwendung" ADD CONSTRAINT "publicverwendung_host_oder_gruppe" CHECK (
("hostId" IS NULL) <> ("hostgruppeId" IS NULL)
);
CREATE UNIQUE INDEX "publicverwendung_eindeutig_host" ON "PublicVerwendung" ("schluesselId", "hostId", "zielbenutzer")
WHERE "hostId" IS NOT NULL AND "zustand" <> 'ENTFERNT';
CREATE UNIQUE INDEX "publicverwendung_eindeutig_gruppe" ON "PublicVerwendung" ("schluesselId", "hostgruppeId", "zielbenutzer")
WHERE "hostgruppeId" IS NOT NULL AND "zustand" <> 'ENTFERNT';
CREATE UNIQUE INDEX "privateverwendung_eindeutig" ON "PrivateVerwendung" ("hostId", "zielbenutzer", "pfad")
WHERE "zustand" <> 'ENTFERNT';
-- Je Host und Algorithmus hoechstens ein vertrauter Host-Key. Ein zweiter waere ein
-- geaenderter Host-Key, der nie still neben dem alten stehen darf.
CREATE UNIQUE INDEX "hostschluessel_ein_vertrauter" ON "HostSchluessel" ("hostId", "algorithmus")
WHERE "status" = 'VERTRAUT';
-- Der Notfall-Admin meldet sich mit Anmeldename und Passwort an.
ALTER TABLE "Benutzer" ADD CONSTRAINT "benutzer_notfall_vollstaendig" CHECK (
NOT "istNotfall" OR ("anmeldename" IS NOT NULL AND "passwortHash" IS NOT NULL)
);
-- =====================================================================================
-- Protokoll: append-only mit Hash-Kette (#005)
-- =====================================================================================
-- Die Hash-Eingabe als eigene Funktion, damit Einfuegen und Pruefen garantiert dasselbe rechnen.
CREATE FUNCTION protokoll_hash_eingabe(z "Protokoll") RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT concat_ws(E'\x1f',
z."vorgaengerHash",
z."id"::text,
to_char(z."zeitpunkt", 'YYYY-MM-DD"T"HH24:MI:SS.MS'),
coalesce(z."akteurId", ''),
z."akteurName",
z."aktion",
coalesce(z."hostName", ''),
coalesce(z."zielbenutzer", ''),
coalesce(z."fingerprint", ''),
coalesce(z."diff", ''),
z."ergebnis"::text,
coalesce(z."details"::text, '')
)
$$;
CREATE FUNCTION protokoll_verketten() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
letzter text;
BEGIN
-- Serialisiert alle Einfuegungen. Die ID wird erst unter der Sperre vergeben: sonst
-- koennte eine spaetere Transaktion mit kleinerer ID nach einer frueheren verketten,
-- und die Kette liesse sich in ID-Reihenfolge nicht mehr pruefen.
PERFORM pg_advisory_xact_lock(hashtext('sshkv_protokoll'));
NEW."id" := nextval(pg_get_serial_sequence('"Protokoll"', 'id'));
SELECT "hash" INTO letzter FROM "Protokoll" ORDER BY "id" DESC LIMIT 1;
NEW."vorgaengerHash" := coalesce(letzter, '');
NEW."hash" := encode(sha256(convert_to(protokoll_hash_eingabe(NEW), 'UTF8')), 'hex');
RETURN NEW;
END;
$$;
CREATE TRIGGER "protokoll_verketten" BEFORE INSERT ON "Protokoll"
FOR EACH ROW EXECUTE FUNCTION protokoll_verketten();
CREATE FUNCTION protokoll_unveraenderlich() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- Nur die Testumgebung darf leeren; die Anwendung setzt diese Einstellung nie.
IF TG_OP = 'TRUNCATE' AND current_setting('sshkv.test_modus', true) = 'ja' THEN
RETURN NULL;
END IF;
RAISE EXCEPTION 'Das Protokoll ist unveränderlich (% verweigert).', TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER "protokoll_kein_update" BEFORE UPDATE ON "Protokoll"
FOR EACH ROW EXECUTE FUNCTION protokoll_unveraenderlich();
CREATE TRIGGER "protokoll_kein_delete" BEFORE DELETE ON "Protokoll"
FOR EACH ROW EXECUTE FUNCTION protokoll_unveraenderlich();
CREATE TRIGGER "protokoll_kein_truncate" BEFORE TRUNCATE ON "Protokoll"
FOR EACH STATEMENT EXECUTE FUNCTION protokoll_unveraenderlich();
-- Liefert die ID der ersten Zeile, deren Hash oder Verkettung nicht stimmt – oder NULL.
CREATE FUNCTION protokoll_kette_pruefen() RETURNS bigint
LANGUAGE plpgsql STABLE AS $$
DECLARE
zeile "Protokoll";
erwartet text := '';
BEGIN
FOR zeile IN SELECT * FROM "Protokoll" ORDER BY "id" LOOP
IF zeile."vorgaengerHash" <> erwartet
OR zeile."hash" <> encode(sha256(convert_to(protokoll_hash_eingabe(zeile), 'UTF8')), 'hex') THEN
RETURN zeile."id";
END IF;
erwartet := zeile."hash";
END LOOP;
RETURN NULL;
END;
$$;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+470 -1
View File
@@ -1,4 +1,9 @@
// SSHKeyVault – Datenmodell. Die vollstaendigen Modelle entstehen in #003.
// SSHKeyVault – Datenmodell (#003).
//
// Bezeichner sind deutsch (siehe CLAUDE.md, Namensabbildung). Regeln, die auch
// gelten muessen, wenn jemand an der Service-Schicht vorbei schreibt, stehen als
// CHECK, partieller Index oder Trigger in der Migration – tests/datenbankregeln.test.ts
// zaehlt sie namentlich auf.
generator client {
provider = "prisma-client"
@@ -9,8 +14,472 @@ datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------- Aufzaehlungen
enum Rolle {
ADMIN
OPERATOR
LESER
}
enum SchluesselTyp {
ED25519
RSA4096
// Nur beim Import: fremde Typen (ecdsa, sk-*) werden verwaltet, aber nie erzeugt.
ANDERER
}
enum SchluesselKategorie {
PERSON
DIENST
DEPLOYMENT
}
enum SchluesselStatus {
AKTIV
ROTIERT
WIDERRUFEN
ABGELAUFEN
}
/// Wie sich sshkv auf einem Host anmeldet (Entscheidung 1 der Freigabe).
enum Anmeldeart {
DIENSTBENUTZER
ROOT
}
enum DriftZustand {
UNBEKANNT
SYNCHRON
DRIFT
}
enum HostSchluesselStatus {
/// Gesehen, aber noch nicht bestaetigt (TOFU).
AUSSTEHEND
VERTRAUT
ABGELEHNT
/// War vertraut und wurde nach Bestaetigung durch einen neuen Key abgeloest.
ERSETZT
}
enum VerwendungsZustand {
GEPLANT
AUSGEROLLT
ABWEICHUNG
ENTFERNEN
ENTFERNT
FEHLGESCHLAGEN
}
enum WiderrufArt {
LOESCHEN
UMBENENNEN
}
enum SudoModus {
KEINER
BEFEHLE
VOLL
}
enum AbweichungsArt {
UNBEKANNTER_SCHLUESSEL
FEHLENDER_SCHLUESSEL
ZUSAETZLICH_IM_BLOCK
DATEI_GEAENDERT
RECHTE
HOSTKEY_GEAENDERT
PRIVATE_KEY_FEHLT
}
enum VorgangsArt {
HOSTKEY_ABRUFEN
ERREICHBARKEIT
BOOTSTRAP
TROCKENLAUF
AUSROLLEN
ABGLEICH
VERBINDUNGSTEST
ROTATION
WIDERRUF
PRIVATE_KEY_ABHOLEN
}
enum VorgangsStatus {
WARTEND
LAEUFT
WARTET_AUF_BESTAETIGUNG
ERLEDIGT
FEHLGESCHLAGEN
ABGEBROCHEN
}
enum ProtokollErgebnis {
ERFOLG
FEHLER
VERWEIGERT
}
// ---------------------------------------------------------------- Benutzer und Personen
/// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen.
model Benutzer {
id String @id @default(cuid())
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
oidcSub String? @unique
email String? @unique
/// Anmeldename des Notfall-Admins.
anmeldename String? @unique
name String
rolle Rolle @default(LESER)
aktiv Boolean @default(true)
istNotfall Boolean @default(false)
passwortHash String?
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
totpGeheimnis Bytes?
totpEingerichtetAm DateTime?
/// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal.
totpLetzterSchritt BigInt?
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig.
sitzungenGueltigAb DateTime @default(now())
/// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten.
bestaetigtAm DateTime?
letzteAnmeldungAm DateTime?
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
person Person?
angeforderteVorgaenge Vorgang[]
erstellteSchluessel Schluessel[] @relation("SchluesselErsteller")
}
/// Ein Mensch, dem Personen-Keys gehoeren – Admin, Mitarbeiter, Dienstleister.
model Person {
id String @id @default(cuid())
name String
email String?
organisation String?
notiz String?
/// Verknuepfung fuer den Self-Service „Meine Schluessel“.
benutzerId String? @unique
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
archiviertAm DateTime?
angelegtAm DateTime @default(now())
schluessel Schluessel[]
}
model Kunde {
id String @id @default(cuid())
name String @unique
hosts Host[]
}
// ---------------------------------------------------------------- Hosts
model Host {
id String @id @default(cuid())
/// Anzeigename, z. B. srv-pve01.
name String @unique
/// Hostname oder IP, unter der sshkv den Host erreicht.
adresse String
port Int @default(22)
beschreibung String?
tags String[]
kundeId String?
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
anmeldeart Anmeldeart @default(DIENSTBENUTZER)
/// Abweichender Deployment-Key; leer = Instanz-Standard bzw. der der Gruppe.
deploymentSchluesselId String?
deploymentSchluessel Schluessel? @relation("HostDeploymentSchluessel", fields: [deploymentSchluesselId], references: [id], onDelete: Restrict)
/// Zeitpunkt, ab dem sshkv schluesselbasiert arbeiten kann.
bootstrapAm DateTime?
erreichbar Boolean?
zuletztGeprueftAm DateTime?
letzterAbgleichAm DateTime?
driftZustand DriftZustand @default(UNBEKANNT)
/// PermitRootLogin, PasswordAuthentication, AuthorizedKeysFile aus `sshd -T`.
sshdBefund Json?
archiviertAm DateTime?
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
gruppen HostgruppeMitglied[]
hostSchluessel HostSchluessel[]
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
zielkonten Zielkonto[]
beobachtungen Beobachtung[]
abweichungen Abweichung[]
vorgaenge Vorgang[]
}
model Hostgruppe {
id String @id @default(cuid())
name String @unique
beschreibung String?
/// Deployment-Key je Gruppe (spaetere Phase, Datenmodell ist vorbereitet).
deploymentSchluesselId String?
deploymentSchluessel Schluessel? @relation("GruppeDeploymentSchluessel", fields: [deploymentSchluesselId], references: [id], onDelete: Restrict)
angelegtAm DateTime @default(now())
mitglieder HostgruppeMitglied[]
publicVerwendungen PublicVerwendung[]
}
model HostgruppeMitglied {
hostId String
hostgruppeId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
hostgruppe Hostgruppe @relation(fields: [hostgruppeId], references: [id], onDelete: Cascade)
@@id([hostId, hostgruppeId])
}
/// Host-Key eines Hosts (TOFU). Je Host und Algorithmus hoechstens ein vertrauter Key
/// (partieller Eindeutigkeitsindex in der Migration).
model HostSchluessel {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
/// ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa …
algorithmus String
/// Base64 des Key-Blobs, wie in known_hosts.
publicKey String
fingerprint String
status HostSchluesselStatus @default(AUSSTEHEND)
erstmalsGesehen DateTime @default(now())
zuletztGesehen DateTime @default(now())
entschiedenVon String?
entschiedenAm DateTime?
@@unique([hostId, fingerprint])
}
// ---------------------------------------------------------------- Schluessel
model Schluessel {
id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String
fingerprint String @unique
kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm DateTime?
zuletztVerwendetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[]
}
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
model PublicVerwendung {
id String @id @default(cuid())
schluesselId String
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
/// Genau eins von hostId / hostgruppeId (CHECK).
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade)
hostgruppeId String?
hostgruppe Hostgruppe? @relation(fields: [hostgruppeId], references: [id], onDelete: Cascade)
zielbenutzer String
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
optionen String?
zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime?
angelegtVonId String?
angelegtAm DateTime @default(now())
}
/// Soll: Private-Key-Datei eines Benutzers auf einem Host.
model PrivateVerwendung {
id String @id @default(cuid())
schluesselId String
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String
/// Absoluter Pfad oder mit ~/ beginnend.
pfad String
/// { alias, hostName, benutzer, port, identitiesOnly } fuer den Block in ~/.ssh/config.
sshConfig Json?
/// Hosts, deren Host-Key in known_hosts dieses Benutzers gepflegt wird.
knownHostsFuer String[]
beimWiderruf WiderrufArt @default(LOESCHEN)
zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime?
angelegtVonId String?
angelegtAm DateTime @default(now())
}
/// Von sshkv angelegter Zielbenutzer (z. B. deploy) samt sudoers-Datei.
model Zielkonto {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
benutzername String
vonSshkvAngelegt Boolean @default(true)
sudoModus SudoModus @default(KEINER)
sudoBefehle String[]
/// /etc/sudoers.d/sshkv-<benutzer>
sudoersDatei String?
sudoersInhalt String?
angelegtAm DateTime @default(now())
entferntAm DateTime?
@@unique([hostId, benutzername])
}
/// Vorlage fuer Assistenten: „Deployment-Zugang“, „Backup-Zugang“, …
model Vorlage {
id String @id @default(cuid())
name String @unique
beschreibung String?
eingebaut Boolean @default(false)
schluesselTyp SchluesselTyp @default(ED25519)
kategorie SchluesselKategorie @default(DIENST)
standardLaufzeitTage Int?
zielbenutzer String
zielbenutzerAnlegen Boolean @default(false)
sudoModus SudoModus @default(KEINER)
sudoBefehle String[]
/// Mit Platzhaltern, z. B. restrict,from="{{quellHostIp}}".
authorizedKeysOptionen String?
/// z. B. deploy-{{zielHost}}
sshConfigAlias String?
knownHostsEintragen Boolean @default(true)
privatNachAusrollenLoeschen Boolean @default(false)
archiviertAm DateTime?
angelegtAm DateTime @default(now())
schluessel Schluessel[]
}
// ---------------------------------------------------------------- Ist-Zustand
/// Letzter gelesener Stand einer Datei auf einem Host (je Host, Benutzer, Datei eine Zeile).
model Beobachtung {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String
datei String
sha256 String
/// Geparste Zeilen: { imBlock, fingerprint, optionen, kommentar, zeile }
eintraege Json
beobachtetAm DateTime @default(now())
@@unique([hostId, zielbenutzer, datei])
}
model Abweichung {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String?
art AbweichungsArt
fingerprint String?
details Json?
eroeffnetAm DateTime @default(now())
behobenAm DateTime?
@@index([hostId, behobenAm])
}
// ---------------------------------------------------------------- Vorgaenge und Protokoll
/// Fachliche Klammer um einen Job im Worker – die Oberflaeche zeigt hierueber den Fortschritt.
/// `eingabe` traegt nie Geheimnisse (Bootstrap-Passwort reist verschluesselt nur im Job).
model Vorgang {
id String @id @default(cuid())
art VorgangsArt
status VorgangsStatus @default(WARTEND)
angefordertVonId String?
angefordertVon Benutzer? @relation(fields: [angefordertVonId], references: [id], onDelete: SetNull)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
schluesselId String?
schluessel Schluessel? @relation(fields: [schluesselId], references: [id], onDelete: SetNull)
eingabe Json?
/// Ergebnis des Trockenlaufs inkl. Datei-Hashes (optimistische Sperre).
plan Json?
ergebnis Json?
fehler String?
jobId String?
angelegtAm DateTime @default(now())
gestartetAm DateTime?
beendetAm DateTime?
@@index([status, angelegtAm])
}
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
model Protokoll {
id BigInt @id @default(autoincrement())
zeitpunkt DateTime @default(now())
akteurId String?
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
akteurName String
/// z. B. schluessel.erzeugt, hostkey.bestaetigt, verwendung.ausgerollt
aktion String
hostName String?
zielbenutzer String?
fingerprint String?
diff String?
ergebnis ProtokollErgebnis
details Json?
vorgaengerHash String @default("")
hash String @default("")
@@index([zeitpunkt])
@@index([aktion])
}
model Einstellung {
schluessel String @id
wert Json
geaendertAm DateTime @updatedAt
}
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
model Ratenbegrenzung {
schluessel String @id
fensterStart DateTime
anzahl Int
}
+53
View File
@@ -0,0 +1,53 @@
import { PrismaPg } from "@prisma/adapter-pg";
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PASSWORT_MINDESTLAENGE, passwortHashen } from "../src/lib/passwort-hash";
import { EINGEBAUTE_VORLAGEN } from "./vorlagen";
/**
* Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen.
*
* Laeuft bei jedem Start des migrate-Dienstes und ist deshalb idempotent. Ein bestehender
* Notfall-Admin wird nie angefasst – sonst setzte jeder Neustart ein in der Oberflaeche
* geaendertes Passwort zurueck.
*/
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }) });
async function notfallAdmin(): Promise<void> {
const anmeldename = process.env["SSHKV_BREAKGLASS_USER"]?.trim() || "notfall";
const passwort = process.env["SSHKV_BREAKGLASS_PASSWORD"] ?? "";
if (await prisma.benutzer.findFirst({ where: { istNotfall: true } })) {
console.log("Notfall-Admin vorhanden – unverändert.");
return;
}
if (passwort.length < PASSWORT_MINDESTLAENGE) {
console.warn(
`Kein Notfall-Admin angelegt: SSHKV_BREAKGLASS_PASSWORD fehlt oder ist kürzer als ${PASSWORT_MINDESTLAENGE} Zeichen.`,
);
return;
}
await prisma.benutzer.create({
data: {
anmeldename,
name: "Notfall-Admin",
rolle: "ADMIN",
istNotfall: true,
passwortHash: await passwortHashen(passwort),
},
});
console.log(`Notfall-Admin „${anmeldename}“ angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.`);
}
async function vorlagen(): Promise<void> {
for (const vorlage of EINGEBAUTE_VORLAGEN) {
await prisma.vorlage.upsert({ where: { name: vorlage.name }, create: vorlage, update: {} });
}
console.log(`${EINGEBAUTE_VORLAGEN.length} eingebaute Vorlagen geprüft.`);
}
try {
await notfallAdmin();
await vorlagen();
} finally {
await prisma.$disconnect();
}
+45
View File
@@ -0,0 +1,45 @@
import type { Prisma } from "../src/generated/prisma/client";
/**
* Eingebaute Vorlagen (#019). Sie werden beim Seed angelegt, aber nie ueberschrieben:
* was ein Admin daran aendert, bleibt.
*/
export const EINGEBAUTE_VORLAGEN: Prisma.VorlageCreateInput[] = [
{
name: "Deployment-Zugang",
beschreibung:
"Projektbezogener Zugang, z. B. für Claude Code: eigener Benutzer deploy, Ablaufdatum, nur von der Quell-IP aus.",
eingebaut: true,
schluesselTyp: "ED25519",
kategorie: "DIENST",
standardLaufzeitTage: 90,
zielbenutzer: "deploy",
zielbenutzerAnlegen: true,
sudoModus: "KEINER",
authorizedKeysOptionen: 'from="{{quellHostIp}}"',
sshConfigAlias: "deploy-{{zielHost}}",
knownHostsEintragen: true,
},
{
name: "Backup-Zugang",
beschreibung: "Host-zu-Host-Zugang für Sicherungen, typischerweise als root, ohne Weiterleitungen.",
eingebaut: true,
schluesselTyp: "ED25519",
kategorie: "DIENST",
zielbenutzer: "root",
authorizedKeysOptionen:
'from="{{quellHostIp}}",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty',
sshConfigAlias: "backup-{{zielHost}}",
knownHostsEintragen: true,
},
{
name: "Admin-Zugang Person",
beschreibung: "Persönlicher Schlüssel eines Admins; der Private Key bleibt bei der Person.",
eingebaut: true,
schluesselTyp: "ED25519",
kategorie: "PERSON",
standardLaufzeitTage: 365,
zielbenutzer: "root",
knownHostsEintragen: false,
},
];
+22
View File
@@ -0,0 +1,22 @@
import { hash, verify } from "@node-rs/argon2";
/**
* Passwort-Hashing fuer den Notfall-Admin (#004): argon2id mit den OWASP-Empfehlungen
* (19 MiB, 2 Durchlaeufe, 1 Thread).
*/
const PARAMETER = { memoryCost: 19456, timeCost: 2, parallelism: 1 } as const;
export const PASSWORT_MINDESTLAENGE = 16;
export async function passwortHashen(passwort: string): Promise<string> {
return hash(passwort, PARAMETER);
}
/** Wirft nie – ein kaputter Hash ist schlicht „falsch“. */
export async function passwortPruefen(hashWert: string, passwort: string): Promise<boolean> {
try {
return await verify(hashWert, passwort);
} catch {
return false;
}
}
+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`),
]);
}
+1 -1
View File
@@ -34,5 +34,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".claude"]
}
+2 -1
View File
@@ -7,8 +7,9 @@ export default defineConfig({
},
test: {
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
exclude: ["tests/integration/**", "node_modules/**"],
exclude: ["tests/integration/**", "node_modules/**", ".claude/**"],
environment: "node",
globalSetup: ["tests/global-setup.ts"],
setupFiles: ["tests/setup-env.ts"],
// Datenbanktests teilen sich eine Datenbank und duerfen sich nicht ueberholen.
fileParallelism: false,