Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per Webhook mit HMAC-Signatur, eingestellt unter /einstellungen. Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung, geaenderte Host-Keys im Fuenf-Minuten-Takt sofort. Versand und Testnachricht laufen im Worker, damit der Webprozess keinen Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
542 lines
19 KiB
Plaintext
542 lines
19 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
|
||
/// 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-<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
|
||
}
|
||
|
||
/// 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:<abweichungId>, ablauf:<schluesselId>:7:<datum>.
|
||
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
|
||
}
|