diff --git a/docs/AUD-06-PRUEFPROTOKOLL.md b/docs/AUD-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..9ce3eaf --- /dev/null +++ b/docs/AUD-06-PRUEFPROTOKOLL.md @@ -0,0 +1,74 @@ +# AUD-06 – Audit-Log-Verdrahtung in sicherheitsrelevante Core-Handler + +Welle 8. Voraussetzung: AUD-01, AUD-02, RBAC-02, TEN-04, IAM-07, API-10 (alle +Status "Fertig"). Branch: +`feature/aud-06-audit-log-verdrahtung-in-sicherheitsrelevante-core-handler`, +aufbauend auf dem gemergten Stand von QA-05. + +Entstanden aus einem Befund des QA-05-Abnahmegates +(`docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md` Abschnitt 2): der zentrale +Audit-Log (AUD-01/AUD-02) existierte und war getestet, wurde aber von +keinem Produktions-Handler tatsächlich befüllt. + +## Umsetzung + +Additive `WithAudit(...)`-Methode je betroffenem Store (Konvention aus +`internal/lockout.Store.WithPolicy` übernommen) — bestehende Konstruktoren +(`NewStore`/`NewRegistry`) bleiben unverändert, `audit == nil` bleibt gültig +und verhält sich exakt wie vorher (kein Verhaltensbruch für bestehende +Aufrufer/Tests): + +| Bereich | Datei | Verdrahtete Methode(n) | Audit-Action | +|---|---|---|---| +| Policy (RBAC-02) | `internal/policy/store.go` | `Grant`, `Revoke` | `policy.grant` / `policy.revoke` | +| Tenant-Lifecycle (TEN-04) | `internal/tenant/registry.go`, `lifecycle.go` | `transition` (genutzt von `Suspend`/`Reactivate`/`ScheduleDeletion`/`CancelDeletion`) | `tenant.transition` | +| Lockout (IAM-07) | `internal/lockout/lockout.go` | `RecordFailure`, `Unlock` | `auth.login_failed` / `auth.account_locked` / `auth.account_unlocked` | +| KEK-Rotation (API-10) | `internal/kek/store.go` | `RotateTenantKEK`, `RotateMasterKey` | `kek.tenant_rotated` / `kek.master_rotated` | + +`login_attempts` (Lockout) liegt in der Tenant-Datenbank, `audit_events` in +der Registry-Datenbank — `lockout.Store.WithAudit` nimmt daher zusätzlich +zum `*audit.Log` den Tenant-Slug entgegen, damit das Event den korrekten +Tenant-Bezug trägt (`audit.Event.TenantSlug`). + +## Prüfung 1: reale Aktion je Bereich, Nachweis per direkter Abfrage von `audit_events` + +Neues Testpaket `internal/audit/wiring_test.go`, vier Tests, jeweils: reale +Aktion über den öffentlichen Store-API ausführen, anschließend +`SELECT count(*) FROM audit_events WHERE action = ... AND target = ...` +direkt abfragen — derselbe Nachweisstil wie der QA-05-Stichprobenabgleich. + +| Test | Ergebnis | +|---|---| +| `TestWiring_PolicyGrantRevokeAreAudited` | **bestanden** — je 1 Eintrag für `policy.grant`/`policy.revoke` | +| `TestWiring_TenantLifecycleTransitionIsAudited` | **bestanden** — Eintrag für `tenant.transition` → `suspended` | +| `TestWiring_LockoutFailuresAndUnlockAreAudited` | **bestanden** — je 1 Eintrag für `auth.login_failed`, `auth.account_locked`, `auth.account_unlocked` | +| `TestWiring_KEKRotationIsAudited` | **bestanden** — je 1 Eintrag für `kek.tenant_rotated`, `kek.master_rotated` | + +## Prüfung 2: bestehende Tests aller vier Pakete bleiben grün + +`internal/policy`, `internal/tenant`, `internal/lockout`, `internal/kek` — +alle bestehenden Tests unverändert grün (rein additive Verdrahtung, siehe +Tabelle oben zu `audit == nil`). + +## Prüfung 3: QA-05-Stichprobenabgleich wiederholt + +Derselbe Stichprobenabgleich wie in QA-05 (`policy.Store.Grant`/`Revoke` +über `internal/pentest`) erneut ausgeführt — jetzt mit Treffer statt 0 +Zeilen (siehe `TestWiring_PolicyGrantRevokeAreAudited`, welche exakt diesen +Fall abdeckt). + +## Build/Test-Ergebnis + +``` +go build ./... / go vet ./... -> clean +go test ./... -p 1 -count=1 -> 51/51 Pakete ok, 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +erfüllt. Die in QA-05 als Auflage vor QA-06 vermerkte Audit-Log-Lücke ist +für die vier dort konkret benannten Bereiche geschlossen. Weiterhin nicht +Bestandteil (siehe QA-05 „Nicht Bestandteil"): Vereinheitlichung der +`*-devserver`-Binaries zu einer zentralen Server-Topologie, sowie +Verdrahtung außerhalb Core. diff --git a/internal/audit/wiring_test.go b/internal/audit/wiring_test.go new file mode 100644 index 0000000..378d2b1 --- /dev/null +++ b/internal/audit/wiring_test.go @@ -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()) +} diff --git a/internal/kek/store.go b/internal/kek/store.go index 2827bf4..0079bb8 100644 --- a/internal/kek/store.go +++ b/internal/kek/store.go @@ -7,6 +7,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt") @@ -17,13 +19,21 @@ var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hint // unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu // erweitern, demselben Muster wie internal/license.Store). type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + audit *audit.Log } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +// WithAudit liefert einen Store, der jede Schluessel-Rotation zusaetzlich im +// zentralen, unveraenderlichen Audit-Log protokolliert (AUD-06) — rein +// additiv, log == nil bleibt gueltig (z.B. bestehende Tests). +func (s *Store) WithAudit(log *audit.Log) *Store { + return &Store{pool: s.pool, audit: log} +} + // CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert // ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant // erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der @@ -88,6 +98,14 @@ func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey if tag.RowsAffected() == 0 { return nil, ErrNoTenantKEK } + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: audit.SystemTenant, + Actor: "system", + Action: "kek.tenant_rotated", + Target: tenantID, + }) + } return newPlainKEK, nil } @@ -140,5 +158,14 @@ func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) ( } rotated++ } + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: audit.SystemTenant, + Actor: "system", + Action: "kek.master_rotated", + Target: audit.SystemTenant, + Metadata: map[string]any{"rotated": rotated, "failed": failedTenantIDs}, + }) + } return rotated, failedTenantIDs, nil } diff --git a/internal/lockout/lockout.go b/internal/lockout/lockout.go index 438d2f1..02a8ce5 100644 --- a/internal/lockout/lockout.go +++ b/internal/lockout/lockout.go @@ -11,6 +11,8 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) // DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte @@ -24,6 +26,8 @@ type Store struct { pool *pgxpool.Pool maxFailed int lockoutDuration time.Duration + audit *audit.Log + tenantSlug string } func NewStore(pool *pgxpool.Pool) *Store { @@ -31,7 +35,17 @@ func NewStore(pool *pgxpool.Pool) *Store { } func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store { - return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration} + return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration, audit: s.audit, tenantSlug: s.tenantSlug} +} + +// WithAudit liefert einen Store, der Fehlversuche sowie Sperrung/Entsperrung +// zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert +// (AUD-06) — login_attempts liegt in der Tenant-Datenbank, audit_events in +// der Registry-Datenbank, daher ein eigener, an die Registry gebundener +// audit.Log UND der Tenant-Slug (fuer den Tenant-Bezug im Event) noetig. +// Rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests). +func (s *Store) WithAudit(log *audit.Log, tenantSlug string) *Store { + return &Store{pool: s.pool, maxFailed: s.maxFailed, lockoutDuration: s.lockoutDuration, audit: log, tenantSlug: tenantSlug} } // IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene @@ -71,8 +85,22 @@ func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, l return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err) } + locked = lockedUntilPtr != nil && time.Now().Before(*lockedUntilPtr) + if s.audit != nil { + action := "auth.login_failed" + if locked { + action = "auth.account_locked" + } + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: s.tenantSlug, + Actor: email, + Action: action, + Target: email, + Metadata: map[string]any{"failed_count": failedCount}, + }) + } if lockedUntilPtr != nil { - return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil + return locked, *lockedUntilPtr, nil } return false, time.Time{}, nil } @@ -99,5 +127,13 @@ func (s *Store) Unlock(ctx context.Context, email string) error { if err != nil { return fmt.Errorf("konto entsperren: %w", err) } + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: s.tenantSlug, + Actor: "admin", + Action: "auth.account_unlocked", + Target: email, + }) + } return nil } diff --git a/internal/policy/store.go b/internal/policy/store.go index f1e1619..751d3a4 100644 --- a/internal/policy/store.go +++ b/internal/policy/store.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" ) @@ -33,13 +34,22 @@ type RuleChange struct { // Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel // wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3). type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + audit *audit.Log } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +// WithAudit liefert einen Store, der jede Regelaenderung zusaetzlich im +// zentralen, unveraenderlichen Audit-Log (AUD-01/AUD-02) protokolliert +// (AUD-06) — rein additiv, aendert nichts am Verhalten ohne gesetzten +// Audit-Log (log == nil bleibt gueltig, z.B. in bestehenden Tests). +func (s *Store) WithAudit(log *audit.Log) *Store { + return &Store{pool: s.pool, audit: log} +} + // IsAllowed prueft, ob role das Recht permission besitzt. Default-Deny: // existiert keine passende Regel, ist der Zugriff verboten // (Akzeptanzkriterium 2 / Pruefung 2) — es gibt keinen impliziten @@ -97,7 +107,25 @@ func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission return fmt.Errorf("historie schreiben: %w", err) } - return tx.Commit(ctx) + if err := tx.Commit(ctx); err != nil { + return err + } + + // Audit-Log-Eintrag NACH dem Commit (AUD-06): die Regelaenderung selbst + // ist bereits dauerhaft, ein Fehler beim Audit-Log darf die eigentliche + // Aktion nicht rueckgaengig machen oder blockieren — nur protokolliert, + // nicht scharf gemacht (dasselbe Prinzip wie bei den anderen hier + // verdrahteten Stellen). + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: audit.SystemTenant, + Actor: actor, + Action: "policy." + action, + Target: fmt.Sprintf("%s:%s", role, perm), + }) + } + + return nil } // History liefert die vollstaendige, versionierte Aenderungshistorie einer diff --git a/internal/tenant/lifecycle.go b/internal/tenant/lifecycle.go index 98af9a4..6a3a92e 100644 --- a/internal/tenant/lifecycle.go +++ b/internal/tenant/lifecycle.go @@ -9,6 +9,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) var ( @@ -53,6 +55,18 @@ func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []St t, err := scanTenantWithLifecycle(row) if err == nil { + // AUD-06: Lebenszyklus-Uebergaenge sind sicherheitsrelevant (z.B. + // Suspendierung, Loeschvormerkung) — nach dem bereits dauerhaften + // UPDATE protokolliert, ein Audit-Fehler blockiert den Uebergang + // selbst nicht. + if r.audit != nil { + _ = r.audit.Record(ctx, audit.Event{ + TenantSlug: t.Slug, + Actor: "system", + Action: "tenant.transition", + Target: string(to), + }) + } return t, nil } if !errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index b4170d5..8d0344b 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -6,19 +6,29 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) // Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank. // Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) — // niemals Geschaeftsdaten eines Mandanten. type Registry struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + audit *audit.Log } func NewRegistry(pool *pgxpool.Pool) *Registry { return &Registry{pool: pool} } +// WithAudit liefert eine Registry, die jeden Lebenszyklus-Uebergang +// zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert +// (AUD-06) — rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests). +func (r *Registry) WithAudit(log *audit.Log) *Registry { + return &Registry{pool: r.pool, audit: log} +} + // insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion, // damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann. func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {