Add self-service account deletion, DELETE /api/users/me #60

Merged
rob merged 1 commit from feat/account-deletion-endpoint into main 2026-08-05 07:10:42 +00:00
Owner

Implements task 122 under ADR-0011 / ADR-0019.

  • DELETE /api/users/me, re-confirmed by current password (IPasswordHasher.Verify). An account with no local password (a seeded one today, an OIDC-only one from task 121) can never satisfy this — see the remarks on UserEndpoints.DeleteMe. Left open deliberately; task 121 should close it.
  • AccountDeletionRepository.DeleteAsync runs ADR-0019's refuse/delete-groups/delete-memberships-and-user sequence in one transaction, with FOR UPDATE row locks taken up front to close the write-skew race docs/data-model.md names (two co-Owners of one group deleting concurrently).
  • 409 refusal names every shared group the caller solely owns. "Solely" accounts for a second accepted Owner (ADR-0054) — a co-Owner is not blocked. Traced the escape route end to end: promote-to-owner, then self-demote, then delete succeeds.
  • Refresh tokens are revoked by being deleted outright (fk_refresh_tokens_users_user_id cascades). The already-issued access token stays valid for its remaining 15 minutes exactly as ADR-0032 accepts for logout — this endpoint additionally resolves sub to a users row and refuses (404) when absent, so a second call with the same stale token can't attempt a no-op deletion.
  • AccountDeletionAtomicityTests forces the final DELETE FROM users to fail via a trigger and asserts every earlier statement in the same call (group deletion, membership deletion) rolls back with it.

No schema change and no new ADR — the FK delete behaviours and the locking strategy are already fixed by ADR-0019 and docs/data-model.md's "Account deletion" section; this is that design, built.

Implements task 122 under ADR-0011 / ADR-0019. - `DELETE /api/users/me`, re-confirmed by current password (`IPasswordHasher.Verify`). An account with no local password (a seeded one today, an OIDC-only one from task 121) can never satisfy this — see the remarks on `UserEndpoints.DeleteMe`. Left open deliberately; task 121 should close it. - `AccountDeletionRepository.DeleteAsync` runs ADR-0019's refuse/delete-groups/delete-memberships-and-user sequence in one transaction, with `FOR UPDATE` row locks taken up front to close the write-skew race `docs/data-model.md` names (two co-Owners of one group deleting concurrently). - 409 refusal names every shared group the caller solely owns. "Solely" accounts for a second accepted Owner (ADR-0054) — a co-Owner is not blocked. Traced the escape route end to end: promote-to-owner, then self-demote, then delete succeeds. - Refresh tokens are revoked by being deleted outright (`fk_refresh_tokens_users_user_id` cascades). The already-issued access token stays valid for its remaining 15 minutes exactly as ADR-0032 accepts for logout — this endpoint additionally resolves `sub` to a `users` row and refuses (404) when absent, so a second call with the same stale token can't attempt a no-op deletion. - `AccountDeletionAtomicityTests` forces the final `DELETE FROM users` to fail via a trigger and asserts every earlier statement in the same call (group deletion, membership deletion) rolls back with it. No schema change and no new ADR — the FK delete behaviours and the locking strategy are already fixed by ADR-0019 and `docs/data-model.md`'s "Account deletion" section; this is that design, built.
Add self-service account deletion, DELETE /api/users/me
All checks were successful
CI / build (pull_request) Successful in 2m15s
d93cc5b6da
rob left a comment

Verdict: mergeable

Reviewed against task 122, ADR-0005/0011/0018/0019/0028/0032/0047/0054 and docs/data-model.md. Full solution build and test suite pass under the pinned SDK (10.0.100), 851/851 tests green.

Verified by mutation, not just reading:

  • Removed FOR UPDATE OF gm from the lock query — the co-Owner concurrent-delete test reddens deterministically (20/20 runs), with a genuine assertion failure (groupGone || acceptedOwnerCount >= 1 false), not a flaky pass. The lock is real.
  • Widened the sole-inhabited-group predicate to true — two tests immediately catch the resulting cross-member data loss (AnEditorOfAGroupSharedWithAnotherOwner, ASecondAcceptedOwnerAlreadyPromoted). The "don't touch another member's group" boundary is genuinely covered, not just asserted.

Confirmed by reading: group_memberships.user_id is still RESTRICT in 0001-initial-schema.sql, unweakened; pending-Owner is correctly excluded from "another accepted Owner" in the refusal predicate (DeleteAsync_TheSoleAcceptedOwnerWithOnlyAPendingInvitee_Refuses); the promote-then-demote-then-delete escape route is traced end to end at the HTTP layer; the atomicity test forces the trigger failure after both the group deletion and the membership deletion have run, and asserts full rollback; the "no local password" gap is accurate as described — no OIDC endpoint exists yet in this codebase, and a seeded account can't sign in to reach this route at all, so it's correctly unreachable today.

Nothing to act on.

Verdict: mergeable Reviewed against task 122, ADR-0005/0011/0018/0019/0028/0032/0047/0054 and `docs/data-model.md`. Full solution build and test suite pass under the pinned SDK (10.0.100), 851/851 tests green. Verified by mutation, not just reading: - Removed `FOR UPDATE OF gm` from the lock query — the co-Owner concurrent-delete test reddens deterministically (20/20 runs), with a genuine assertion failure (`groupGone || acceptedOwnerCount >= 1` false), not a flaky pass. The lock is real. - Widened the sole-inhabited-group predicate to `true` — two tests immediately catch the resulting cross-member data loss (`AnEditorOfAGroupSharedWithAnotherOwner`, `ASecondAcceptedOwnerAlreadyPromoted`). The "don't touch another member's group" boundary is genuinely covered, not just asserted. Confirmed by reading: `group_memberships.user_id` is still `RESTRICT` in `0001-initial-schema.sql`, unweakened; pending-Owner is correctly excluded from "another accepted Owner" in the refusal predicate (`DeleteAsync_TheSoleAcceptedOwnerWithOnlyAPendingInvitee_Refuses`); the promote-then-demote-then-delete escape route is traced end to end at the HTTP layer; the atomicity test forces the trigger failure after both the group deletion and the membership deletion have run, and asserts full rollback; the "no local password" gap is accurate as described — no OIDC endpoint exists yet in this codebase, and a seeded account can't sign in to reach this route at all, so it's correctly unreachable today. Nothing to act on.
rob merged commit 5e4efcc931 into main 2026-08-05 07:10:42 +00:00
rob deleted branch feat/account-deletion-endpoint 2026-08-05 07:10:42 +00:00
rob referenced this pull request from a commit 2026-08-05 07:10:43 +00:00
Sign in to join this conversation.
No reviewers
No labels
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
rob/PlaceMark!60
No description provided.