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:
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user