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
+31
View File
@@ -0,0 +1,31 @@
"""Directory adapter: the only code that talks to Active Directory (AD-1)."""
from .base import (
READABLE_ATTRIBUTES,
WRITABLE_ATTRIBUTES,
Directory,
DirectoryError,
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
from .fake import FakeDirectory
from .ldap_directory import LdapDirectory
__all__ = [
"READABLE_ATTRIBUTES",
"WRITABLE_ATTRIBUTES",
"Directory",
"DirectoryError",
"DirectoryUnavailable",
"DirectoryUser",
"FakeDirectory",
"Forbidden",
"LdapDirectory",
"PasswordPolicy",
"PasswordRejected",
"ValidationFailed",
]
+117
View File
@@ -0,0 +1,117 @@
"""The directory interface, its data types and its typed errors (AD-1, AD-2, AD-17)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Protocol
# Fixed allow-lists (AD-2). unicodePwd is writable only through change_password.
WRITABLE_ATTRIBUTES = (
"givenName",
"sn",
"displayName",
"mail",
"telephoneNumber",
"mobile",
)
READABLE_ATTRIBUTES = (
*WRITABLE_ATTRIBUTES,
"sAMAccountName",
"memberOf",
"thumbnailPhoto",
"pwdLastSet",
"userAccountControl",
"msDS-User-Account-Control-Computed",
"lockoutTime",
"msDS-ResultantPSO",
)
POLICY_ATTRIBUTES = (
"minPwdLength",
"pwdProperties",
"pwdHistoryLength",
"minPwdAge",
"maxPwdAge",
"lockoutDuration",
)
PSO_ATTRIBUTES = (
"msDS-MinimumPasswordLength",
"msDS-PasswordComplexityEnabled",
"msDS-PasswordHistoryLength",
"msDS-MinimumPasswordAge",
"msDS-MaximumPasswordAge",
)
class DirectoryError(Exception):
"""Base class of every error the directory adapter raises."""
class DirectoryUnavailable(DirectoryError):
"""The directory cannot be reached or refused the service account."""
class Forbidden(DirectoryError):
"""The request is outside the allow-list or the service account lacks the right."""
class ValidationFailed(DirectoryError):
"""The directory refused a value."""
class PasswordRejected(ValidationFailed):
"""A password change was refused; `reason` is `wrong_current` or `policy`."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(f"Password change refused: {reason}")
@dataclass(frozen=True)
class DirectoryUser:
sam: str # exact sAMAccountName
given_name: str = ""
surname: str = ""
display_name: str = ""
mail: str = ""
telephone: str = ""
mobile: str = ""
enabled: bool = True
locked: bool = False
password_last_set: datetime | None = None # UTC; None when the user must change it at sign-in
is_publisher: bool = False
is_admin: bool = False
@property
def must_change_password(self) -> bool:
return self.password_last_set is None
@dataclass(frozen=True)
class PasswordPolicy:
min_length: int
complexity: bool
history_length: int
min_age: timedelta
max_age: timedelta | None # None: passwords do not expire
fine_grained: bool = False # a fine-grained policy applies to the user
fine_grained_readable: bool = True # False: the values above are the domain's, not the user's
class Directory(Protocol):
"""The only way the rest of the portal talks to the directory."""
def find_user(self, sam: str) -> DirectoryUser | None:
"""Look the user up by sAMAccountName inside the configured OU."""
def verify_password(self, sam: str, password: str) -> bool:
"""True when the user can bind with this password. Empty passwords are never valid."""
def password_policy(self, sam: str) -> PasswordPolicy:
"""The policy that applies to this user (the domain's, or a fine-grained one)."""
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
"""Replace allow-listed attributes; None or an empty string clears one."""
def change_password(self, sam: str, current: str, new: str) -> None:
"""Change the password as the user does, which needs the current password."""
+111
View File
@@ -0,0 +1,111 @@
"""In-memory directory with the same rules as the real one, for tests (AD-1)."""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from .base import (
WRITABLE_ATTRIBUTES,
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
_FIELD = {
"givenName": "given_name",
"sn": "surname",
"displayName": "display_name",
"mail": "mail",
"telephoneNumber": "telephone",
"mobile": "mobile",
}
DEFAULT_POLICY = PasswordPolicy(
min_length=8,
complexity=True,
history_length=24,
min_age=timedelta(0),
max_age=timedelta(days=90),
)
def _character_classes(password: str) -> int:
tests = (str.isupper, str.islower, str.isdigit, lambda c: not c.isalnum())
return sum(any(test(c) for c in password) for test in tests)
class FakeDirectory:
def __init__(self, policy: PasswordPolicy = DEFAULT_POLICY) -> None:
self._users: dict[str, DirectoryUser] = {}
self._passwords: dict[str, str] = {}
self._history: dict[str, list[str]] = {}
self.policy = policy
self.unavailable = False # set True to simulate an outage
def add_user(self, sam: str, password: str = "Correct-Horse-9", **fields) -> DirectoryUser: # noqa: S107
fields.setdefault("password_last_set", datetime.now(UTC))
user = DirectoryUser(sam=sam, **fields)
self._users[sam.casefold()] = user
self._passwords[sam.casefold()] = password
self._history[sam.casefold()] = [password]
return user
def _check_up(self) -> None:
if self.unavailable:
raise DirectoryUnavailable("The directory is not available")
def _get(self, sam: str) -> DirectoryUser:
user = self._users.get(sam.casefold()) if sam else None
if user is None:
raise ValidationFailed("The user does not exist in the configured OU")
return user
def find_user(self, sam: str) -> DirectoryUser | None:
self._check_up()
return self._users.get(sam.casefold()) if sam else None
def verify_password(self, sam: str, password: str) -> bool:
self._check_up()
if not password or not sam:
return False
user = self._users.get(sam.casefold())
return user is not None and self._passwords[sam.casefold()] == password
def password_policy(self, sam: str) -> PasswordPolicy:
self._check_up()
self._get(sam)
return self.policy
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
self._check_up()
refused = [name for name in changes if name not in WRITABLE_ATTRIBUTES]
if refused:
raise Forbidden(f"Attributes are not writable: {', '.join(sorted(refused))}")
user = self._get(sam)
values = {}
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[_FIELD[name]] = value or ""
self._users[sam.casefold()] = replace(user, **values)
def change_password(self, sam: str, current: str, new: str) -> None:
self._check_up()
user = self._get(sam)
key = sam.casefold()
if not current or self._passwords[key] != current:
raise PasswordRejected("wrong_current")
age = datetime.now(UTC) - user.password_last_set if user.password_last_set else None
too_young = age is not None and age < self.policy.min_age
weak = len(new) < self.policy.min_length or (
self.policy.complexity and _character_classes(new) < 3
)
reused = new in self._history[key][-self.policy.history_length :]
if not new or too_young or weak or reused:
raise PasswordRejected("policy")
self._passwords[key] = new
self._history[key].append(new)
self._users[key] = replace(user, password_last_set=datetime.now(UTC))
+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"