Files
ebbe.bassandClaude Sonnet 5.5 b87ffae94c feat: read the lockout threshold and window in the password policy
The password change must stay below the domain's lockout threshold. The policy now carries lockout_threshold and lockout_window, from the domain or from the fine-grained policy that applies. AD-2's read allow-list names the new attributes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-18 09:55:10 +02:00

126 lines
3.8 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",
"lockoutThreshold",
"lockoutObservationWindow",
)
PSO_ATTRIBUTES = (
"msDS-MinimumPasswordLength",
"msDS-PasswordComplexityEnabled",
"msDS-PasswordHistoryLength",
"msDS-MinimumPasswordAge",
"msDS-MaximumPasswordAge",
"msDS-LockoutThreshold",
"msDS-LockoutObservationWindow",
)
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
# Wrong passwords before AD locks the account; 0 means it never locks. Failed attempts are
# forgotten after the observation window (None: never forgotten).
lockout_threshold: int = 0
lockout_window: timedelta | None = None
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."""