feat: add directory adapter

LDAPS directory adapter with certificate verification, allow-listed reads and
writes, escaped filters, password verification and change, password policy
and account state, plus an in-memory fake. Adds the LDAP_* configuration,
python-ldap with its image libraries, the CA file mount and an opt-in live
test for the test OU.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-17 18:28:50 +02:00
co-authored by Claude Sonnet 5.5
parent 5f72ea8db6
commit 96d3e77161
18 changed files with 1324 additions and 3 deletions
+41 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import ipaddress
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -31,6 +31,17 @@ class ConfigError(Exception):
return "\n".join(lines)
@dataclass(frozen=True)
class DirectoryConfig:
server: str # host name, optionally host:port; always reached over LDAPS
user_ou_dn: str
bind_dn: str # distinguished name or user principal name of the service account
bind_password: str = field(repr=False)
ca_file: Path
publisher_group_dn: str
admin_group_dn: str
@dataclass(frozen=True)
class Config:
secret_key: str
@@ -40,6 +51,7 @@ class Config:
trusted_proxies: list[str]
log_level: str
default_timezone: str
directory: DirectoryConfig
def load_config(environ: Mapping[str, str]) -> Config:
@@ -104,6 +116,25 @@ def load_config(environ: Mapping[str, str]) -> Config:
except (ZoneInfoNotFoundError, ValueError, OSError):
problems.append("DEFAULT_TIMEZONE: must be a valid IANA time zone name")
def required(name: str, rule: str) -> str:
value = raw(name)
if value is None:
problems.append(f"{name}: required, {rule}")
return ""
return value
server = required("LDAP_SERVER", "host name of the directory server, with optional :port")
if "://" in server or "/" in server:
problems.append("LDAP_SERVER: must be a host name, not a URL (LDAPS is always used)")
user_ou_dn = required("LDAP_USER_OU_DN", "distinguished name of the OU that holds the users")
bind_dn = required("LDAP_BIND_DN", "service account distinguished name or user principal name")
bind_password = required("LDAP_BIND_PASSWORD", "password of the service account")
publisher_group_dn = required("LDAP_PUBLISHER_GROUP_DN", "distinguished name of the group")
admin_group_dn = required("LDAP_ADMIN_GROUP_DN", "distinguished name of the group")
ca_file = Path(required("LDAP_CA_FILE", "path of the PEM file with the CA chain"))
if raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
problems.append("LDAP_CA_FILE: must be an existing file")
if problems:
raise ConfigError(problems)
@@ -115,4 +146,13 @@ def load_config(environ: Mapping[str, str]) -> Config:
trusted_proxies=proxies,
log_level=log_level,
default_timezone=timezone,
directory=DirectoryConfig(
server=server,
user_ou_dn=user_ou_dn,
bind_dn=bind_dn,
bind_password=bind_password,
ca_file=ca_file,
publisher_group_dn=publisher_group_dn,
admin_group_dn=admin_group_dn,
),
)
+1
View File
@@ -23,6 +23,7 @@ ALLOWED_HOSTS = _config.allowed_hosts
TRUSTED_PROXIES = _config.trusted_proxies
DATA_DIR = _config.data_dir
DISPLAY_TIME_ZONE = _config.default_timezone
DIRECTORY = _config.directory
INSTALLED_APPS = [
"django.contrib.contenttypes",