Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich nur über Neuanlage, Zuweisen und Widerruf ersetzen. Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen ist, beginnt die Rotation wieder bei Schritt 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
549 lines
19 KiB
Plaintext
549 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. Nach einer abgebrochenen Rotation
|
||
/// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
|
||
/// (partieller Index, #033).
|
||
vorgaengerId String?
|
||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||
/// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
|
||
nachfolgerListe 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[]
|
||
|
||
@@index([vorgaengerId])
|
||
}
|
||
|
||
/// 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 {
|
||
/// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
|
||
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
|
||
id BigInt @id @default(0)
|
||
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
|
||
}
|