// 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 /// Testnachricht ueber einen Benachrichtigungskanal (#028). BENACHRICHTIGUNG_TEST } enum VorgangsStatus { WARTEND LAEUFT WARTET_AUF_BESTAETIGUNG ERLEDIGT FEHLGESCHLAGEN ABGEBROCHEN } /// Versandweg einer Benachrichtigung (#028). enum MeldungsKanal { EMAIL WEBHOOK } enum ZustellErgebnis { GESENDET FEHLGESCHLAGEN } 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? /// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene /// Keys (#022), sonst fasst sshkv fremde Zeilen nie an. ausserhalbEntfernen Boolean @default(false) 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- 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 } /// Versandprotokoll der Benachrichtigungen (#028): je Anlass und Kanal hoechstens eine Zeile. /// Vor jedem Versand wird hiergegen geprueft – so meldet sshkv dieselbe Abweichung oder dieselbe /// Ablaufstufe nie zweimal, auch wenn ein Zeitplan doppelt feuert oder der Worker neu startet. model Zustellung { id String @id @default(cuid()) /// Fingerabdruck des Anlasses, z. B. drift:, ablauf::7:. anlass String kanal MeldungsKanal ergebnis ZustellErgebnis /// Kurzer Fehlertext ohne Zugangsdaten. fehler String? versuche Int @default(1) /// Wann ein Fehlschlag fruehestens erneut versucht wird; leer = nicht mehr. naechsterVersuchAm DateTime? /// Gemeinsame Kennung aller Anlaesse einer Nachricht – die Oberflaeche zeigt je Nachricht eine Zeile. sendung String betreff String zeitpunkt DateTime @default(now()) @@unique([anlass, kanal]) @@index([zeitpunkt]) @@index([sendung]) } /// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster). model Ratenbegrenzung { schluessel String @id fensterStart DateTime anzahl Int }