Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub. Warum so: - Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil nur er den Master-Key hat. Passphrase und importierter Private Key reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach. - Ein reiner Public Key ist kein Geheimnis und wird direkt im Web angelegt, mit verstaendlicher Meldung bei einem Duplikat. - Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist, verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf Schluessel ausserhalb des Workers ein festes select. - Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das Loeschen des Private Keys; CHECKs fuer den Slug (landet in Dateinamen) und "geloescht nur ohne Chiffrat". - Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
492 lines
17 KiB
Plaintext
492 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
|
||
}
|
||
|
||
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
|
||
fensterStart DateTime
|
||
anzahl Int
|
||
}
|