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:
1 parent
4e238d2d22
commit
61dac85ff6
6 files changed
+411
No files matched your search
@@ -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 +++++++++++++++++--------
|
||||
|
||||
---
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user