Files
ping-mee-self-service-platform/portal/adapters/directory/base.py
T
ebbe.bassandClaude Sonnet 5.5 96d3e77161 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
2026-10-17 18:28:50 +02:00

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."""