AUD-07: technische-durchsetzung-append-only-audit-log
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 51s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 51s
AUD-02 hat bereits einen Trigger, der UPDATE/DELETE auf audit_events per
RAISE EXCEPTION ablehnt (Akzeptanzkriterium 3 aus AUD-07 war damit schon
erfuellt). Zwei Luecken blieben: (1) die Anwendungsrolle hatte weiterhin
UPDATE/DELETE-Rechte auf DB-Ebene, der Schutz war nur ein Code-Pfad
(Trigger), keine fehlenden Rechte; (2) ein abgelehnter Aenderungsversuch
wurde nirgends selbst protokolliert.
- migrations/0010_audit_events_role_enforcement.{up,down}.sql: Gruppenrolle
nexarch_app ohne LOGIN/Passwort, REVOKE ALL + GRANT INSERT, SELECT auf
audit_events fuer PUBLIC/nexarch_app; Trigger-Funktion um RAISE WARNING
mit Akteur/Session/Client-Kontext ergaenzt, bevor der Versuch weiterhin
per RAISE EXCEPTION abgelehnt wird (RAISE WARNING ist nicht transaktional,
landet also auch bei Rollback im Postgres-Server-Log).
- internal/audit/append_only_test.go: Tests je Akzeptanzkriterium -
TestAppendOnly_AppRoleHasNoUpdateOrDeletePrivilege (AC1/Pruefung1,
erwartet "permission denied" statt Trigger-Fehler),
TestAppendOnly_TamperAttemptIsLogged (AC2/Pruefung2, faengt die
RAISE-WARNING-Notice ueber pgx OnNotice ab),
TestAppendOnly_NormalWriteAndReadStillWork (AC3/Pruefung3, Regression).
Pruefungen auf 192.168.1.131 durchgefuehrt:
1. UPDATE/DELETE mit Anwendungsrolle (Mitglied nexarch_app) -> "permission
denied for table audit_events" statt Trigger-Fehler: PASS.
2. Ablehnungsversuch selbst protokolliert (RAISE WARNING mit
Akteur/Session/Zeitpunkt, unabhaengig von Transaktions-Rollback) via
pgx-Notice-Capture nachgewiesen: PASS.
3. Regression Schreiben/Lesen (Record + CountByTenant, mehrere Eintraege):
PASS.
Gesamtlauf go test ./... -p 1 -count=1: internal/audit vollstaendig gruen
(alle 22 Tests inkl. der 3 neuen). Bestehende, dokumentierte Altlasten
unveraendert beobachtet (nicht Bestandteil dieser Kachel): internal/migrate
(TestRolloutAll_* reihenfolgeabhaengig), internal/notify
(TestDispatcher_EnqueueAndProcess flakt), internal/pentest
(TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation), internal/user
(verwaiste test_iam01_users/test_iam01_superadmins-DBs).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
This commit is contained in:
1 parent
e8c15ca188
commit
6866845aca
3 files changed
+317
-1
No files matched your search
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -34,6 +37,110 @@ func setupAppendOnlyTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE WARNING 'audit_events tamper attempt rejected: op=% current_user=% session_user=% application_name=% client_addr=% row_id=%',
|
||||
TG_OP,
|
||||
current_user,
|
||||
session_user,
|
||||
current_setting('application_name', true),
|
||||
inet_client_addr(),
|
||||
COALESCE(OLD.id::text, 'unknown');
|
||||
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();
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nexarch_app') THEN
|
||||
CREATE ROLE nexarch_app NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
REVOKE ALL ON audit_events FROM PUBLIC;
|
||||
REVOKE ALL ON audit_events FROM nexarch_app;
|
||||
GRANT INSERT, SELECT ON audit_events TO nexarch_app;
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
pool.Close()
|
||||
}
|
||||
return NewLog(pool), pool, cleanup
|
||||
}
|
||||
|
||||
// notice sammelt eine ueber pgx' OnNotice-Hook empfangene Server-Meldung
|
||||
// (z.B. RAISE WARNING) mitsamt Zeitpunkt des Empfangs.
|
||||
type capturedNotices struct {
|
||||
mu sync.Mutex
|
||||
msgs []string
|
||||
}
|
||||
|
||||
func (c *capturedNotices) add(n *pgconn.Notice) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.msgs = append(c.msgs, n.Message)
|
||||
}
|
||||
|
||||
func (c *capturedNotices) snapshot() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]string, len(c.msgs))
|
||||
copy(out, c.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
// setupAppendOnlyTestWithNoticeCapture ist wie setupAppendOnlyTest, liefert
|
||||
// aber zusaetzlich einen Sammler fuer RAISE WARNING/NOTICE-Meldungen der
|
||||
// Verbindung (Pruefung 2: Ablehnungsversuch wird selbst sichtbar
|
||||
// protokolliert).
|
||||
func setupAppendOnlyTestWithNoticeCapture(t *testing.T) (*Log, *pgxpool.Pool, *capturedNotices, func()) {
|
||||
t.Helper()
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
notices := &capturedNotices{}
|
||||
|
||||
cfg, err := pgxpool.ParseConfig(adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("parse dsn: %v", err)
|
||||
}
|
||||
cfg.ConnConfig.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) {
|
||||
notices.add(n)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
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 WARNING 'audit_events tamper attempt rejected: op=% current_user=% session_user=% application_name=% client_addr=% row_id=%',
|
||||
TG_OP,
|
||||
current_user,
|
||||
session_user,
|
||||
current_setting('application_name', true),
|
||||
inet_client_addr(),
|
||||
COALESCE(OLD.id::text, 'unknown');
|
||||
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -52,7 +159,7 @@ func setupAppendOnlyTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
||||
cleanup := func() {
|
||||
pool.Close()
|
||||
}
|
||||
return NewLog(pool), pool, cleanup
|
||||
return NewLog(pool), pool, notices, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: direkter UPDATE/DELETE-Versuch wird von
|
||||
@@ -95,3 +202,152 @@ func TestAppendOnly_RejectsUpdateAndDelete(t *testing.T) {
|
||||
t.Fatalf("eintrag haette trotz fehlgeschlagener update/delete-versuche erhalten bleiben muessen, count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: die vom Core-Prozess verwendete
|
||||
// Datenbankrolle (Mitglied der Gruppenrolle nexarch_app) hat auf DB-Ebene
|
||||
// gar keine UPDATE/DELETE-Rechte auf audit_events — der Fehler muss ein
|
||||
// Rechte-Fehler sein, nicht der Trigger-Fehler (der greift erst danach).
|
||||
func TestAppendOnly_AppRoleHasNoUpdateOrDeletePrivilege(t *testing.T) {
|
||||
log, adminPool, cleanup := setupAppendOnlyTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
tenantSlug := fmt.Sprintf("test_appendonly_role_%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)
|
||||
}
|
||||
|
||||
loginRole := fmt.Sprintf("test_aud07_app_%d", time.Now().UnixNano())
|
||||
password := "aud07-test-only"
|
||||
if _, err := adminPool.Exec(ctx, fmt.Sprintf(
|
||||
`CREATE ROLE %s LOGIN PASSWORD '%s' IN ROLE nexarch_app`, loginRole, password,
|
||||
)); err != nil {
|
||||
t.Fatalf("testrolle anlegen: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
// Aktive Verbindungen der Testrolle beenden, bevor sie gedroppt wird.
|
||||
_, _ = adminPool.Exec(ctx, `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = $1`, loginRole)
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP ROLE IF EXISTS %s`, loginRole))
|
||||
}()
|
||||
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
cfg, err := pgxpool.ParseConfig(adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("parse dsn: %v", err)
|
||||
}
|
||||
cfg.ConnConfig.User = loginRole
|
||||
cfg.ConnConfig.Password = password
|
||||
|
||||
appPool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("app-pool: %v", err)
|
||||
}
|
||||
defer appPool.Close()
|
||||
|
||||
// INSERT/SELECT muessen weiterhin funktionieren (Akzeptanzkriterium 3).
|
||||
if _, err := appPool.Exec(ctx, `
|
||||
INSERT INTO audit_events (tenant_slug, actor, action, target)
|
||||
VALUES ($1, 'app-role', 'test.event', 'x')`, tenantSlug); err != nil {
|
||||
t.Fatalf("insert mit app-rolle sollte erlaubt sein: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := appPool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE tenant_slug = $1`, tenantSlug).Scan(&n); err != nil {
|
||||
t.Fatalf("select mit app-rolle sollte erlaubt sein: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("erwartet 2 eintraege nach insert der app-rolle, habe %d", n)
|
||||
}
|
||||
|
||||
_, err = appPool.Exec(ctx, `UPDATE audit_events SET actor = 'mallory' WHERE tenant_slug = $1`, tenantSlug)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet rechte-fehler bei UPDATE mit app-rolle, habe nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("erwartet 'permission denied' (fehlende rechte, nicht nur trigger), habe: %v", err)
|
||||
}
|
||||
|
||||
_, err = appPool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug = $1`, tenantSlug)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet rechte-fehler bei DELETE mit app-rolle, habe nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("erwartet 'permission denied' (fehlende rechte, nicht nur trigger), habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: der abgelehnte Aenderungsversuch wird
|
||||
// von der Datenbank selbst sichtbar protokolliert (RAISE WARNING, das
|
||||
// unabhaengig vom Transaktionsausgang ins Postgres-Log geschrieben und der
|
||||
// Verbindung als Notice gemeldet wird), nicht nur mit einem generischen
|
||||
// Fehler quittiert.
|
||||
func TestAppendOnly_TamperAttemptIsLogged(t *testing.T) {
|
||||
log, pool, notices, cleanup := setupAppendOnlyTestWithNoticeCapture(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
tenantSlug := fmt.Sprintf("test_appendonly_log_%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")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range notices.snapshot() {
|
||||
if strings.Contains(msg, "tamper attempt rejected") && strings.Contains(msg, "op=UPDATE") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("erwartet protokollierte tamper-warnung fuer UPDATE, habe notices: %+v", notices.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: normales Schreiben/Lesen von
|
||||
// Audit-Eintraegen funktioniert nach der technischen Durchsetzung
|
||||
// unveraendert.
|
||||
func TestAppendOnly_NormalWriteAndReadStillWork(t *testing.T) {
|
||||
log, _, cleanup := setupAppendOnlyTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
tenantSlug := fmt.Sprintf("test_appendonly_regression_%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)
|
||||
}
|
||||
if err := log.Record(ctx, Event{
|
||||
TenantSlug: tenantSlug,
|
||||
Actor: "bob",
|
||||
Action: "test.event.two",
|
||||
Target: "y",
|
||||
}); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
count, err := log.CountByTenant(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("erwartet 2 eintraege nach zwei normalen Record-Aufrufen, habe %d", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Trigger-Funktion auf den AUD-02-Stand zuruecksetzen (ohne RAISE WARNING).
|
||||
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;
|
||||
|
||||
REVOKE INSERT, SELECT ON audit_events FROM nexarch_app;
|
||||
-- PUBLIC hatte vor dieser Migration keine expliziten Rechte auf
|
||||
-- audit_events (Postgres-Standard: nur der Tabelleneigentuemer), daher kein
|
||||
-- Re-GRANT hier. Die nexarch_app-Rolle selbst bleibt bestehen (kann von
|
||||
-- anderen Objekten referenziert sein); nur die audit_events-spezifischen
|
||||
-- Rechte werden zurueckgenommen.
|
||||
@@ -0,0 +1,47 @@
|
||||
-- AUD-07: technische Durchsetzung des Append-only-Versprechens aus AUD-01/
|
||||
-- AUD-02 (siehe core-kanban/tickets/AUD-07.md). AUD-02 hat bereits einen
|
||||
-- Trigger eingefuehrt, der UPDATE/DELETE auf audit_events ablehnt — das
|
||||
-- schuetzt aber nicht davor, dass eine kompromittierte oder falsch
|
||||
-- konfigurierte Anwendungsrolle den Trigger per
|
||||
-- "ALTER TABLE ... DISABLE TRIGGER" oder als Tabelleneigentuemer umgeht.
|
||||
-- Akzeptanzkriterium 1 verlangt fehlende Rechte, nicht nur einen fehlenden
|
||||
-- Code-Pfad: die vom Core-Prozess verwendete Datenbankrolle bekommt daher
|
||||
-- auf DB-Ebene ausschliesslich INSERT/SELECT auf audit_events.
|
||||
--
|
||||
-- nexarch_app ist eine reine Gruppenrolle ohne LOGIN und ohne Passwort
|
||||
-- (keine Zugangsdaten in Migrationen). Der Betrieb ordnet die tatsaechliche
|
||||
-- Login-Rolle des Core-Prozesses dieser Gruppe zu:
|
||||
-- GRANT nexarch_app TO <login-rolle-des-core-prozesses>;
|
||||
-- Migrationen selbst laufen weiterhin ueber die separate Owner-/Admin-Rolle
|
||||
-- (NEXARCH_ADMIN_DSN), die von diesem REVOKE nicht betroffen ist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nexarch_app') THEN
|
||||
CREATE ROLE nexarch_app NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON audit_events FROM PUBLIC;
|
||||
REVOKE ALL ON audit_events FROM nexarch_app;
|
||||
GRANT INSERT, SELECT ON audit_events TO nexarch_app;
|
||||
|
||||
-- Verteidigung in der Tiefe (Akzeptanzkriterium 2): selbst wenn eine Rolle
|
||||
-- doch UPDATE/DELETE-Rechte haette (z.B. der Tabelleneigentuemer beim
|
||||
-- Ausfuehren von Migrationen), lehnt der Trigger den Versuch weiterhin ab
|
||||
-- UND protokolliert ihn selbst. RAISE WARNING wird unabhaengig vom Ausgang
|
||||
-- der Transaktion sofort ins Postgres-Server-Log geschrieben (nicht
|
||||
-- transaktional) und zusaetzlich als Notice an die Datenbankverbindung
|
||||
-- gemeldet, bevor die Aenderung per RAISE EXCEPTION zurueckgewiesen wird.
|
||||
CREATE OR REPLACE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE WARNING 'audit_events tamper attempt rejected: op=% current_user=% session_user=% application_name=% client_addr=% row_id=%',
|
||||
TG_OP,
|
||||
current_user,
|
||||
session_user,
|
||||
current_setting('application_name', true),
|
||||
inet_client_addr(),
|
||||
COALESCE(OLD.id::text, 'unknown');
|
||||
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
Reference in new issue
Block a user