From b87ffae94ca014af0c74c72a8833b3f03f459d1b Mon Sep 17 00:00:00 2001 From: Ebbe Date: Sun, 18 Oct 2026 09:55:10 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC --- .../ARCHITECTURE-SPINE.md | 2 +- portal/adapters/directory/base.py | 8 ++++++++ portal/adapters/directory/fake.py | 2 ++ portal/adapters/directory/ldap_directory.py | 4 ++++ portal/tests/test_directory.py | 1 + portal/tests/test_directory_live.py | 1 + portal/tests/test_ldap_directory.py | 7 +++++++ 7 files changed, 24 insertions(+), 1 deletion(-) diff --git a/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md index f0915c4..c132567 100644 --- a/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md +++ b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md @@ -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 - **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 -- **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 - **Binds:** FR-5, FR-8, FR-10..FR-15, FR-21 diff --git a/portal/adapters/directory/base.py b/portal/adapters/directory/base.py index 14f2b95..06e885c 100644 --- a/portal/adapters/directory/base.py +++ b/portal/adapters/directory/base.py @@ -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): diff --git a/portal/adapters/directory/fake.py b/portal/adapters/directory/fake.py index acdd089..c0c9fb4 100644 --- a/portal/adapters/directory/fake.py +++ b/portal/adapters/directory/fake.py @@ -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), ) diff --git a/portal/adapters/directory/ldap_directory.py b/portal/adapters/directory/ldap_directory.py index 477bdfe..0384194 100644 --- a/portal/adapters/directory/ldap_directory.py +++ b/portal/adapters/directory/ldap_directory.py @@ -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: diff --git a/portal/tests/test_directory.py b/portal/tests/test_directory.py index 7da9d68..d35d471 100644 --- a/portal/tests/test_directory.py +++ b/portal/tests/test_directory.py @@ -86,6 +86,7 @@ def test_password_policy_is_returned(directory): policy = directory.password_policy("alice") assert policy.min_length == 8 assert policy.min_age == timedelta(0) + assert policy.lockout_threshold == 5 def test_password_change_needs_the_current_password(directory): diff --git a/portal/tests/test_directory_live.py b/portal/tests/test_directory_live.py index 833ddbd..19b2c79 100644 --- a/portal/tests/test_directory_live.py +++ b/portal/tests/test_directory_live.py @@ -70,6 +70,7 @@ def test_other_attributes_are_refused(live): def test_password_policy_is_returned(live): policy = live.password_policy("ptest-user1") assert policy.min_length >= 0 + assert policy.lockout_threshold >= 0 # 0: the domain never locks accounts def test_verify_password(live): diff --git a/portal/tests/test_ldap_directory.py b/portal/tests/test_ldap_directory.py index e185bd4..295a4b4 100644 --- a/portal/tests/test_ldap_directory.py +++ b/portal/tests/test_ldap_directory.py @@ -77,6 +77,8 @@ def world(): minPwdAge=str(-864000000000), maxPwdAge=str(-77760000000000), 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.max_age == timedelta(days=90) 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): @@ -216,6 +220,8 @@ def test_a_fine_grained_policy_is_used_when_readable(directory, world): "msDS-PasswordHistoryLength": "5", "msDS-MinimumPasswordAge": "0", "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") 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.lockout_threshold == 3 and policy.lockout_window == timedelta(minutes=10) def test_an_unreadable_fine_grained_policy_is_flagged_not_hidden(directory, world, monkeypatch):