Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
20 KiB
Go
480 lines
20 KiB
Go
// Package audit protokolliert sicherheitsrelevante Handlungen.
|
|
//
|
|
// Das Protokoll ist append-only: die Datenbank verhindert Änderungen und
|
|
// Löschungen per Trigger, damit ein Angreifer mit Datenbankzugriff seine Spuren
|
|
// nicht verwischen kann (PROMPT.md §40).
|
|
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
)
|
|
|
|
// Action benennt eine protokollierte Handlung.
|
|
//
|
|
// Die Werte sind Teil des Auditvertrags: Auswertungen und Alarmregeln stützen
|
|
// sich darauf, weshalb bestehende Werte nicht umbenannt werden dürfen.
|
|
type Action string
|
|
|
|
// Sicherheitsereignisse laut PROMPT.md §125.
|
|
const (
|
|
// ActionLoginSucceeded meldet eine erfolgreiche Anmeldung.
|
|
ActionLoginSucceeded Action = "USER_LOGIN_SUCCEEDED"
|
|
// ActionLoginFailed meldet eine fehlgeschlagene Anmeldung.
|
|
ActionLoginFailed Action = "USER_LOGIN_FAILED"
|
|
// ActionLogout meldet eine Abmeldung.
|
|
ActionLogout Action = "USER_LOGOUT"
|
|
// ActionTokenRefreshed meldet die Erneuerung einer Sitzung.
|
|
ActionTokenRefreshed Action = "SESSION_REFRESHED"
|
|
// ActionMFASucceeded meldet einen erfolgreichen zweiten Faktor.
|
|
ActionMFASucceeded Action = "MFA_SUCCEEDED"
|
|
// ActionMFAFailed meldet einen fehlgeschlagenen zweiten Faktor.
|
|
ActionMFAFailed Action = "MFA_FAILED"
|
|
// ActionMFAEnrolled meldet die Einrichtung eines zweiten Faktors.
|
|
ActionMFAEnrolled Action = "MFA_ENROLLED"
|
|
// ActionMFADisabled meldet die Abschaltung eines zweiten Faktors.
|
|
ActionMFADisabled Action = "MFA_DISABLED"
|
|
// ActionRecoveryCodeUsed meldet die Verwendung eines Wiederherstellungscodes.
|
|
ActionRecoveryCodeUsed Action = "RECOVERY_CODE_USED"
|
|
// ActionAccountLocked meldet die Sperre eines Kontos nach zu vielen Fehlversuchen.
|
|
ActionAccountLocked Action = "ACCOUNT_LOCKED"
|
|
// ActionPermissionDenied meldet einen abgewiesenen Zugriff.
|
|
ActionPermissionDenied Action = "PERMISSION_DENIED"
|
|
// ActionUserCreated meldet die Anlage eines Benutzers.
|
|
ActionUserCreated Action = "USER_CREATED"
|
|
// ActionUserUpdated meldet die Änderung eines Benutzers.
|
|
ActionUserUpdated Action = "USER_UPDATED"
|
|
// ActionUserDeleted meldet die Löschung eines Benutzers.
|
|
ActionUserDeleted Action = "USER_DELETED"
|
|
// ActionPasswordChanged meldet eine Passwortänderung.
|
|
ActionPasswordChanged Action = "PASSWORD_CHANGED"
|
|
// ActionRoleAssigned meldet die Zuweisung einer Rolle.
|
|
ActionRoleAssigned Action = "ROLE_ASSIGNED"
|
|
// ActionRoleRevoked meldet den Entzug einer Rolle.
|
|
ActionRoleRevoked Action = "ROLE_REVOKED"
|
|
// ActionRoleCreated meldet die Anlage einer Rolle.
|
|
ActionRoleCreated Action = "ROLE_CREATED"
|
|
|
|
// ActionBackupJobCreated meldet die Anlage eines Sicherungsauftrags.
|
|
ActionBackupJobCreated Action = "BACKUP_JOB_CREATED"
|
|
// ActionBackupJobDeleted meldet die Löschung eines Sicherungsauftrags.
|
|
ActionBackupJobDeleted Action = "BACKUP_JOB_DELETED"
|
|
// ActionBackupJobPaused meldet die Aussetzung eines Sicherungsauftrags.
|
|
//
|
|
// Das Aussetzen ist sicherheitsrelevant: Es lässt den Schutz still
|
|
// auslaufen, ohne dass etwas kaputtgeht. Deshalb wird es protokolliert wie
|
|
// eine Löschung.
|
|
ActionBackupJobPaused Action = "BACKUP_JOB_PAUSED"
|
|
// ActionBackupJobResumed meldet die Fortsetzung eines Sicherungsauftrags.
|
|
ActionBackupJobResumed Action = "BACKUP_JOB_RESUMED"
|
|
// ActionBackupJobRunRequested meldet einen von Hand angestoßenen Lauf.
|
|
ActionBackupJobRunRequested Action = "BACKUP_JOB_RUN_REQUESTED"
|
|
// ActionRestoreRequested meldet eine angeforderte Wiederherstellung.
|
|
//
|
|
// Jede Wiederherstellung wird protokolliert — auch die harmlose. Sie holt
|
|
// Daten zurück, die jemand einmal für schützenswert hielt.
|
|
ActionRestoreRequested Action = "RESTORE_REQUESTED"
|
|
// ActionRestoreOverwriteRequested meldet eine überschreibende Wiederherstellung.
|
|
//
|
|
// Sie richtet sich gegen Daten, die es noch gibt, und ist damit etwas
|
|
// anderes als eine Wiederherstellung an einen leeren Ort.
|
|
ActionRestoreOverwriteRequested Action = "RESTORE_OVERWRITE_REQUESTED"
|
|
// ActionRestoreCancelled meldet den Abbruch einer Wiederherstellung.
|
|
ActionRestoreCancelled Action = "RESTORE_CANCELLED"
|
|
// ActionRestoreResumed meldet die Fortsetzung einer Wiederherstellung.
|
|
ActionRestoreResumed Action = "RESTORE_RESUMED"
|
|
// ActionVerificationRequested meldet eine angeforderte Prüfung.
|
|
//
|
|
// Eine Prüfung ist harmlos — sie schreibt nichts. Protokolliert wird sie
|
|
// trotzdem, und zwar aus der Gegenrichtung: Wenn später jemand behauptet,
|
|
// ein Backup sei geprüft worden, muss nachvollziehbar sein, wer das wann
|
|
// veranlasst hat.
|
|
ActionVerificationRequested Action = "VERIFICATION_REQUESTED"
|
|
// ActionVerificationCancelled meldet den Abbruch einer Prüfung.
|
|
ActionVerificationCancelled Action = "VERIFICATION_CANCELLED"
|
|
|
|
// ActionBackupDeleted meldet die Löschung eines Backups.
|
|
//
|
|
// Die destruktivste Handlung der ganzen Anlage: Sie vernichtet genau die
|
|
// Daten, für deren Schutz das Produkt existiert.
|
|
ActionBackupDeleted Action = "BACKUP_DELETED"
|
|
// ActionBackupDeletionDenied meldet eine am Schutz gescheiterte Löschung.
|
|
//
|
|
// Der abgewehrte Versuch wird protokolliert wie der erfolgreiche. Wer
|
|
// wiederholt gegen den Aufbewahrungsschutz läuft, tut entweder etwas
|
|
// Falsches oder etwas Böses — beides will man sehen.
|
|
ActionBackupDeletionDenied Action = "BACKUP_DELETION_DENIED"
|
|
// ActionLegalHoldPlaced meldet einen angeordneten Legal Hold.
|
|
ActionLegalHoldPlaced Action = "LEGAL_HOLD_PLACED"
|
|
// ActionLegalHoldReleased meldet die Aufhebung eines Legal Holds.
|
|
//
|
|
// Die Aufhebung wiegt schwerer als die Anordnung: Sie gibt Daten frei, die
|
|
// jemand aus rechtlichen Gründen halten wollte.
|
|
ActionLegalHoldReleased Action = "LEGAL_HOLD_RELEASED"
|
|
// ActionRetentionExtended meldet eine verlängerte Aufbewahrungsfrist.
|
|
ActionRetentionExtended Action = "RETENTION_EXTENDED"
|
|
// ActionRetentionApplied meldet eine angewandte Aufbewahrungsregel.
|
|
ActionRetentionApplied Action = "RETENTION_APPLIED"
|
|
// ActionRetentionPolicyCreated meldet eine neue Aufbewahrungsregel.
|
|
ActionRetentionPolicyCreated Action = "RETENTION_POLICY_CREATED"
|
|
// ActionRetentionPolicyUpdated meldet eine geänderte Aufbewahrungsregel.
|
|
//
|
|
// PROMPT.md §16 verlangt für Retention-Änderungen ausdrücklich: bestätigt,
|
|
// protokolliert, auditierbar. Eine verschärfte Regel löscht beim nächsten
|
|
// Lauf Backups, die es heute noch gibt.
|
|
ActionRetentionPolicyUpdated Action = "RETENTION_POLICY_UPDATED"
|
|
// ActionRetentionPolicyDeleted meldet eine gelöschte Aufbewahrungsregel.
|
|
ActionRetentionPolicyDeleted Action = "RETENTION_POLICY_DELETED"
|
|
|
|
// ActionNotificationChannelCreated meldet einen neuen Benachrichtigungskanal.
|
|
//
|
|
// Wer Benachrichtigungen umleitet, kann damit erreichen, dass niemand mehr
|
|
// von einem Ausfall erfährt. Das ist ein sicherheitsrelevanter Vorgang.
|
|
ActionNotificationChannelCreated Action = "NOTIFICATION_CHANNEL_CREATED"
|
|
// ActionNotificationChannelDeleted meldet einen entfernten Kanal.
|
|
ActionNotificationChannelDeleted Action = "NOTIFICATION_CHANNEL_DELETED"
|
|
|
|
// ActionReportGenerated meldet einen erzeugten Bericht.
|
|
//
|
|
// Ein Bericht liest nur und veraendert nichts — protokolliert wird er
|
|
// trotzdem: Er ist ein **Datenexport**. Der Bericht fuer Pruefungen und der
|
|
// Sicherheitsbericht nennen die Schwachstellen der Anlage in geordneter
|
|
// Form, und wer eine solche Datei aus dem System traegt, gehoert zu den
|
|
// Fragen, die ein Pruefer als erstes stellt.
|
|
ActionReportGenerated Action = "REPORT_GENERATED"
|
|
|
|
// ActionHypervisorClusterCreated meldet eine eingerichtete Virtualisierungsumgebung.
|
|
//
|
|
// Mit ihr kommen Zugangsdaten ins System, die Sicherungen anstoßen und
|
|
// Maschinen wiederherstellen können. Wer sie einträgt, gehört protokolliert.
|
|
ActionHypervisorClusterCreated Action = "HYPERVISOR_CLUSTER_CREATED"
|
|
// ActionHypervisorClusterDeleted meldet eine entfernte Virtualisierungsumgebung.
|
|
//
|
|
// Die Gegenrichtung wiegt schwerer: Ohne Verbund lässt sich keine
|
|
// gesicherte Maschine mehr zurückspielen. Die Backups bleiben, der Weg
|
|
// dorthin ist weg.
|
|
ActionHypervisorClusterDeleted Action = "HYPERVISOR_CLUSTER_DELETED"
|
|
|
|
// ActionRepositoryRegistered meldet ein eingetragenes Repository.
|
|
//
|
|
// Mit ihm bekommt die Anlage ein neues Sicherungsziel. Wer eines einträgt,
|
|
// bestimmt, wohin Daten geschrieben werden — das gehört protokolliert.
|
|
ActionRepositoryRegistered Action = "REPOSITORY_REGISTERED"
|
|
// ActionRepositoryStatusChanged meldet einen geänderten Betriebszustand.
|
|
//
|
|
// Ein Ziel aus dem Betrieb zu nehmen, hält Sicherungen an. Ohne Eintrag
|
|
// sucht später jemand den Grund für ausbleibende Backups an der falschen
|
|
// Stelle.
|
|
ActionRepositoryStatusChanged Action = "REPOSITORY_STATUS_CHANGED"
|
|
|
|
// ActionBackupRunCancelled meldet den Abbruch eines Laufs.
|
|
//
|
|
// Ein Abbruch ist destruktiv: Er lässt eine begonnene Sicherung unvollendet.
|
|
ActionBackupRunCancelled Action = "BACKUP_RUN_CANCELLED"
|
|
// ActionRoleUpdated meldet die Änderung einer Rolle.
|
|
ActionRoleUpdated Action = "ROLE_UPDATED"
|
|
// ActionRoleDeleted meldet die Löschung einer Rolle.
|
|
ActionRoleDeleted Action = "ROLE_DELETED"
|
|
)
|
|
|
|
// Result ist der Ausgang einer protokollierten Handlung.
|
|
type Result string
|
|
|
|
const (
|
|
// ResultSuccess bedeutet: die Handlung wurde ausgeführt.
|
|
ResultSuccess Result = "success"
|
|
// ResultFailure bedeutet: die Handlung schlug fehl.
|
|
ResultFailure Result = "failure"
|
|
// ResultDenied bedeutet: die Handlung wurde mangels Berechtigung abgewiesen.
|
|
ResultDenied Result = "denied"
|
|
)
|
|
|
|
// Event beschreibt eine zu protokollierende Handlung.
|
|
type Event struct {
|
|
// UserID ist der Handelnde. Leer bei einer Anmeldung mit unbekanntem Konto.
|
|
UserID *uuid.UUID
|
|
// ActorUsername ist der Anmeldename des Handelnden. Er bleibt erhalten,
|
|
// auch wenn der Benutzer später gelöscht wird.
|
|
ActorUsername string
|
|
// Action benennt die Handlung.
|
|
Action Action
|
|
// EntityType benennt die Art des betroffenen Objekts.
|
|
EntityType string
|
|
// EntityID benennt das betroffene Objekt.
|
|
EntityID *uuid.UUID
|
|
// Result ist der Ausgang der Handlung.
|
|
Result Result
|
|
// IPAddress ist die Herkunft der Anfrage.
|
|
IPAddress string
|
|
// UserAgent ist die Kennung des verwendeten Programms.
|
|
UserAgent string
|
|
// Details trägt unbedenklichen Zusatzkontext. Niemals Secrets.
|
|
Details map[string]any
|
|
// CorrelationID verknüpft das Ereignis mit den Logzeilen derselben Operation.
|
|
CorrelationID string
|
|
}
|
|
|
|
// Recorder schreibt Auditereignisse.
|
|
type Recorder interface {
|
|
// Record schreibt ein Ereignis. Ein Fehler wird zurückgegeben und darf vom
|
|
// Aufrufer nicht ignoriert werden.
|
|
Record(recordContext context.Context, auditEvent Event) error
|
|
// Query liest Ereignisse nach Filterkriterien.
|
|
Query(queryContext context.Context, queryFilter Filter) ([]StoredEvent, int64, error)
|
|
}
|
|
|
|
// StoredEvent ist ein gelesenes Auditereignis.
|
|
type StoredEvent struct {
|
|
// ID ist der Bezeichner des Ereignisses.
|
|
ID uuid.UUID `json:"id"`
|
|
// UserID ist der Handelnde, sofern bekannt.
|
|
UserID *uuid.UUID `json:"user_id"`
|
|
// ActorUsername ist der Anmeldename des Handelnden.
|
|
ActorUsername string `json:"actor_username"`
|
|
// Action benennt die Handlung.
|
|
Action string `json:"action"`
|
|
// EntityType benennt die Art des betroffenen Objekts.
|
|
EntityType string `json:"entity_type,omitempty"`
|
|
// EntityID benennt das betroffene Objekt.
|
|
EntityID *uuid.UUID `json:"entity_id,omitempty"`
|
|
// Result ist der Ausgang der Handlung.
|
|
Result string `json:"result"`
|
|
// IPAddress ist die Herkunft der Anfrage.
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
// UserAgent ist die Kennung des verwendeten Programms.
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
// Details trägt unbedenklichen Zusatzkontext.
|
|
Details map[string]any `json:"details,omitempty"`
|
|
// CorrelationID verknüpft das Ereignis mit dem Serverlog.
|
|
CorrelationID *uuid.UUID `json:"correlation_id,omitempty"`
|
|
// CreatedAt ist der Zeitpunkt des Ereignisses in UTC.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Filter schränkt eine Auditabfrage ein.
|
|
type Filter struct {
|
|
// UserID beschränkt auf einen Handelnden.
|
|
UserID *uuid.UUID
|
|
// Action beschränkt auf eine Handlung.
|
|
Action string
|
|
// Result beschränkt auf einen Ausgang.
|
|
Result string
|
|
// From beschränkt auf Ereignisse ab diesem Zeitpunkt.
|
|
From *time.Time
|
|
// To beschränkt auf Ereignisse bis zu diesem Zeitpunkt.
|
|
To *time.Time
|
|
// Page ist die gewünschte Seite, beginnend bei 1.
|
|
Page int
|
|
// PageSize ist die Anzahl der Einträge je Seite.
|
|
PageSize int
|
|
}
|
|
|
|
// PostgresRecorder schreibt Auditereignisse nach PostgreSQL.
|
|
type PostgresRecorder struct {
|
|
// connectionPool ist der Datenbankpool.
|
|
connectionPool *pgxpool.Pool
|
|
// logger protokolliert Schreibfehler zusätzlich im Serverlog.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewPostgresRecorder erzeugt einen Recorder auf Basis von PostgreSQL.
|
|
func NewPostgresRecorder(connectionPool *pgxpool.Pool, baseLogger *slog.Logger) *PostgresRecorder {
|
|
return &PostgresRecorder{
|
|
connectionPool: connectionPool,
|
|
logger: logging.WithComponent(baseLogger, "audit"),
|
|
}
|
|
}
|
|
|
|
// Record schreibt ein Auditereignis.
|
|
//
|
|
// Schlägt der Schreibvorgang fehl, wird der Fehler zusätzlich als CRITICAL
|
|
// geloggt: ein verlorenes Auditereignis ist ein Sicherheitsvorfall und darf
|
|
// nicht unbemerkt bleiben (PROMPT.md §140).
|
|
func (recorder *PostgresRecorder) Record(recordContext context.Context, auditEvent Event) error {
|
|
// Der Kontext der Details wird als JSON abgelegt. Ein nicht serialisierbarer
|
|
// Wert darf das Ereignis nicht verhindern - lieber ohne Details protokollieren.
|
|
var encodedDetails []byte
|
|
if len(auditEvent.Details) > 0 {
|
|
serializedDetails, marshalError := json.Marshal(auditEvent.Details)
|
|
if marshalError != nil {
|
|
recorder.logger.Warn("auditdetails konnten nicht serialisiert werden",
|
|
slog.String("action", string(auditEvent.Action)))
|
|
} else {
|
|
encodedDetails = serializedDetails
|
|
}
|
|
}
|
|
|
|
// Eine Nil-UUID ist kein gültiger Benutzer, sondern der Ausdruck von
|
|
// "kein Benutzer" — etwa bei der Erstinbetriebnahme oder einem Systemvorgang.
|
|
// Ungeprüft weitergereicht verletzte sie den Fremdschlüssel und kostete das
|
|
// Ereignis, obwohl die Handlung selbst stattgefunden hat.
|
|
actorUserID := auditEvent.UserID
|
|
if actorUserID != nil && *actorUserID == uuid.Nil {
|
|
actorUserID = nil
|
|
}
|
|
|
|
affectedEntityID := auditEvent.EntityID
|
|
if affectedEntityID != nil && *affectedEntityID == uuid.Nil {
|
|
affectedEntityID = nil
|
|
}
|
|
|
|
// Eine unlesbare IP-Adresse darf das Protokollieren nicht verhindern.
|
|
var parsedIPAddress *string
|
|
if auditEvent.IPAddress != "" {
|
|
if _, addressError := netip.ParseAddr(auditEvent.IPAddress); addressError == nil {
|
|
parsedIPAddress = &auditEvent.IPAddress
|
|
}
|
|
}
|
|
|
|
var parsedCorrelationID *uuid.UUID
|
|
if auditEvent.CorrelationID != "" {
|
|
if correlationUUID, parseError := uuid.Parse(auditEvent.CorrelationID); parseError == nil {
|
|
parsedCorrelationID = &correlationUUID
|
|
}
|
|
}
|
|
|
|
const insertStatement = `
|
|
INSERT INTO audit_events
|
|
(user_id, actor_username, action, entity_type, entity_id, result,
|
|
ip_address, user_agent, details, correlation_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
|
|
|
|
_, insertError := recorder.connectionPool.Exec(recordContext, insertStatement,
|
|
actorUserID,
|
|
nullIfEmpty(auditEvent.ActorUsername),
|
|
string(auditEvent.Action),
|
|
nullIfEmpty(auditEvent.EntityType),
|
|
affectedEntityID,
|
|
string(auditEvent.Result),
|
|
parsedIPAddress,
|
|
nullIfEmpty(auditEvent.UserAgent),
|
|
encodedDetails,
|
|
parsedCorrelationID,
|
|
)
|
|
|
|
if insertError != nil {
|
|
// Das Ereignis geht verloren - das ist gravierend und wird deshalb
|
|
// mindestens im Serverlog festgehalten.
|
|
recorder.logger.Error("auditereignis konnte nicht gespeichert werden",
|
|
slog.String("action", string(auditEvent.Action)),
|
|
slog.String("result", string(auditEvent.Result)),
|
|
slog.String("actor", auditEvent.ActorUsername),
|
|
slog.String("error", insertError.Error()),
|
|
)
|
|
|
|
return fmt.Errorf("das auditereignis konnte nicht gespeichert werden: %w", insertError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Query liest Auditereignisse nach Filterkriterien.
|
|
//
|
|
// Der zweite Rückgabewert ist die Gesamtzahl passender Ereignisse für die Pagination.
|
|
func (recorder *PostgresRecorder) Query(queryContext context.Context, queryFilter Filter) ([]StoredEvent, int64, error) {
|
|
// Die Bedingungen werden ausschließlich als Parameter gebunden;
|
|
// es fließt niemals Benutzereingabe in den SQL-Text.
|
|
whereClause := " WHERE 1=1"
|
|
queryArguments := make([]any, 0, 6)
|
|
|
|
if queryFilter.UserID != nil {
|
|
queryArguments = append(queryArguments, *queryFilter.UserID)
|
|
whereClause += fmt.Sprintf(" AND user_id = $%d", len(queryArguments))
|
|
}
|
|
|
|
if queryFilter.Action != "" {
|
|
queryArguments = append(queryArguments, queryFilter.Action)
|
|
whereClause += fmt.Sprintf(" AND action = $%d", len(queryArguments))
|
|
}
|
|
|
|
if queryFilter.Result != "" {
|
|
queryArguments = append(queryArguments, queryFilter.Result)
|
|
whereClause += fmt.Sprintf(" AND result = $%d", len(queryArguments))
|
|
}
|
|
|
|
if queryFilter.From != nil {
|
|
queryArguments = append(queryArguments, *queryFilter.From)
|
|
whereClause += fmt.Sprintf(" AND created_at >= $%d", len(queryArguments))
|
|
}
|
|
|
|
if queryFilter.To != nil {
|
|
queryArguments = append(queryArguments, *queryFilter.To)
|
|
whereClause += fmt.Sprintf(" AND created_at <= $%d", len(queryArguments))
|
|
}
|
|
|
|
var totalCount int64
|
|
countError := recorder.connectionPool.QueryRow(queryContext,
|
|
"SELECT count(*) FROM audit_events"+whereClause, queryArguments...).Scan(&totalCount)
|
|
if countError != nil {
|
|
return nil, 0, fmt.Errorf("die anzahl der auditereignisse konnte nicht ermittelt werden: %w", countError)
|
|
}
|
|
|
|
// Die Sortierung ist absteigend: die jüngsten Ereignisse sind die relevanten.
|
|
listStatement := `
|
|
SELECT id, user_id, coalesce(actor_username, ''), action, coalesce(entity_type, ''),
|
|
entity_id, result, coalesce(host(ip_address), ''), coalesce(user_agent, ''),
|
|
details, correlation_id, created_at
|
|
FROM audit_events` + whereClause + fmt.Sprintf(
|
|
" ORDER BY created_at DESC, id DESC LIMIT $%d OFFSET $%d",
|
|
len(queryArguments)+1, len(queryArguments)+2)
|
|
|
|
queryArguments = append(queryArguments, queryFilter.PageSize, (queryFilter.Page-1)*queryFilter.PageSize)
|
|
|
|
eventRows, queryError := recorder.connectionPool.Query(queryContext, listStatement, queryArguments...)
|
|
if queryError != nil {
|
|
return nil, 0, fmt.Errorf("die auditereignisse konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
defer eventRows.Close()
|
|
|
|
storedEvents := make([]StoredEvent, 0, queryFilter.PageSize)
|
|
for eventRows.Next() {
|
|
var storedEvent StoredEvent
|
|
var rawDetails []byte
|
|
|
|
scanError := eventRows.Scan(
|
|
&storedEvent.ID, &storedEvent.UserID, &storedEvent.ActorUsername, &storedEvent.Action,
|
|
&storedEvent.EntityType, &storedEvent.EntityID, &storedEvent.Result,
|
|
&storedEvent.IPAddress, &storedEvent.UserAgent, &rawDetails,
|
|
&storedEvent.CorrelationID, &storedEvent.CreatedAt,
|
|
)
|
|
if scanError != nil {
|
|
return nil, 0, fmt.Errorf("ein auditereignis konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
if len(rawDetails) > 0 {
|
|
// Ein unlesbares Detailfeld darf die gesamte Abfrage nicht scheitern lassen.
|
|
if unmarshalError := json.Unmarshal(rawDetails, &storedEvent.Details); unmarshalError != nil {
|
|
storedEvent.Details = map[string]any{"_hinweis": "Die Zusatzangaben konnten nicht gelesen werden."}
|
|
}
|
|
}
|
|
|
|
storedEvents = append(storedEvents, storedEvent)
|
|
}
|
|
|
|
if rowsError := eventRows.Err(); rowsError != nil {
|
|
return nil, 0, fmt.Errorf("die auditereignisse konnten nicht vollständig gelesen werden: %w", rowsError)
|
|
}
|
|
|
|
return storedEvents, totalCount, nil
|
|
}
|
|
|
|
// nullIfEmpty wandelt eine leere Zeichenkette in NULL.
|
|
//
|
|
// So entstehen keine Einträge, die zwischen "nicht gesetzt" und "leer" nicht
|
|
// mehr unterscheidbar wären.
|
|
func nullIfEmpty(textValue string) *string {
|
|
if textValue == "" {
|
|
return nil
|
|
}
|
|
|
|
return &textValue
|
|
}
|