AUD-06: audit-log-verdrahtung-in-sicherheitsrelevante-core-handler
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s

Schliesst die in QA-05 gefundene Luecke: der zentrale Audit-Log (AUD-01/02)
existierte und war getestet, wurde aber von keinem Produktions-Handler
befuellt. Additive WithAudit(...)-Methode je Store (Konvention aus
lockout.Store.WithPolicy uebernommen, audit==nil bleibt gueltig, kein
Verhaltensbruch fuer bestehende Aufrufer):

- internal/policy.Store.Grant/Revoke -> policy.grant/policy.revoke
- internal/tenant.Registry (Suspend/Reactivate/ScheduleDeletion/
  CancelDeletion via transition) -> tenant.transition
- internal/lockout.Store.RecordFailure/Unlock -> auth.login_failed/
  auth.account_locked/auth.account_unlocked
- internal/kek.Store.RotateTenantKEK/RotateMasterKey -> kek.tenant_rotated/
  kek.master_rotated

Neues Testpaket internal/audit/wiring_test.go: fuer jeden der vier Bereiche
eine reale Aktion ausgefuehrt und per direkter audit_events-Abfrage
nachgewiesen (derselbe Nachweisstil wie der QA-05-Stichprobenabgleich, der
die Luecke fand). Alle bestehenden Tests der vier Pakete bleiben gruen.
51/51 Pakete gruen auf 192.168.1.131.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-29 17:24:26 +02:00
co-authored by Claude Sonnet 5
parent d8d5aaf3bc
commit 4de9310213
7 changed files with 447 additions and 6 deletions
+252
View File
@@ -0,0 +1,252 @@
// wiring_test.go ist der Nachweis fuer AUD-06: fuer jeden der vier
// sicherheitsrelevanten Bereiche (Policy, Tenant-Lifecycle, Lockout,
// KEK-Rotation) wird eine reale Aktion ausgefuehrt und per direkter Abfrage
// der audit_events-Tabelle nachgewiesen, dass ein passender Eintrag
// entstanden ist - derselbe Nachweisstil wie der QA-05-Stichprobenabgleich
// (docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md Abschnitt 2), der die Luecke
// ursprünglich aufdeckte.
package audit_test
import (
"context"
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
return dsn
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
return filepath.Join(wd, "..", "..")
}
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("migration %q lesen: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710" || pgErr.Code == "42723") {
continue
}
t.Fatalf("migration %q anwenden: %v", name, err)
}
}
}
func countAuditEvents(t *testing.T, ctx context.Context, pool *pgxpool.Pool, action, target string) int {
t.Helper()
var n int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM audit_events WHERE action = $1 AND target = $2
`, action, target).Scan(&n); err != nil {
t.Fatalf("audit_events abfragen: %v", err)
}
return n
}
func setupWiringTest(t *testing.T) (*pgxpool.Pool, *audit.Log) {
t.Helper()
adminDSN := requireAdminDSN(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
root := repoRoot(t)
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
// login_attempts liegt in Produktion in der Tenant-Datenbank - fuer diesen
// Verdrahtungsnachweis genuegt dieselbe physische Datenbank, da es nur um
// den Audit-Log-Schreibpfad geht, nicht um Tenant-Isolation (die ist
// bereits durch TEN-01/QA-02 nachgewiesen).
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
return pool, audit.NewLog(pool)
}
// TestWiring_PolicyGrantRevokeAreAudited ist der Nachweis fuer
// Akzeptanzkriterium 1: internal/policy.Store.Grant/Revoke schreiben einen
// Audit-Log-Eintrag.
func TestWiring_PolicyGrantRevokeAreAudited(t *testing.T) {
pool, log := setupWiringTest(t)
ctx := context.Background()
store := policy.NewStore(pool).WithAudit(log)
target := "user:self.read"
if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil {
t.Fatalf("grant: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "policy.grant", target); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer policy.grant %q, habe %d", target, n)
}
if err := store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil {
t.Fatalf("revoke: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "policy.revoke", target); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer policy.revoke %q, habe %d", target, n)
}
}
// TestWiring_TenantLifecycleTransitionIsAudited ist der Nachweis fuer
// Akzeptanzkriterium 2: internal/tenant.Registry-Uebergaenge schreiben einen
// Audit-Log-Eintrag.
func TestWiring_TenantLifecycleTransitionIsAudited(t *testing.T) {
pool, log := setupWiringTest(t)
ctx := context.Background()
registry := tenant.NewRegistry(pool).WithAudit(log)
dsnTemplate := strings.Replace(requireAdminDSN(t), "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate)
slug := uniqueSlug("aud06_lifecycle")
tn, err := provisioner.Provision(ctx, slug, "AUD-06 Lifecycle GmbH")
if err != nil {
t.Fatalf("provision: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
_, _ = pool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`)
})
_ = tn
if _, err := registry.Suspend(ctx, slug); err != nil {
t.Fatalf("suspend: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "tenant.transition", "suspended"); n == 0 {
t.Fatal("erwartet mindestens 1 audit-eintrag fuer tenant.transition -> suspended, habe 0")
}
}
// TestWiring_LockoutFailuresAndUnlockAreAudited ist der Nachweis fuer
// Akzeptanzkriterium 3 (Teil Lockout): internal/lockout.Store.RecordFailure
// und Unlock schreiben je einen Audit-Log-Eintrag.
func TestWiring_LockoutFailuresAndUnlockAreAudited(t *testing.T) {
pool, log := setupWiringTest(t)
ctx := context.Background()
email := fmt.Sprintf("aud06-%d@example.test", time.Now().UnixNano())
store := lockout.NewStore(pool).WithPolicy(2, time.Minute).WithAudit(log, "aud06-tenant")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM login_attempts WHERE email = $1`, email)
})
if _, _, err := store.RecordFailure(ctx, email); err != nil {
t.Fatalf("fehlversuch 1: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "auth.login_failed", email); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer auth.login_failed %q, habe %d", email, n)
}
locked, _, err := store.RecordFailure(ctx, email)
if err != nil {
t.Fatalf("fehlversuch 2 (sperrt): %v", err)
}
if !locked {
t.Fatal("konto haette nach 2 fehlversuchen (policy maxFailed=2) gesperrt sein muessen")
}
if n := countAuditEvents(t, ctx, pool, "auth.account_locked", email); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_locked %q, habe %d", email, n)
}
if err := store.Unlock(ctx, email); err != nil {
t.Fatalf("entsperren: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "auth.account_unlocked", email); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_unlocked %q, habe %d", email, n)
}
}
// TestWiring_KEKRotationIsAudited ist der Nachweis fuer Akzeptanzkriterium 3
// (Teil KEK): internal/kek.Store.RotateTenantKEK/RotateMasterKey schreiben
// je einen Audit-Log-Eintrag.
func TestWiring_KEKRotationIsAudited(t *testing.T) {
pool, log := setupWiringTest(t)
ctx := context.Background()
store := kek.NewStore(pool).WithAudit(log)
var tenantID string
slug := uniqueSlug("aud06_kek")
if err := pool.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
`, slug).Scan(&tenantID); err != nil {
t.Fatalf("tenant-zeile anlegen: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenant_keks WHERE tenant_id = $1`, tenantID)
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
})
masterKey := randomMasterKey(t)
if _, err := store.CreateForTenant(ctx, tenantID, masterKey); err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
if _, err := store.RotateTenantKEK(ctx, tenantID, masterKey); err != nil {
t.Fatalf("tenant-kek rotieren: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "kek.tenant_rotated", tenantID); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer kek.tenant_rotated %q, habe %d", tenantID, n)
}
newMasterKey := randomMasterKey(t)
if _, _, err := store.RotateMasterKey(ctx, masterKey, newMasterKey); err != nil {
t.Fatalf("master-key rotieren: %v", err)
}
if n := countAuditEvents(t, ctx, pool, "kek.master_rotated", audit.SystemTenant); n != 1 {
t.Fatalf("erwartet 1 audit-eintrag fuer kek.master_rotated, habe %d", n)
}
}
func randomMasterKey(t *testing.T) kek.MasterKey {
t.Helper()
buf := make([]byte, kek.MasterKeySize)
if _, err := rand.Read(buf); err != nil {
t.Fatalf("master-key erzeugen: %v", err)
}
return kek.MasterKey(buf)
}
func uniqueSlug(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}