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
+287
View File
@@ -0,0 +1,287 @@
"""The LDAPS implementation. The only module in the portal that imports `ldap` (AD-1, AD-2)."""
from __future__ import annotations
import logging
import re
from contextlib import contextmanager
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from types import ModuleType
from portal.config.env import DirectoryConfig
from .base import (
POLICY_ATTRIBUTES,
PSO_ATTRIBUTES,
READABLE_ATTRIBUTES,
WRITABLE_ATTRIBUTES,
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
UF_ACCOUNTDISABLE = 0x2
UF_LOCKOUT = 0x10
COMPLEX_PASSWORDS = 0x1
# Windows FILETIME counts 100 ns ticks since 1601-01-01.
_FILETIME_EPOCH = datetime(1601, 1, 1, tzinfo=UTC)
_NEVER = -(2**63)
_AD_ERROR_CODE = re.compile(r"\b0000([0-9A-Fa-f]{4}):")
_WRONG_CURRENT_CODE = "0056" # ERROR_INVALID_PASSWORD; a policy refusal is 052D
def _load_ldap() -> ModuleType:
# Imported on first use so the rest of the portal (and its tests) load without the C library.
import ldap
import ldap.filter
return ldap
def _normalize_dn(dn: str) -> str:
return re.sub(r"\s*([,=])\s*", r"\1", dn).casefold()
def _text(attrs: dict[str, list[bytes]], name: str) -> str:
values = attrs.get(name)
return values[0].decode("utf-8") if values else ""
def _filetime(value: str) -> datetime | None:
ticks = int(value or 0)
if ticks <= 0:
return None
return _FILETIME_EPOCH + timedelta(microseconds=ticks // 10)
def _interval(value: str) -> timedelta | None:
"""AD stores durations as negative 100 ns ticks; 0 and the minimum value mean 'never'."""
ticks = int(value or 0)
if ticks == 0 or ticks == _NEVER:
return None
return timedelta(microseconds=abs(ticks) // 10)
class LdapDirectory:
def __init__(self, config: DirectoryConfig, ldap_module: ModuleType | None = None) -> None:
self._config = config
self._ldap = ldap_module or _load_ldap()
host = config.server
self._url = f"ldaps://{host}" if ":" in host else f"ldaps://{host}:636"
# -- connections -----------------------------------------------------------
def _connect(self):
ldap = self._ldap
conn = ldap.initialize(self._url)
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
conn.set_option(ldap.OPT_REFERRALS, 0)
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, TIMEOUT_SECONDS)
conn.set_option(ldap.OPT_TIMEOUT, TIMEOUT_SECONDS)
conn.set_option(ldap.OPT_X_TLS_CACERTFILE, str(self._config.ca_file))
conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # apply the options above to this connection
return conn
@contextmanager
def _service(self):
"""A bound connection of the service account; library errors become typed errors."""
ldap = self._ldap
conn = self._connect()
try:
conn.simple_bind_s(self._config.bind_dn, self._config.bind_password)
yield conn
except ldap.INSUFFICIENT_ACCESS as exc:
raise Forbidden("The service account lacks the right for this request") from exc
except ldap.LDAPError as exc:
# Name the class only: messages can echo a bind DN or a filter.
logger.warning("Directory request failed: %s", type(exc).__name__)
raise DirectoryUnavailable("The directory is not available") from exc
finally:
try:
conn.unbind_s()
except self._ldap.LDAPError:
pass
def _search(self, conn, base: str, scope: int, flt: str, attrs: tuple[str, ...]):
found = conn.search_s(base, scope, flt, list(attrs))
return [(dn, a) for dn, a in found if dn is not None] # drop referrals
def _find_entry(self, conn, sam: str):
"""The (dn, attributes) of the user in the configured OU, or None."""
if not sam:
return None
ldap = self._ldap
escaped = ldap.filter.escape_filter_chars(sam)
flt = f"(&(objectClass=user)(objectCategory=person)(sAMAccountName={escaped}))"
entries = self._search(
conn, self._config.user_ou_dn, ldap.SCOPE_SUBTREE, flt, READABLE_ATTRIBUTES
)
if len(entries) != 1:
if len(entries) > 1:
logger.warning("Directory lookup was ambiguous; treating the user as not found")
return None
return entries[0]
def _require_entry(self, conn, sam: str):
entry = self._find_entry(conn, sam)
if entry is None:
raise ValidationFailed("The user does not exist in the configured OU")
return entry
# -- interface ---------------------------------------------------------------
def find_user(self, sam: str) -> DirectoryUser | None:
with self._service() as conn:
entry = self._find_entry(conn, sam)
if entry is None:
return None
attrs = entry[1]
computed = _text(attrs, "msDS-User-Account-Control-Computed")
if computed:
locked = bool(int(computed) & UF_LOCKOUT)
else:
locked = self._locked_by_time(conn, _text(attrs, "lockoutTime"))
groups = {_normalize_dn(v.decode("utf-8")) for v in attrs.get("memberOf", [])}
return DirectoryUser(
sam=_text(attrs, "sAMAccountName"),
given_name=_text(attrs, "givenName"),
surname=_text(attrs, "sn"),
display_name=_text(attrs, "displayName"),
mail=_text(attrs, "mail"),
telephone=_text(attrs, "telephoneNumber"),
mobile=_text(attrs, "mobile"),
enabled=not int(_text(attrs, "userAccountControl") or 0) & UF_ACCOUNTDISABLE,
locked=locked,
password_last_set=_filetime(_text(attrs, "pwdLastSet")),
is_publisher=_normalize_dn(self._config.publisher_group_dn) in groups,
is_admin=_normalize_dn(self._config.admin_group_dn) in groups,
)
def _locked_by_time(self, conn, lockout_time: str) -> bool:
"""Fallback when the computed account-control attribute is not readable."""
locked_at = _filetime(lockout_time)
if locked_at is None:
return False
domain_dn = self._domain_dn(conn)
policy = self._search(
conn, domain_dn, self._ldap.SCOPE_BASE, "(objectClass=*)", POLICY_ATTRIBUTES
)
duration = _interval(_text(policy[0][1], "lockoutDuration")) if policy else None
if duration is None: # 'never unlock': only an administrator clears the lock
return True
return datetime.now(UTC) < locked_at + duration
def _domain_dn(self, conn) -> str:
root = self._search(
conn, "", self._ldap.SCOPE_BASE, "(objectClass=*)", ("defaultNamingContext",)
)
return _text(root[0][1], "defaultNamingContext")
def verify_password(self, sam: str, password: str) -> bool:
if not password:
return False # an empty password would be an anonymous bind, which succeeds
ldap = self._ldap
with self._service() as conn:
entry = self._find_entry(conn, sam)
if entry is None:
return False
user_conn = self._connect()
try:
user_conn.simple_bind_s(entry[0], password)
return True
except ldap.INVALID_CREDENTIALS:
return False
except ldap.LDAPError as exc:
logger.warning("Directory request failed: %s", type(exc).__name__)
raise DirectoryUnavailable("The directory is not available") from exc
finally:
try:
user_conn.unbind_s()
except ldap.LDAPError:
pass
def password_policy(self, sam: str) -> PasswordPolicy:
ldap = self._ldap
with self._service() as conn:
entry = self._require_entry(conn, sam)
domain_dn = self._domain_dn(conn)
domain = self._search(
conn, domain_dn, ldap.SCOPE_BASE, "(objectClass=*)", POLICY_ATTRIBUTES
)[0][1]
policy = PasswordPolicy(
min_length=int(_text(domain, "minPwdLength") or 0),
complexity=bool(int(_text(domain, "pwdProperties") or 0) & COMPLEX_PASSWORDS),
history_length=int(_text(domain, "pwdHistoryLength") or 0),
min_age=_interval(_text(domain, "minPwdAge")) or timedelta(0),
max_age=_interval(_text(domain, "maxPwdAge")),
)
pso_dn = _text(entry[1], "msDS-ResultantPSO")
if not pso_dn:
return policy
try:
pso = self._search(conn, pso_dn, ldap.SCOPE_BASE, "(objectClass=*)", PSO_ATTRIBUTES)
except ldap.INSUFFICIENT_ACCESS:
return replace(policy, fine_grained=True, fine_grained_readable=False)
attrs = pso[0][1]
return PasswordPolicy(
min_length=int(_text(attrs, "msDS-MinimumPasswordLength") or 0),
complexity=_text(attrs, "msDS-PasswordComplexityEnabled").upper() == "TRUE",
history_length=int(_text(attrs, "msDS-PasswordHistoryLength") or 0),
min_age=_interval(_text(attrs, "msDS-MinimumPasswordAge")) or timedelta(0),
max_age=_interval(_text(attrs, "msDS-MaximumPasswordAge")),
fine_grained=True,
)
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
ldap = self._ldap
refused = [name for name in changes if name not in WRITABLE_ATTRIBUTES]
if refused:
raise Forbidden(f"Attributes are not writable: {', '.join(sorted(refused))}")
mods = []
for name, value in changes.items():
if value is not None and (not isinstance(value, str) or "\x00" in value):
raise ValidationFailed(f"Invalid value for {name}")
values = [value.encode("utf-8")] if value else []
mods.append((ldap.MOD_REPLACE, name, values))
if not mods:
return
with self._service() as conn:
dn = self._require_entry(conn, sam)[0]
try:
conn.modify_s(dn, mods)
except (ldap.CONSTRAINT_VIOLATION, ldap.INVALID_SYNTAX) as exc:
raise ValidationFailed("The directory refused a value") from exc
def change_password(self, sam: str, current: str, new: str) -> None:
ldap = self._ldap
if not current or not new:
raise PasswordRejected("wrong_current" if not current else "policy")
old_value = f'"{current}"'.encode("utf-16-le")
new_value = f'"{new}"'.encode("utf-16-le")
mods = [
(ldap.MOD_DELETE, "unicodePwd", [old_value]),
(ldap.MOD_ADD, "unicodePwd", [new_value]),
]
with self._service() as conn:
dn = self._require_entry(conn, sam)[0]
try:
conn.modify_s(dn, mods)
except (ldap.CONSTRAINT_VIOLATION, ldap.UNWILLING_TO_PERFORM) as exc:
raise PasswordRejected(self._password_reason(exc)) from exc
@staticmethod
def _password_reason(exc: Exception) -> str:
detail = exc.args[0] if exc.args and isinstance(exc.args[0], dict) else {}
info = str(detail.get("info", ""))
match = _AD_ERROR_CODE.search(info)
if match and match.group(1).upper() == _WRONG_CURRENT_CODE:
return "wrong_current"
return "policy"