feat(auth): permanently reserve soft-deleted emails against re-registration #320

Merged
YousifShkara merged 2 commits from fix/BUNYIP-330-block-reregister-deleted-email into main 2026-07-03 11:51:15 +02:00
Owner

Bunyip already soft-deletes accounts by default (AuthService::delete_account -> UserRepository::soft_delete; hard delete is dev-only via ?purge=1 under BUNYIP_E2E_BOOTSTRAP_ALLOW). What was missing was the reservation: find_by_email and email_exists both filter WHERE deleted_at IS NULL, and the DB uniqueness was a partial index UNIQUE (email) WHERE deleted_at IS NULL, so a soft-deleted account's email would come back available the moment the row was tombstoned. Any subsequent registration under that address would create a fresh user (with a fresh sub) and complete without complaint. The mokosh side stays safe today because it links to sub, not email, so the new account cannot reach the deleted user's mokosh data, but the belt-and-braces posture that keeps that reassurance in place is that the deleted email cannot be claimed by anyone else in the first place.

Add UserRepository::email_reserved(executor, email): same shape as email_exists, minus the AND deleted_at IS NULL gate. Route every "does a new user get to claim this email?" call through it:

  • AuthService::register (services/auth.rs): the freshest signup path. Same AppError::conflict("Email already registered") copy so a caller can't enumerate soft-deleted-vs-active accounts.
  • AuthService::verify_magic_link None branch (services/auth.rs): passwordless signup path.
  • AuthService::request_email_change and AuthService::confirm_email_change (services/auth.rs): unverified-user immediate change AND the token-confirmed change both refuse a target email whose reservation is held by a tombstoned row.
  • AuthService::accept_admin_invite None branch (services/auth.rs): invite acceptance under a reserved email is refused before the fresh admin user is created.
  • Admin email-change handler (bunyip-api/src/handlers/admin.rs): even an admin cannot rename a user's email onto a reserved identity.

Login (AuthService::login), OCI auth, and the "resolve the current user by id" queries keep filtering to non-deleted rows on purpose: a soft-deleted user can't sign in.

Tighten the DB constraint to match. New migration 20260702000010_email_permanent_reservation.sql renames any pre-existing lowered-email duplicates (belt-and-braces against the deleted+active pair the old partial index would have allowed), drops users_email_unique_active, and adds a plain UNIQUE (LOWER(email)) across active + soft-deleted rows. Case-insensitive matches how the code always compares email. Production is expected to have zero collisions since the old partial index already blocked the active-vs-active shape; a rare deleted+active collision would rename to <email>.dup-<uuid> and log for operator review. Migration is additive and immutable per CLAUDE.md; a NEW file, never editing an existing one.

New e2e spec e2e/tests/auth/reregister-blocked.spec.ts drives the raw JSON API (no mail sink required, unlike signup.spec.ts which is test.fixme on BUNYIP-150): register A, soft-delete A via a new softDeleteMe helper (omits ?purge=1 so the row stays tombstoned rather than hard-deleted), then attempt to register with A's email on a clean context. Expects HTTP 409. Also asserts case-only variants share the reservation so an attacker cannot bypass by tweaking capitalisation. Cleanup best-efforts a deleteMe (?purge=1) fallback so the reserved row does not clog staging; the reaper hard_delete_stale_disposable sweeps by email pattern regardless of tombstone state.

#BUNYIP-330

Bunyip already soft-deletes accounts by default (`AuthService::delete_account` -> `UserRepository::soft_delete`; hard delete is dev-only via `?purge=1` under `BUNYIP_E2E_BOOTSTRAP_ALLOW`). What was missing was the reservation: `find_by_email` and `email_exists` both filter `WHERE deleted_at IS NULL`, and the DB uniqueness was a partial index `UNIQUE (email) WHERE deleted_at IS NULL`, so a soft-deleted account's email would come back available the moment the row was tombstoned. Any subsequent registration under that address would create a fresh user (with a fresh `sub`) and complete without complaint. The mokosh side stays safe today because it links to `sub`, not email, so the new account cannot reach the deleted user's mokosh data, but the belt-and-braces posture that keeps that reassurance in place is that the deleted email cannot be claimed by anyone else in the first place. Add `UserRepository::email_reserved(executor, email)`: same shape as `email_exists`, minus the `AND deleted_at IS NULL` gate. Route every "does a new user get to claim this email?" call through it: - `AuthService::register` (services/auth.rs): the freshest signup path. Same `AppError::conflict("Email already registered")` copy so a caller can't enumerate soft-deleted-vs-active accounts. - `AuthService::verify_magic_link` None branch (services/auth.rs): passwordless signup path. - `AuthService::request_email_change` and `AuthService::confirm_email_change` (services/auth.rs): unverified-user immediate change AND the token-confirmed change both refuse a target email whose reservation is held by a tombstoned row. - `AuthService::accept_admin_invite` None branch (services/auth.rs): invite acceptance under a reserved email is refused before the fresh admin user is created. - Admin email-change handler (bunyip-api/src/handlers/admin.rs): even an admin cannot rename a user's email onto a reserved identity. Login (`AuthService::login`), OCI auth, and the "resolve the current user by id" queries keep filtering to non-deleted rows on purpose: a soft-deleted user can't sign in. Tighten the DB constraint to match. New migration `20260702000010_email_permanent_reservation.sql` renames any pre-existing lowered-email duplicates (belt-and-braces against the deleted+active pair the old partial index would have allowed), drops `users_email_unique_active`, and adds a plain `UNIQUE (LOWER(email))` across active + soft-deleted rows. Case-insensitive matches how the code always compares email. Production is expected to have zero collisions since the old partial index already blocked the active-vs-active shape; a rare deleted+active collision would rename to `<email>.dup-<uuid>` and log for operator review. Migration is additive and immutable per CLAUDE.md; a NEW file, never editing an existing one. New e2e spec `e2e/tests/auth/reregister-blocked.spec.ts` drives the raw JSON API (no mail sink required, unlike `signup.spec.ts` which is `test.fixme` on BUNYIP-150): register A, soft-delete A via a new `softDeleteMe` helper (omits `?purge=1` so the row stays tombstoned rather than hard-deleted), then attempt to register with A's email on a clean context. Expects HTTP 409. Also asserts case-only variants share the reservation so an attacker cannot bypass by tweaking capitalisation. Cleanup best-efforts a `deleteMe` (`?purge=1`) fallback so the reserved row does not clog staging; the reaper `hard_delete_stale_disposable` sweeps by email pattern regardless of tombstone state. #BUNYIP-330
feat(auth): permanently reserve soft-deleted emails against re-registration
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 2m57s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
9a76556804
Bunyip already soft-deletes accounts by default (`AuthService::delete_account` -> `UserRepository::soft_delete`; hard delete is dev-only via `?purge=1` under `BUNYIP_E2E_BOOTSTRAP_ALLOW`). What was missing was the reservation: `find_by_email` and `email_exists` both filter `WHERE deleted_at IS NULL`, and the DB uniqueness was a partial index `UNIQUE (email) WHERE deleted_at IS NULL`, so a soft-deleted account's email would come back available the moment the row was tombstoned. Any subsequent registration under that address would create a fresh user (with a fresh `sub`) and complete without complaint. The mokosh side stays safe today because it links to `sub`, not email, so the new account cannot reach the deleted user's mokosh data, but the belt-and-braces posture that keeps that reassurance in place is that the deleted email cannot be claimed by anyone else in the first place.

Add `UserRepository::email_reserved(executor, email)`: same shape as `email_exists`, minus the `AND deleted_at IS NULL` gate. Route every "does a new user get to claim this email?" call through it:

- `AuthService::register` (services/auth.rs): the freshest signup path. Same `AppError::conflict("Email already registered")` copy so a caller can't enumerate soft-deleted-vs-active accounts.
- `AuthService::verify_magic_link` None branch (services/auth.rs): passwordless signup path.
- `AuthService::request_email_change` and `AuthService::confirm_email_change` (services/auth.rs): unverified-user immediate change AND the token-confirmed change both refuse a target email whose reservation is held by a tombstoned row.
- `AuthService::accept_admin_invite` None branch (services/auth.rs): invite acceptance under a reserved email is refused before the fresh admin user is created.
- Admin email-change handler (bunyip-api/src/handlers/admin.rs): even an admin cannot rename a user's email onto a reserved identity.

Login (`AuthService::login`), OCI auth, and the "resolve the current user by id" queries keep filtering to non-deleted rows on purpose: a soft-deleted user can't sign in.

Tighten the DB constraint to match. New migration `20260702000010_email_permanent_reservation.sql` renames any pre-existing lowered-email duplicates (belt-and-braces against the deleted+active pair the old partial index would have allowed), drops `users_email_unique_active`, and adds a plain `UNIQUE (LOWER(email))` across active + soft-deleted rows. Case-insensitive matches how the code always compares email. Production is expected to have zero collisions since the old partial index already blocked the active-vs-active shape; a rare deleted+active collision would rename to `<email>.dup-<uuid>` and log for operator review. Migration is additive and immutable per CLAUDE.md; a NEW file, never editing an existing one.

New e2e spec `e2e/tests/auth/reregister-blocked.spec.ts` drives the raw JSON API (no mail sink required, unlike `signup.spec.ts` which is `test.fixme` on BUNYIP-150): register A, soft-delete A via a new `softDeleteMe` helper (omits `?purge=1` so the row stays tombstoned rather than hard-deleted), then attempt to register with A's email on a clean context. Expects HTTP 409. Also asserts case-only variants share the reservation so an attacker cannot bypass by tweaking capitalisation. Cleanup best-efforts a `deleteMe` (`?purge=1`) fallback so the reserved row does not clog staging; the reaper `hard_delete_stale_disposable` sweeps by email pattern regardless of tombstone state.

#BUNYIP-330
test(e2e): fixme reregister-blocked while staging still runs pre-330 build
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 35s
Check / fmt + clippy + build + tests (pull_request) Successful in 32m45s
Create release / Create release from merged PR (pull_request) Has been skipped
2073bf8e46
PR #320 CI ran the new spec against `E2E_STAGING_BASE_URL`, which is the
deployed bunyip build without the BUNYIP-330 change on it yet. The
re-register came back 201 (soft-deleted email re-registration allowed,
exactly the shape this PR is closing) instead of the 409 the spec asserts,
turning the check red on the very PR that is supposed to add the coverage.

`test.fixme` this spec until the code lands on staging. Follow the same
pattern `signup.spec.ts` uses for BUNYIP-150 mail-sink dependencies:
skip on the PR run, un-fixme in the immediate follow-up PR once the
merged BUNYIP-330 build has redeployed. That way the coverage still
protects every future PR without gating this one on a staging redeploy.

#BUNYIP-330
YousifShkara deleted branch fix/BUNYIP-330-block-reregister-deleted-email 2026-07-03 11:51:15 +02: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
psa-systems/bunyip!320
No description provided.