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>
278 lines
9.5 KiB
Go
278 lines
9.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"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/platform/logging"
|
|
)
|
|
|
|
// roleHandler bedient Rollen und Berechtigungen (SYNCOVA_API.md §5).
|
|
type roleHandler struct {
|
|
// repository ist die Datenzugriffsschicht der Identitätsverwaltung.
|
|
repository *auth.Repository
|
|
// auditRecorder protokolliert Änderungen an Rollen.
|
|
auditRecorder audit.Recorder
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// roleRequest ist der Rumpf von POST und PATCH auf /roles.
|
|
type roleRequest struct {
|
|
// Name ist der technische Name der Rolle (nur beim Anlegen ausgewertet).
|
|
Name string `json:"name"`
|
|
// Description erklärt den Zweck der Rolle.
|
|
Description *string `json:"description"`
|
|
// Permissions sind die zuzuordnenden Berechtigungen.
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
// handleListRoles bedient GET /roles.
|
|
func (handler *roleHandler) handleListRoles(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
loadedRoles, listError := handler.repository.ListRoles(request.Context())
|
|
if listError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedRoles)
|
|
}
|
|
|
|
// handleGetRole bedient GET /roles/{id}.
|
|
func (handler *roleHandler) handleGetRole(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
roleID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
loadedRole, loadError := handler.repository.FindRoleByID(request.Context(), roleID)
|
|
if loadError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(loadError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedRole)
|
|
}
|
|
|
|
// handleCreateRole bedient POST /roles.
|
|
func (handler *roleHandler) handleCreateRole(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
var rolePayload roleRequest
|
|
if decodeError := decodeJSONBody(request, &rolePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
if rolePayload.Name == "" {
|
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Der Rollenname ist erforderlich."))
|
|
return
|
|
}
|
|
|
|
// Eine Rolle ohne Berechtigungen wäre wirkungslos und fast immer ein Versehen.
|
|
if len(rolePayload.Permissions) == 0 {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewValidationError("Es muss mindestens eine Berechtigung zugeordnet werden."))
|
|
return
|
|
}
|
|
|
|
description := ""
|
|
if rolePayload.Description != nil {
|
|
description = *rolePayload.Description
|
|
}
|
|
|
|
createdRoleID, createError := handler.repository.CreateRole(
|
|
request.Context(), rolePayload.Name, description, rolePayload.Permissions)
|
|
if createError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(createError))
|
|
return
|
|
}
|
|
|
|
handler.recordRoleAudit(request, audit.ActionRoleCreated, createdRoleID, actingUser, map[string]any{
|
|
"name": rolePayload.Name,
|
|
"berechtigungen": rolePayload.Permissions,
|
|
})
|
|
|
|
createdRole, loadError := handler.repository.FindRoleByID(request.Context(), createdRoleID)
|
|
if loadError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(loadError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdRole)
|
|
}
|
|
|
|
// handleUpdateRole bedient PATCH /roles/{id}.
|
|
func (handler *roleHandler) handleUpdateRole(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
roleID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
var rolePayload roleRequest
|
|
if decodeError := decodeJSONBody(request, &rolePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
if updateError := handler.repository.UpdateRole(
|
|
request.Context(), roleID, rolePayload.Description, rolePayload.Permissions); updateError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(updateError))
|
|
return
|
|
}
|
|
|
|
handler.recordRoleAudit(request, audit.ActionRoleUpdated, roleID, actingUser, map[string]any{
|
|
"berechtigungen": rolePayload.Permissions,
|
|
})
|
|
|
|
updatedRole, loadError := handler.repository.FindRoleByID(request.Context(), roleID)
|
|
if loadError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(loadError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, updatedRole)
|
|
}
|
|
|
|
// handleDeleteRole bedient DELETE /roles/{id}.
|
|
func (handler *roleHandler) handleDeleteRole(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
roleID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
if deleteError := handler.repository.DeleteRole(request.Context(), roleID); deleteError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(deleteError))
|
|
return
|
|
}
|
|
|
|
handler.recordRoleAudit(request, audit.ActionRoleDeleted, roleID, actingUser, nil)
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// handleListPermissions bedient GET /permissions.
|
|
func (handler *roleHandler) handleListPermissions(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
loadedPermissions, listError := handler.repository.ListPermissions(request.Context())
|
|
if listError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedPermissions)
|
|
}
|
|
|
|
// recordRoleAudit protokolliert eine Rollenänderung.
|
|
func (handler *roleHandler) recordRoleAudit(request *http.Request, auditAction audit.Action, roleID uuid.UUID, actingUser auth.User, auditDetails map[string]any) {
|
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
|
|
|
if recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
|
UserID: &actingUser.ID,
|
|
ActorUsername: actingUser.Username,
|
|
Action: auditAction,
|
|
EntityType: "role",
|
|
EntityID: &roleID,
|
|
Result: audit.ResultSuccess,
|
|
IPAddress: clientIPAddress(request),
|
|
UserAgent: request.UserAgent(),
|
|
CorrelationID: correlationID,
|
|
Details: auditDetails,
|
|
}); recordError != nil {
|
|
logging.WithContext(request.Context(), handler.logger).Error(
|
|
"rollenänderung konnte nicht protokolliert werden",
|
|
slog.String("action", string(auditAction)),
|
|
slog.String("error", recordError.Error()))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auditprotokoll
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// auditHandler bedient die Auditabfrage (SYNCOVA_API.md §17).
|
|
type auditHandler struct {
|
|
// auditRecorder liest die protokollierten Ereignisse.
|
|
auditRecorder audit.Recorder
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// handleListAuditEvents bedient GET /audit-events.
|
|
func (handler *auditHandler) handleListAuditEvents(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
page, pageSize := parsePagination(request)
|
|
queryParameters := request.URL.Query()
|
|
|
|
queryFilter := audit.Filter{
|
|
Action: queryParameters.Get("action"),
|
|
Result: queryParameters.Get("result"),
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}
|
|
|
|
if rawUserID := queryParameters.Get("user_id"); rawUserID != "" {
|
|
parsedUserID, parseError := uuid.Parse(rawUserID)
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Der Parameter user_id ist keine gültige UUID."))
|
|
return
|
|
}
|
|
|
|
queryFilter.UserID = &parsedUserID
|
|
}
|
|
|
|
// Zeitangaben werden in ISO 8601 erwartet, passend zur Ausgabe der API.
|
|
if rawFrom := queryParameters.Get("from"); rawFrom != "" {
|
|
parsedFrom, parseError := time.Parse(time.RFC3339, rawFrom)
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Der Parameter from muss im Format RFC 3339 angegeben werden."))
|
|
return
|
|
}
|
|
|
|
queryFilter.From = &parsedFrom
|
|
}
|
|
|
|
if rawTo := queryParameters.Get("to"); rawTo != "" {
|
|
parsedTo, parseError := time.Parse(time.RFC3339, rawTo)
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError("Der Parameter to muss im Format RFC 3339 angegeben werden."))
|
|
return
|
|
}
|
|
|
|
queryFilter.To = &parsedTo
|
|
}
|
|
|
|
auditEvents, totalCount, queryError := handler.auditRecorder.Query(request.Context(), queryFilter)
|
|
if queryError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(queryError))
|
|
return
|
|
}
|
|
|
|
WritePaginatedSuccess(responseWriter, request, auditEvents, PaginationMeta{
|
|
Page: page, PageSize: pageSize, Total: totalCount,
|
|
})
|
|
}
|