diff --git a/.gitignore b/.gitignore index 9d2191d..b10e04f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ playwright-report/ .DS_Store .vscode/* !.vscode/extensions.json + +# Worktrees der Sub-Agenten +.claude/worktrees/ diff --git a/.prettierignore b/.prettierignore index f745dc3..3fb5836 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ docs pnpm-lock.yaml src/generated prisma/migrations +.claude diff --git a/docs/issues/003-datenmodell.md b/docs/issues/003-datenmodell.md index a771480..de2505e 100644 --- a/docs/issues/003-datenmodell.md +++ b/docs/issues/003-datenmodell.md @@ -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). diff --git a/eslint.config.mjs b/eslint.config.mjs index 662c31a..b17d147 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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/**", diff --git a/prisma/migrations/20261012070851_grundschema/migration.sql b/prisma/migrations/20261012070851_grundschema/migration.sql new file mode 100644 index 0000000..3c7974e --- /dev/null +++ b/prisma/migrations/20261012070851_grundschema/migration.sql @@ -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; +$$; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9e31ff0..36b04b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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- + 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 - wert Json - geaendertAm DateTime @updatedAt + fensterStart DateTime + anzahl Int } diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..809ff6b --- /dev/null +++ b/prisma/seed.ts @@ -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 { + 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 { + 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(); +} diff --git a/prisma/vorlagen.ts b/prisma/vorlagen.ts new file mode 100644 index 0000000..80cae19 --- /dev/null +++ b/prisma/vorlagen.ts @@ -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, + }, +]; diff --git a/src/lib/passwort-hash.ts b/src/lib/passwort-hash.ts new file mode 100644 index 0000000..aae44f4 --- /dev/null +++ b/src/lib/passwort-hash.ts @@ -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 { + return hash(passwort, PARAMETER); +} + +/** Wirft nie – ein kaputter Hash ist schlicht „falsch“. */ +export async function passwortPruefen(hashWert: string, passwort: string): Promise { + try { + return await verify(hashWert, passwort); + } catch { + return false; + } +} diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts new file mode 100644 index 0000000..5696fdb --- /dev/null +++ b/tests/datenbankregeln.test.ts @@ -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[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(); + }); +}); diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..acb0cd7 --- /dev/null +++ b/tests/global-setup.ts @@ -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", + }); +} diff --git a/tests/hilfen/db.ts b/tests/hilfen/db.ts new file mode 100644 index 0000000..c07ac15 --- /dev/null +++ b/tests/hilfen/db.ts @@ -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 { + 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`), + ]); +} diff --git a/tsconfig.json b/tsconfig.json index 25d752c..68df8d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,5 +34,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".claude"] } diff --git a/vitest.config.mts b/vitest.config.mts index ddeadc2..8560c93 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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,