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).
62 lines
1.9 KiB
Go
62 lines
1.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
|
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
|
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
|
type AuditEntry struct {
|
|
ID string
|
|
ActorUserID string
|
|
Action string
|
|
TargetType string
|
|
TargetID string
|
|
Details string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
|
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
|
var e AuditEntry
|
|
err := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
|
`, actorUserID, action, targetType, targetID, details).Scan(
|
|
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
|
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
|
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list audit log: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []AuditEntry
|
|
for rows.Next() {
|
|
var e AuditEntry
|
|
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: list audit log: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|