syncova-backup/packages/auth/repository.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

498 lines
18 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository kapselt sämtliche Datenbankzugriffe der Identitätsverwaltung.
//
// Alle Abfragen verwenden ausschließlich gebundene Parameter; Benutzereingaben
// fließen niemals in den SQL-Text (PROMPT.md §97).
type Repository struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
}
// NewRepository erzeugt eine Datenzugriffsschicht.
func NewRepository(connectionPool *pgxpool.Pool) *Repository {
return &Repository{connectionPool: connectionPool}
}
// credentialRecord bündelt die zur Anmeldung nötigen Felder eines Benutzers.
//
// Der Typ ist paketintern, damit der Passworthash die Domäne nicht verlässt.
type credentialRecord struct {
// id ist der Bezeichner des Benutzers.
id uuid.UUID
// username ist der Anmeldename.
username string
// passwordHash ist der gespeicherte Argon2id-Hash.
passwordHash string
// status ist der Kontozustand.
status UserStatus
// mfaEnabled meldet einen bestätigten zweiten Faktor.
mfaEnabled bool
// failedLoginAttempts zählt aufeinanderfolgende Fehlversuche.
failedLoginAttempts int
// lockedUntil ist der Zeitpunkt, bis zu dem das Konto gesperrt ist.
lockedUntil *time.Time
}
// findCredentialsByUsername liest die Anmeldedaten zu einem Anmeldenamen.
func (repository *Repository) findCredentialsByUsername(queryContext context.Context, username string) (credentialRecord, error) {
const selectStatement = `
SELECT id, username, coalesce(password_hash, ''), status, mfa_enabled,
failed_login_attempts, locked_until
FROM users
WHERE username = $1 AND deleted_at IS NULL`
var record credentialRecord
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, username).Scan(
&record.id, &record.username, &record.passwordHash, &record.status,
&record.mfaEnabled, &record.failedLoginAttempts, &record.lockedUntil,
)
if errors.Is(scanError, pgx.ErrNoRows) {
return credentialRecord{}, ErrUserNotFound
}
if scanError != nil {
return credentialRecord{}, fmt.Errorf("die anmeldedaten konnten nicht gelesen werden: %w", scanError)
}
return record, nil
}
// FindUserByID liest einen Benutzer samt Rollen und Berechtigungen.
func (repository *Repository) FindUserByID(queryContext context.Context, userID uuid.UUID) (User, error) {
const selectStatement = `
SELECT id, username, coalesce(email, ''), status, mfa_enabled,
created_at, updated_at, last_login_at
FROM users
WHERE id = $1 AND deleted_at IS NULL`
var loadedUser User
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, userID).Scan(
&loadedUser.ID, &loadedUser.Username, &loadedUser.Email, &loadedUser.Status,
&loadedUser.MFAEnabled, &loadedUser.CreatedAt, &loadedUser.UpdatedAt, &loadedUser.LastLoginAt,
)
if errors.Is(scanError, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
if scanError != nil {
return User{}, fmt.Errorf("der benutzer konnte nicht gelesen werden: %w", scanError)
}
if loadError := repository.loadRolesAndPermissions(queryContext, &loadedUser); loadError != nil {
return User{}, loadError
}
return loadedUser, nil
}
// loadRolesAndPermissions ergänzt Rollen und abgeleitete Berechtigungen.
func (repository *Repository) loadRolesAndPermissions(queryContext context.Context, targetUser *User) error {
const rolesStatement = `
SELECT r.name
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
ORDER BY r.name`
roleRows, roleError := repository.connectionPool.Query(queryContext, rolesStatement, targetUser.ID)
if roleError != nil {
return fmt.Errorf("die rollen konnten nicht gelesen werden: %w", roleError)
}
defer roleRows.Close()
targetUser.Roles = []string{}
for roleRows.Next() {
var roleName string
if scanError := roleRows.Scan(&roleName); scanError != nil {
return fmt.Errorf("eine rolle konnte nicht gelesen werden: %w", scanError)
}
targetUser.Roles = append(targetUser.Roles, roleName)
}
if rowsError := roleRows.Err(); rowsError != nil {
return fmt.Errorf("die rollen konnten nicht vollständig gelesen werden: %w", rowsError)
}
// DISTINCT ist nötig, weil mehrere Rollen dieselbe Berechtigung enthalten können.
const permissionsStatement = `
SELECT DISTINCT p.name
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1
ORDER BY p.name`
permissionRows, permissionError := repository.connectionPool.Query(queryContext, permissionsStatement, targetUser.ID)
if permissionError != nil {
return fmt.Errorf("die berechtigungen konnten nicht gelesen werden: %w", permissionError)
}
defer permissionRows.Close()
targetUser.Permissions = []string{}
for permissionRows.Next() {
var permissionName string
if scanError := permissionRows.Scan(&permissionName); scanError != nil {
return fmt.Errorf("eine berechtigung konnte nicht gelesen werden: %w", scanError)
}
targetUser.Permissions = append(targetUser.Permissions, permissionName)
}
if rowsError := permissionRows.Err(); rowsError != nil {
return fmt.Errorf("die berechtigungen konnten nicht vollständig gelesen werden: %w", rowsError)
}
return nil
}
// ListUsers liest eine Seite von Benutzern.
func (repository *Repository) ListUsers(queryContext context.Context, searchTerm string, page int, pageSize int) ([]User, int64, error) {
whereClause := " WHERE deleted_at IS NULL"
queryArguments := make([]any, 0, 3)
if searchTerm != "" {
// ILIKE erlaubt eine Suche ohne Beachtung der Groß-/Kleinschreibung.
queryArguments = append(queryArguments, "%"+searchTerm+"%")
whereClause += fmt.Sprintf(" AND (username ILIKE $%d OR email ILIKE $%d)", len(queryArguments), len(queryArguments))
}
var totalCount int64
countError := repository.connectionPool.QueryRow(queryContext,
"SELECT count(*) FROM users"+whereClause, queryArguments...).Scan(&totalCount)
if countError != nil {
return nil, 0, fmt.Errorf("die anzahl der benutzer konnte nicht ermittelt werden: %w", countError)
}
listStatement := `
SELECT id, username, coalesce(email, ''), status, mfa_enabled, created_at, updated_at, last_login_at
FROM users` + whereClause + fmt.Sprintf(
" ORDER BY username LIMIT $%d OFFSET $%d", len(queryArguments)+1, len(queryArguments)+2)
queryArguments = append(queryArguments, pageSize, (page-1)*pageSize)
userRows, queryError := repository.connectionPool.Query(queryContext, listStatement, queryArguments...)
if queryError != nil {
return nil, 0, fmt.Errorf("die benutzer konnten nicht gelesen werden: %w", queryError)
}
defer userRows.Close()
loadedUsers := make([]User, 0, pageSize)
for userRows.Next() {
var loadedUser User
if scanError := userRows.Scan(&loadedUser.ID, &loadedUser.Username, &loadedUser.Email,
&loadedUser.Status, &loadedUser.MFAEnabled, &loadedUser.CreatedAt,
&loadedUser.UpdatedAt, &loadedUser.LastLoginAt); scanError != nil {
return nil, 0, fmt.Errorf("ein benutzer konnte nicht gelesen werden: %w", scanError)
}
loadedUsers = append(loadedUsers, loadedUser)
}
if rowsError := userRows.Err(); rowsError != nil {
return nil, 0, fmt.Errorf("die benutzer konnten nicht vollständig gelesen werden: %w", rowsError)
}
// Rollen werden je Benutzer nachgeladen. Bei den hier üblichen Seitengrößen
// ist das unkritisch; für sehr große Installationen wäre eine Sammelabfrage nötig.
for userIndex := range loadedUsers {
if loadError := repository.loadRolesAndPermissions(queryContext, &loadedUsers[userIndex]); loadError != nil {
return nil, 0, loadError
}
}
return loadedUsers, totalCount, nil
}
// CreateUser legt einen Benutzer an und weist ihm Rollen zu.
//
// Anlage und Rollenzuweisung laufen in einer Transaktion: ein Benutzer ohne
// Rollen wäre ein halb angelegtes Konto.
func (repository *Repository) CreateUser(createContext context.Context, username string, email string, passwordHash string, roleNames []string) (uuid.UUID, error) {
databaseTransaction, transactionError := repository.connectionPool.Begin(createContext)
if transactionError != nil {
return uuid.Nil, fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
// Ein Rollback nach erfolgreichem Commit ist wirkungslos und damit unschädlich.
defer func() { _ = databaseTransaction.Rollback(createContext) }()
const insertStatement = `
INSERT INTO users (username, email, password_hash, status)
VALUES ($1, $2, $3, 'active')
RETURNING id`
var createdUserID uuid.UUID
insertError := databaseTransaction.QueryRow(createContext, insertStatement,
username, nullIfEmpty(email), nullIfEmpty(passwordHash)).Scan(&createdUserID)
if insertError != nil {
return uuid.Nil, translateUniqueViolation(insertError)
}
if assignError := assignRolesInTransaction(createContext, databaseTransaction, createdUserID, roleNames); assignError != nil {
return uuid.Nil, assignError
}
if commitError := databaseTransaction.Commit(createContext); commitError != nil {
return uuid.Nil, fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", commitError)
}
return createdUserID, nil
}
// assignRolesInTransaction weist Rollen innerhalb einer Transaktion zu.
func assignRolesInTransaction(assignContext context.Context, databaseTransaction pgx.Tx, userID uuid.UUID, roleNames []string) error {
for _, roleName := range roleNames {
const assignStatement = `
INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`
commandTag, assignError := databaseTransaction.Exec(assignContext, assignStatement, userID, roleName)
if assignError != nil {
return fmt.Errorf("die rolle %q konnte nicht zugewiesen werden: %w", roleName, assignError)
}
// Keine betroffene Zeile bedeutet: die Rolle existiert nicht.
// Das darf nicht stillschweigend übergangen werden (PROMPT.md §140).
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("%w: %q", ErrRoleNotFound, roleName)
}
}
return nil
}
// UpdateUser ändert die veränderlichen Felder eines Benutzers.
//
// Ein nil-Zeiger bedeutet "unverändert"; so lässt sich ein Feld gezielt leeren,
// ohne alle übrigen mitsenden zu müssen.
func (repository *Repository) UpdateUser(updateContext context.Context, userID uuid.UUID, newEmail *string, newStatus *UserStatus, newPasswordHash *string) error {
const updateStatement = `
UPDATE users SET
email = coalesce($2, email),
status = coalesce($3, status),
password_hash = coalesce($4, password_hash),
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`
var statusValue *string
if newStatus != nil {
statusText := string(*newStatus)
statusValue = &statusText
}
commandTag, updateError := repository.connectionPool.Exec(updateContext, updateStatement,
userID, newEmail, statusValue, newPasswordHash)
if updateError != nil {
return translateUniqueViolation(updateError)
}
if commandTag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// SoftDeleteUser markiert einen Benutzer als gelöscht und widerruft seine Sitzungen.
//
// Die weiche Löschung erhält die Auditspur: ein hartes DELETE würde die
// Zuordnung vergangener Handlungen zerstören (SYNCOVA_DATABASE.md §1).
func (repository *Repository) SoftDeleteUser(deleteContext context.Context, userID uuid.UUID) error {
databaseTransaction, transactionError := repository.connectionPool.Begin(deleteContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(deleteContext) }()
// Der Anmeldename wird freigegeben, damit er neu vergeben werden kann;
// der ursprüngliche Name bleibt im Auditprotokoll erhalten.
const deleteStatement = `
UPDATE users
SET deleted_at = now(),
status = 'disabled',
username = username || '.deleted.' || extract(epoch from now())::bigint,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`
commandTag, deleteError := databaseTransaction.Exec(deleteContext, deleteStatement, userID)
if deleteError != nil {
return fmt.Errorf("der benutzer konnte nicht gelöscht werden: %w", deleteError)
}
if commandTag.RowsAffected() == 0 {
return ErrUserNotFound
}
// Ein gelöschtes Konto darf keine gültige Sitzung behalten.
if _, revokeError := databaseTransaction.Exec(deleteContext,
"UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", userID); revokeError != nil {
return fmt.Errorf("die sitzungen konnten nicht widerrufen werden: %w", revokeError)
}
if commitError := databaseTransaction.Commit(deleteContext); commitError != nil {
return fmt.Errorf("die löschung konnte nicht gespeichert werden: %w", commitError)
}
return nil
}
// ReplaceUserRoles ersetzt sämtliche Rollen eines Benutzers.
func (repository *Repository) ReplaceUserRoles(replaceContext context.Context, userID uuid.UUID, roleNames []string) error {
databaseTransaction, transactionError := repository.connectionPool.Begin(replaceContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(replaceContext) }()
if _, deleteError := databaseTransaction.Exec(replaceContext,
"DELETE FROM user_roles WHERE user_id = $1", userID); deleteError != nil {
return fmt.Errorf("die bisherigen rollen konnten nicht entfernt werden: %w", deleteError)
}
if assignError := assignRolesInTransaction(replaceContext, databaseTransaction, userID, roleNames); assignError != nil {
return assignError
}
if commitError := databaseTransaction.Commit(replaceContext); commitError != nil {
return fmt.Errorf("die rollen konnten nicht gespeichert werden: %w", commitError)
}
return nil
}
// CountAdministrators zählt die aktiven Konten mit Benutzerverwaltungsrechten.
//
// Der Wert verhindert, dass sich die letzte Administration selbst aussperrt.
func (repository *Repository) CountAdministrators(queryContext context.Context, excludedUserID *uuid.UUID) (int, error) {
const countStatement = `
SELECT count(DISTINCT u.id)
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE p.name = 'users.write'
AND u.status = 'active'
AND u.deleted_at IS NULL
AND ($1::uuid IS NULL OR u.id <> $1)`
var administratorCount int
if scanError := repository.connectionPool.QueryRow(queryContext, countStatement, excludedUserID).Scan(&administratorCount); scanError != nil {
return 0, fmt.Errorf("die anzahl der administratoren konnte nicht ermittelt werden: %w", scanError)
}
return administratorCount, nil
}
// recordFailedLogin erhöht den Fehlversuchszähler und sperrt bei Bedarf.
func (repository *Repository) recordFailedLogin(updateContext context.Context, userID uuid.UUID, maxAttempts int, lockoutDuration time.Duration) (bool, error) {
// Zählen und Sperren erfolgen in einer Anweisung, damit gleichzeitige
// Anmeldeversuche den Zähler nicht überschreiben.
const updateStatement = `
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= $2 THEN now() + $3::interval
ELSE locked_until
END,
updated_at = now()
WHERE id = $1
RETURNING failed_login_attempts >= $2`
var isNowLocked bool
scanError := repository.connectionPool.QueryRow(updateContext, updateStatement,
userID, maxAttempts, fmt.Sprintf("%d seconds", int(lockoutDuration.Seconds()))).Scan(&isNowLocked)
if scanError != nil {
return false, fmt.Errorf("der fehlversuch konnte nicht vermerkt werden: %w", scanError)
}
return isNowLocked, nil
}
// recordSuccessfulLogin setzt den Fehlversuchszähler zurück.
func (repository *Repository) recordSuccessfulLogin(updateContext context.Context, userID uuid.UUID) error {
const updateStatement = `
UPDATE users
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = now(), updated_at = now()
WHERE id = $1`
if _, updateError := repository.connectionPool.Exec(updateContext, updateStatement, userID); updateError != nil {
return fmt.Errorf("die anmeldung konnte nicht vermerkt werden: %w", updateError)
}
return nil
}
// nullIfEmpty wandelt eine leere Zeichenkette in NULL.
func nullIfEmpty(textValue string) *string {
if textValue == "" {
return nil
}
return &textValue
}
// translateUniqueViolation übersetzt Eindeutigkeitsverletzungen in Domänenfehler.
//
// Der Aufrufer soll "Benutzername vergeben" erhalten und nicht eine technische
// Meldung, die den Aufbau der Datenbank preisgibt.
func translateUniqueViolation(databaseError error) error {
var postgresError *pgconn.PgError
if !errors.As(databaseError, &postgresError) {
return fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", databaseError)
}
// 23505 ist der SQLSTATE für unique_violation.
if postgresError.Code != "23505" {
return fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", databaseError)
}
switch postgresError.ConstraintName {
case "users_username_key":
return ErrUsernameTaken
case "users_email_key":
return ErrEmailTaken
default:
return fmt.Errorf("der datensatz existiert bereits: %w", databaseError)
}
}
// FindUserByUsername liest einen Benutzer anhand seines Anmeldenamens.
//
// Die Funktion dient der Kommandozeile; die Anmeldung verwendet stattdessen
// findCredentialsByUsername, das zusätzlich den Passworthash liefert.
func (repository *Repository) FindUserByUsername(queryContext context.Context, username string) (User, error) {
const selectStatement = `SELECT id FROM users WHERE username = $1 AND deleted_at IS NULL`
var foundUserID uuid.UUID
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, username).Scan(&foundUserID)
if errors.Is(scanError, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
if scanError != nil {
return User{}, fmt.Errorf("der benutzer konnte nicht gelesen werden: %w", scanError)
}
return repository.FindUserByID(queryContext, foundUserID)
}