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,136 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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