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>
240 lines
8.2 KiB
Go
240 lines
8.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
)
|
|
|
|
// Grenzen der Seitengröße.
|
|
//
|
|
// Eine Obergrenze verhindert, dass ein einzelner Aufruf die Datenbank und den
|
|
// Speicher des Dienstes belastet.
|
|
const (
|
|
// defaultPageSize ist die Seitengröße ohne ausdrückliche Angabe.
|
|
defaultPageSize = 50
|
|
// maxPageSize ist die größte erlaubte Seitengröße.
|
|
maxPageSize = 200
|
|
)
|
|
|
|
// userHandler bedient die Benutzerverwaltung (SYNCOVA_API.md §4).
|
|
type userHandler struct {
|
|
// authService ist die Domänenlogik der Identitätsverwaltung.
|
|
authService *auth.Service
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// createUserRequest ist der Rumpf von POST /users.
|
|
type createUserRequest struct {
|
|
// Username ist der gewünschte Anmeldename.
|
|
Username string `json:"username"`
|
|
// Email ist die optionale Mailadresse.
|
|
Email string `json:"email"`
|
|
// Password ist das Anfangspasswort.
|
|
Password string `json:"password"`
|
|
// Roles sind die zuzuweisenden Rollennamen.
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
// updateUserRequest ist der Rumpf von PATCH /users/{id}.
|
|
//
|
|
// Alle Felder sind Zeiger: nur ausdrücklich gesendete Werte werden geändert.
|
|
type updateUserRequest struct {
|
|
// Email ist die neue Mailadresse.
|
|
Email *string `json:"email"`
|
|
// Status ist der neue Kontozustand.
|
|
Status *string `json:"status"`
|
|
// Password ist das neue Passwort.
|
|
Password *string `json:"password"`
|
|
// Roles sind die neuen Rollen; sie ersetzen die bisherigen vollständig.
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
// handleListUsers bedient GET /users.
|
|
func (handler *userHandler) handleListUsers(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
page, pageSize := parsePagination(request)
|
|
searchTerm := request.URL.Query().Get("search")
|
|
|
|
loadedUsers, totalCount, listError := handler.authService.ListUsers(request.Context(), searchTerm, page, pageSize)
|
|
if listError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
|
return
|
|
}
|
|
|
|
WritePaginatedSuccess(responseWriter, request, loadedUsers, PaginationMeta{
|
|
Page: page, PageSize: pageSize, Total: totalCount,
|
|
})
|
|
}
|
|
|
|
// handleCreateUser bedient POST /users.
|
|
func (handler *userHandler) handleCreateUser(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
var createPayload createUserRequest
|
|
if decodeError := decodeJSONBody(request, &createPayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
createdUser, createError := handler.authService.CreateUser(request.Context(), auth.CreateUserRequest{
|
|
Username: createPayload.Username,
|
|
Email: createPayload.Email,
|
|
Password: createPayload.Password,
|
|
RoleNames: createPayload.Roles,
|
|
}, actingUser, RequestContextFrom(request))
|
|
|
|
if createError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(createError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdUser)
|
|
}
|
|
|
|
// handleGetUser bedient GET /users/{id}.
|
|
func (handler *userHandler) handleGetUser(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
userID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
loadedUser, loadError := handler.authService.GetUser(request.Context(), userID)
|
|
if loadError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(loadError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedUser)
|
|
}
|
|
|
|
// handleUpdateUser bedient PATCH /users/{id}.
|
|
func (handler *userHandler) handleUpdateUser(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
userID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
var updatePayload updateUserRequest
|
|
if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
// Der Zustand wird gegen die erlaubten Werte geprüft, bevor er die Domäne erreicht.
|
|
var newStatus *auth.UserStatus
|
|
if updatePayload.Status != nil {
|
|
parsedStatus := auth.UserStatus(*updatePayload.Status)
|
|
switch parsedStatus {
|
|
case auth.UserStatusActive, auth.UserStatusDisabled, auth.UserStatusLocked:
|
|
newStatus = &parsedStatus
|
|
default:
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewValidationError("Der Status muss active, disabled oder locked sein."))
|
|
return
|
|
}
|
|
}
|
|
|
|
updatedUser, updateError := handler.authService.UpdateUser(request.Context(), userID, auth.UpdateUserRequest{
|
|
Email: updatePayload.Email,
|
|
Status: newStatus,
|
|
Password: updatePayload.Password,
|
|
RoleNames: updatePayload.Roles,
|
|
}, actingUser, RequestContextFrom(request))
|
|
|
|
if updateError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(updateError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, updatedUser)
|
|
}
|
|
|
|
// handleDeleteUser bedient DELETE /users/{id}.
|
|
func (handler *userHandler) handleDeleteUser(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
userID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
if deleteError := handler.authService.DeleteUser(request.Context(), userID, actingUser, RequestContextFrom(request)); deleteError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(deleteError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// handleDisableUserMFA bedient POST /users/{id}/mfa/disable.
|
|
//
|
|
// Der Weg dient dem Fall, dass ein Benutzer sein Gerät verloren hat.
|
|
func (handler *userHandler) handleDisableUserMFA(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
userID, parseError := parsePathUUID(request, "id")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
if disableError := handler.authService.DisableMFA(request.Context(), userID, actingUser, RequestContextFrom(request)); disableError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAuthError(disableError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"mfa_enabled": false})
|
|
}
|
|
|
|
// parsePagination liest Seitennummer und Seitengröße aus der Anfrage.
|
|
func parsePagination(request *http.Request) (page int, pageSize int) {
|
|
page = 1
|
|
pageSize = defaultPageSize
|
|
|
|
if rawPage := request.URL.Query().Get("page"); rawPage != "" {
|
|
if parsedPage, parseError := strconv.Atoi(rawPage); parseError == nil && parsedPage > 0 {
|
|
page = parsedPage
|
|
}
|
|
}
|
|
|
|
if rawPageSize := request.URL.Query().Get("page_size"); rawPageSize != "" {
|
|
if parsedPageSize, parseError := strconv.Atoi(rawPageSize); parseError == nil && parsedPageSize > 0 {
|
|
// Eine übergroße Angabe wird gekappt statt abgelehnt: der Aufrufer
|
|
// erhält Daten, und der Dienst bleibt geschützt.
|
|
pageSize = min(parsedPageSize, maxPageSize)
|
|
}
|
|
}
|
|
|
|
return page, pageSize
|
|
}
|
|
|
|
// parsePathUUID liest eine UUID aus dem Anfragepfad.
|
|
func parsePathUUID(request *http.Request, parameterName string) (uuid.UUID, *APIError) {
|
|
rawValue := request.PathValue(parameterName)
|
|
|
|
parsedUUID, parseError := uuid.Parse(rawValue)
|
|
if parseError != nil {
|
|
return uuid.Nil, NewBadRequestError("Die angegebene Kennung ist keine gültige UUID.")
|
|
}
|
|
|
|
return parsedUUID, nil
|
|
}
|