Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 1bfb2efd94 AUD-02: unveraenderliches-protokoll-append-only
Append-only per Trigger (nicht nur GRANT/REVOKE): audit_events_prevent_mutation()
wirft bei jedem UPDATE/DELETE auf audit_events eine Exception, unabhaengig
von der verbindenden Rolle (Akzeptanzkriterium 1).

internal/audit/four_eyes.go: Vier-Augen-Prinzip fuer sicherheitskritische
Entscheidungen (Loeschbestaetigung, Rechtevergabe), 1:1 nach archivdms-
Vorbild. Request erzeugt einen Klartext-Code (wird ausserhalb des Systems an
eine ZWEITE Person uebermittelt) und speichert nur dessen SHA-256-Hash.
Confirm sperrt die Zeile mit FOR UPDATE (Akzeptanzkriterium 2 — serialisiert
zwei gleichzeitige Bestaetigungsversuche, verhindert doppelte Ausfuehrung),
weist eine Bestaetigung durch dieselbe Person wie die anfordernde ab
(ErrSameActor, echtes Vier-Augen-Prinzip statt nur Code-Pruefung), und
vergleicht den Code timing-safe (Akzeptanzkriterium 3).

internal/audit/timingsafe.go: timingSafeEqual als projektweite Referenz-
implementierung (subtle.ConstantTimeCompare) fuer sicherheitsrelevante
Vergleiche — andere Module (z.B. Archive CMP-06 Freigabelinks) uebernehmen
dasselbe Muster laut IAM-02-Konvention.

Nebenbei behoben: AUD-01s eigener Test nutzte einen festen Tenant-Slug mit
DELETE-basiertem Cleanup — seit dem neuen Append-only-Trigger schlaegt dieses
Cleanup lautlos fehl, wodurch Zeilen sich ueber Testlaeufe hinweg summierten
und die Zaehl-Assertion brach. Auf eindeutigen Slug pro Lauf umgestellt
(direkte, notwendige Folge dieser Kachel, keine Umgestaltung von AUD-01
selbst).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Direkter UPDATE/DELETE-Versuch von der Datenbank abgewiesen —
   TestAppendOnly_RejectsUpdateAndDelete: beide Operationen scheitern,
   Eintrag bleibt unveraendert erhalten. PASS.
2. Vier-Augen-Prinzip mit FOR-UPDATE-Lock race-frei unter parallelen
   Anfragen — TestFourEyes_ConcurrentConfirmIsRaceFree: zwei gleichzeitige
   Bestaetigungsversuche fuer denselben Vorgang, genau einer erfolgreich,
   der andere ErrAlreadyDecided. PASS.
3. Timing-safe Vergleich per Laufzeitmessung stichprobenartig verifiziert —
   TestTimingSafeEqual_NoEarlyExitTiming: Mismatch am Anfang (603µs) vs. am
   Ende (574µs) ueber 20000 Iterationen, kein Hinweis auf Short-Circuit-
   Vergleich (Ratio innerhalb Faktor 3 Toleranz). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:57:51 +02:00
sysopsandClaude Sonnet 5 b12d53f469 AUD-01: zentrales-audit-log-modell
internal/audit: eigenes, strukturiertes Audit-Datenmodell (Akteur, Aktion,
Zielobjekt, Zeitpunkt, Tenant) in der Registry-DB, getrennt von jedem
allgemeinen Anwendungs-Log (eigenes Paket, eigene Tabelle audit_events,
kein Logging-Framework). Log.Record ist der EINE zentrale Schreibpfad —
es gibt keine zweite Schreibmoeglichkeit, ueber die ein Handler die
Validierung umgehen koennte.

Fehlender Tenant-Bezug wird zweifach verhindert (Akzeptanzkriterium 2):
Log.Record weist leeren TenantSlug direkt ab (ErrMissingTenant), zusaetzlich
erzwingt eine CHECK-Constraint in der Migration dasselbe auf Datenbankebene,
selbst wenn Log.Record umgangen wuerde. Mandantenuebergreifende Ereignisse
(z.B. Superadmin-Aktionen) nutzen den reservierten Wert audit.SystemTenant
statt NULL oder leerem String — es gibt keinen Weg, ganz ohne Tenant-Bezug
zu schreiben.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Automatisierter Test belegt genau einen Audit-Eintrag pro
   sicherheitsrelevantem Vorgang — TestRecord_PersistsExactlyOneEventPerSecurityIncident
   (simulierter fehlgeschlagener Login), Feldinhalte verifiziert. PASS.
2. Fehlender Tenant-Bezug durch Constraint/Test verhindert —
   TestRecord_RejectsMissingTenant (App-Ebene) UND
   TestConstraint_RejectsMissingTenantAtDatabaseLevel (direkter INSERT unter
   Umgehung von Log.Record, durch CHECK-Constraint abgewiesen). PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:22:50 +02:00
20 changed files with 711 additions and 407 deletions
+97
View File
@@ -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)
}
}
+96
View File
@@ -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
}
+139
View File
@@ -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)
}
}
+120
View File
@@ -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[:]
}
+128
View File
@@ -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)
}
}
+15
View File
@@ -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
}
+62
View File
@@ -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)
}
-87
View File
@@ -1,87 +0,0 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
-43
View File
@@ -1,43 +0,0 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
-87
View File
@@ -1,87 +0,0 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
-179
View File
@@ -1,179 +0,0 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_events;
+17
View File
@@ -0,0 +1,17 @@
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
-- Wert 'system', niemals NULL oder leeren String.
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS feature_flags;
-10
View File
@@ -1,10 +0,0 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -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();
+17
View File
@@ -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
);
+1
View File
@@ -14,6 +14,7 @@ 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