feat(auth): return 410 Gone (ACCOUNT_DELETED) for tombstoned users on the API surface #421

Merged
YousifShkara merged 1 commit from feat/MAPPS-348-410-gone-account-deleted into main 2026-07-03 12:56:01 +02:00
Owner

PMS-591 stamps users.deleted_at when Bunyip fires the account_deleted webhook; the auth extractors already close down access via the deleted_at IS NULL gate on get_user_by_id and find_user_placement. What was missing was a distinct signal on the wire: every downstream request came back as a plain 401, indistinguishable from "your session expired, please refresh". The SPA (msp.a8n.systems) took that at face value, sat on its cached bearer, ran the token-refresh loop until it also failed, and in the meantime rendered the page-level "server unreachable" degradation with demo rows instead of telling the user their account was gone.

Add a distinct 410 path so the SPA can catch the terminal state directly (MAPPS-348).

  • Add AuthService::is_user_tombstoned(sub) -> bool: probes SELECT (deleted_at IS NOT NULL) FROM users WHERE id = $1, unscoped by tenant so the auth middleware can call it before any tenant GUC is set. Returns true only when the row physically exists AND its deleted_at is stamped; a truly missing row and an active row both return false, preserving the pre-348 401 for those cases.
  • Extend AuthState (mokosh-types) with a deleted: bool field, #[serde(default)] so any older consumer that still deserializes the shape stays backward-compatible. A new AuthState::deleted() factory sets it.
  • Teach the auth middleware to track the JWT-verified sub across both the bunyip-RS and legacy HS256 paths. If NEITHER path establishes an authenticated user AND we still have that sub, probe is_user_tombstoned; on a positive result, upgrade the extension state from the plain default to AuthState::deleted(). Missing / malformed bearer skips the probe entirely.
  • Add AppError::AccountDeleted: 410 Gone, error code ACCOUNT_DELETED, message "Account has been deleted.". Distinct from the existing Gone(String) so the SPA can pattern-match the code, not the message.
  • Refactor every auth extractor (RequireAuth, TenantScope, RequireRole, RequireModuleEnabled) through a shared user_or_auth_error helper that returns AccountDeleted when the deleted flag is set and falls through to the pre-348 Unauthorized otherwise. Ordering matters: deleted-first ensures the SPA never sees a spurious 401 for a tombstoned bearer.

Tests:

  • Three new pure-Rust tests in src/modules/auth/middleware.rs: pin the three arms of user_or_auth_error (deleted -> AccountDeleted, empty -> Unauthorized, authenticated -> user).
  • One new #[sqlx::test] in tests/auth.rs: seed admin, log in, UPDATE users SET deleted_at = NOW(), hit /api/v1/auth/me on the same bearer, assert 410 Gone with body {error:{code:"ACCOUNT_DELETED"}}. Reproduces the exact production shape PMS-591 leaves the row in.

SPA-side companion (mokosh-apps: Signal<TerminalAuthError> catches 410 in the shared fetch layer, AppLayout renders a terminal modal, 5s countdown, force-redirect to Bunyip logout) ships in a separate PR against mokosh-apps main. Rollout order is server-first: the SPA reads the 410 code, but if it lands on staging before the SPA update, older SPAs still get the same "signed out" behaviour they would have hit via the 401 path (just without the polished modal), so no regression.

#MAPPS-348

PMS-591 stamps `users.deleted_at` when Bunyip fires the `account_deleted` webhook; the auth extractors already close down access via the `deleted_at IS NULL` gate on `get_user_by_id` and `find_user_placement`. What was missing was a distinct signal on the wire: every downstream request came back as a plain 401, indistinguishable from "your session expired, please refresh". The SPA (msp.a8n.systems) took that at face value, sat on its cached bearer, ran the token-refresh loop until it also failed, and in the meantime rendered the page-level "server unreachable" degradation with demo rows instead of telling the user their account was gone. Add a distinct 410 path so the SPA can catch the terminal state directly (MAPPS-348). - Add `AuthService::is_user_tombstoned(sub) -> bool`: probes `SELECT (deleted_at IS NOT NULL) FROM users WHERE id = $1`, unscoped by tenant so the auth middleware can call it before any tenant GUC is set. Returns true only when the row physically exists AND its `deleted_at` is stamped; a truly missing row and an active row both return false, preserving the pre-348 401 for those cases. - Extend `AuthState` (mokosh-types) with a `deleted: bool` field, `#[serde(default)]` so any older consumer that still deserializes the shape stays backward-compatible. A new `AuthState::deleted()` factory sets it. - Teach the auth middleware to track the JWT-verified sub across both the bunyip-RS and legacy HS256 paths. If NEITHER path establishes an authenticated user AND we still have that sub, probe `is_user_tombstoned`; on a positive result, upgrade the extension state from the plain default to `AuthState::deleted()`. Missing / malformed bearer skips the probe entirely. - Add `AppError::AccountDeleted`: 410 Gone, error code `ACCOUNT_DELETED`, message `"Account has been deleted."`. Distinct from the existing `Gone(String)` so the SPA can pattern-match the code, not the message. - Refactor every auth extractor (`RequireAuth`, `TenantScope`, `RequireRole`, `RequireModuleEnabled`) through a shared `user_or_auth_error` helper that returns `AccountDeleted` when the deleted flag is set and falls through to the pre-348 `Unauthorized` otherwise. Ordering matters: deleted-first ensures the SPA never sees a spurious 401 for a tombstoned bearer. Tests: - Three new pure-Rust tests in `src/modules/auth/middleware.rs`: pin the three arms of `user_or_auth_error` (deleted -> AccountDeleted, empty -> Unauthorized, authenticated -> user). - One new `#[sqlx::test]` in `tests/auth.rs`: seed admin, log in, `UPDATE users SET deleted_at = NOW()`, hit `/api/v1/auth/me` on the same bearer, assert `410 Gone` with body `{error:{code:"ACCOUNT_DELETED"}}`. Reproduces the exact production shape PMS-591 leaves the row in. SPA-side companion (mokosh-apps: `Signal<TerminalAuthError>` catches 410 in the shared fetch layer, `AppLayout` renders a terminal modal, 5s countdown, force-redirect to Bunyip logout) ships in a separate PR against `mokosh-apps` main. Rollout order is server-first: the SPA reads the 410 code, but if it lands on staging before the SPA update, older SPAs still get the same "signed out" behaviour they would have hit via the 401 path (just without the polished modal), so no regression. #MAPPS-348
YousifShkara force-pushed feat/MAPPS-348-410-gone-account-deleted from b21a1a15dc
Some checks failed
E2E / Playwright against staging (pull_request) Failing after 16s
Check / fmt + clippy + build + tests (pull_request) Successful in 6m48s
Integration / integration tests (pull_request) Successful in 14m59s
to 05fdfc7170
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 39s
Check / fmt + clippy + build + tests (pull_request) Successful in 3m33s
Integration / integration tests (pull_request) Successful in 11m16s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
2026-07-03 12:43:45 +02:00
Compare
YousifShkara deleted branch feat/MAPPS-348-410-gone-account-deleted 2026-07-03 12:56:01 +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/mokosh-server!421
No description provided.