FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user