diff --git a/DEVLOG.md b/DEVLOG.md index ae451efa..75c1a65d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -384,3 +384,14 @@ Keine Commits in dieser Session. - web/notifications/tsconfig.json | 21 +++++++++++++++++++++ --- +## 2026-09-17 11:28 – 11:29 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- internal/tenant/router_test.go | 25 +++++++++++++++++-------- + +--- diff --git a/internal/kek/master.go b/internal/kek/master.go index 0f9196e0..5af8082a 100644 --- a/internal/kek/master.go +++ b/internal/kek/master.go @@ -90,6 +90,26 @@ func wrap(key, plaintext []byte) ([]byte, error) { // Schluessel oder manipulierte Daten). var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)") +// EncryptWithTenantKEK verschluesselt plaintext (z.B. ein LDAP-Bind-Passwort, +// IAM-19) mit dem ENTSCHLUESSELTEN Tenant-KEK eines Mandanten (siehe +// Store.GetDecrypted) — dieselbe AES-256-GCM-Envelope-Verschluesselung wie +// fuer den Tenant-KEK selbst, um kein zweites Krypto-Schema einzufuehren. +// Aufrufer ausserhalb dieses Pakets (z.B. internal/ldapsync) MUESSEN +// ausschliesslich diese Funktion (bzw. DecryptWithTenantKEK) fuer schuetzens- +// werte Tenant-Geheimnisse verwenden statt eigener Krypto-Logik. +func EncryptWithTenantKEK(tenantKEK, plaintext []byte) ([]byte, error) { + return wrap(tenantKEK, plaintext) +} + +// DecryptWithTenantKEK entschluesselt einen mit EncryptWithTenantKEK +// erzeugten Chiffretext. Liefert ErrUnwrapFailed, wenn der uebergebene +// Tenant-KEK nicht zu diesem Chiffretext passt — insbesondere wenn der KEK +// eines ANDEREN Mandanten verwendet wird (kein Cross-Tenant-Zugriff, +// IAM-19 Akzeptanzkriterium 1 / Pruefung 3). +func DecryptWithTenantKEK(tenantKEK, ciphertext []byte) ([]byte, error) { + return unwrap(tenantKEK, ciphertext) +} + func unwrap(key, wrapped []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { diff --git a/internal/ldapsync/config_store.go b/internal/ldapsync/config_store.go new file mode 100644 index 00000000..fbd31aeb --- /dev/null +++ b/internal/ldapsync/config_store.go @@ -0,0 +1,109 @@ +package ldapsync + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/kek" +) + +// ErrNoBindConfig wird geliefert, wenn fuer den Tenant noch keine +// LDAP-Bind-Konfiguration hinterlegt wurde. +var ErrNoBindConfig = errors.New("ldapsync: keine ldap-bind-konfiguration fuer diesen mandanten hinterlegt") + +// BindConfig ist die LDAP-Verbindungskonfiguration eines Mandanten +// (IAM-19). Password enthaelt IMMER das bereits entschluesselte +// Klartext-Bind-Passwort — die Verschluesselung ist eine reine +// Persistenzangelegenheit von ConfigStore, kein Bestandteil dieses Typs. +type BindConfig struct { + Host string + BindDN string + Password string + BaseDN string +} + +// ConfigStore persistiert die LDAP-Bind-Konfiguration EINES Mandanten in +// dessen eigener Tenant-Datenbank (physische Isolation, TEN-01 — genau wie +// tenant_settings/TEN-03). Das Bind-Passwort wird dabei AUSSCHLIESSLICH ueber +// den Tenant-KEK aus der Core-Schluesselhierarchie (API-10) verschluesselt +// abgelegt (Akzeptanzkriterium 1) — kein separates Secret-Schema, kein +// Klartext (Akzeptanzkriterium 2, "Bewusst vermeiden": Keycloak-Muster). +type ConfigStore struct { + tenantPool *pgxpool.Pool + keks *kek.Store + masterKey kek.MasterKey + tenantID string +} + +// NewConfigStore erstellt einen ConfigStore fuer GENAU EINEN Mandanten. +// tenantPool ist die Verbindung zur Tenant-Datenbank (dort liegt die +// Konfigurationstabelle), keks+masterKey+tenantID dienen ausschliesslich +// dazu, ueber Store.GetDecrypted den Tenant-KEK dieses EINEN Mandanten aus +// der Control-Plane zu holen — niemals den eines anderen Tenants +// (kein Cross-Tenant-Zugriff, Akzeptanzkriterium 1 / Pruefung 3). +func NewConfigStore(tenantPool *pgxpool.Pool, keks *kek.Store, masterKey kek.MasterKey, tenantID string) *ConfigStore { + return &ConfigStore{tenantPool: tenantPool, keks: keks, masterKey: masterKey, tenantID: tenantID} +} + +// Save verschluesselt cfg.Password mit dem Tenant-KEK dieses Mandanten und +// legt die Konfiguration (Host/BindDN/BaseDN im Klartext, Passwort +// ausschliesslich als Chiffrat) in der Tenant-Datenbank ab. Eine bereits +// bestehende Konfiguration wird ersetzt (genau ein Satz pro Tenant-DB, siehe +// Migration 0009). +func (s *ConfigStore) Save(ctx context.Context, cfg BindConfig) error { + tenantKEK, err := s.keks.GetDecrypted(ctx, s.tenantID, s.masterKey, "ldapsync") + if err != nil { + return fmt.Errorf("tenant-kek fuer ldap-bind-konfiguration lesen: %w", err) + } + + ciphertext, err := kek.EncryptWithTenantKEK(tenantKEK, []byte(cfg.Password)) + if err != nil { + return fmt.Errorf("ldap-bind-passwort verschluesseln: %w", err) + } + + _, err = s.tenantPool.Exec(ctx, ` + DELETE FROM ldap_bind_config; + INSERT INTO ldap_bind_config (host, bind_dn, bind_password_ciphertext, base_dn) + VALUES ($1, $2, $3, $4) + `, cfg.Host, cfg.BindDN, ciphertext, cfg.BaseDN) + if err != nil { + return fmt.Errorf("ldap-bind-konfiguration speichern: %w", err) + } + return nil +} + +// Get liefert die LDAP-Bind-Konfiguration mit dem BEREITS ENTSCHLUESSELTEN +// Bind-Passwort — zur Verwendung durch den eigentlichen LDAP-Client +// (client.go). Wird das Chiffrat mit dem KEK eines ANDEREN Mandanten +// entschluesselt, schlaegt dies fehl (kek.ErrUnwrapFailed), niemals wird ein +// falsches/fremdes Klartext-Passwort geliefert (Akzeptanzkriterium 1 / +// Pruefung 3). +func (s *ConfigStore) Get(ctx context.Context) (BindConfig, error) { + var cfg BindConfig + var ciphertext []byte + err := s.tenantPool.QueryRow(ctx, ` + SELECT host, bind_dn, bind_password_ciphertext, base_dn FROM ldap_bind_config LIMIT 1 + `).Scan(&cfg.Host, &cfg.BindDN, &ciphertext, &cfg.BaseDN) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return BindConfig{}, ErrNoBindConfig + } + return BindConfig{}, fmt.Errorf("ldap-bind-konfiguration lesen: %w", err) + } + + tenantKEK, err := s.keks.GetDecrypted(ctx, s.tenantID, s.masterKey, "ldapsync") + if err != nil { + return BindConfig{}, fmt.Errorf("tenant-kek fuer ldap-bind-konfiguration lesen: %w", err) + } + + plainPassword, err := kek.DecryptWithTenantKEK(tenantKEK, ciphertext) + if err != nil { + return BindConfig{}, fmt.Errorf("ldap-bind-passwort entschluesseln: %w", err) + } + cfg.Password = string(plainPassword) + return cfg, nil +} diff --git a/internal/ldapsync/config_store_test.go b/internal/ldapsync/config_store_test.go new file mode 100644 index 00000000..dfd978b1 --- /dev/null +++ b/internal/ldapsync/config_store_test.go @@ -0,0 +1,245 @@ +package ldapsync + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/kek" +) + +// setupConfigTest legt Control-Plane- (tenants, tenant_keks) und +// Tenant-Tabellen (ldap_bind_config) in derselben Test-Datenbank an — in der +// echten Topologie sind das zwei getrennte Datenbanken (Control-Plane vs. +// physisch isolierte Tenant-DB, TEN-01), fuer den Integrationstest genuegt +// eine gemeinsame DSN (identisches Muster wie internal/kek/kek_test.go). +func setupConfigTest(t *testing.T) (*pgxpool.Pool, *kek.Store, kek.MasterKey, 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 tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_keks ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS ldap_bind_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host TEXT NOT NULL, bind_dn TEXT NOT NULL, bind_password_ciphertext BYTEA NOT NULL, + base_dn TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + if _, err := pool.Exec(ctx, `TRUNCATE tenants, tenant_keks, ldap_bind_config CASCADE`); err != nil { + t.Fatalf("truncate: %v", err) + } + + masterKey := kek.MasterKey(make([]byte, kek.MasterKeySize)) + for i := range masterKey { + masterKey[i] = byte(i + 1) + } + + cleanup := func() { pool.Close() } + return pool, kek.NewStore(pool), masterKey, cleanup +} + +func createTenant(t *testing.T, ctx context.Context, pool *pgxpool.Pool, slug string) string { + t.Helper() + var id string + err := pool.QueryRow(ctx, ` + INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') + RETURNING id + `, slug).Scan(&id) + if err != nil { + t.Fatalf("tenant anlegen: %v", err) + } + return id +} + +// Akzeptanzkriterium 1 + Pruefung 1: Direkter Lesezugriff auf die +// Konfigurationstabelle liefert AUSSCHLIESSLICH Chiffrat — kein verwertbares +// Klartext-Bind-Passwort. +func TestConfigStore_StoresOnlyCiphertextInDB(t *testing.T) { + pool, keks, masterKey, cleanup := setupConfigTest(t) + defer cleanup() + ctx := context.Background() + + tenantID := createTenant(t, ctx, pool, fmt.Sprintf("t-%d", 1)) + if _, err := keks.CreateForTenant(ctx, tenantID, masterKey); err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + + store := NewConfigStore(pool, keks, masterKey, tenantID) + const plainPassword = "s3hr-geheimes-bind-passwort" + if err := store.Save(ctx, BindConfig{ + Host: "ldap://ldap.example.com", BindDN: "cn=service,dc=example,dc=com", + Password: plainPassword, BaseDN: "dc=example,dc=com", + }); err != nil { + t.Fatalf("save: %v", err) + } + + // Direkter Lesezugriff auf die Tabelle, OHNE den ConfigStore/die + // Entschluesselung zu benutzen — simuliert einen Datenbank-Dump oder + // einen kompromittierten Lesezugriff. + var raw []byte + if err := pool.QueryRow(ctx, `SELECT bind_password_ciphertext FROM ldap_bind_config LIMIT 1`).Scan(&raw); err != nil { + t.Fatalf("rohdaten lesen: %v", err) + } + if len(raw) == 0 { + t.Fatal("erwartet: nicht-leeres chiffrat") + } + if string(raw) == plainPassword { + t.Fatal("bind-passwort liegt im klartext in der datenbank") + } + if bytesContainSubstring(raw, plainPassword) { + t.Fatal("chiffrat enthaelt das klartext-passwort als erkennbaren teilstring") + } +} + +func bytesContainSubstring(haystack []byte, needle string) bool { + n := len(needle) + for i := 0; i+n <= len(haystack); i++ { + if string(haystack[i:i+n]) == needle { + return true + } + } + return false +} + +// Akzeptanzkriterium 1 + Pruefung: die gespeicherte Konfiguration wird ueber +// den Tenant-KEK korrekt entschluesselt und liefert wieder das urspruengliche +// Klartext-Passwort (Grundlage fuer den echten LDAP-Bind in client.go). +func TestConfigStore_SaveAndGetRoundtrip(t *testing.T) { + pool, keks, masterKey, cleanup := setupConfigTest(t) + defer cleanup() + ctx := context.Background() + + tenantID := createTenant(t, ctx, pool, fmt.Sprintf("t-%d", 2)) + if _, err := keks.CreateForTenant(ctx, tenantID, masterKey); err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + + store := NewConfigStore(pool, keks, masterKey, tenantID) + want := BindConfig{ + Host: "ldaps://ldap.example.com:636", BindDN: "cn=service,dc=example,dc=com", + Password: "rundlauf-passwort", BaseDN: "dc=example,dc=com", + } + if err := store.Save(ctx, want); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := store.Get(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got != want { + t.Fatalf("roundtrip mismatch: got %+v, want %+v", got, want) + } +} + +// Akzeptanzkriterium 1 + Pruefung 3: Entschluesselung mit dem Tenant-KEK +// eines ANDEREN Mandanten schlaegt fehl — kein Cross-Tenant-Zugriff auf +// Bind-Credentials. +func TestConfigStore_WrongTenantKEK_CannotDecrypt(t *testing.T) { + pool, keks, masterKey, cleanup := setupConfigTest(t) + defer cleanup() + ctx := context.Background() + + tenantA := createTenant(t, ctx, pool, fmt.Sprintf("t-a-%d", 3)) + tenantB := createTenant(t, ctx, pool, fmt.Sprintf("t-b-%d", 3)) + if _, err := keks.CreateForTenant(ctx, tenantA, masterKey); err != nil { + t.Fatalf("tenant-a-kek anlegen: %v", err) + } + if _, err := keks.CreateForTenant(ctx, tenantB, masterKey); err != nil { + t.Fatalf("tenant-b-kek anlegen: %v", err) + } + + storeA := NewConfigStore(pool, keks, masterKey, tenantA) + if err := storeA.Save(ctx, BindConfig{ + Host: "ldap://a.example.com", BindDN: "cn=service,dc=a,dc=com", + Password: "tenant-a-geheimnis", BaseDN: "dc=a,dc=com", + }); err != nil { + t.Fatalf("save fuer tenant a: %v", err) + } + + // storeB zeigt auf DIESELBE (in diesem Test physisch gemeinsame) + // Konfigurationstabelle, verwendet aber den Tenant-KEK von Tenant B — + // entspricht dem Fall, dass jemand versehentlich oder boeswillig mit + // dem falschen Tenant-KEK entschluesseln will. + storeB := NewConfigStore(pool, keks, masterKey, tenantB) + _, err := storeB.Get(ctx) + if err == nil { + t.Fatal("erwartet: entschluesselung mit fremdem tenant-kek schlaegt fehl, hat aber keinen fehler geliefert") + } + if !errors.Is(err, kek.ErrUnwrapFailed) { + t.Fatalf("erwartet kek.ErrUnwrapFailed, bekam: %v", err) + } +} + +// Akzeptanzkriterium 3 + Pruefung 2 (Regression): Die bestehende +// LDAP-Synchronisation aus IAM-05 (Syncer.Run) funktioniert nach Einfuehrung +// der verschluesselten Bind-Konfiguration unveraendert weiter — die neue +// Ablage ist rein additiv und beruehrt Syncer/RoleMappingStore nicht. +func TestConfigStore_DoesNotAffectExistingSync(t *testing.T) { + pool, keks, masterKey, cleanup := setupConfigTest(t) + defer cleanup() + ctx := context.Background() + + tenantID := createTenant(t, ctx, pool, fmt.Sprintf("t-%d", 4)) + if _, err := keks.CreateForTenant(ctx, tenantID, masterKey); err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + store := NewConfigStore(pool, keks, masterKey, tenantID) + if err := store.Save(ctx, BindConfig{ + Host: "ldap://x.example.com", BindDN: "cn=service,dc=x,dc=com", + Password: "beliebig", BaseDN: "dc=x,dc=com", + }); err != nil { + t.Fatalf("save: %v", err) + } + + // Bestehender Sync-Pfad aus IAM-05, unveraendert genutzt (siehe + // ldapsync_test.go: gleicher Aufbau ueber setupTest/fakeClient). + users, roles, mappings, syncCleanup := setupTest(t) + defer syncCleanup() + + if err := mappings.SetMapping(ctx, "cn=Users,ou=groups,dc=x,dc=com", "user"); err != nil { + t.Fatalf("set mapping: %v", err) + } + client := &fakeClient{entries: []Entry{{Email: uniqueEmail(), Name: "Regression Nutzer", Groups: []string{"cn=Users,ou=groups,dc=x,dc=com"}}}} + syncer := NewSyncer(client, users, roles, mappings) + + result, err := syncer.Run(ctx) + if err != nil { + t.Fatalf("sync run: %v", err) + } + if result.Created != 1 { + t.Fatalf("erwartet 1 neu angelegten benutzer, bekam %d (failed=%v)", result.Created, result.Failed) + } + + // Nach dem Sync ist die verschluesselte Bind-Konfiguration weiterhin + // unveraendert lesbar — beide Belange sind unabhaengig voneinander. + got, err := store.Get(ctx) + if err != nil { + t.Fatalf("get nach sync: %v", err) + } + if got.Password != "beliebig" { + t.Fatalf("bind-konfiguration nach sync veraendert: %+v", got) + } +} diff --git a/migrations/0009_ldap_bind_credentials.down.sql b/migrations/0009_ldap_bind_credentials.down.sql new file mode 100644 index 00000000..76b783cb --- /dev/null +++ b/migrations/0009_ldap_bind_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ldap_bind_config; diff --git a/migrations/0009_ldap_bind_credentials.up.sql b/migrations/0009_ldap_bind_credentials.up.sql new file mode 100644 index 00000000..c1d167cc --- /dev/null +++ b/migrations/0009_ldap_bind_credentials.up.sql @@ -0,0 +1,25 @@ +-- IAM-19: Verschluesselte Ablage der LDAP/AD-Bind-Credentials je Tenant +-- (siehe core-kanban/tickets/IAM-19.md). Liegt wie alle anderen +-- Tenant-Fachdaten in der Tenant-Datenbank (Modell C, TEN-01) — NICHT in der +-- Control-Plane-Registry, in der ausschliesslich der verpackte Tenant-KEK +-- selbst liegt (internal/kek, API-10). +-- +-- bind_password_ciphertext enthaelt AUSSCHLIESSLICH das mit dem Tenant-KEK +-- verschluesselte Bind-Passwort (AES-256-GCM, Nonce vorangestellt) — niemals +-- Klartext (Akzeptanzkriterium 1 + 2). Kein separates Secret-Schema: dieselbe +-- Envelope-Encryption wie fuer alle anderen ueber den Tenant-KEK geschuetzten +-- Daten (bewusst vermieden: Keycloaks historische Klartextablage, siehe +-- Ticket "Bewusst vermeiden"). +CREATE TABLE ldap_bind_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host TEXT NOT NULL, + bind_dn TEXT NOT NULL, + bind_password_ciphertext BYTEA NOT NULL, + base_dn TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Genau ein LDAP-Bind-Konfigurationssatz pro Tenant-Datenbank (physische +-- Isolation macht einen tenant_id-Fremdschluessel hier unnoetig, siehe +-- identisches Muster in tenant_settings/TEN-03). +CREATE UNIQUE INDEX ldap_bind_config_singleton_idx ON ldap_bind_config ((true));