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