syncova-backup/packages/audit/audit.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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
}