feat(PROJ-21/23): Pro-Tenant Xapian-Index + Tenant-LDAP Backend
PROJ-21 Phase 4:
- internal/index/tenant_manager.go: TenantIndexManager mit lazy-loading Pool
- internal/index/tenant_worker.go: TenantIndexWorker leitet Submit an richtigen Index
- Jeder Mandant bekommt eigenes Xapian-Verzeichnis (tenant-<id>/)
- handleSearch nutzt direkt Tenant-Index statt nachgelagertem Post-Filter
- runBackfill re-indexiert pro Mandant beim Start
PROJ-23 / PROJ-16 Phase B:
- internal/ldapconfig/tenant_store.go: TenantStore mit AES-256-GCM für tenant_ldap
- internal/api/ldap_tenants.go: 8 neue Handler (GET/PUT/DELETE/test für
/api/tenant/ldap und /api/admin/tenants/{id}/ldap)
- internal/auth/auth.go: Login-Fallback prüft tenant_ldap nach globalem LDAP
(Domain-Extraktion → tenant_ldap config → UpsertLDAPUser mit tenant_id)
- internal/api/server.go: SetTenantLDAP(), neue Routen registriert
- internal/tenantstore/store.go: GetByDomain() Interface für auth-Package
- cmd/archivmail/main.go: TenantLDAPStore + TenantIndexManager verdrahtet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user