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
+114
View File
@@ -0,0 +1,114 @@
"""A stand-in for the `ldap` module that records calls. Real python-ldap needs a C library."""
from types import SimpleNamespace
class LDAPError(Exception):
pass
class SERVER_DOWN(LDAPError): # noqa: N801
pass
class INVALID_CREDENTIALS(LDAPError): # noqa: N801
pass
class INSUFFICIENT_ACCESS(LDAPError): # noqa: N801
pass
class CONSTRAINT_VIOLATION(LDAPError): # noqa: N801
pass
class UNWILLING_TO_PERFORM(LDAPError): # noqa: N801
pass
class INVALID_SYNTAX(LDAPError): # noqa: N801
pass
def escape_filter_chars(value):
table = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00"}
return "".join(table.get(c, c) for c in value)
class Connection:
def __init__(self, world, url):
self.world = world
self.url = url
self.options = {}
self.bound = None
def set_option(self, name, value):
self.options[name] = value
def simple_bind_s(self, dn, password):
if self.world.down:
raise SERVER_DOWN()
if self.world.passwords.get(dn) != password:
raise INVALID_CREDENTIALS()
self.bound = dn
def unbind_s(self):
pass
def search_s(self, base, scope, flt, attrs):
self.world.searches.append((base, scope, flt, tuple(attrs)))
return self.world.respond(base, scope, flt)
def modify_s(self, dn, mods):
self.world.modifications.append((dn, mods))
if self.world.modify_error:
raise self.world.modify_error
class World:
"""The server side: canned search answers and the connections the adapter opened."""
def __init__(self):
self.down = False
self.passwords = {"svc@example.test": "service-password"}
self.searches = []
self.modifications = []
self.modify_error = None
self.connections = []
self.entries = {} # (base, scope) -> list of (dn, attrs)
def respond(self, base, scope, flt):
return self.entries.get((base, scope), [])
def build(world):
def initialize(url):
conn = Connection(world, url)
world.connections.append(conn)
return conn
return SimpleNamespace(
initialize=initialize,
LDAPError=LDAPError,
SERVER_DOWN=SERVER_DOWN,
INVALID_CREDENTIALS=INVALID_CREDENTIALS,
INSUFFICIENT_ACCESS=INSUFFICIENT_ACCESS,
CONSTRAINT_VIOLATION=CONSTRAINT_VIOLATION,
UNWILLING_TO_PERFORM=UNWILLING_TO_PERFORM,
INVALID_SYNTAX=INVALID_SYNTAX,
filter=SimpleNamespace(escape_filter_chars=escape_filter_chars),
SCOPE_BASE=0,
SCOPE_SUBTREE=2,
MOD_ADD=0,
MOD_DELETE=1,
MOD_REPLACE=2,
OPT_PROTOCOL_VERSION="protocol",
OPT_REFERRALS="referrals",
OPT_NETWORK_TIMEOUT="network_timeout",
OPT_TIMEOUT="timeout",
OPT_X_TLS_CACERTFILE="cacertfile",
OPT_X_TLS_REQUIRE_CERT="require_cert",
OPT_X_TLS_DEMAND="demand",
OPT_X_TLS_NEWCTX="newctx",
)