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

233 lines
8.5 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ListRoles liest alle Rollen samt ihrer Berechtigungen.
//
// Die Anzahl der Rollen ist naturgemäß klein, weshalb hier bewusst nicht
// paginiert wird.
func (repository *Repository) ListRoles(queryContext context.Context) ([]Role, error) {
const selectStatement = `
SELECT r.id, r.name, coalesce(r.description, ''), r.is_system,
coalesce(array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}')
FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.id
LEFT JOIN permissions p ON p.id = rp.permission_id
GROUP BY r.id, r.name, r.description, r.is_system
ORDER BY r.name`
roleRows, queryError := repository.connectionPool.Query(queryContext, selectStatement)
if queryError != nil {
return nil, fmt.Errorf("die rollen konnten nicht gelesen werden: %w", queryError)
}
defer roleRows.Close()
loadedRoles := make([]Role, 0)
for roleRows.Next() {
var loadedRole Role
if scanError := roleRows.Scan(&loadedRole.ID, &loadedRole.Name, &loadedRole.Description,
&loadedRole.IsSystem, &loadedRole.Permissions); scanError != nil {
return nil, fmt.Errorf("eine rolle konnte nicht gelesen werden: %w", scanError)
}
loadedRoles = append(loadedRoles, loadedRole)
}
if rowsError := roleRows.Err(); rowsError != nil {
return nil, fmt.Errorf("die rollen konnten nicht vollständig gelesen werden: %w", rowsError)
}
return loadedRoles, nil
}
// FindRoleByID liest eine Rolle samt ihrer Berechtigungen.
func (repository *Repository) FindRoleByID(queryContext context.Context, roleID uuid.UUID) (Role, error) {
const selectStatement = `
SELECT r.id, r.name, coalesce(r.description, ''), r.is_system,
coalesce(array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}')
FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.id
LEFT JOIN permissions p ON p.id = rp.permission_id
WHERE r.id = $1
GROUP BY r.id, r.name, r.description, r.is_system`
var loadedRole Role
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, roleID).Scan(
&loadedRole.ID, &loadedRole.Name, &loadedRole.Description, &loadedRole.IsSystem, &loadedRole.Permissions)
if errors.Is(scanError, pgx.ErrNoRows) {
return Role{}, ErrRoleNotFound
}
if scanError != nil {
return Role{}, fmt.Errorf("die rolle konnte nicht gelesen werden: %w", scanError)
}
return loadedRole, nil
}
// ListPermissions liest alle verfügbaren Berechtigungen.
func (repository *Repository) ListPermissions(queryContext context.Context) ([]Permission, error) {
permissionRows, queryError := repository.connectionPool.Query(queryContext,
"SELECT name, description FROM permissions ORDER BY name")
if queryError != nil {
return nil, fmt.Errorf("die berechtigungen konnten nicht gelesen werden: %w", queryError)
}
defer permissionRows.Close()
loadedPermissions := make([]Permission, 0)
for permissionRows.Next() {
var loadedPermission Permission
if scanError := permissionRows.Scan(&loadedPermission.Name, &loadedPermission.Description); scanError != nil {
return nil, fmt.Errorf("eine berechtigung konnte nicht gelesen werden: %w", scanError)
}
loadedPermissions = append(loadedPermissions, loadedPermission)
}
if rowsError := permissionRows.Err(); rowsError != nil {
return nil, fmt.Errorf("die berechtigungen konnten nicht vollständig gelesen werden: %w", rowsError)
}
return loadedPermissions, nil
}
// CreateRole legt eine Rolle mit den angegebenen Berechtigungen an.
func (repository *Repository) CreateRole(createContext context.Context, roleName string, description string, permissionNames []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)
}
defer func() { _ = databaseTransaction.Rollback(createContext) }()
var createdRoleID uuid.UUID
insertError := databaseTransaction.QueryRow(createContext,
"INSERT INTO roles (name, description, is_system) VALUES ($1, $2, FALSE) RETURNING id",
roleName, nullIfEmpty(description)).Scan(&createdRoleID)
if insertError != nil {
return uuid.Nil, fmt.Errorf("die rolle konnte nicht angelegt werden: %w", insertError)
}
if permissionError := setRolePermissionsInTransaction(createContext, databaseTransaction, createdRoleID, permissionNames); permissionError != nil {
return uuid.Nil, permissionError
}
if commitError := databaseTransaction.Commit(createContext); commitError != nil {
return uuid.Nil, fmt.Errorf("die rolle konnte nicht gespeichert werden: %w", commitError)
}
return createdRoleID, nil
}
// setRolePermissionsInTransaction ersetzt die Berechtigungen einer Rolle.
func setRolePermissionsInTransaction(setContext context.Context, databaseTransaction pgx.Tx, roleID uuid.UUID, permissionNames []string) error {
if _, deleteError := databaseTransaction.Exec(setContext,
"DELETE FROM role_permissions WHERE role_id = $1", roleID); deleteError != nil {
return fmt.Errorf("die bisherigen berechtigungen konnten nicht entfernt werden: %w", deleteError)
}
for _, permissionName := range permissionNames {
commandTag, insertError := databaseTransaction.Exec(setContext, `
INSERT INTO role_permissions (role_id, permission_id)
SELECT $1, id FROM permissions WHERE name = $2
ON CONFLICT DO NOTHING`, roleID, permissionName)
if insertError != nil {
return fmt.Errorf("die berechtigung %q konnte nicht zugewiesen werden: %w", permissionName, insertError)
}
// Eine unbekannte Berechtigung wäre ein stillschweigend wirkungsloser
// Eintrag und damit eine falsche Sicherheitsannahme.
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("die berechtigung %q existiert nicht", permissionName)
}
}
return nil
}
// UpdateRole ändert Beschreibung und Berechtigungen einer Rolle.
//
// Mitgelieferte Rollen sind unveränderlich: eine Änderung würde die Bedeutung
// bestehender Zuweisungen nachträglich verschieben.
func (repository *Repository) UpdateRole(updateContext context.Context, roleID uuid.UUID, newDescription *string, permissionNames []string) error {
existingRole, lookupError := repository.FindRoleByID(updateContext, roleID)
if lookupError != nil {
return lookupError
}
if existingRole.IsSystem {
return ErrSystemRoleImmutable
}
databaseTransaction, transactionError := repository.connectionPool.Begin(updateContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(updateContext) }()
if newDescription != nil {
if _, updateError := databaseTransaction.Exec(updateContext,
"UPDATE roles SET description = $2, updated_at = now() WHERE id = $1",
roleID, nullIfEmpty(*newDescription)); updateError != nil {
return fmt.Errorf("die rolle konnte nicht geändert werden: %w", updateError)
}
}
if permissionNames != nil {
if permissionError := setRolePermissionsInTransaction(updateContext, databaseTransaction, roleID, permissionNames); permissionError != nil {
return permissionError
}
}
if commitError := databaseTransaction.Commit(updateContext); commitError != nil {
return fmt.Errorf("die änderung konnte nicht gespeichert werden: %w", commitError)
}
return nil
}
// DeleteRole löscht eine Rolle.
func (repository *Repository) DeleteRole(deleteContext context.Context, roleID uuid.UUID) error {
existingRole, lookupError := repository.FindRoleByID(deleteContext, roleID)
if lookupError != nil {
return lookupError
}
if existingRole.IsSystem {
return ErrSystemRoleImmutable
}
// Eine noch zugewiesene Rolle zu löschen würde Benutzern unbemerkt Rechte
// entziehen; der Fremdschlüssel verhindert das bereits, doch die Meldung
// soll verständlich sein.
var assignmentCount int
if scanError := repository.connectionPool.QueryRow(deleteContext,
"SELECT count(*) FROM user_roles WHERE role_id = $1", roleID).Scan(&assignmentCount); scanError != nil {
return fmt.Errorf("die zuweisungen konnten nicht geprüft werden: %w", scanError)
}
if assignmentCount > 0 {
return fmt.Errorf("%w (%d Zuweisungen)", ErrRoleInUse, assignmentCount)
}
commandTag, deleteError := repository.connectionPool.Exec(deleteContext, "DELETE FROM roles WHERE id = $1", roleID)
if deleteError != nil {
return fmt.Errorf("die rolle konnte nicht gelöscht werden: %w", deleteError)
}
if commandTag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}