// Package ldapstore is a PostgreSQL-backed CRUD store for per-tenant LDAP // directory configuration (ldap_configs table). It follows the same // Store-per-schema pattern as userstore/tenantstore: initSchema() is idempotent // and called from New(). // // The LDAP service-bind password is never stored in plaintext: it is encrypted // with AES-256-GCM via internal/cryptutil (key derived from the application // master secret) and stored as ciphertext + nonce. Get() returns the config // WITHOUT the password (only HasBindPassword); GetWithSecret() decrypts it for // the actual bind at login time. package ldapstore import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "archivdms/internal/cryptutil" ) // ErrNotFound is returned when no ldap_configs row exists for a tenant. var ErrNotFound = errors.New("ldapstore: config not found") // TLS mode values for Config.UseTLS. const ( TLSModeLDAPS = "ldaps" TLSModeStartTLS = "starttls" ) // Config mirrors a row of ldap_configs, minus the encrypted password columns. // HasBindPassword reports whether a bind password is stored (surfaced to the // API as "is_set"); the plaintext is only ever available via GetWithSecret. type Config struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` UseTLS string `json:"use_tls"` BindDN string `json:"bind_dn"` HasBindPassword bool `json:"bind_password_set"` BaseDN string `json:"base_dn"` UserFilter string `json:"user_filter"` AttrUsername string `json:"attr_username"` AttrEmail string `json:"attr_email"` AttrName string `json:"attr_name"` GroupBaseDN string `json:"group_base_dn"` GroupFilter string `json:"group_filter"` AdminGroupDN string `json:"admin_group_dn"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Store is a PostgreSQL-backed LDAP config store. type Store struct { pool *pgxpool.Pool box *cryptutil.Box } // New connects to PostgreSQL, initialises the schema, and derives the // password-encryption key from secret (the application master/JWT secret). func New(dsn, secret string) (*Store, error) { ctx := context.Background() box, err := cryptutil.NewBox(secret) if err != nil { return nil, fmt.Errorf("ldapstore: crypto init: %w", err) } pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("ldapstore: connect: %w", err) } s := &Store{pool: pool, box: box} if err := s.initSchema(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ldapstore: init schema: %w", err) } return s, nil } // initSchema creates ldap_configs and adds the LDAP columns to users. // Idempotent. Documented in migrations/011_ldap.sql. // // No FK on tenant_id: consistent with the rest of the schema (documents / // permissions use a plain BIGINT tenant_id) and required because tenants/users // are created by other stores whose init order relative to this one is not // guaranteed (see cmd/archivdms/main.go). func (s *Store) initSchema(ctx context.Context) error { _, err := s.pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS ldap_configs ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL UNIQUE, enabled BOOLEAN NOT NULL DEFAULT false, host VARCHAR(255) NOT NULL, port INTEGER NOT NULL DEFAULT 636, use_tls VARCHAR(20) NOT NULL DEFAULT 'ldaps', bind_dn VARCHAR(500) NOT NULL, bind_password_enc BYTEA NOT NULL, bind_password_nonce BYTEA NOT NULL, base_dn VARCHAR(500) NOT NULL, user_filter VARCHAR(500) NOT NULL DEFAULT '(uid=%s)', attr_username VARCHAR(100) NOT NULL DEFAULT 'uid', attr_email VARCHAR(100) NOT NULL DEFAULT 'mail', attr_name VARCHAR(100) NOT NULL DEFAULT 'cn', group_base_dn VARCHAR(500), group_filter VARCHAR(500), admin_group_dn VARCHAR(500), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'local'; ALTER TABLE users ADD COLUMN IF NOT EXISTS ldap_uid VARCHAR(255); ALTER TABLE users ADD COLUMN IF NOT EXISTS ldap_synced_at TIMESTAMPTZ; CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_ldap_uid ON users (tenant_id, ldap_uid) WHERE ldap_uid IS NOT NULL; `) return err } // Close closes the underlying connection pool. func (s *Store) Close() error { s.pool.Close() return nil } const selectCols = `id, tenant_id, enabled, host, port, use_tls, bind_dn, (octet_length(bind_password_enc) > 0) AS has_pw, base_dn, user_filter, attr_username, attr_email, attr_name, COALESCE(group_base_dn, ''), COALESCE(group_filter, ''), COALESCE(admin_group_dn, ''), created_at, updated_at` func scanConfig(row pgx.Row) (*Config, error) { var c Config err := row.Scan( &c.ID, &c.TenantID, &c.Enabled, &c.Host, &c.Port, &c.UseTLS, &c.BindDN, &c.HasBindPassword, &c.BaseDN, &c.UserFilter, &c.AttrUsername, &c.AttrEmail, &c.AttrName, &c.GroupBaseDN, &c.GroupFilter, &c.AdminGroupDN, &c.CreatedAt, &c.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("ldapstore: scan: %w", err) } return &c, nil } // Get returns the LDAP config for a tenant WITHOUT the bind password. func (s *Store) Get(ctx context.Context, tenantID int64) (*Config, error) { row := s.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM ldap_configs WHERE tenant_id = $1`, tenantID) return scanConfig(row) } // GetWithSecret returns the LDAP config together with the decrypted bind // password. Only used at login/test time — never surfaced to API responses. func (s *Store) GetWithSecret(ctx context.Context, tenantID int64) (*Config, string, error) { cfg, err := s.Get(ctx, tenantID) if err != nil { return nil, "", err } var enc, nonce []byte err = s.pool.QueryRow(ctx, `SELECT bind_password_enc, bind_password_nonce FROM ldap_configs WHERE tenant_id = $1`, tenantID, ).Scan(&enc, &nonce) if err != nil { return nil, "", fmt.Errorf("ldapstore: read secret: %w", err) } pw, err := s.box.Decrypt(enc, nonce) if err != nil { return nil, "", fmt.Errorf("ldapstore: decrypt bind password: %w", err) } return cfg, string(pw), nil } // Upsert creates or updates the LDAP config for cfg.TenantID. // // newPassword semantics: // - non-nil: the bind password is (re)encrypted and stored. // - nil on an existing row: the stored password is kept unchanged. // - nil on a new row: an error is returned (a bind password is mandatory). func (s *Store) Upsert(ctx context.Context, cfg Config, newPassword *string) (*Config, error) { if cfg.UseTLS != TLSModeLDAPS && cfg.UseTLS != TLSModeStartTLS { return nil, fmt.Errorf("ldapstore: use_tls must be %q or %q (cleartext LDAP not permitted)", TLSModeLDAPS, TLSModeStartTLS) } if cfg.Port == 0 { cfg.Port = 636 } // Determine the password bytes to store. var enc, nonce []byte _, existing, existErr := s.GetWithSecret(ctx, cfg.TenantID) switch { case newPassword != nil: var err error enc, nonce, err = s.box.Encrypt([]byte(*newPassword)) if err != nil { return nil, fmt.Errorf("ldapstore: encrypt bind password: %w", err) } case existErr == nil: // Keep the existing password — re-encrypt to get fresh bytes. var err error enc, nonce, err = s.box.Encrypt([]byte(existing)) if err != nil { return nil, fmt.Errorf("ldapstore: re-encrypt bind password: %w", err) } default: return nil, fmt.Errorf("ldapstore: bind password required for new config") } nullable := func(s string) any { if s == "" { return nil } return s } _, err := s.pool.Exec(ctx, ` INSERT INTO ldap_configs (tenant_id, enabled, host, port, use_tls, bind_dn, bind_password_enc, bind_password_nonce, base_dn, user_filter, attr_username, attr_email, attr_name, group_base_dn, group_filter, admin_group_dn, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, NOW(), NOW()) ON CONFLICT (tenant_id) DO UPDATE SET enabled = EXCLUDED.enabled, host = EXCLUDED.host, port = EXCLUDED.port, use_tls = EXCLUDED.use_tls, bind_dn = EXCLUDED.bind_dn, bind_password_enc = EXCLUDED.bind_password_enc, bind_password_nonce = EXCLUDED.bind_password_nonce, base_dn = EXCLUDED.base_dn, user_filter = EXCLUDED.user_filter, attr_username = EXCLUDED.attr_username, attr_email = EXCLUDED.attr_email, attr_name = EXCLUDED.attr_name, group_base_dn = EXCLUDED.group_base_dn, group_filter = EXCLUDED.group_filter, admin_group_dn = EXCLUDED.admin_group_dn, updated_at = NOW()`, cfg.TenantID, cfg.Enabled, cfg.Host, cfg.Port, cfg.UseTLS, cfg.BindDN, enc, nonce, cfg.BaseDN, cfg.UserFilter, cfg.AttrUsername, cfg.AttrEmail, cfg.AttrName, nullable(cfg.GroupBaseDN), nullable(cfg.GroupFilter), nullable(cfg.AdminGroupDN), ) if err != nil { return nil, fmt.Errorf("ldapstore: upsert: %w", err) } return s.Get(ctx, cfg.TenantID) } // Delete removes the LDAP config for a tenant. Returns ErrNotFound if absent. func (s *Store) Delete(ctx context.Context, tenantID int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM ldap_configs WHERE tenant_id = $1`, tenantID) if err != nil { return fmt.Errorf("ldapstore: delete: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil }