// Package ldapconfig — TenantStore manages per-tenant LDAP/AD configuration // stored in the tenant_ldap table. The bind password is encrypted with // AES-256-GCM using a SHA-256 derived key, identical to the global Store. package ldapconfig import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/json" "fmt" "io" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // TenantLDAPConfig is the persisted LDAP/AD configuration for a single tenant. type TenantLDAPConfig struct { TenantID int64 `json:"tenant_id"` Enabled bool `json:"enabled"` URL string `json:"url"` BindDN string `json:"bind_dn"` BindPassword string `json:"bind_password"` BaseDN string `json:"base_dn"` UserFilter string `json:"user_filter"` TLS bool `json:"tls"` TLSSkipVerify bool `json:"tls_skip_verify"` DefaultRole string `json:"default_role"` GroupMappings []GroupMapping `json:"group_mappings"` } // TenantLDAPSummary is the abbreviated view returned by ListAll (for superadmin). type TenantLDAPSummary struct { TenantID int64 `json:"tenant_id"` TenantName string `json:"tenant_name"` Enabled bool `json:"enabled"` URL string `json:"url"` } // TenantStore manages per-tenant LDAP configuration persistence. type TenantStore struct { pool *pgxpool.Pool encKey [32]byte } // NewTenantStore connects to PostgreSQL and returns a TenantStore. // The tenant_ldap table is created by the tenantstore package schema migration. // keyHex is the hex-encoded AES key (same as used for the global LDAP store). func NewTenantStore(dsn, keyHex string) (*TenantStore, error) { ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("tenant ldap store: connect: %w", err) } key := sha256.Sum256([]byte(keyHex)) return &TenantStore{pool: pool, encKey: key}, nil } // Close releases the underlying connection pool. func (s *TenantStore) Close() { s.pool.Close() } // Get returns the LDAP configuration for a tenant with the bind password masked. // Returns nil, nil when no configuration exists. func (s *TenantStore) Get(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) { cfg, err := s.query(ctx, tenantID) if err != nil { return nil, err } if cfg == nil { return nil, nil } if cfg.BindPassword != "" { cfg.BindPassword = "\u2022\u2022\u2022\u2022\u2022\u2022" } return cfg, nil } // GetWithPassword returns the LDAP configuration including the decrypted bind password. // Returns nil, nil when no configuration exists. func (s *TenantStore) GetWithPassword(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) { return s.query(ctx, tenantID) } // Save upserts the LDAP configuration for a tenant. // When bindPassword is empty the existing stored password is preserved. func (s *TenantStore) Save(ctx context.Context, cfg TenantLDAPConfig, updatedBy string) error { mappingsJSON, err := json.Marshal(cfg.GroupMappings) if err != nil { return fmt.Errorf("tenant ldap store: marshal group_mappings: %w", err) } var encryptedPw []byte if cfg.BindPassword != "" { encryptedPw, err = s.encrypt(cfg.BindPassword) if err != nil { return fmt.Errorf("tenant ldap store: encrypt password: %w", err) } } else { // Preserve existing password. existing, qErr := s.query(ctx, cfg.TenantID) if qErr == nil && existing != nil && existing.BindPassword != "" { encryptedPw, err = s.encrypt(existing.BindPassword) if err != nil { return fmt.Errorf("tenant ldap store: re-encrypt existing password: %w", err) } } } _, err = s.pool.Exec(ctx, ` INSERT INTO tenant_ldap (tenant_id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify, default_role, group_mappings) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (tenant_id) DO UPDATE SET enabled = EXCLUDED.enabled, url = EXCLUDED.url, bind_dn = EXCLUDED.bind_dn, bind_password = CASE WHEN EXCLUDED.bind_password IS NULL THEN tenant_ldap.bind_password ELSE EXCLUDED.bind_password END, base_dn = EXCLUDED.base_dn, user_filter = EXCLUDED.user_filter, tls = EXCLUDED.tls, tls_skip_verify = EXCLUDED.tls_skip_verify, default_role = EXCLUDED.default_role, group_mappings = EXCLUDED.group_mappings `, cfg.TenantID, cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN, cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole, string(mappingsJSON), ) if err != nil { return fmt.Errorf("tenant ldap store: upsert: %w", err) } return nil } // Delete removes the LDAP configuration for a tenant. func (s *TenantStore) Delete(ctx context.Context, tenantID int64) error { _, err := s.pool.Exec(ctx, `DELETE FROM tenant_ldap WHERE tenant_id = $1`, tenantID) return err } // ListAll returns a summary of all tenant LDAP configurations (for superadmin). func (s *TenantStore) ListAll(ctx context.Context) ([]TenantLDAPSummary, error) { rows, err := s.pool.Query(ctx, ` SELECT tl.tenant_id, t.name, tl.enabled, tl.url FROM tenant_ldap tl JOIN tenants t ON t.id = tl.tenant_id ORDER BY tl.tenant_id `) if err != nil { return nil, fmt.Errorf("tenant ldap store: list all: %w", err) } defer rows.Close() var summaries []TenantLDAPSummary for rows.Next() { var s TenantLDAPSummary if err := rows.Scan(&s.TenantID, &s.TenantName, &s.Enabled, &s.URL); err != nil { return nil, fmt.Errorf("tenant ldap store: scan: %w", err) } summaries = append(summaries, s) } if summaries == nil { summaries = []TenantLDAPSummary{} } return summaries, rows.Err() } // query reads the tenant_ldap row for a tenant and decrypts the bind password. func (s *TenantStore) query(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) { row := s.pool.QueryRow(ctx, ` SELECT tenant_id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify, default_role, group_mappings FROM tenant_ldap WHERE tenant_id = $1 `, tenantID) var cfg TenantLDAPConfig var encPw []byte var mappingsRaw []byte err := row.Scan( &cfg.TenantID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw, &cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify, &cfg.DefaultRole, &mappingsRaw, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("tenant ldap store: query: %w", err) } if len(encPw) > 0 { plain, err := s.decrypt(encPw) if err != nil { return nil, fmt.Errorf("tenant ldap store: decrypt password: %w", err) } cfg.BindPassword = plain } if len(mappingsRaw) > 0 { if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil { return nil, fmt.Errorf("tenant ldap store: unmarshal group_mappings: %w", err) } } if cfg.GroupMappings == nil { cfg.GroupMappings = []GroupMapping{} } return &cfg, nil } // encrypt encrypts plaintext with AES-256-GCM using the store's key. func (s *TenantStore) encrypt(plaintext string) ([]byte, error) { block, err := aes.NewCipher(s.encKey[:]) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil } // decrypt decrypts ciphertext produced by encrypt. func (s *TenantStore) decrypt(ciphertext []byte) (string, error) { block, err := aes.NewCipher(s.encKey[:]) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } if len(ciphertext) < gcm.NonceSize() { return "", fmt.Errorf("tenant ldap store: ciphertext too short") } nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] plain, err := gcm.Open(nil, nonce, data, nil) if err != nil { return "", err } return string(plain), nil }