diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..b012d98 --- /dev/null +++ b/internal/audit/audit.go @@ -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 +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..ad856b3 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,132 @@ +package audit + +import ( + "context" + "errors" + "os" + "testing" + + "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() + + err := log.Record(ctx, Event{ + TenantSlug: "test_acme", + 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, "test_acme") + 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 = 'test_acme' + `).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) + } +} diff --git a/migrations/0004_audit_events.down.sql b/migrations/0004_audit_events.down.sql new file mode 100644 index 0000000..3713ba9 --- /dev/null +++ b/migrations/0004_audit_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_events; diff --git a/migrations/0004_audit_events.up.sql b/migrations/0004_audit_events.up.sql new file mode 100644 index 0000000..2f2d942 --- /dev/null +++ b/migrations/0004_audit_events.up.sql @@ -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); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..0eee269 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -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." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..83f26b6 --- /dev/null +++ b/scripts/run-checks.sh @@ -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