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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user