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
@@ -45,7 +45,7 @@ Arrows are the only allowed dependency direction. Views never call adapters. Fea
### AD-2 — Directory access is allow-listed and injection-proof ### AD-2 — Directory access is allow-listed and injection-proof
- **Binds:** FR-1, FR-5, FR-8, FR-9, FR-10, FR-14, FR-20; NFR §8.1 - **Binds:** FR-1, FR-5, FR-8, FR-9, FR-10, FR-14, FR-20; NFR §8.1
- **Prevents:** LDAP injection, writes outside the OU, writes of unlisted attributes, the service account used anywhere else - **Prevents:** LDAP injection, writes outside the OU, writes of unlisted attributes, the service account used anywhere else
- **Rule:** the Service Account is used only inside `directory`. Filters are built only with LDAP filter escaping; DNs are never built from input — the DN is found by searching `sAMAccountName` inside the configured OU. Writable attributes are a fixed allow-list: `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`, `unicodePwd`. Readable attributes are also a fixed allow-list: the writable ones, `sAMAccountName`, `memberOf` (or a group-membership query), `thumbnailPhoto`, `pwdLastSet`, the account's enabled and locked state, and the Password Policy (the domain's `minPwdLength`, `pwdProperties`, `pwdHistoryLength`, `minPwdAge`, `maxPwdAge`, and the user's `msDS-ResultantPSO` when a fine-grained policy applies). The portal reads only the attributes on this list. The Service Account is given no rights beyond the writes above and the Change Password right; its read access is the Active Directory default for authenticated accounts and is not narrowed further. Everything runs over LDAPS with certificate verification against a CA bundle supplied by environment variable (the homelab uses an internal CA); verification is never disabled. - **Rule:** the Service Account is used only inside `directory`. Filters are built only with LDAP filter escaping; DNs are never built from input — the DN is found by searching `sAMAccountName` inside the configured OU. Writable attributes are a fixed allow-list: `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`, `unicodePwd`. Readable attributes are also a fixed allow-list: the writable ones, `sAMAccountName`, `memberOf` (or a group-membership query), `thumbnailPhoto`, `pwdLastSet`, the account's enabled and locked state, and the Password Policy (the domain's `minPwdLength`, `pwdProperties`, `pwdHistoryLength`, `minPwdAge`, `maxPwdAge`, `lockoutThreshold`, `lockoutObservationWindow`, and the user's `msDS-ResultantPSO` when a fine-grained policy applies, with its `msDS-Lockout*` values). The portal reads only the attributes on this list. The Service Account is given no rights beyond the writes above and the Change Password right; its read access is the Active Directory default for authenticated accounts and is not narrowed further. Everything runs over LDAPS with certificate verification against a CA bundle supplied by environment variable (the homelab uses an internal CA); verification is never disabled.
### AD-3 — Self-service acts on the session user only ### AD-3 — Self-service acts on the session user only
- **Binds:** FR-5, FR-8, FR-10..FR-15, FR-21 - **Binds:** FR-5, FR-8, FR-10..FR-15, FR-21
+8
View File
@@ -33,6 +33,8 @@ POLICY_ATTRIBUTES = (
"minPwdAge", "minPwdAge",
"maxPwdAge", "maxPwdAge",
"lockoutDuration", "lockoutDuration",
"lockoutThreshold",
"lockoutObservationWindow",
) )
PSO_ATTRIBUTES = ( PSO_ATTRIBUTES = (
"msDS-MinimumPasswordLength", "msDS-MinimumPasswordLength",
@@ -40,6 +42,8 @@ PSO_ATTRIBUTES = (
"msDS-PasswordHistoryLength", "msDS-PasswordHistoryLength",
"msDS-MinimumPasswordAge", "msDS-MinimumPasswordAge",
"msDS-MaximumPasswordAge", "msDS-MaximumPasswordAge",
"msDS-LockoutThreshold",
"msDS-LockoutObservationWindow",
) )
@@ -96,6 +100,10 @@ class PasswordPolicy:
max_age: timedelta | None # None: passwords do not expire max_age: timedelta | None # None: passwords do not expire
fine_grained: bool = False # a fine-grained policy applies to the user 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 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): class Directory(Protocol):
+2
View File
@@ -29,6 +29,8 @@ DEFAULT_POLICY = PasswordPolicy(
history_length=24, history_length=24,
min_age=timedelta(0), min_age=timedelta(0),
max_age=timedelta(days=90), 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), history_length=int(_text(domain, "pwdHistoryLength") or 0),
min_age=_interval(_text(domain, "minPwdAge")) or timedelta(0), min_age=_interval(_text(domain, "minPwdAge")) or timedelta(0),
max_age=_interval(_text(domain, "maxPwdAge")), 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") pso_dn = _text(entry[1], "msDS-ResultantPSO")
if not pso_dn: if not pso_dn:
@@ -234,6 +236,8 @@ class LdapDirectory:
min_age=_interval(_text(attrs, "msDS-MinimumPasswordAge")) or timedelta(0), min_age=_interval(_text(attrs, "msDS-MinimumPasswordAge")) or timedelta(0),
max_age=_interval(_text(attrs, "msDS-MaximumPasswordAge")), max_age=_interval(_text(attrs, "msDS-MaximumPasswordAge")),
fine_grained=True, 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: def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
+1
View File
@@ -86,6 +86,7 @@ def test_password_policy_is_returned(directory):
policy = directory.password_policy("alice") policy = directory.password_policy("alice")
assert policy.min_length == 8 assert policy.min_length == 8
assert policy.min_age == timedelta(0) assert policy.min_age == timedelta(0)
assert policy.lockout_threshold == 5
def test_password_change_needs_the_current_password(directory): def test_password_change_needs_the_current_password(directory):
+1
View File
@@ -70,6 +70,7 @@ def test_other_attributes_are_refused(live):
def test_password_policy_is_returned(live): def test_password_policy_is_returned(live):
policy = live.password_policy("ptest-user1") policy = live.password_policy("ptest-user1")
assert policy.min_length >= 0 assert policy.min_length >= 0
assert policy.lockout_threshold >= 0 # 0: the domain never locks accounts
def test_verify_password(live): def test_verify_password(live):
+7
View File
@@ -77,6 +77,8 @@ def world():
minPwdAge=str(-864000000000), minPwdAge=str(-864000000000),
maxPwdAge=str(-77760000000000), maxPwdAge=str(-77760000000000),
lockoutDuration=str(-18000000000), lockoutDuration=str(-18000000000),
lockoutThreshold="5",
lockoutObservationWindow=str(-18000000000),
), ),
) )
] ]
@@ -201,6 +203,8 @@ def test_password_policy_converts_the_directory_units(directory):
assert policy.min_age == timedelta(days=1) assert policy.min_age == timedelta(days=1)
assert policy.max_age == timedelta(days=90) assert policy.max_age == timedelta(days=90)
assert policy.fine_grained is False assert policy.fine_grained is False
assert policy.lockout_threshold == 5
assert policy.lockout_window == timedelta(minutes=30)
def test_a_fine_grained_policy_is_used_when_readable(directory, world): def test_a_fine_grained_policy_is_used_when_readable(directory, world):
@@ -216,6 +220,8 @@ def test_a_fine_grained_policy_is_used_when_readable(directory, world):
"msDS-PasswordHistoryLength": "5", "msDS-PasswordHistoryLength": "5",
"msDS-MinimumPasswordAge": "0", "msDS-MinimumPasswordAge": "0",
"msDS-MaximumPasswordAge": str(-(2**63)), "msDS-MaximumPasswordAge": str(-(2**63)),
"msDS-LockoutThreshold": "3",
"msDS-LockoutObservationWindow": str(-6000000000),
} }
), ),
) )
@@ -223,6 +229,7 @@ def test_a_fine_grained_policy_is_used_when_readable(directory, world):
policy = directory.password_policy("alice") policy = directory.password_policy("alice")
assert (policy.min_length, policy.complexity, policy.history_length) == (20, False, 5) assert (policy.min_length, policy.complexity, policy.history_length) == (20, False, 5)
assert policy.max_age is None and policy.fine_grained is True assert policy.max_age is None and policy.fine_grained is True
assert policy.lockout_threshold == 3 and policy.lockout_window == timedelta(minutes=10)
def test_an_unreadable_fine_grained_policy_is_flagged_not_hidden(directory, world, monkeypatch): def test_an_unreadable_fine_grained_policy_is_flagged_not_hidden(directory, world, monkeypatch):