Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023). Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite nennt die offenen Ziele. Zielkonten lassen sich optional mit entfernen, aber erst nach den Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt. Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie automatisch. Migration 20261012130000_widerruf_eingeleitet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
502 lines
17 KiB
Plaintext
502 lines
17 KiB
Plaintext
// 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"
|
||
output = "../src/generated/prisma"
|
||
}
|
||
|
||
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
|
||
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
|
||
SCHLUESSEL_ERZEUGEN
|
||
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
|
||
SCHLUESSEL_IMPORTIEREN
|
||
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||
PRIVATE_KEY_LOESCHEN
|
||
/// Zielbenutzer anlegen und sudoers setzen (#018).
|
||
ZIELKONTO_ANLEGEN
|
||
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||
ZIELKONTO_ENTFERNEN
|
||
}
|
||
|
||
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
|
||
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
||
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
||
emailBestaetigt Boolean @default(false)
|
||
/// 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?
|
||
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
|
||
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||
widerrufEingeleitetAm DateTime?
|
||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||
vorgaengerId String? @unique
|
||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||
nachfolger Schluessel? @relation("Rotation")
|
||
erstelltVonId String?
|
||
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
|
||
selbstHinterlegt Boolean @default(false)
|
||
angelegtAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
publicVerwendungen PublicVerwendung[]
|
||
privateVerwendungen PrivateVerwendung[]
|
||
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
|
||
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
|
||
vorgaenge Vorgang[]
|
||
}
|
||
|
||
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
|
||
model PublicVerwendung {
|
||
id String @id @default(cuid())
|
||
schluesselId String
|
||
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
|
||
/// Genau eins von hostId / hostgruppeId (CHECK).
|
||
hostId String?
|
||
host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
hostgruppeId String?
|
||
hostgruppe Hostgruppe? @relation(fields: [hostgruppeId], references: [id], onDelete: Cascade)
|
||
zielbenutzer String
|
||
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
||
optionen String?
|
||
zustand VerwendungsZustand @default(GEPLANT)
|
||
zuletztAngewendetAm DateTime?
|
||
angelegtVonId String?
|
||
angelegtAm DateTime @default(now())
|
||
}
|
||
|
||
/// Soll: Private-Key-Datei eines Benutzers auf einem Host.
|
||
model PrivateVerwendung {
|
||
id String @id @default(cuid())
|
||
schluesselId String
|
||
schluessel Schluessel @relation(fields: [schluesselId], references: [id], onDelete: Cascade)
|
||
hostId String
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
zielbenutzer String
|
||
/// Absoluter Pfad oder mit ~/ beginnend.
|
||
pfad String
|
||
/// { alias, hostName, benutzer, port, identitiesOnly } fuer den Block in ~/.ssh/config.
|
||
sshConfig Json?
|
||
/// Hosts, deren Host-Key in known_hosts dieses Benutzers gepflegt wird.
|
||
knownHostsFuer String[]
|
||
beimWiderruf WiderrufArt @default(LOESCHEN)
|
||
zustand VerwendungsZustand @default(GEPLANT)
|
||
zuletztAngewendetAm DateTime?
|
||
angelegtVonId String?
|
||
angelegtAm DateTime @default(now())
|
||
}
|
||
|
||
/// Von sshkv angelegter Zielbenutzer (z. B. deploy) samt sudoers-Datei.
|
||
model Zielkonto {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
benutzername String
|
||
vonSshkvAngelegt Boolean @default(true)
|
||
sudoModus SudoModus @default(KEINER)
|
||
sudoBefehle String[]
|
||
/// /etc/sudoers.d/sshkv-<benutzer>
|
||
sudoersDatei String?
|
||
sudoersInhalt String?
|
||
angelegtAm DateTime @default(now())
|
||
entferntAm DateTime?
|
||
|
||
@@unique([hostId, benutzername])
|
||
}
|
||
|
||
/// Vorlage fuer Assistenten: „Deployment-Zugang“, „Backup-Zugang“, …
|
||
model Vorlage {
|
||
id String @id @default(cuid())
|
||
name String @unique
|
||
beschreibung String?
|
||
eingebaut Boolean @default(false)
|
||
schluesselTyp SchluesselTyp @default(ED25519)
|
||
kategorie SchluesselKategorie @default(DIENST)
|
||
standardLaufzeitTage Int?
|
||
zielbenutzer String
|
||
zielbenutzerAnlegen Boolean @default(false)
|
||
sudoModus SudoModus @default(KEINER)
|
||
sudoBefehle String[]
|
||
/// Mit Platzhaltern, z. B. restrict,from="{{quellHostIp}}".
|
||
authorizedKeysOptionen String?
|
||
/// z. B. deploy-{{zielHost}}
|
||
sshConfigAlias String?
|
||
knownHostsEintragen Boolean @default(true)
|
||
privatNachAusrollenLoeschen Boolean @default(false)
|
||
archiviertAm DateTime?
|
||
angelegtAm DateTime @default(now())
|
||
|
||
schluessel Schluessel[]
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Ist-Zustand
|
||
|
||
/// Letzter gelesener Stand einer Datei auf einem Host (je Host, Benutzer, Datei eine Zeile).
|
||
model Beobachtung {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
zielbenutzer String
|
||
datei String
|
||
sha256 String
|
||
/// Geparste Zeilen: { imBlock, fingerprint, optionen, kommentar, zeile }
|
||
eintraege Json
|
||
beobachtetAm DateTime @default(now())
|
||
|
||
@@unique([hostId, zielbenutzer, datei])
|
||
}
|
||
|
||
model Abweichung {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
zielbenutzer String?
|
||
art AbweichungsArt
|
||
fingerprint String?
|
||
details Json?
|
||
eroeffnetAm DateTime @default(now())
|
||
behobenAm DateTime?
|
||
|
||
@@index([hostId, behobenAm])
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Vorgaenge und Protokoll
|
||
|
||
/// Fachliche Klammer um einen Job im Worker – die Oberflaeche zeigt hierueber den Fortschritt.
|
||
/// `eingabe` traegt nie Geheimnisse (Bootstrap-Passwort reist verschluesselt nur im Job).
|
||
model Vorgang {
|
||
id String @id @default(cuid())
|
||
art VorgangsArt
|
||
status VorgangsStatus @default(WARTEND)
|
||
angefordertVonId String?
|
||
angefordertVon Benutzer? @relation(fields: [angefordertVonId], references: [id], onDelete: SetNull)
|
||
hostId String?
|
||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||
schluesselId String?
|
||
schluessel Schluessel? @relation(fields: [schluesselId], references: [id], onDelete: SetNull)
|
||
eingabe Json?
|
||
/// Ergebnis des Trockenlaufs inkl. Datei-Hashes (optimistische Sperre).
|
||
plan Json?
|
||
ergebnis Json?
|
||
fehler String?
|
||
jobId String?
|
||
angelegtAm DateTime @default(now())
|
||
gestartetAm DateTime?
|
||
beendetAm DateTime?
|
||
|
||
@@index([status, angelegtAm])
|
||
}
|
||
|
||
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
||
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
||
model Protokoll {
|
||
id BigInt @id @default(autoincrement())
|
||
zeitpunkt DateTime @default(now())
|
||
akteurId String?
|
||
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
||
akteurName String
|
||
/// z. B. schluessel.erzeugt, hostkey.bestaetigt, verwendung.ausgerollt
|
||
aktion String
|
||
hostName String?
|
||
zielbenutzer String?
|
||
fingerprint String?
|
||
diff String?
|
||
ergebnis ProtokollErgebnis
|
||
details Json?
|
||
vorgaengerHash String @default("")
|
||
hash String @default("")
|
||
|
||
@@index([zeitpunkt])
|
||
@@index([aktion])
|
||
}
|
||
|
||
model Einstellung {
|
||
schluessel String @id
|
||
wert Json
|
||
geaendertAm DateTime @updatedAt
|
||
}
|
||
|
||
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
||
model Ratenbegrenzung {
|
||
schluessel String @id
|
||
fensterStart DateTime
|
||
anzahl Int
|
||
}
|