Skip to content

SCRUM-259 (backend) — admin-auth self-service account endpoints

Plan ref: UI-14 (docs/11-admin-plane-plan.md) needs these; D5 governs MFA.

What exists

All under /admin-auth/account/*, bearer access token of an active account (re-read from the database), strict JSON:

Route Behaviour
POST …/password {current_password, new_password} current verified before the lock check (same work as login); a wrong one counts toward the lockout; new must pass the password policy and differ; one transaction: new hash, every other session revoked (the caller's own family is kept, found from the refresh cookie), audit account.password_changed; 204
GET …/sessions the caller's live sessions (one per rotation family): created, last used (newest rotation), ip, user agent, current
POST …/sessions/revoke-others revokes all but the current family ({revoked, current_kept}); without a cookie revokes all; audit account.sessions_revoked
POST …/mfa/recovery-codes {code} fresh TOTP required (replay-proof); 10 new codes replace the old; audit mfa.recovery_regenerated
POST …/mfa/disable {code} TOTP or recovery code; only accounts without the admin role (admin → 403 mfa_required, root → 403 root_protected, not enrolled → 409); clears factor, codes, tickets; audit mfa.disabled

Limit worth knowing: revoking a session stops its refresh token at once, but an access token it already holds stays valid until it expires (15 minutes), as for logout.

How to verify

cd services/admin_auth
ADMIN_AUTH_TEST_DATABASE_URL=postgres://auth_rw:pw@127.0.0.1:5433/admin_auth_test?sslmode=disable \
  go test -race -count=1 ./...

internal/server/account_test.go covers every row above incl. lockout, the current session surviving a password change while others are refused, replayed TOTP, old recovery codes dying, and 401s for missing/invalid tokens and disabled accounts.

GET /admin-auth/me also returns is_root and mfa_enabled, read from the row, so the account page can show the factor state without calling enroll (which would write a new pending secret on every visit). internal/server/session_test.go:TestMeReadsTheUserFromTheDatabase checks both are present and that mfa_enabled flips once totp_confirmed_at is set.

Results at time of writing

  • gofmt, go vet, go test -race with the DB (9 packages): pass.

How it was built

DeepSeek run scoped (Landlock) to services/admin_auth (256 s, ~38k output tokens, reasoning effort low). Claude review: password/lockout order and the revocation transaction checked. Added by Claude while building the UI: the two /me fields.