syncova-backup/apps/api/internal/httpapi/role_handler.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

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,
})
}