internal/audit: eigenes, strukturiertes Audit-Datenmodell (Akteur, Aktion, Zielobjekt, Zeitpunkt, Tenant) in der Registry-DB, getrennt von jedem allgemeinen Anwendungs-Log (eigenes Paket, eigene Tabelle audit_events, kein Logging-Framework). Log.Record ist der EINE zentrale Schreibpfad — es gibt keine zweite Schreibmoeglichkeit, ueber die ein Handler die Validierung umgehen koennte. Fehlender Tenant-Bezug wird zweifach verhindert (Akzeptanzkriterium 2): Log.Record weist leeren TenantSlug direkt ab (ErrMissingTenant), zusaetzlich erzwingt eine CHECK-Constraint in der Migration dasselbe auf Datenbankebene, selbst wenn Log.Record umgangen wuerde. Mandantenuebergreifende Ereignisse (z.B. Superadmin-Aktionen) nutzen den reservierten Wert audit.SystemTenant statt NULL oder leerem String — es gibt keinen Weg, ganz ohne Tenant-Bezug zu schreiben. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Automatisierter Test belegt genau einen Audit-Eintrag pro sicherheitsrelevantem Vorgang — TestRecord_PersistsExactlyOneEventPerSecurityIncident (simulierter fehlgeschlagener Login), Feldinhalte verifiziert. PASS. 2. Fehlender Tenant-Bezug durch Constraint/Test verhindert — TestRecord_RejectsMissingTenant (App-Ebene) UND TestConstraint_RejectsMissingTenantAtDatabaseLevel (direkter INSERT unter Umgehung von Log.Record, durch CHECK-Constraint abgewiesen). PASS. 3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
97 lines
3.1 KiB
Go
97 lines
3.1 KiB
Go
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
|
|
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
|
|
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
|
|
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
|
|
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
|
|
// Schreibpfad (Akzeptanzkriterium 3).
|
|
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
|
|
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
|
|
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
|
|
const SystemTenant = "system"
|
|
|
|
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
|
|
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
|
|
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
|
|
|
|
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
|
|
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
|
|
type Event struct {
|
|
TenantSlug string
|
|
Actor string
|
|
Action string
|
|
Target string
|
|
Metadata map[string]any
|
|
OccurredAt time.Time
|
|
}
|
|
|
|
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
|
|
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
|
|
// direkt in audit_events schreibt und dabei die Validierung umgeht
|
|
// (Akzeptanzkriterium 3).
|
|
type Log struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewLog(pool *pgxpool.Pool) *Log {
|
|
return &Log{pool: pool}
|
|
}
|
|
|
|
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
|
|
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
|
|
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
|
|
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
|
|
func (l *Log) Record(ctx context.Context, e Event) error {
|
|
if e.TenantSlug == "" {
|
|
return ErrMissingTenant
|
|
}
|
|
if e.Actor == "" {
|
|
return ErrMissingActor
|
|
}
|
|
if e.Action == "" {
|
|
return ErrMissingAction
|
|
}
|
|
if e.Metadata == nil {
|
|
e.Metadata = map[string]any{}
|
|
}
|
|
metadataJSON, err := json.Marshal(e.Metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("metadaten serialisieren: %w", err)
|
|
}
|
|
if e.OccurredAt.IsZero() {
|
|
e.OccurredAt = time.Now()
|
|
}
|
|
|
|
_, err = l.pool.Exec(ctx, `
|
|
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("audit-ereignis schreiben: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
|
|
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
|
|
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
|
|
var n int
|
|
if err := l.pool.QueryRow(ctx, `
|
|
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
|
|
`, tenantSlug).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|