Skip to content

fix: hold an account to the password lifetime policy when it is edited - #828

Merged
blaipr merged 1 commit into
mainfrom
fix/a-password-lifetime-cap-survives-an-edit
Aug 19, 2026
Merged

fix: hold an account to the password lifetime policy when it is edited#828
blaipr merged 1 commit into
mainfrom
fix/a-password-lifetime-cap-survives-an-edit

Conversation

@blaipr

@blaipr blaipr commented Aug 19, 2026

Copy link
Copy Markdown
Member

A password preset marked fixed sets a maximum lifetime, and checkPasswordPreset() clamps passDateChange to time() + expireTime. It is called where a password is being set — create, copy, edit-pass, on both the web and the API — and nowhere else.

Editing an account writes passDateChange just the same, from a field the form offers (password_date_expire in account.inc and account_bulkedit.inc; expireDate on the API). None of those paths clamped it:

  • web ACCOUNT_EDIT — create the account under a ninety-day policy, edit it a moment later to expire in a decade;
  • web ACCOUNTMGR_BULK_EDIT — the same, to a whole selection at once;
  • API account/edit — the same, as expireDate.

Anyone with edit rights on an account could exempt it from a rotation policy the administrator had made mandatory.

Why not just call the existing check

Because it would refuse every edit. checkPasswordPreset() also runs PasswordValidator::validate(), which measures mb_strlen($password) against the preset's required length and throws — and an edit legitimately carries no password. While a fixed preset existed, editing any account would have failed.

So the clamp becomes its own method, checkPasswordExpiry(), and the edit paths call that. checkPasswordPreset() delegates to the same clamp, so the paths that already enforced it are untouched — its twelve existing tests pass unchanged.

expects(never())->method('validate') in the new test is holding that distinction, and it is as much the point as the clamp:

removing the clamp        -> Failed asserting that 1820534228 is … less than 1788998228
validating on this path   -> PasswordValidator::validate(…, ''): was not expected to be called

Three things the full suite caught

All mine, all from the new call site: two PHPStan complaints (@throws naming classes in the wrong namespace, and the widened AccountDto reaching AccountService::update() which wants AccountUpdateDto — fixed with @template T of AccountDto), and AccountFormTest, whose mocked preset service returned a stub for the new call and broke the form's typed property.

3991 unit + 976 integration pass; PHPStan level 6 on src and PHPCS clean.

A password preset marked fixed sets a maximum lifetime, and
`checkPasswordPreset()` clamps `passDateChange` to `time() + expireTime`. It is
called where a password is being set — creating an account, copying one,
changing its password, on both the web and the API — and nowhere else.

Editing an account writes `passDateChange` just the same, from a field the form
offers (`password_date_expire`), and none of those paths clamped it: an account
created under a ninety-day policy could be edited a moment later to expire in a
decade, and bulk edit could do it to a whole selection at once. The API's
`account/edit` takes the same value as `expireDate`.

The clamp is now its own method and the edit paths call that, not the whole
check. `checkPasswordPreset()` also validates the password against the preset,
and an edit legitimately carries none — `PasswordValidator::validate()` measures
`mb_strlen('')` against the required length and throws, so calling the whole
thing there would have refused every edit while a fixed preset existed. That is
what `expects(never())->method('validate')` in the new test is holding.

`checkPasswordPreset()` delegates to the same clamp, so the paths that already
enforced it are unchanged — its existing tests pass untouched.
@blaipr
blaipr merged commit 70d62f1 into main Aug 19, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-password-lifetime-cap-survives-an-edit branch August 19, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant