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
118 lines
3.4 KiB
Python
118 lines
3.4 KiB
Python
"""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."""
|