Merge branch 'phase/003-datenmodell'
This commit is contained in:
@@ -30,3 +30,6 @@ playwright-report/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Worktrees der Sub-Agenten
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ docs
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
src/generated
|
src/generated
|
||||||
prisma/migrations
|
prisma/migrations
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
# #003 Datenmodell und Migrationen
|
# #003 Datenmodell und Migrationen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** datenbank
|
**Labels:** datenbank
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Prisma-Schema für alle Phasen (Hosts, Gruppen, Personen, Schlüssel, Verwendungen, Host-Keys, Vorgänge, Protokoll), erste Migration, Seed.
|
Prisma-Schema für alle Phasen (Hosts, Gruppen, Personen, Schlüssel, Verwendungen, Host-Keys, Vorgänge, Protokoll), erste Migration, Seed.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Schema mit deutschen Bezeichnern (siehe `CLAUDE.md`)
|
- [x] Schema mit deutschen Bezeichnern (siehe `CLAUDE.md`)
|
||||||
- [ ] Datenbankregeln (CHECK, Trigger) für das, was auch an der Service-Schicht vorbei gelten muss
|
- [x] Datenbankregeln (CHECK, Trigger) für das, was auch an der Service-Schicht vorbei gelten muss
|
||||||
- [ ] Seed: Notfall-Admin, eingebaute Vorlagen, optional Demo-Daten
|
- [x] Seed: Notfall-Admin, eingebaute Vorlagen (Demo-Daten folgen mit #008)
|
||||||
- [ ] Test-Datenbank-Setup für Vitest
|
- [x] Test-Datenbank-Setup für Vitest
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Migration läuft auf leerer Datenbank
|
- [x] Migration läuft auf leerer Datenbank
|
||||||
- [ ] Tests für die Datenbankregeln
|
- [x] Tests für die Datenbankregeln
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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).
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"coverage/**",
|
"coverage/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Worktrees der Sub-Agenten liegen im Projektverzeichnis.
|
||||||
|
".claude/**",
|
||||||
// Erzeugter Prisma-Client.
|
// Erzeugter Prisma-Client.
|
||||||
"src/generated/**",
|
"src/generated/**",
|
||||||
"test-results/**",
|
"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;
|
||||||
|
$$;
|
||||||
@@ -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
@@ -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 {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
@@ -9,8 +14,472 @@ datasource db {
|
|||||||
provider = "postgresql"
|
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 {
|
model Einstellung {
|
||||||
schluessel String @id
|
schluessel String @id
|
||||||
wert Json
|
wert Json
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
||||||
|
model Ratenbegrenzung {
|
||||||
|
schluessel String @id
|
||||||
|
fensterStart DateTime
|
||||||
|
anzahl Int
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -34,5 +34,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", ".claude"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -7,8 +7,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
|
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
|
||||||
exclude: ["tests/integration/**", "node_modules/**"],
|
exclude: ["tests/integration/**", "node_modules/**", ".claude/**"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
globalSetup: ["tests/global-setup.ts"],
|
||||||
setupFiles: ["tests/setup-env.ts"],
|
setupFiles: ["tests/setup-env.ts"],
|
||||||
// Datenbanktests teilen sich eine Datenbank und duerfen sich nicht ueberholen.
|
// Datenbanktests teilen sich eine Datenbank und duerfen sich nicht ueberholen.
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user