Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bfb2efd94 | ||
|
|
b12d53f469 |
@@ -0,0 +1,97 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupAppendOnlyTest(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
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_update
|
||||
BEFORE UPDATE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||
DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events;
|
||||
CREATE TRIGGER audit_events_no_delete
|
||||
BEFORE DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
pool.Close()
|
||||
}
|
||||
return NewLog(pool), pool, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: direkter UPDATE/DELETE-Versuch wird von
|
||||
// der Datenbank abgewiesen.
|
||||
func TestAppendOnly_RejectsUpdateAndDelete(t *testing.T) {
|
||||
log, pool, cleanup := setupAppendOnlyTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Append-only bedeutet: dieser Testeintrag kann NIE wieder geloescht
|
||||
// werden, auch nicht vom Test selbst. Eindeutiger Tenant-Slug pro Lauf,
|
||||
// damit wiederholte Testlaeufe sich nicht gegenseitig die Zaehlung
|
||||
// verfaelschen.
|
||||
tenantSlug := fmt.Sprintf("test_appendonly_%d", time.Now().UnixNano())
|
||||
|
||||
if err := log.Record(ctx, Event{
|
||||
TenantSlug: tenantSlug,
|
||||
Actor: "alice",
|
||||
Action: "test.event",
|
||||
Target: "x",
|
||||
}); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE audit_events SET actor = 'mallory' WHERE tenant_slug = $1`, tenantSlug)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei UPDATE auf audit_events, habe nil")
|
||||
}
|
||||
|
||||
_, err = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug = $1`, tenantSlug)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei DELETE auf audit_events, habe nil")
|
||||
}
|
||||
|
||||
count, err := log.CountByTenant(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("eintrag haette trotz fehlgeschlagener update/delete-versuche erhalten bleiben muessen, count=%d", count)
|
||||
}
|
||||
}
|
||||
@@ -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,139 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
|
||||
// Seit AUD-02 ist audit_events append-only — Zeilen koennen nie wieder
|
||||
// geloescht werden (auch nicht vom Test-Cleanup). Eindeutiger Slug pro
|
||||
// Lauf, damit wiederholte Testlaeufe die Zaehlung nicht verfaelschen.
|
||||
tenantSlug := "test_acme_" + fmt.Sprint(time.Now().UnixNano())
|
||||
|
||||
err := log.Record(ctx, Event{
|
||||
TenantSlug: tenantSlug,
|
||||
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, tenantSlug)
|
||||
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 = $1
|
||||
`, tenantSlug).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,120 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConfirmationNotFound = errors.New("audit: bestaetigungsvorgang nicht gefunden")
|
||||
ErrAlreadyDecided = errors.New("audit: bestaetigungsvorgang wurde bereits entschieden")
|
||||
ErrSameActor = errors.New("audit: bestaetigung muss von einer anderen person als der anfordernden erfolgen")
|
||||
ErrInvalidCode = errors.New("audit: bestaetigungscode ungueltig")
|
||||
)
|
||||
|
||||
type ConfirmationStatus string
|
||||
|
||||
const (
|
||||
StatusPending ConfirmationStatus = "pending"
|
||||
StatusConfirmed ConfirmationStatus = "confirmed"
|
||||
)
|
||||
|
||||
// FourEyes implementiert das Vier-Augen-Prinzip fuer sicherheitskritische
|
||||
// Entscheidungen (Akzeptanzkriterium 2) nach dem archivdms-Vorbild:
|
||||
// FOR-UPDATE-Lock gegen Race-Bedingungen bei paralleler Bestaetigung,
|
||||
// Timing-safe Vergleich des Bestaetigungscodes (Akzeptanzkriterium 3).
|
||||
type FourEyes struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFourEyes(pool *pgxpool.Pool) *FourEyes {
|
||||
return &FourEyes{pool: pool}
|
||||
}
|
||||
|
||||
// Request legt einen neuen, zu bestaetigenden Vorgang an (z.B. Loeschbestaetigung,
|
||||
// Rechtevergabe) und liefert einen einmaligen Klartext-Code, der ausserhalb
|
||||
// dieses Systems (z.B. per E-Mail) an eine ZWEITE Person uebermittelt wird —
|
||||
// niemals der anfordernden Person selbst.
|
||||
func (f *FourEyes) Request(ctx context.Context, action, target, requestedBy string) (id, code string, err error) {
|
||||
code, err = generateCode()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("bestaetigungscode erzeugen: %w", err)
|
||||
}
|
||||
hash := hashCode(code)
|
||||
|
||||
err = f.pool.QueryRow(ctx, `
|
||||
INSERT INTO security_confirmations (action, target, requested_by, code_hash, status)
|
||||
VALUES ($1, $2, $3, $4, 'pending')
|
||||
RETURNING id
|
||||
`, action, target, requestedBy, hash).Scan(&id)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("bestaetigungsvorgang anlegen: %w", err)
|
||||
}
|
||||
return id, code, nil
|
||||
}
|
||||
|
||||
// Confirm bestaetigt einen Vorgang. confirmedBy MUSS sich von der
|
||||
// anfordernden Person unterscheiden (echtes Vier-Augen-Prinzip). Der Zugriff
|
||||
// auf die Zeile erfolgt mit FOR UPDATE, damit zwei gleichzeitige
|
||||
// Bestaetigungsversuche serialisiert werden und niemals beide durchgehen
|
||||
// (Akzeptanzkriterium 2 / Pruefung 2).
|
||||
func (f *FourEyes) Confirm(ctx context.Context, id, confirmedBy, code string) error {
|
||||
tx, err := f.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var requestedBy, status string
|
||||
var codeHash []byte
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT requested_by, status, code_hash FROM security_confirmations
|
||||
WHERE id = $1 FOR UPDATE
|
||||
`, id).Scan(&requestedBy, &status, &codeHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrConfirmationNotFound
|
||||
}
|
||||
return fmt.Errorf("bestaetigungsvorgang lesen: %w", err)
|
||||
}
|
||||
|
||||
if status != string(StatusPending) {
|
||||
return ErrAlreadyDecided
|
||||
}
|
||||
if confirmedBy == requestedBy {
|
||||
return ErrSameActor
|
||||
}
|
||||
if !timingSafeEqual(hashCode(code), codeHash) {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE security_confirmations
|
||||
SET status = 'confirmed', confirmed_by = $2, confirmed_at = now()
|
||||
WHERE id = $1
|
||||
`, id, confirmedBy); err != nil {
|
||||
return fmt.Errorf("bestaetigung speichern: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func generateCode() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashCode(code string) []byte {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return sum[:]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupFourEyesTest(t *testing.T) (*FourEyes, 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 security_confirmations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
requested_by TEXT NOT NULL,
|
||||
code_hash BYTEA NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')),
|
||||
confirmed_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmed_at TIMESTAMPTZ
|
||||
)`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM security_confirmations WHERE action LIKE 'test.%'`)
|
||||
pool.Close()
|
||||
}
|
||||
return NewFourEyes(pool), cleanup
|
||||
}
|
||||
|
||||
func TestFourEyes_RequestAndConfirm(t *testing.T) {
|
||||
fe, cleanup := setupFourEyesTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
if err := fe.Confirm(ctx, id, "bob@example.com", code); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFourEyes_RejectsSameActor(t *testing.T) {
|
||||
fe, cleanup := setupFourEyesTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
if err := fe.Confirm(ctx, id, "alice@example.com", code); !errors.Is(err, ErrSameActor) {
|
||||
t.Fatalf("erwartet ErrSameActor, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFourEyes_RejectsWrongCode(t *testing.T) {
|
||||
fe, cleanup := setupFourEyesTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
if err := fe.Confirm(ctx, id, "bob@example.com", "falscher-code"); !errors.Is(err, ErrInvalidCode) {
|
||||
t.Fatalf("erwartet ErrInvalidCode, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: FOR-UPDATE-Lock unter parallelen
|
||||
// Anfragen race-frei — von zwei gleichzeitigen Bestaetigungsversuchen fuer
|
||||
// denselben Vorgang darf genau einer durchgehen.
|
||||
func TestFourEyes_ConcurrentConfirmIsRaceFree(t *testing.T) {
|
||||
fe, cleanup := setupFourEyesTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
confirmers := []string{"bob@example.com", "carol@example.com"}
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = fe.Confirm(ctx, id, confirmers[i], code)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
} else if !errors.Is(err, ErrAlreadyDecided) {
|
||||
t.Fatalf("unerwarteter fehler: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Fatalf("erwartet genau eine erfolgreiche bestaetigung, habe %d", successCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package audit
|
||||
|
||||
import "crypto/subtle"
|
||||
|
||||
// timingSafeEqual ist die projektweite Referenzimplementierung fuer
|
||||
// Timing-safe-Vergleiche sicherheitsrelevanter Geheimnisse (Bestaetigungs-
|
||||
// codes hier, spaeter Freigabelinks in Archive CMP-06 — siehe IAM-02-Ticket-
|
||||
// Konvention). subtle.ConstantTimeCompare vergleicht in konstanter Zeit
|
||||
// bezogen auf die Laenge von a, unabhaengig vom Inhalt.
|
||||
func timingSafeEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(a, b) == 1
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimingSafeEqual_Correctness(t *testing.T) {
|
||||
a := hashCode("geheimnis-a")
|
||||
b := hashCode("geheimnis-a")
|
||||
c := hashCode("geheimnis-b")
|
||||
|
||||
if !timingSafeEqual(a, b) {
|
||||
t.Fatal("identische hashes sollten gleich sein")
|
||||
}
|
||||
if timingSafeEqual(a, c) {
|
||||
t.Fatal("unterschiedliche hashes sollten ungleich sein")
|
||||
}
|
||||
if timingSafeEqual(a, []byte("kuerzer")) {
|
||||
t.Fatal("unterschiedliche laenge sollte ungleich sein")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: Timing-safe Vergleich stichprobenartig
|
||||
// per Laufzeitmessung verifiziert — ein Mismatch am Anfang darf nicht
|
||||
// messbar schneller sein als ein Mismatch am Ende (klassisches Merkmal
|
||||
// eines NICHT timing-safen Vergleichs wie bytes.Equal mit Short-Circuit).
|
||||
func TestTimingSafeEqual_NoEarlyExitTiming(t *testing.T) {
|
||||
reference := hashCode("referenzwert-fuer-timing-test")
|
||||
|
||||
mismatchAtStart := make([]byte, len(reference))
|
||||
copy(mismatchAtStart, reference)
|
||||
mismatchAtStart[0] ^= 0xFF
|
||||
|
||||
mismatchAtEnd := make([]byte, len(reference))
|
||||
copy(mismatchAtEnd, reference)
|
||||
mismatchAtEnd[len(mismatchAtEnd)-1] ^= 0xFF
|
||||
|
||||
const iterations = 20000
|
||||
startDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtStart) })
|
||||
endDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtEnd) })
|
||||
|
||||
t.Logf("mismatch am anfang: %s, mismatch am ende: %s (%d iterationen)", startDur, endDur, iterations)
|
||||
|
||||
ratio := float64(startDur) / float64(endDur)
|
||||
// Grosszuegige Toleranz (Faktor 3), da es ein Stichprobentest auf einer
|
||||
// geteilten Testmaschine ist, kein isolierter Benchmark — es geht darum,
|
||||
// eine grobe Short-Circuit-Implementierung zuverlaessig aufzudecken
|
||||
// (die haette typischerweise eine Groessenordnung Unterschied), nicht um
|
||||
// kryptographisch praezise Constant-Time-Beweise.
|
||||
if ratio > 3.0 || ratio < 1.0/3.0 {
|
||||
t.Fatalf("timing-unterschied zu gross (verdacht auf short-circuit-vergleich): ratio=%.2f", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func measure(iterations int, fn func()) time.Duration {
|
||||
start := time.Now()
|
||||
for i := 0; i < iterations; i++ {
|
||||
fn()
|
||||
}
|
||||
return time.Since(start)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
|
||||
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
|
||||
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
|
||||
// laufen, siehe TEN-01).
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
const upSuffix = ".up.sql"
|
||||
|
||||
type Migration struct {
|
||||
Version string
|
||||
SQL string
|
||||
}
|
||||
|
||||
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
|
||||
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
|
||||
// sorgt fuer eine stabile Reihenfolge).
|
||||
func LoadMigrations(dir string) ([]Migration, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
migrations := make([]Migration, 0, len(names))
|
||||
for _, name := range names {
|
||||
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
|
||||
}
|
||||
version := strings.TrimSuffix(name, upSuffix)
|
||||
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
|
||||
}
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
|
||||
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
|
||||
// Fehler.
|
||||
type MigrationStatus struct {
|
||||
Version string
|
||||
AppliedAt time.Time
|
||||
Success bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
|
||||
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
|
||||
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
|
||||
type TenantResult struct {
|
||||
TenantSlug string
|
||||
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
|
||||
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
|
||||
Err error
|
||||
}
|
||||
|
||||
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
|
||||
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
|
||||
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
|
||||
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
|
||||
type Orchestrator struct {
|
||||
registry *tenant.Registry
|
||||
}
|
||||
|
||||
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
|
||||
return &Orchestrator{registry: registry}
|
||||
}
|
||||
|
||||
const ensureTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT
|
||||
);`
|
||||
|
||||
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
|
||||
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
|
||||
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
|
||||
// (Akzeptanzkriterium 1 + 2).
|
||||
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
|
||||
tenants, err := o.registry.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
|
||||
}
|
||||
|
||||
results := make([]TenantResult, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
results = append(results, o.rolloutForTenant(ctx, t, migrations))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
|
||||
result := TenantResult{TenantSlug: t.Slug}
|
||||
|
||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
|
||||
return result
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
|
||||
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
|
||||
return result
|
||||
}
|
||||
if alreadyApplied {
|
||||
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
|
||||
}
|
||||
|
||||
_, execErr := pool.Exec(ctx, m.SQL)
|
||||
if execErr != nil {
|
||||
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
|
||||
result.FailedAt = m.Version
|
||||
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
|
||||
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
|
||||
}
|
||||
|
||||
recordAttempt(ctx, pool, m.Version, true, "")
|
||||
result.Applied = append(result.Applied, m.Version)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
|
||||
var success bool
|
||||
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return success, nil
|
||||
}
|
||||
|
||||
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
|
||||
var errVal *string
|
||||
if errMsg != "" {
|
||||
errVal = &errMsg
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `
|
||||
INSERT INTO schema_migrations (version, applied_at, success, error)
|
||||
VALUES ($1, now(), $2, $3)
|
||||
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
|
||||
`, version, success, errVal)
|
||||
}
|
||||
|
||||
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
|
||||
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
|
||||
t, err := o.registry.GetBySlug(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT version, applied_at, success, COALESCE(error, '')
|
||||
FROM schema_migrations ORDER BY version
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []MigrationStatus
|
||||
for rows.Next() {
|
||||
var s MigrationStatus
|
||||
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
|
||||
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
||||
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
||||
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
||||
|
||||
migrations, err := LoadMigrations(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(migrations) != 2 {
|
||||
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
||||
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *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()
|
||||
|
||||
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 := tenant.NewRegistry(registryPool)
|
||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||
|
||||
var tenants []tenant.Tenant
|
||||
var slugs []string
|
||||
for i := 0; i < tenantCount; i++ {
|
||||
slug := fmt.Sprintf("mig_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)
|
||||
}
|
||||
|
||||
orchestrator := NewOrchestrator(registry)
|
||||
|
||||
cleanup := func() {
|
||||
for _, slug := range slugs {
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
||||
}
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
||||
registryPool.Close()
|
||||
adminPool.Close()
|
||||
}
|
||||
return orchestrator, tenants, adminPool, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
||||
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
||||
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
migrations := []Migration{
|
||||
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
||||
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
||||
}
|
||||
|
||||
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
||||
// migration 0002 schlaegt dort mit "already exists" fehl.
|
||||
badTenant := tenants[1]
|
||||
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("connect bad tenant: %v", err)
|
||||
}
|
||||
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
||||
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
||||
}
|
||||
badPool.Close()
|
||||
|
||||
results, err := orchestrator.RolloutAll(ctx, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("rollout: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
||||
}
|
||||
|
||||
byslug := map[string]TenantResult{}
|
||||
for _, r := range results {
|
||||
byslug[r.TenantSlug] = r
|
||||
}
|
||||
|
||||
good0 := byslug[tenants[0].Slug]
|
||||
if good0.Err != nil || len(good0.Applied) != 2 {
|
||||
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
||||
}
|
||||
good2 := byslug[tenants[2].Slug]
|
||||
if good2.Err != nil || len(good2.Applied) != 2 {
|
||||
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
||||
}
|
||||
|
||||
bad := byslug[badTenant.Slug]
|
||||
if bad.Err == nil {
|
||||
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
||||
}
|
||||
if bad.FailedAt != "0002_demo_b" {
|
||||
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
||||
}
|
||||
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
||||
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
||||
// jeden Tenant den korrekten, unabhaengigen Stand.
|
||||
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
migrations := []Migration{
|
||||
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
||||
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
||||
}
|
||||
|
||||
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
||||
t.Fatalf("rollout: %v", err)
|
||||
}
|
||||
|
||||
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
||||
if err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
if len(status) != 2 {
|
||||
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
||||
}
|
||||
if !status[0].Success || status[0].Version != "0001_ok" {
|
||||
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
||||
}
|
||||
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
||||
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
||||
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
||||
// sobald die Ursache behoben ist.
|
||||
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
||||
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
||||
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
||||
t.Fatalf("vorbedingung: %v", err)
|
||||
}
|
||||
|
||||
migrations := []Migration{
|
||||
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
||||
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
||||
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
||||
}
|
||||
|
||||
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("rollout 1: %v", err)
|
||||
}
|
||||
if firstRun[0].FailedAt != "0002_conflict" {
|
||||
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
||||
}
|
||||
|
||||
// Ursache beheben.
|
||||
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
||||
t.Fatalf("ursache beheben: %v", err)
|
||||
}
|
||||
pool.Close()
|
||||
|
||||
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("rollout 2: %v", err)
|
||||
}
|
||||
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
||||
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
||||
// Applied-Eintrags fuer 0002_conflict.
|
||||
if secondRun[0].Err != nil {
|
||||
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
||||
}
|
||||
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
||||
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events;
|
||||
DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events;
|
||||
DROP FUNCTION IF EXISTS audit_events_prevent_mutation();
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Audit-Log technisch gegen Aenderung/Loeschung absichern (AUD-02, siehe
|
||||
-- core-kanban/tickets/AUD-02.md). Ein Trigger statt nur GRANT/REVOKE, damit
|
||||
-- der Schutz unabhaengig davon greift, mit welcher Rolle verbunden wird
|
||||
-- (Akzeptanzkriterium 1: "auf Datenbankebene technisch unterbunden").
|
||||
CREATE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER audit_events_no_update
|
||||
BEFORE UPDATE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||
|
||||
CREATE TRIGGER audit_events_no_delete
|
||||
BEFORE DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS security_confirmations;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Vier-Augen-Prinzip fuer sicherheitskritische Entscheidungen (AUD-02
|
||||
-- Akzeptanzkriterium 2), Vorbild: archivdms FOR-UPDATE-Lock + Timing-safe
|
||||
-- Vergleich. code_hash speichert NIEMALS den Bestaetigungscode im Klartext.
|
||||
CREATE TABLE security_confirmations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
requested_by TEXT NOT NULL,
|
||||
code_hash BYTEA NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')),
|
||||
confirmed_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmed_at TIMESTAMPTZ
|
||||
);
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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."
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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