IAM-19: verschl-sselte-ablage-von-ldap-ad-bind-credentials

Speichert das LDAP-Bind-Passwort je Tenant ausschliesslich verschluesselt
mit dem Tenant-KEK aus API-10 (AES-256-GCM, kek.EncryptWithTenantKEK/
DecryptWithTenantKEK) statt in einem separaten Secret-Schema oder Klartext
(Keycloak-Antipattern bewusst vermieden). Neue Tabelle ldap_bind_config in
der Tenant-Datenbank (Migration 0009), neuer internal/ldapsync.ConfigStore
fuer Save/Get, rein additiv zu IAM-05 (Syncer/RoleMappingStore unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 11:32:37 +02:00
1 parent 4e238d2d22
commit 61dac85ff6
6 files changed
+411

No files matched your search

+11
View File
@@ -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 +++++++++++++++++--------
---
+20
View File
@@ -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 {
+109
View File
@@ -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
}
+245
View File
@@ -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)
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ldap_bind_config;
@@ -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));