Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.
Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
dorthin — /register bietet die Rolle nicht an, erster Admin wird
einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
(§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
wie finding/extraction/evidence_package).
Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
und -Detail (Logins je Account), requireAdmin (404 statt 403 für
angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
"kanzlei" haben.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
104 lines
3.2 KiB
Go
104 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
|
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
|
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
|
|
|
// User ist ein Login innerhalb eines Account (Mandanten).
|
|
type User struct {
|
|
ID string
|
|
AccountID string
|
|
Email string
|
|
PasswordHash string
|
|
Role string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
|
// speichert nur, es hasht nicht selbst.
|
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
|
var u User
|
|
err := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, account_id, email, password_hash, role, created_at
|
|
`, accountID, email, passwordHash, role).Scan(
|
|
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
|
var u User
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT id, account_id, email, password_hash, role, created_at
|
|
FROM app_user WHERE email = $1
|
|
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// GetUser liest einen Nutzer anhand seiner ID.
|
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
|
var u User
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT id, account_id, email, password_hash, role, created_at
|
|
FROM app_user WHERE id = $1
|
|
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: get user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
|
// Admin-Bereich (Account-Detailansicht).
|
|
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT id, account_id, email, password_hash, role, created_at
|
|
FROM app_user WHERE account_id = $1 ORDER BY created_at
|
|
`, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []User
|
|
for rows.Next() {
|
|
var u User
|
|
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: scan user: %w", err)
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|