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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:12:06 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit 03c7c45576
15 changed files with 1480 additions and 13 deletions
+472 -3
View File
@@ -1,4 +1,9 @@
// SSHKeyVault – Datenmodell. Die vollstaendigen Modelle entstehen in #003.
// SSHKeyVault – Datenmodell (#003).
//
// Bezeichner sind deutsch (siehe CLAUDE.md, Namensabbildung). Regeln, die auch
// gelten muessen, wenn jemand an der Service-Schicht vorbei schreibt, stehen als
// CHECK, partieller Index oder Trigger in der Migration – tests/datenbankregeln.test.ts
// zaehlt sie namentlich auf.
generator client {
provider = "prisma-client"
@@ -9,8 +14,472 @@ datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------- Aufzaehlungen
enum Rolle {
ADMIN
OPERATOR
LESER
}
enum SchluesselTyp {
ED25519
RSA4096
// Nur beim Import: fremde Typen (ecdsa, sk-*) werden verwaltet, aber nie erzeugt.
ANDERER
}
enum SchluesselKategorie {
PERSON
DIENST
DEPLOYMENT
}
enum SchluesselStatus {
AKTIV
ROTIERT
WIDERRUFEN
ABGELAUFEN
}
/// Wie sich sshkv auf einem Host anmeldet (Entscheidung 1 der Freigabe).
enum Anmeldeart {
DIENSTBENUTZER
ROOT
}
enum DriftZustand {
UNBEKANNT
SYNCHRON
DRIFT
}
enum HostSchluesselStatus {
/// Gesehen, aber noch nicht bestaetigt (TOFU).
AUSSTEHEND
VERTRAUT
ABGELEHNT
/// War vertraut und wurde nach Bestaetigung durch einen neuen Key abgeloest.
ERSETZT
}
enum VerwendungsZustand {
GEPLANT
AUSGEROLLT
ABWEICHUNG
ENTFERNEN
ENTFERNT
FEHLGESCHLAGEN
}
enum WiderrufArt {
LOESCHEN
UMBENENNEN
}
enum SudoModus {
KEINER
BEFEHLE
VOLL
}
enum AbweichungsArt {
UNBEKANNTER_SCHLUESSEL
FEHLENDER_SCHLUESSEL
ZUSAETZLICH_IM_BLOCK
DATEI_GEAENDERT
RECHTE
HOSTKEY_GEAENDERT
PRIVATE_KEY_FEHLT
}
enum VorgangsArt {
HOSTKEY_ABRUFEN
ERREICHBARKEIT
BOOTSTRAP
TROCKENLAUF
AUSROLLEN
ABGLEICH
VERBINDUNGSTEST
ROTATION
WIDERRUF
PRIVATE_KEY_ABHOLEN
}
enum VorgangsStatus {
WARTEND
LAEUFT
WARTET_AUF_BESTAETIGUNG
ERLEDIGT
FEHLGESCHLAGEN
ABGEBROCHEN
}
enum ProtokollErgebnis {
ERFOLG
FEHLER
VERWEIGERT
}
// ---------------------------------------------------------------- Benutzer und Personen
/// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen.
model Benutzer {
id String @id @default(cuid())
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
oidcSub String? @unique
email String? @unique
/// Anmeldename des Notfall-Admins.
anmeldename String? @unique
name String
rolle Rolle @default(LESER)
aktiv Boolean @default(true)
istNotfall Boolean @default(false)
passwortHash String?
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
totpGeheimnis Bytes?
totpEingerichtetAm DateTime?
/// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal.
totpLetzterSchritt BigInt?
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig.
sitzungenGueltigAb DateTime @default(now())
/// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten.
bestaetigtAm DateTime?
letzteAnmeldungAm DateTime?
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
person Person?
angeforderteVorgaenge Vorgang[]
erstellteSchluessel Schluessel[] @relation("SchluesselErsteller")
}
/// Ein Mensch, dem Personen-Keys gehoeren – Admin, Mitarbeiter, Dienstleister.
model Person {
id String @id @default(cuid())
name String
email String?
organisation String?
notiz String?
/// Verknuepfung fuer den Self-Service „Meine Schluessel“.
benutzerId String? @unique
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
archiviertAm DateTime?
angelegtAm DateTime @default(now())
schluessel Schluessel[]
}
model Kunde {
id String @id @default(cuid())
name String @unique
hosts Host[]
}
// ---------------------------------------------------------------- Hosts
model Host {
id String @id @default(cuid())
/// Anzeigename, z. B. srv-pve01.
name String @unique
/// Hostname oder IP, unter der sshkv den Host erreicht.
adresse String
port Int @default(22)
beschreibung String?
tags String[]
kundeId String?
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
anmeldeart Anmeldeart @default(DIENSTBENUTZER)
/// Abweichender Deployment-Key; leer = Instanz-Standard bzw. der der Gruppe.
deploymentSchluesselId String?
deploymentSchluessel Schluessel? @relation("HostDeploymentSchluessel", fields: [deploymentSchluesselId], references: [id], onDelete: Restrict)
/// Zeitpunkt, ab dem sshkv schluesselbasiert arbeiten kann.
bootstrapAm DateTime?
erreichbar Boolean?
zuletztGeprueftAm DateTime?
letzterAbgleichAm DateTime?
driftZustand DriftZustand @default(UNBEKANNT)
/// PermitRootLogin, PasswordAuthentication, AuthorizedKeysFile aus `sshd -T`.
sshdBefund Json?
archiviertAm DateTime?
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
gruppen HostgruppeMitglied[]
hostSchluessel HostSchluessel[]
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
zielkonten Zielkonto[]
beobachtungen Beobachtung[]
abweichungen Abweichung[]
vorgaenge Vorgang[]
}
model Hostgruppe {
id String @id @default(cuid())
name String @unique
beschreibung String?
/// Deployment-Key je Gruppe (spaetere Phase, Datenmodell ist vorbereitet).
deploymentSchluesselId String?
deploymentSchluessel Schluessel? @relation("GruppeDeploymentSchluessel", fields: [deploymentSchluesselId], references: [id], onDelete: Restrict)
angelegtAm DateTime @default(now())
mitglieder HostgruppeMitglied[]
publicVerwendungen PublicVerwendung[]
}
model HostgruppeMitglied {
hostId String
hostgruppeId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
hostgruppe Hostgruppe @relation(fields: [hostgruppeId], references: [id], onDelete: Cascade)
@@id([hostId, hostgruppeId])
}
/// Host-Key eines Hosts (TOFU). Je Host und Algorithmus hoechstens ein vertrauter Key
/// (partieller Eindeutigkeitsindex in der Migration).
model HostSchluessel {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
/// ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa …
algorithmus String
/// Base64 des Key-Blobs, wie in known_hosts.
publicKey String
fingerprint String
status HostSchluesselStatus @default(AUSSTEHEND)
erstmalsGesehen DateTime @default(now())
zuletztGesehen DateTime @default(now())
entschiedenVon String?
entschiedenAm DateTime?
@@unique([hostId, fingerprint])
}
// ---------------------------------------------------------------- Schluessel
model Schluessel {
id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String
fingerprint String @unique
kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm DateTime?
zuletztVerwendetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[]
}
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
model PublicVerwendung {
id String @id @default(cuid())
schluesselId String
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
/// Genau eins von hostId / hostgruppeId (CHECK).
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade)
hostgruppeId String?
hostgruppe Hostgruppe? @relation(fields: [hostgruppeId], references: [id], onDelete: Cascade)
zielbenutzer String
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
optionen String?
zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime?
angelegtVonId String?
angelegtAm DateTime @default(now())
}
/// Soll: Private-Key-Datei eines Benutzers auf einem Host.
model PrivateVerwendung {
id String @id @default(cuid())
schluesselId String
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String
/// Absoluter Pfad oder mit ~/ beginnend.
pfad String
/// { alias, hostName, benutzer, port, identitiesOnly } fuer den Block in ~/.ssh/config.
sshConfig Json?
/// Hosts, deren Host-Key in known_hosts dieses Benutzers gepflegt wird.
knownHostsFuer String[]
beimWiderruf WiderrufArt @default(LOESCHEN)
zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime?
angelegtVonId String?
angelegtAm DateTime @default(now())
}
/// Von sshkv angelegter Zielbenutzer (z. B. deploy) samt sudoers-Datei.
model Zielkonto {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
benutzername String
vonSshkvAngelegt Boolean @default(true)
sudoModus SudoModus @default(KEINER)
sudoBefehle String[]
/// /etc/sudoers.d/sshkv-<benutzer>
sudoersDatei String?
sudoersInhalt String?
angelegtAm DateTime @default(now())
entferntAm DateTime?
@@unique([hostId, benutzername])
}
/// Vorlage fuer Assistenten: „Deployment-Zugang“, „Backup-Zugang“, …
model Vorlage {
id String @id @default(cuid())
name String @unique
beschreibung String?
eingebaut Boolean @default(false)
schluesselTyp SchluesselTyp @default(ED25519)
kategorie SchluesselKategorie @default(DIENST)
standardLaufzeitTage Int?
zielbenutzer String
zielbenutzerAnlegen Boolean @default(false)
sudoModus SudoModus @default(KEINER)
sudoBefehle String[]
/// Mit Platzhaltern, z. B. restrict,from="{{quellHostIp}}".
authorizedKeysOptionen String?
/// z. B. deploy-{{zielHost}}
sshConfigAlias String?
knownHostsEintragen Boolean @default(true)
privatNachAusrollenLoeschen Boolean @default(false)
archiviertAm DateTime?
angelegtAm DateTime @default(now())
schluessel Schluessel[]
}
// ---------------------------------------------------------------- Ist-Zustand
/// Letzter gelesener Stand einer Datei auf einem Host (je Host, Benutzer, Datei eine Zeile).
model Beobachtung {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String
datei String
sha256 String
/// Geparste Zeilen: { imBlock, fingerprint, optionen, kommentar, zeile }
eintraege Json
beobachtetAm DateTime @default(now())
@@unique([hostId, zielbenutzer, datei])
}
model Abweichung {
id String @id @default(cuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
zielbenutzer String?
art AbweichungsArt
fingerprint String?
details Json?
eroeffnetAm DateTime @default(now())
behobenAm DateTime?
@@index([hostId, behobenAm])
}
// ---------------------------------------------------------------- Vorgaenge und Protokoll
/// Fachliche Klammer um einen Job im Worker – die Oberflaeche zeigt hierueber den Fortschritt.
/// `eingabe` traegt nie Geheimnisse (Bootstrap-Passwort reist verschluesselt nur im Job).
model Vorgang {
id String @id @default(cuid())
art VorgangsArt
status VorgangsStatus @default(WARTEND)
angefordertVonId String?
angefordertVon Benutzer? @relation(fields: [angefordertVonId], references: [id], onDelete: SetNull)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
schluesselId String?
schluessel Schluessel? @relation(fields: [schluesselId], references: [id], onDelete: SetNull)
eingabe Json?
/// Ergebnis des Trockenlaufs inkl. Datei-Hashes (optimistische Sperre).
plan Json?
ergebnis Json?
fehler String?
jobId String?
angelegtAm DateTime @default(now())
gestartetAm DateTime?
beendetAm DateTime?
@@index([status, angelegtAm])
}
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
model Protokoll {
id BigInt @id @default(autoincrement())
zeitpunkt DateTime @default(now())
akteurId String?
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
akteurName String
/// z. B. schluessel.erzeugt, hostkey.bestaetigt, verwendung.ausgerollt
aktion String
hostName String?
zielbenutzer String?
fingerprint String?
diff String?
ergebnis ProtokollErgebnis
details Json?
vorgaengerHash String @default("")
hash String @default("")
@@index([zeitpunkt])
@@index([aktion])
}
model Einstellung {
schluessel String @id
wert Json
geaendertAm DateTime @updatedAt
}
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
model Ratenbegrenzung {
schluessel String @id
wert Json
geaendertAm DateTime @updatedAt
fensterStart DateTime
anzahl Int
}