Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 4180a26c6e CFG-01: zentraler-konfigurationsdienst
internal/cfgservice: Store (Schreiben/Historie) + Service (Lesen mit
Vorrangregel + TTL-Cache, Default 5s, analog internal/flag). Genannt
"cfgservice" statt "config", da internal/config bereits die Bootstrap-
Konfiguration des Core-Prozesses selbst belegt.

Store.Set schreibt aktuellen Stand (config_values) und Historieneintrag
(config_value_history) atomar in einer Transaktion — eine Aenderung ohne
Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
Version wird pro (key, scope) monoton hochgezaehlt.

Service.Resolve wendet die Vorrangregel an: Tenant-spezifischer Override
(scope = Tenant-Slug) hat Vorrang vor globalem Default (scope = 'global'),
faellt sauber zurueck wenn kein Override existiert (Akzeptanzkriterium 1).
Invalidate erzwingt sofortiges Neuladen fuer den Schreiber, andere Instanzen
sehen Aenderungen spaetestens nach der TTL.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Vorrangregel automatisiert getestet —
   TestService_TenantOverrideTakesPrecedenceOverGlobal: Tenant mit Override
   bekommt Tenant-Wert, Tenant ohne Override bekommt Global-Default. PASS.
2. Cache-Invalidierung nach Aenderung innerhalb dokumentierter Zeit
   gemessen — TestService_CacheInvalidationTiming: wirksam nach 154ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter
   Stand. PASS.
3. Versionierungshistorie ueber mehrere Aenderungen nachvollzogen —
   TestStore_HistoryTracksAllChanges: 3 aufeinanderfolgende Aenderungen,
   Historie liefert alle 3 in korrekter Reihenfolge mit korrekten
   Versionsnummern. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:26:14 +02:00
14 changed files with 477 additions and 663 deletions
-96
View File
@@ -1,96 +0,0 @@
// 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
}
-132
View File
@@ -1,132 +0,0 @@
package audit
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
pool.Close()
}
return NewLog(pool), pool, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
log, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{
TenantSlug: "test_acme",
Actor: "alice@example.com",
Action: "iam.login_failed",
Target: "user:alice@example.com",
Metadata: map[string]any{"reason": "falsches passwort"},
})
if err != nil {
t.Fatalf("record: %v", err)
}
count, err := log.CountByTenant(ctx, "test_acme")
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
}
var actor, action, target string
if err := pool.QueryRow(ctx, `
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
`).Scan(&actor, &action, &target); err != nil {
t.Fatalf("eintrag lesen: %v", err)
}
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
// bereits vom zentralen Schreibpfad abgewiesen.
func TestRecord_RejectsMissingTenant(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
if !errors.Is(err, ErrMissingTenant) {
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
// Schutz haengt nicht allein von der Go-Validierung ab.
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
_, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
_, err := pool.Exec(ctx, `
INSERT INTO audit_events (tenant_slug, actor, action, target)
VALUES ('', 'alice', 'irgendwas', 'ziel')
`)
if err == nil {
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
}
}
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
}
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
}
}
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
t.Fatalf("record mit SystemTenant: %v", err)
}
}
-122
View File
@@ -1,122 +0,0 @@
package audit
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"time"
)
// Filter grenzt einen Export ein (Akzeptanzkriterium 1). Leere/Nil-Felder
// bedeuten "kein Filter auf diesem Feld".
type Filter struct {
TenantSlug string
Actor string
Action string
From *time.Time
To *time.Time
}
func buildFilterQuery(f Filter) (string, []any) {
query := `SELECT occurred_at, tenant_slug, actor, action, target, metadata FROM audit_events WHERE 1=1`
var args []any
if f.TenantSlug != "" {
args = append(args, f.TenantSlug)
query += fmt.Sprintf(" AND tenant_slug = $%d", len(args))
}
if f.Actor != "" {
args = append(args, f.Actor)
query += fmt.Sprintf(" AND actor = $%d", len(args))
}
if f.Action != "" {
args = append(args, f.Action)
query += fmt.Sprintf(" AND action = $%d", len(args))
}
if f.From != nil {
args = append(args, *f.From)
query += fmt.Sprintf(" AND occurred_at >= $%d", len(args))
}
if f.To != nil {
args = append(args, *f.To)
query += fmt.Sprintf(" AND occurred_at <= $%d", len(args))
}
query += " ORDER BY occurred_at"
return query, args
}
// StreamCSV schreibt gefilterte Audit-Eintraege direkt als CSV in w, Zeile
// fuer Zeile ueber rows.Next() — es wird zu keinem Zeitpunkt das gesamte
// Ergebnis im Speicher aufgebaut (Akzeptanzkriterium 3 / Pruefung 1).
func (l *Log) StreamCSV(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
cw := csv.NewWriter(w)
if err := cw.Write([]string{"occurred_at", "tenant_slug", "actor", "action", "target", "metadata"}); err != nil {
return fmt.Errorf("csv-header schreiben: %w", err)
}
for rows.Next() {
var occurredAt time.Time
var tenantSlug, actor, action, target string
var metadataJSON []byte
if err := rows.Scan(&occurredAt, &tenantSlug, &actor, &action, &target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
if err := cw.Write([]string{
occurredAt.Format(time.RFC3339), tenantSlug, actor, action, target, string(metadataJSON),
}); err != nil {
return fmt.Errorf("csv-zeile schreiben: %w", err)
}
}
cw.Flush()
if err := rows.Err(); err != nil {
return fmt.Errorf("export lesen: %w", err)
}
return cw.Error()
}
// exportRecord ist die JSON-Repraesentation einer exportierten Zeile.
type exportRecord struct {
OccurredAt time.Time `json:"occurred_at"`
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Metadata json.RawMessage `json:"metadata"`
}
// StreamJSON schreibt gefilterte Audit-Eintraege als JSON Lines (ein
// JSON-Objekt pro Zeile) — bewusst kein einzelnes grosses JSON-Array, da
// dessen korrektes Streaming (Kommas/Klammern ohne Zwischenpufferung)
// unnoetige Komplexitaet fuer denselben Zweck waere. Wie StreamCSV
// zeilenweise ueber rows.Next(), kein Aufbau im Speicher.
func (l *Log) StreamJSON(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
enc := json.NewEncoder(w)
for rows.Next() {
var rec exportRecord
var metadataJSON []byte
if err := rows.Scan(&rec.OccurredAt, &rec.TenantSlug, &rec.Actor, &rec.Action, &rec.Target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
rec.Metadata = metadataJSON
if err := enc.Encode(rec); err != nil {
return fmt.Errorf("json-zeile schreiben: %w", err)
}
}
return rows.Err()
}
-73
View File
@@ -1,73 +0,0 @@
package audit
import (
"context"
"net/http"
"time"
)
// Authorize entscheidet, ob caller den Export ausfuehren darf. Die
// eigentliche Rollen-/Rechtepruefung (RBAC-02 Policy-Enforcement) ist nicht
// Teil dieser Kachel — ExportHandler kennt nur diese schmale Schnittstelle,
// analog zum RetentionRegistrar-Muster aus AUD-05.
type Authorize func(ctx context.Context, caller string) bool
// ExportHandler stellt den Export als HTTP-Endpunkt bereit
// (Akzeptanzkriterium 2: fuer berechtigte Rollen verfuegbar).
type ExportHandler struct {
log *Log
authorize Authorize
}
func NewExportHandler(log *Log, authorize Authorize) *ExportHandler {
return &ExportHandler{log: log, authorize: authorize}
}
// Export liest Filter-Query-Parameter (tenant, actor, action, from, to,
// format) und schreibt DIREKT auf den ResponseWriter (io.Writer) — dieselbe
// Streaming-Funktion wie in export.go, kein zusaetzlicher Pufferungsschritt.
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
// "caller" identifiziert die anfragende Person fuer die Berechtigungs-
// pruefung — bewusst getrennt vom Filterfeld "actor" (das den
// AUDIT-Akteur meint, ueber den gefiltert wird).
caller := r.URL.Query().Get("caller")
if caller == "" || !h.authorize(r.Context(), caller) {
http.Error(w, "keine berechtigung fuer audit-log-export", http.StatusForbidden)
return
}
filter := Filter{
TenantSlug: r.URL.Query().Get("tenant"),
Actor: r.URL.Query().Get("actor"),
Action: r.URL.Query().Get("action"),
}
if from := r.URL.Query().Get("from"); from != "" {
t, err := time.Parse(time.RFC3339, from)
if err != nil {
http.Error(w, "ungueltiges from-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.From = &t
}
if to := r.URL.Query().Get("to"); to != "" {
t, err := time.Parse(time.RFC3339, to)
if err != nil {
http.Error(w, "ungueltiges to-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.To = &t
}
switch r.URL.Query().Get("format") {
case "json":
w.Header().Set("Content-Type", "application/x-ndjson")
if err := h.log.StreamJSON(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
default:
w.Header().Set("Content-Type", "text/csv")
if err := h.log.StreamCSV(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
}
}
-222
View File
@@ -1,222 +0,0 @@
package audit
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupExportTest(t *testing.T) (*Log, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewLog(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte
// Teilmengen.
func TestExport_FilterCombinations(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
run := fmt.Sprintf("_%d", time.Now().UnixNano())
tenantA, tenantB := "test_fa"+run, "test_fb"+run
alice, bob := "alice"+run, "bob"+run
events := []Event{
{TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"},
{TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"},
}
for _, e := range events {
if err := log.Record(ctx, e); err != nil {
t.Fatalf("record: %v", err)
}
}
cases := []struct {
name string
filter Filter
wantLen int
}{
{"nach tenant", Filter{TenantSlug: tenantA}, 3},
{"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2},
{"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1},
{"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var buf bytes.Buffer
if err := log.StreamCSV(ctx, c.filter, &buf); err != nil {
t.Fatalf("stream: %v", err)
}
rows, err := csv.NewReader(&buf).ReadAll()
if err != nil {
t.Fatalf("csv parsen: %v", err)
}
got := len(rows) - 1 // Header abziehen
if got != c.wantLen {
t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got)
}
})
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter.
func TestExport_TimeRangeFilter(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano())
past := time.Now().Add(-48 * time.Hour)
future := time.Now().Add(48 * time.Hour)
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil {
t.Fatalf("record: %v", err)
}
var buf bytes.Buffer
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil {
t.Fatalf("stream (innerhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 1 {
t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got)
}
farPast := time.Now().Add(-96 * time.Hour)
buf.Reset()
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil {
t.Fatalf("stream (ausserhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 0 {
t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got)
}
}
func countLines(s string) int {
s = strings.TrimRight(s, "\n")
if s == "" {
return 0
}
return len(strings.Split(s, "\n"))
}
// Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne
// uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats.
func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano())
const n = 20000
for i := 0; i < n; i++ {
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil {
t.Fatalf("record %d: %v", i, err)
}
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
lineCount := 0
cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }}
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil {
t.Fatalf("stream: %v", err)
}
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if lineCount != n+1 { // +1 Header
t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount)
}
// Grobe Stichprobe: ein NICHT streamender Export haette hier locker
// mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige
// Schwelle, da Go-Heap-Messungen naturgemaess rauschen.
const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB
growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth)
if growth > maxAcceptableGrowth {
t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth)
}
}
type countingWriter struct {
onWrite func(p []byte)
}
func (w *countingWriter) Write(p []byte) (int, error) {
w.onWrite(p)
return len(p), nil
}
// Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen.
func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
handler := NewExportHandler(log, func(ctx context.Context, caller string) bool {
return caller == "berechtigte-person@example.com"
})
req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil)
rec := httptest.NewRecorder()
handler.Export(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unberechtigt: status = %d, want 403", rec.Code)
}
reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil)
recNoCaller := httptest.NewRecorder()
handler.Export(recNoCaller, reqNoCaller)
if recNoCaller.Code != http.StatusForbidden {
t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code)
}
reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil)
recOK := httptest.NewRecorder()
handler.Export(recOK, reqOK)
if recOK.Code != http.StatusOK {
t.Fatalf("berechtigt: status = %d, want 200", recOK.Code)
}
}
+88
View File
@@ -0,0 +1,88 @@
package cfgservice
import (
"context"
"errors"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2 / Pruefung 2 in diesem Ticket bezieht sich auf die
// Aenderungsnachvollziehbarkeit — die Cache-Frist selbst folgt demselben
// Muster wie internal/flag.DefaultCacheTTL).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
value Value
expiresAt time.Time
}
// Service ist die Leseseite mit Vorrangregel (Akzeptanzkriterium 1:
// Tenant-Override vor Global-Default) und lokalem TTL-Cache.
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry // Schluessel: key + "\x00" + tenantSlug
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
func cacheKey(key, tenantSlug string) string {
return key + "\x00" + tenantSlug
}
// Resolve liefert den Konfigurationswert fuer einen Tenant: ein
// Tenant-spezifischer Override hat Vorrang vor dem globalen Default
// (Akzeptanzkriterium 1 / Pruefung 1). tenantSlug == "" wertet nur den
// globalen Wert aus.
func (s *Service) Resolve(ctx context.Context, tenantSlug, key string) (Value, error) {
ck := cacheKey(key, tenantSlug)
s.mu.RLock()
entry, exists := s.cache[ck]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.value, nil
}
v, err := s.resolveUncached(ctx, tenantSlug, key)
if err != nil {
return Value{}, err
}
s.mu.Lock()
s.cache[ck] = cacheEntry{value: v, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return v, nil
}
func (s *Service) resolveUncached(ctx context.Context, tenantSlug, key string) (Value, error) {
if tenantSlug != "" {
v, err := s.store.Get(ctx, key, tenantSlug)
if err == nil {
return v, nil
}
if !errors.Is(err, ErrNotFound) {
return Value{}, err
}
}
return s.store.Get(ctx, key, GlobalScope)
}
// Invalidate erzwingt beim naechsten Resolve-Aufruf ein sofortiges Neuladen
// fuer einen bestimmten (key, tenantSlug) statt auf den TTL-Ablauf zu warten
// — analog internal/flag.Service.Invalidate.
func (s *Service) Invalidate(key, tenantSlug string) {
s.mu.Lock()
delete(s.cache, cacheKey(key, tenantSlug))
s.mu.Unlock()
}
+114
View File
@@ -0,0 +1,114 @@
package cfgservice
import (
"context"
"testing"
"time"
)
// Akzeptanzkriterium 1 + Pruefung 1: Tenant-Override hat Vorrang vor
// Global-Default, automatisiert getestet.
func TestService_TenantOverrideTakesPrecedenceOverGlobal(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Set(ctx, "test_precedence_key", GlobalScope, "global-wert"); err != nil {
t.Fatalf("set global: %v", err)
}
if _, err := store.Set(ctx, "test_precedence_key", "test_acme", "tenant-wert"); err != nil {
t.Fatalf("set tenant: %v", err)
}
svc := NewService(store, time.Hour)
got, err := svc.Resolve(ctx, "test_acme", "test_precedence_key")
if err != nil {
t.Fatalf("resolve mit override: %v", err)
}
if got.Value != "tenant-wert" {
t.Fatalf("erwartet tenant-override, habe %q", got.Value)
}
gotOther, err := svc.Resolve(ctx, "test_anderer_tenant", "test_precedence_key")
if err != nil {
t.Fatalf("resolve ohne override: %v", err)
}
if gotOther.Value != "global-wert" {
t.Fatalf("erwartet global-default fuer tenant ohne override, habe %q", gotOther.Value)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Cache-Invalidierung nach
// Konfigurationsaenderung innerhalb dokumentierter Zeit gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "alt"); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
v, err := svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value != "alt" {
t.Fatalf("erwartet 'alt', habe %q", v.Value)
}
changedAt := time.Now()
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "neu"); err != nil {
t.Fatalf("set: %v", err)
}
v, err = svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve direkt nach aenderung: %v", err)
}
if v.Value != "alt" {
t.Fatalf("cache haette den alten wert liefern sollen, habe %q", v.Value)
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
v, err := svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value == "neu" {
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", time.Since(changedAt), ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "alt"); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
_, _ = svc.Resolve(ctx, "", "test_invalidate_key")
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "neu"); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_key", "")
v, err := svc.Resolve(ctx, "", "test_invalidate_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value != "neu" {
t.Fatalf("erwartet sofort sichtbaren neuen wert nach Invalidate, habe %q", v.Value)
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer
// globale und tenant-spezifische Konfigurationswerte mit Versionierung und
// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH
// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen.
package cfgservice
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder
// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1).
const GlobalScope = "global"
var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden")
type Value struct {
Key string
Scope string
Value string
Version int
}
type HistoryEntry struct {
Key string
Scope string
Value string
Version int
}
// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den
// aktuellen Stand (config_values) als auch einen Historieneintrag
// (config_value_history) in derselben Transaktion — eine Aenderung ohne
// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1
// (Version 1 bei erstmaligem Setzen).
func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) {
if scope == "" {
return Value{}, errors.New("cfgservice: scope darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Value{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var currentVersion int
err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(&currentVersion)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Value{}, fmt.Errorf("aktuelle version lesen: %w", err)
}
newVersion := currentVersion + 1
if _, err := tx.Exec(ctx, `
INSERT INTO config_values (key, scope, value, version, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now()
`, key, scope, value, newVersion); err != nil {
return Value{}, fmt.Errorf("wert speichern: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO config_value_history (key, scope, value, version, changed_at)
VALUES ($1, $2, $3, $4, now())
`, key, scope, value, newVersion); err != nil {
return Value{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Value{}, fmt.Errorf("transaktion committen: %w", err)
}
return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil
}
// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die
// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit
// Store rein CRUD bleibt.
func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) {
var v Value
v.Key, v.Scope = key, scope
err := s.pool.QueryRow(ctx, `
SELECT value, version FROM config_values WHERE key = $1 AND scope = $2
`, key, scope).Scan(&v.Value, &v.Version)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Value{}, ErrNotFound
}
return Value{}, fmt.Errorf("wert lesen: %w", err)
}
return v, nil
}
// History liefert die vollstaendige Versionshistorie eines (key, scope) in
// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3).
func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT key, scope, value, version FROM config_value_history
WHERE key = $1 AND scope = $2 ORDER BY version
`, key, scope)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var h HistoryEntry
if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, h)
}
return out, rows.Err()
}
+119
View File
@@ -0,0 +1,119 @@
package cfgservice
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS config_values (
key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope <> ''),
value TEXT NOT NULL,
version INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (key, scope)
);
CREATE TABLE IF NOT EXISTS config_value_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
scope TEXT NOT NULL,
value TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`)
_, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
func TestStore_SetIncrementsVersion(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert")
if err != nil {
t.Fatalf("set 1: %v", err)
}
if v1.Version != 1 {
t.Fatalf("erwartet version 1, habe %d", v1.Version)
}
v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert")
if err != nil {
t.Fatalf("set 2: %v", err)
}
if v2.Version != 2 {
t.Fatalf("erwartet version 2, habe %d", v2.Version)
}
got, err := store.Get(ctx, "test_key", GlobalScope)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Value != "zweiter-wert" || got.Version != 2 {
t.Fatalf("aktueller wert unerwartet: %+v", got)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere
// Aenderungen hinweg nachvollzogen.
func TestStore_HistoryTracksAllChanges(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
values := []string{"v1", "v2", "v3"}
for _, v := range values {
if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil {
t.Fatalf("set %q: %v", v, err)
}
}
history, err := store.History(ctx, "test_history_key", GlobalScope)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 3 {
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
}
for i, h := range history {
if h.Version != i+1 || h.Value != values[i] {
t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h)
}
}
}
func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartet ErrNotFound, habe %v", err)
}
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS audit_events;
-17
View File
@@ -1,17 +0,0 @@
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
-- Wert 'system', niemals NULL oder leeren String.
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS config_value_history;
DROP TABLE IF EXISTS config_values;
+23
View File
@@ -0,0 +1,23 @@
-- Zentraler Konfigurationsdienst (CFG-01, siehe core-kanban/tickets/CFG-01.md).
-- scope = 'global' fuer globale Defaults, sonst der Tenant-Slug. config_values
-- haelt den AKTUELLEN Stand je (key, scope); config_value_history haelt JEDE
-- Aenderung fest (Akzeptanzkriterium 2: versioniert nachvollziehbar).
CREATE TABLE config_values (
key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope <> ''),
value TEXT NOT NULL,
version INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (key, scope)
);
CREATE TABLE config_value_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
scope TEXT NOT NULL,
value TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX config_value_history_key_scope_idx ON config_value_history (key, scope, version);
+2
View File
@@ -15,6 +15,8 @@ export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do