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