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