Files
sshkeyvault/prisma/schema.prisma
T
florian.wildeandClaude Opus 5.5 b5b43f41f6 feat(personen): Personen und Self-Service Meine Schluessel (#013)
Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail
zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle
eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung
durch einen Operator oder Admin.

Warum so:
- Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft:
  per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter
  die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt
  emailBestaetigt (Migration); sonst koennte jemand mit selbst
  gewaehlter Adresse die Keys einer fremden Person sehen.
- Die Self-Service-Services nehmen nur den angemeldeten Benutzer und
  leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht
  vorhanden.
- Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung,
  bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per
  Cascade verschwindet.
- Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt
  nachvollziehbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:55 +02:00

495 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
/// 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?
/// 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
}