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>
482 lines
18 KiB
Go
482 lines
18 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/jobs"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// repositoryHandler bedient die Repository-Endpunkte (SYNCOVA_API.md §8).
|
|
//
|
|
// Bis Phase 22 gab es hier nur eine Liste: Repositories entstanden über
|
|
// `syncova-repo create` und wurden **von Hand in die Datenbank eingetragen**.
|
|
// Das fiel erst beim vollständigen Durchlauf auf — die Anlage ließ sich über
|
|
// ihre eigene API nicht in Betrieb nehmen.
|
|
type repositoryHandler struct {
|
|
// store ist die Datenzugriffsschicht.
|
|
store *jobs.PostgresStore
|
|
// auditRecorder protokolliert die verändernden Zugriffe.
|
|
auditRecorder audit.Recorder
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// registerRepositoryRequest ist der Rumpf von POST /repositories.
|
|
type registerRepositoryRequest struct {
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string `json:"name"`
|
|
// Location ist der Pfad der Ablage.
|
|
Location string `json:"location"`
|
|
// RepositoryType benennt die Ablageart; leer bedeutet "local".
|
|
RepositoryType string `json:"repository_type,omitempty"`
|
|
}
|
|
|
|
// updateRepositoryRequest ist der Rumpf von PATCH /repositories/{id}.
|
|
type updateRepositoryRequest struct {
|
|
// Status ist der neue Betriebszustand.
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// repositoryCheckResponse ist die Antwort der Prüfendpunkte.
|
|
type repositoryCheckResponse struct {
|
|
// RepositoryID ist das geprüfte Repository.
|
|
RepositoryID uuid.UUID `json:"repository_id"`
|
|
// Reachable meldet, ob das Repository geöffnet werden konnte.
|
|
Reachable bool `json:"reachable"`
|
|
// RepositoryUUID ist die im Repository hinterlegte Kennung.
|
|
RepositoryUUID string `json:"repository_uuid,omitempty"`
|
|
// Error ist der Grund eines Fehlschlags.
|
|
Error string `json:"error,omitempty"`
|
|
// Details tragen das Ergebnis der jeweiligen Prüfung.
|
|
Details map[string]any `json:"details,omitempty"`
|
|
}
|
|
|
|
// handleRegisterRepository bedient POST /repositories.
|
|
//
|
|
// **Es wird nichts angelegt, sondern übernommen.** Ein Repository entsteht auf
|
|
// einem Datenträger — mit `syncova-repo create`, das Descriptor und
|
|
// Verzeichnisse schreibt und den gehärteten Modus setzt. Dieser Endpunkt öffnet
|
|
// das vorhandene Repository, liest seine Kennung aus dem Descriptor und trägt
|
|
// es ein.
|
|
//
|
|
// Ein Eintrag ohne Repository dahinter wäre ein Ziel, das erst um zwei Uhr
|
|
// nachts als nicht vorhanden auffällt.
|
|
func (handler *repositoryHandler) handleRegisterRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
var registerPayload registerRepositoryRequest
|
|
if decodeError := decodeJSONBody(request, ®isterPayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
|
|
return
|
|
}
|
|
|
|
if registerPayload.Name == "" || registerPayload.Location == "" {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewValidationError("Name und Ort des Repositorys sind erforderlich."))
|
|
|
|
return
|
|
}
|
|
|
|
repositoryType := registerPayload.RepositoryType
|
|
if repositoryType == "" {
|
|
repositoryType = "local"
|
|
}
|
|
|
|
// Erst nachsehen, dann eintragen. Der Descriptor sagt, ob dort überhaupt
|
|
// ein Repository liegt und welche Kennung es trägt.
|
|
inspectedRepository, inspectError := openRepositoryForInspection(request.Context(),
|
|
registerPayload.Location, handler.logger)
|
|
if inspectError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
|
"Unter "+registerPayload.Location+" liegt kein lesbares Repository: "+inspectError.Error()+
|
|
". Legen Sie es zuerst mit 'syncova-repo create' an."))
|
|
|
|
return
|
|
}
|
|
|
|
repositoryDescriptor := inspectedRepository.Descriptor()
|
|
|
|
if closeError := inspectedRepository.Close(); closeError != nil {
|
|
requestLogger.Warn("das geprüfte repository liess sich nicht schliessen",
|
|
slog.String("grund", closeError.Error()))
|
|
}
|
|
|
|
registeredRepository, registerError := handler.store.RegisterRepository(request.Context(),
|
|
jobs.RepositoryRegistration{
|
|
Name: registerPayload.Name,
|
|
RepositoryType: repositoryType,
|
|
Location: registerPayload.Location,
|
|
Status: jobs.RepositoryStatusActive,
|
|
Hardened: repositoryDescriptor.Immutable,
|
|
RepositoryUUID: repositoryDescriptor.RepositoryID,
|
|
})
|
|
|
|
if errors.Is(registerError, jobs.ErrRepositoryNameTaken) ||
|
|
errors.Is(registerError, jobs.ErrRepositoryLocationTaken) {
|
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
|
StatusCode: http.StatusConflict,
|
|
Code: ErrorCodeConflict,
|
|
Message: registerError.Error(),
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
if registerError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(registerError))
|
|
|
|
return
|
|
}
|
|
|
|
handler.recordAudit(request, actingUser, audit.ActionRepositoryRegistered, registeredRepository.ID,
|
|
map[string]any{
|
|
"name": registeredRepository.Name,
|
|
"location": registeredRepository.Location,
|
|
"hardened": registeredRepository.Hardened,
|
|
})
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusCreated, buildRepositoryResponse(*registeredRepository))
|
|
}
|
|
|
|
// handleGetRepository bedient GET /repositories/{id}.
|
|
func (handler *repositoryHandler) handleGetRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
|
|
|
return
|
|
}
|
|
|
|
foundRepository, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
|
|
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
|
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
|
|
|
return
|
|
}
|
|
|
|
if readError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
|
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*foundRepository))
|
|
}
|
|
|
|
// handleUpdateRepository bedient PATCH /repositories/{id}.
|
|
//
|
|
// Änderbar ist ausschließlich der Betriebszustand. Ort und Name gehören zum
|
|
// Repository selbst; sie hier zu ändern hieße, den Eintrag von der Ablage zu
|
|
// lösen, auf die er zeigt.
|
|
func (handler *repositoryHandler) handleUpdateRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
|
|
|
return
|
|
}
|
|
|
|
var updatePayload updateRepositoryRequest
|
|
if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
|
|
return
|
|
}
|
|
|
|
newStatus := jobs.RepositoryStatus(updatePayload.Status)
|
|
|
|
if !isKnownRepositoryStatus(newStatus) {
|
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
|
"Zulässige Zustände sind active, read_only, unavailable und maintenance."))
|
|
|
|
return
|
|
}
|
|
|
|
updatedRepository, updateError := handler.store.UpdateRepositoryStatus(request.Context(),
|
|
repositoryIdentifier, newStatus)
|
|
if errors.Is(updateError, jobs.ErrRepositoryNotFound) {
|
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
|
|
|
return
|
|
}
|
|
|
|
if updateError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(updateError))
|
|
|
|
return
|
|
}
|
|
|
|
// Ein Ziel aus dem Betrieb zu nehmen, hält Sicherungen an. Das gehört ins
|
|
// Protokoll: Sonst sucht später jemand den Grund für ausbleibende Backups.
|
|
handler.recordAudit(request, actingUser, audit.ActionRepositoryStatusChanged, updatedRepository.ID,
|
|
map[string]any{"status": string(newStatus)})
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*updatedRepository))
|
|
}
|
|
|
|
// handleTestRepository bedient POST /repositories/{id}/test.
|
|
//
|
|
// Die schnelle Prüfung: Lässt sich das Repository öffnen und ist es dasselbe
|
|
// wie beim Eintragen? Ein Ziel, dessen Kennung sich geändert hat, ist ein
|
|
// anderes Repository am selben Pfad — und die dort vermerkten Backups sind
|
|
// nicht die, die man sucht.
|
|
func (handler *repositoryHandler) handleTestRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
|
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error) {
|
|
repositoryDescriptor := openedRepository.Descriptor()
|
|
|
|
checkDetails := map[string]any{
|
|
"repository_uuid": repositoryDescriptor.RepositoryID,
|
|
"format_version": repositoryDescriptor.FormatVersion,
|
|
"hardened": repositoryDescriptor.Immutable,
|
|
}
|
|
|
|
if storedRecord.RepositoryUUID != "" && storedRecord.RepositoryUUID != repositoryDescriptor.RepositoryID {
|
|
checkDetails["identity_mismatch"] = true
|
|
|
|
return checkDetails, errors.New("das repository unter diesem pfad ist nicht mehr dasselbe " +
|
|
"(erwartet " + storedRecord.RepositoryUUID + ", vorgefunden " + repositoryDescriptor.RepositoryID + ")")
|
|
}
|
|
|
|
return checkDetails, nil
|
|
})
|
|
}
|
|
|
|
// handleRepositoryHealth bedient POST /repositories/{id}/health-check.
|
|
func (handler *repositoryHandler) handleRepositoryHealth(responseWriter http.ResponseWriter, request *http.Request) {
|
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
|
healthReport, healthError := openedRepository.Health(checkContext)
|
|
if healthError != nil {
|
|
return nil, healthError
|
|
}
|
|
|
|
return map[string]any{
|
|
"status": string(healthReport.Status),
|
|
"message": healthReport.Message,
|
|
"recommended_action": healthReport.RecommendedAction,
|
|
"backup_count": healthReport.BackupCount,
|
|
"capacity_bytes": healthReport.CapacityBytes,
|
|
"used_bytes": healthReport.UsedBytes,
|
|
"free_bytes": healthReport.FreeBytes,
|
|
"used_percentage": healthReport.UsedPercentage(),
|
|
"latency_ms": healthReport.LatencyMilliseconds,
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// handleRepositoryIntegrityScan bedient POST /repositories/{id}/integrity-scan.
|
|
//
|
|
// Der vollständige Lauf liest jeden Block und prüft ihn gegen seine Prüfsumme.
|
|
// Er läuft synchron: Ein Endpunkt, der sofort „gestartet" meldet und das
|
|
// Ergebnis nirgends hinterlegt, wäre ein Prüfwerkzeug ohne Prüfergebnis.
|
|
func (handler *repositoryHandler) handleRepositoryIntegrityScan(responseWriter http.ResponseWriter, request *http.Request) {
|
|
deepScan := request.URL.Query().Get("deep") != "false"
|
|
|
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
|
scanReport, scanError := openedRepository.Scan(checkContext, repository.ScanOptions{
|
|
VerifyChunkContents: deepScan,
|
|
})
|
|
if scanError != nil {
|
|
return nil, scanError
|
|
}
|
|
|
|
scanDetails := map[string]any{
|
|
"verified_chunk_contents": scanReport.VerifiedChunkContents,
|
|
"backups_checked": scanReport.BackupsChecked,
|
|
"backups_healthy": scanReport.BackupsHealthy,
|
|
"chunks_checked": scanReport.ChunksChecked,
|
|
"missing_chunks": scanReport.MissingChunks,
|
|
"corrupted_chunks": scanReport.CorruptedChunks,
|
|
"orphaned_chunks": scanReport.OrphanedChunks,
|
|
"healthy": scanReport.IsHealthy(),
|
|
"summary": scanReport.Summary(),
|
|
"affected_backup_ids": scanReport.AffectedBackupIDs,
|
|
}
|
|
|
|
// Ein Befund ist **kein** Fehler des Endpunkts: Die Prüfung ist
|
|
// ordnungsgemäß gelaufen und hat ein Ergebnis. Sie als Fehler zu melden
|
|
// verwechselte „die Prüfung schlug fehl" mit „das Repository ist
|
|
// beschädigt" — zwei völlig verschiedene Lagen.
|
|
return scanDetails, nil
|
|
})
|
|
}
|
|
|
|
// handleRebuildCatalog bedient POST /repositories/{id}/rebuild-catalog.
|
|
//
|
|
// Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Genau
|
|
// deshalb lässt er sich jederzeit neu bauen — und genau deshalb ist der
|
|
// Wiederaufbau harmlos.
|
|
func (handler *repositoryHandler) handleRebuildCatalog(responseWriter http.ResponseWriter, request *http.Request) {
|
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
|
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(checkContext)
|
|
if rebuildError != nil {
|
|
return nil, rebuildError
|
|
}
|
|
|
|
return map[string]any{"backups_in_catalog": len(rebuiltCatalog.Entries)}, nil
|
|
})
|
|
}
|
|
|
|
// repositoryCheckFunction führt eine Prüfung auf einem geöffneten Repository aus.
|
|
type repositoryCheckFunction func(checkContext context.Context,
|
|
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error)
|
|
|
|
// runRepositoryCheck öffnet das Repository und führt eine Prüfung aus.
|
|
//
|
|
// Der gemeinsame Rahmen aller vier Prüfendpunkte: Kennung lesen, Datensatz
|
|
// holen, Repository öffnen, prüfen, schließen. Ohne ihn stünde derselbe Ablauf
|
|
// viermal da, und beim vierten vergisst jemand das Schließen.
|
|
func (handler *repositoryHandler) runRepositoryCheck(responseWriter http.ResponseWriter,
|
|
request *http.Request, checkFunction repositoryCheckFunction) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
|
|
|
return
|
|
}
|
|
|
|
storedRecord, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
|
|
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
|
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
|
|
|
return
|
|
}
|
|
|
|
if readError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
|
|
|
return
|
|
}
|
|
|
|
openedRepository, openError := openRepositoryForInspection(request.Context(),
|
|
storedRecord.Location, handler.logger)
|
|
if openError != nil {
|
|
// Ein nicht erreichbares Repository ist eine Auskunft über die Anlage,
|
|
// kein Serverfehler. Die Antwort trägt sie in der Hülle, damit die
|
|
// Oberfläche sie anzeigen kann.
|
|
WriteSuccess(responseWriter, request, http.StatusOK, repositoryCheckResponse{
|
|
RepositoryID: repositoryIdentifier,
|
|
Reachable: false,
|
|
Error: openError.Error(),
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if closeError := openedRepository.Close(); closeError != nil {
|
|
requestLogger.Warn("das repository liess sich nicht schliessen",
|
|
slog.String("grund", closeError.Error()))
|
|
}
|
|
}()
|
|
|
|
checkDetails, checkError := checkFunction(request.Context(), openedRepository, storedRecord)
|
|
|
|
checkResponse := repositoryCheckResponse{
|
|
RepositoryID: repositoryIdentifier,
|
|
Reachable: checkError == nil,
|
|
RepositoryUUID: openedRepository.Descriptor().RepositoryID,
|
|
Details: checkDetails,
|
|
}
|
|
|
|
if checkError != nil {
|
|
checkResponse.Error = checkError.Error()
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, checkResponse)
|
|
}
|
|
|
|
// openRepositoryForInspection öffnet ein Repository schreibgeschützt.
|
|
//
|
|
// Schreibgeschützt, weil jede Prüfung nur liest — und weil ein Prüfaufruf sonst
|
|
// die Schreibsperre einer laufenden Sicherung bräuchte und daran wartete
|
|
// (dieselbe Entscheidung wie bei der Wiederherstellung in Phase 9).
|
|
func openRepositoryForInspection(openContext context.Context, repositoryLocation string,
|
|
baseLogger *slog.Logger) (*repository.LocalRepository, error) {
|
|
inspectionContext, cancelInspection := context.WithTimeout(openContext, 30*time.Second)
|
|
defer cancelInspection()
|
|
|
|
return repository.Open(inspectionContext, repositoryLocation,
|
|
repository.OpenOptions{ReadOnly: true}, baseLogger)
|
|
}
|
|
|
|
// isKnownRepositoryStatus prüft einen Zustandswert.
|
|
func isKnownRepositoryStatus(candidateStatus jobs.RepositoryStatus) bool {
|
|
switch candidateStatus {
|
|
case jobs.RepositoryStatusActive, jobs.RepositoryStatusReadOnly,
|
|
jobs.RepositoryStatusUnavailable, jobs.RepositoryStatusMaintenance:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// buildRepositoryResponse formt die Antwort eines Repositorys.
|
|
func buildRepositoryResponse(storedRepository jobs.Repository) repositoryResponse {
|
|
return repositoryResponse{
|
|
ID: storedRepository.ID,
|
|
Name: storedRepository.Name,
|
|
RepositoryType: storedRepository.RepositoryType,
|
|
Location: storedRepository.Location,
|
|
Status: string(storedRepository.Status),
|
|
AcceptsBackups: storedRepository.Status.AcceptsWrites(),
|
|
Hardened: storedRepository.Hardened,
|
|
CreatedAt: storedRepository.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// recordAudit schreibt einen Eintrag ins Auditprotokoll.
|
|
func (handler *repositoryHandler) recordAudit(request *http.Request, actingUser auth.User,
|
|
auditAction audit.Action, entityIdentifier uuid.UUID, auditDetails map[string]any) {
|
|
if handler.auditRecorder == nil {
|
|
return
|
|
}
|
|
|
|
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
|
|
|
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
|
UserID: &actingUser.ID,
|
|
ActorUsername: actingUser.Username,
|
|
Action: auditAction,
|
|
EntityType: "repository",
|
|
EntityID: &entityIdentifier,
|
|
Result: audit.ResultSuccess,
|
|
IPAddress: clientIPAddress(request),
|
|
UserAgent: request.UserAgent(),
|
|
CorrelationID: correlationIdentifier,
|
|
Details: auditDetails,
|
|
})
|
|
|
|
if recordError != nil {
|
|
logging.WithContext(request.Context(), handler.logger).Error(
|
|
"der audit-eintrag liess sich nicht schreiben",
|
|
slog.String("aktion", string(auditAction)),
|
|
slog.String("grund", recordError.Error()))
|
|
}
|
|
}
|