Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3376cf99dc | ||
|
|
b12d53f469 |
@@ -0,0 +1,96 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AuditLogObjectType ist der Objekttyp-Bezeichner, unter dem Audit-Log-
|
||||||
|
// Eintraege bei der Archive-Retention-Engine registriert werden
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
const AuditLogObjectType = "audit_log_entry"
|
||||||
|
|
||||||
|
// DefaultAuditRetentionYears ist die GoBD-Buchungsbeleg-Frist (Akzeptanz-
|
||||||
|
// kriterium 2) — Default, pro Tenant ueberschreibbar sofern rechtlich
|
||||||
|
// zulaessig. Die eigentliche Ueberschreibung/Durchsetzung liegt vollstaendig
|
||||||
|
// bei Archive, siehe AuditRetentionTenantOverridable.
|
||||||
|
const DefaultAuditRetentionYears = 10
|
||||||
|
|
||||||
|
// AuditRetentionTenantOverridable erlaubt Archive, die Default-Frist pro
|
||||||
|
// Tenant zu ueberschreiben — Core trifft dabei keine rechtliche Entscheidung,
|
||||||
|
// sondern erlaubt Archive lediglich, so eine Entscheidung zuzulassen.
|
||||||
|
const AuditRetentionTenantOverridable = true
|
||||||
|
|
||||||
|
// RetentionRegistrar ist der Modul-Adapter-Vertrag aus Archive RET-05, wie
|
||||||
|
// Core ihn konsumiert. Die tatsaechliche Implementierung lebt im
|
||||||
|
// Archive-Modul (RET-01/RET-02/RET-05) und existiert zum Zeitpunkt dieser
|
||||||
|
// Kachel noch nicht als Code — Core kennt nur diese Schnittstelle.
|
||||||
|
//
|
||||||
|
// WICHTIG: Core implementiert absichtlich KEINE eigene Loeschlogik fuer
|
||||||
|
// Audit-Eintraege (Akzeptanzkriterium 3). Dieses Paket enthaelt keinen
|
||||||
|
// Delete-Codepfad fuer audit_events ausser dem durch AUD-02 technisch
|
||||||
|
// unterbundenen — die tatsaechliche Loeschung/Aufbewahrungssperre erfolgt
|
||||||
|
// ausschliesslich innerhalb von Archive, ausserhalb dieses Prozesses.
|
||||||
|
type RetentionRegistrar interface {
|
||||||
|
RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWithArchive meldet den Audit-Log-Objekttyp bei der Archive-
|
||||||
|
// Retention-Engine an. Dies ist die EINZIGE Beruehrung dieses Pakets mit
|
||||||
|
// Retention ueberhaupt — kein zweites, Core-eigenes Retention-System
|
||||||
|
// (siehe "Bekannte Fehler vermeiden" im AUD-05-Ticket).
|
||||||
|
func RegisterWithArchive(ctx context.Context, registrar RetentionRegistrar) error {
|
||||||
|
return registrar.RegisterObjectType(ctx, AuditLogObjectType, DefaultAuditRetentionYears, AuditRetentionTenantOverridable)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRegistrar simuliert den RET-05-Modul-Adapter-Vertrag, da Archive
|
||||||
|
// (RET-01/RET-02/RET-05) zum Zeitpunkt dieser Kachel noch nicht als Code
|
||||||
|
// existiert (nur geplant in archive-kanban). Belegt NUR, dass Core mit den
|
||||||
|
// richtigen Parametern registriert — ersetzt KEINE Integrationspruefung
|
||||||
|
// gegen die echte Archive-Engine, siehe Pruefungen-Abschnitt im Commit.
|
||||||
|
type fakeRegistrar struct {
|
||||||
|
objectType string
|
||||||
|
defaultRetentionYears int
|
||||||
|
tenantOverridable bool
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistrar) RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error {
|
||||||
|
f.called = true
|
||||||
|
f.objectType = objectType
|
||||||
|
f.defaultRetentionYears = defaultRetentionYears
|
||||||
|
f.tenantOverridable = tenantOverridable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 2: Registrierung mit korrektem Objekttyp und
|
||||||
|
// GoBD-Default-Frist von 10 Jahren, tenant-ueberschreibbar.
|
||||||
|
func TestRegisterWithArchive_UsesCorrectObjectTypeAndRetention(t *testing.T) {
|
||||||
|
fake := &fakeRegistrar{}
|
||||||
|
|
||||||
|
if err := RegisterWithArchive(context.Background(), fake); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fake.called {
|
||||||
|
t.Fatal("erwartet aufruf von RegisterObjectType")
|
||||||
|
}
|
||||||
|
if fake.objectType != AuditLogObjectType {
|
||||||
|
t.Fatalf("objectType = %q, want %q", fake.objectType, AuditLogObjectType)
|
||||||
|
}
|
||||||
|
if fake.defaultRetentionYears != 10 {
|
||||||
|
t.Fatalf("defaultRetentionYears = %d, want 10 (GoBD-Frist)", fake.defaultRetentionYears)
|
||||||
|
}
|
||||||
|
if !fake.tenantOverridable {
|
||||||
|
t.Fatal("erwartet tenantOverridable = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
// 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(¤tVersion)
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS audit_events;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- 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);
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS config_value_history;
|
|
||||||
DROP TABLE IF EXISTS config_values;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -15,8 +15,6 @@ 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 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 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 '\'")
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
for db in $dbs; do
|
for db in $dbs; do
|
||||||
|
|||||||
Reference in New Issue
Block a user