Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2026514404 |
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
||||||
|
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
||||||
|
// (TEN-06 Akzeptanzkriterium 3).
|
||||||
|
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
||||||
|
|
||||||
|
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
||||||
|
// nicht existiert.
|
||||||
|
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
||||||
|
|
||||||
|
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
||||||
|
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
||||||
|
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
||||||
|
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
||||||
|
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
||||||
|
type Router struct {
|
||||||
|
registry *Registry
|
||||||
|
maxOpen int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
order *list.List // vorne = zuletzt benutzt
|
||||||
|
items map[string]*list.Element // slug -> element mit *routerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type routerEntry struct {
|
||||||
|
slug string
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(registry *Registry, maxOpen int) *Router {
|
||||||
|
if maxOpen < 1 {
|
||||||
|
maxOpen = 1
|
||||||
|
}
|
||||||
|
return &Router{
|
||||||
|
registry: registry,
|
||||||
|
maxOpen: maxOpen,
|
||||||
|
order: list.New(),
|
||||||
|
items: make(map[string]*list.Element),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
||||||
|
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 3).
|
||||||
|
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
||||||
|
if tenantSlug == "" {
|
||||||
|
return nil, ErrMissingTenantContext
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
if el, ok := r.items[tenantSlug]; ok {
|
||||||
|
r.order.MoveToFront(el)
|
||||||
|
pool := el.Value.(*routerEntry).pool
|
||||||
|
r.mu.Unlock()
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
||||||
|
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
||||||
|
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
||||||
|
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
||||||
|
// Verbindung wieder schliessen und die vorhandene verwenden.
|
||||||
|
if el, ok := r.items[tenantSlug]; ok {
|
||||||
|
r.order.MoveToFront(el)
|
||||||
|
existing := el.Value.(*routerEntry).pool
|
||||||
|
pool.Close()
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
||||||
|
r.items[tenantSlug] = el
|
||||||
|
|
||||||
|
if r.order.Len() > r.maxOpen {
|
||||||
|
r.evictOldest()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
||||||
|
// gehaltenem r.mu aufgerufen werden.
|
||||||
|
func (r *Router) evictOldest() {
|
||||||
|
oldest := r.order.Back()
|
||||||
|
if oldest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := oldest.Value.(*routerEntry)
|
||||||
|
r.order.Remove(oldest)
|
||||||
|
delete(r.items, entry.slug)
|
||||||
|
entry.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
||||||
|
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
||||||
|
func (r *Router) OpenCount() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.order.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
||||||
|
// Herunterfahren des Core-Prozesses.
|
||||||
|
func (r *Router) Close() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for el := r.order.Front(); el != nil; el = el.Next() {
|
||||||
|
el.Value.(*routerEntry).pool.Close()
|
||||||
|
}
|
||||||
|
r.order.Init()
|
||||||
|
r.items = make(map[string]*list.Element)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registryPool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
var tenants []Tenant
|
||||||
|
var slugs []string
|
||||||
|
for i := 0; i < tenantCount; i++ {
|
||||||
|
slug := fmt.Sprintf("router_t%d", i)
|
||||||
|
slugs = append(slugs, slug)
|
||||||
|
tn, err := provisioner.Provision(ctx, slug, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
tenants = append(tenants, tn)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
router.Close()
|
||||||
|
for _, slug := range slugs {
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
||||||
|
}
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
return router, tenants, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
|
||||||
|
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, 2)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
var dbName string
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
|
||||||
|
t.Fatalf("current_database: %v", err)
|
||||||
|
}
|
||||||
|
if dbName != tenants[0].DBName {
|
||||||
|
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
|
||||||
|
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
|
||||||
|
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
|
||||||
|
router, _, cleanup := newTestRouterSetup(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
|
||||||
|
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
|
||||||
|
}
|
||||||
|
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
|
||||||
|
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
|
||||||
|
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
|
||||||
|
// erneuter Verbindungsaufbau.
|
||||||
|
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve 1: %v", err)
|
||||||
|
}
|
||||||
|
second, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve 2: %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
|
||||||
|
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
|
||||||
|
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
|
||||||
|
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
|
||||||
|
const tenantCount = 6
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, tn := range tenants {
|
||||||
|
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
|
||||||
|
t.Fatalf("resolve %s: %v", tn.Slug, err)
|
||||||
|
}
|
||||||
|
if router.OpenCount() > 2 {
|
||||||
|
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if router.OpenCount() != 2 {
|
||||||
|
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
|
||||||
|
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
|
||||||
|
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve nach eviction: %v", err)
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
|
||||||
|
t.Fatalf("query nach re-resolve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS audit_events;
|
|
||||||
@@ -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);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
|
||||||
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
|
||||||
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
|
||||||
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
|
||||||
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
ROLE="nexarch_test"
|
|
||||||
|
|
||||||
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;"
|
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
|
||||||
for db in $dbs; do
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
|
||||||
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
|
||||||
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
|
||||||
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
|
||||||
|
|
||||||
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
|
||||||
|
|
||||||
echo "== go build =="
|
|
||||||
go build ./...
|
|
||||||
|
|
||||||
echo "== go vet =="
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
echo "== go test (-p 1) =="
|
|
||||||
go test ./... -p 1 -count=1
|
|
||||||
Reference in New Issue
Block a user