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:
+472
-3
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user