Files
deklarix/internal/store/auditlog.go
noroot fe28278615 feat: Row-Level-Security für Mandantenisolation auf DB-Ebene
Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten-
Geschäftsdaten (antrag und alles darüber verkettete, abteilung,
werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel,
loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die
Anwendung verbindet als postgres-Superuser, der RLS immer umgeht -
Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle
"deklarix_app" an, nur für die greifen die Policies tatsächlich.

internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion
und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per
set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über
s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die
komplette Handler-Ausführung damit - jeder Request läuft dadurch auch
atomar in einer Transaktion (positiver Nebeneffekt).

Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf
die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation
über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für
beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt
(Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren,
siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange
die App weiter als Superuser verbindet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:22:20 +02:00

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.db(ctx).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.db(ctx).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
}