domain: Write lockout state only through narrow atomic repo ops #86

Merged
rosa merged 1 commit from domain/lockout-narrow-atomic-repo-ops into main 2026-07-04 04:03:21 +00:00
Owner

Implements #56 per ADR 0011 and the Authentication vocabulary in CONTEXT.md.

What changed

The two lock columns (failed_login_attempts, locked_until) are now written by exactly two repository operations and nothing else:

  • record_failed_login(user_id, &LockoutPolicy) — one atomic act (a single UPDATE in the Postgres adapter) that counts the failure and, on reaching the threshold, enters the Lockout and restarts the count in the same statement. Reports a LockoutOutcome: Counted { attempts } or Locked { until }.
  • clear_lockout(user_id) — zeroes the count and lifts the Lockout; called unconditionally on every successful login. Idempotent.

UpdateUserRequest loses the lock-column fields entirely, so no profile round-trip can clobber Lockout state — the rule is a compile-time fact, not discipline. The old increment_login_attempts port op is removed, and its now-infallible TryFrom<User> became From<User>.

New domain types alongside User: LockoutPolicy (threshold + duration, Default of 10 attempts / 15 minutes, held by the service, no app-config surface) and LockoutOutcome.

Behavior changes (deliberate, per ADR 0011)

  • The attempt that crosses the threshold returns UserLocked(until) instead of reading as one more wrong password.
  • Confirming an email no longer clears the Lockout as a side effect.

Tests

  • lockout_repository_conformance.rs pins both adapters to the same semantics in the established both_adapters! pattern: below-threshold counting, the crossing recording (expiry ≈ now + duration, count restarted), idempotent clearing, and update_user leaving both a mid-count and a locked user untouched.
  • Service tests cover the crossing attempt reporting the lock, the pre-verification gate refusing a locked user even with the correct password, a successful login clearing the count, and confirm_email leaving a Lockout in place.

The ci mise task passes; the sqlx offline cache is regenerated.

Closes #56

Implements #56 per ADR 0011 and the Authentication vocabulary in CONTEXT.md. ## What changed The two lock columns (`failed_login_attempts`, `locked_until`) are now written by exactly two repository operations and nothing else: - **`record_failed_login(user_id, &LockoutPolicy)`** — one atomic act (a single `UPDATE` in the Postgres adapter) that counts the failure and, on reaching the threshold, enters the Lockout and restarts the count in the same statement. Reports a `LockoutOutcome`: `Counted { attempts }` or `Locked { until }`. - **`clear_lockout(user_id)`** — zeroes the count and lifts the Lockout; called unconditionally on every successful login. Idempotent. `UpdateUserRequest` loses the lock-column fields entirely, so no profile round-trip can clobber Lockout state — the rule is a compile-time fact, not discipline. The old `increment_login_attempts` port op is removed, and its now-infallible `TryFrom<User>` became `From<User>`. New domain types alongside `User`: `LockoutPolicy` (threshold + duration, `Default` of 10 attempts / 15 minutes, held by the service, no app-config surface) and `LockoutOutcome`. ## Behavior changes (deliberate, per ADR 0011) - The attempt that crosses the threshold returns `UserLocked(until)` instead of reading as one more wrong password. - Confirming an email no longer clears the Lockout as a side effect. ## Tests - `lockout_repository_conformance.rs` pins both adapters to the same semantics in the established `both_adapters!` pattern: below-threshold counting, the crossing recording (expiry ≈ now + duration, count restarted), idempotent clearing, and `update_user` leaving both a mid-count and a locked user untouched. - Service tests cover the crossing attempt reporting the lock, the pre-verification gate refusing a locked user even with the correct password, a successful login clearing the count, and `confirm_email` leaving a Lockout in place. The `ci` mise task passes; the sqlx offline cache is regenerated. Closes #56
domain: Write lockout state only through narrow atomic repo ops
Some checks failed
ci/woodpecker/pr/test Pipeline was canceled
ci/woodpecker/push/test Pipeline was successful
5831f565c8
authenticate raced itself and profile edits: it incremented the
failed-login counter atomically, but decided the lock from a
pre-increment User snapshot and wrote it back through the whole-row
update_user. Concurrent failures around the threshold could leave the
account unlocked with the counter reset, and any login's full-row write
could revert a concurrent profile edit (issue #56, ADR 0011).

The two lock columns are now written by exactly two repository
operations: record_failed_login, a single statement that counts the
failure and, on reaching the LockoutPolicy threshold, enters the
Lockout and restarts the count in the same act; and clear_lockout,
called unconditionally on successful login. UpdateUserRequest loses the
lock-column fields entirely, so profile writes cannot clobber lockout
state by construction, and increment_login_attempts is removed.

Behavior changes per ADR 0011: the attempt that crosses the threshold
reports UserLocked(until) instead of one more IncorrectPassword, and
confirming an email no longer clears the lockout as a side effect.

A lockout conformance suite pins both adapters to the same semantics,
and service tests cover the crossing attempt, the pre-verification
gate, clearing on success, and confirm_email leaving the lock alone.

Closes #56
rosa merged commit ead93fd0e1 into main 2026-07-04 04:03:21 +00:00
rosa deleted branch domain/lockout-narrow-atomic-repo-ops 2026-07-04 04:03:21 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier!86
No description provided.