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>
98 lines
2.9 KiB
Go
98 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|