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