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:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+263
View File
@@ -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
}