// 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 }