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
This commit is contained in:
2026-10-18 09:55:10 +02:00
co-authored by Claude Sonnet 5.5
parent ead56709bc
commit b87ffae94c
7 changed files with 24 additions and 1 deletions
+8
View File
@@ -33,6 +33,8 @@ POLICY_ATTRIBUTES = (
"minPwdAge",
"maxPwdAge",
"lockoutDuration",
"lockoutThreshold",
"lockoutObservationWindow",
)
PSO_ATTRIBUTES = (
"msDS-MinimumPasswordLength",
@@ -40,6 +42,8 @@ PSO_ATTRIBUTES = (
"msDS-PasswordHistoryLength",
"msDS-MinimumPasswordAge",
"msDS-MaximumPasswordAge",
"msDS-LockoutThreshold",
"msDS-LockoutObservationWindow",
)
@@ -96,6 +100,10 @@ class PasswordPolicy:
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):
+2
View File
@@ -29,6 +29,8 @@ DEFAULT_POLICY = PasswordPolicy(
history_length=24,
min_age=timedelta(0),
max_age=timedelta(days=90),
lockout_threshold=5,
lockout_window=timedelta(minutes=30),
)
@@ -218,6 +218,8 @@ class LdapDirectory:
history_length=int(_text(domain, "pwdHistoryLength") or 0),
min_age=_interval(_text(domain, "minPwdAge")) or timedelta(0),
max_age=_interval(_text(domain, "maxPwdAge")),
lockout_threshold=int(_text(domain, "lockoutThreshold") or 0),
lockout_window=_interval(_text(domain, "lockoutObservationWindow")),
)
pso_dn = _text(entry[1], "msDS-ResultantPSO")
if not pso_dn:
@@ -234,6 +236,8 @@ class LdapDirectory:
min_age=_interval(_text(attrs, "msDS-MinimumPasswordAge")) or timedelta(0),
max_age=_interval(_text(attrs, "msDS-MaximumPasswordAge")),
fine_grained=True,
lockout_threshold=int(_text(attrs, "msDS-LockoutThreshold") or 0),
lockout_window=_interval(_text(attrs, "msDS-LockoutObservationWindow")),
)
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None: