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
+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))