Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
264 lines
9.5 KiB
Go
264 lines
9.5 KiB
Go
// 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
|
|
}
|