fix(auth): skip the per-request bunyip userinfo round-trip (PMS-723) #487

Merged
longjacksonle merged 2 commits from fix/PMS-713-skip-userinfo-per-request into main 2026-08-03 03:42:03 +02:00

PMS-723: skip the per-request bunyip /oauth2/userinfo round-trip

Split from PMS-713 (the "clicking Refresh freezes the UI" report). A browser Performance capture proved the freeze is not a Dioxus render issue: ~3.6s total with only ~16ms scripting and an idle main thread on a 1-ticket dataset - pure I/O wait.

Root cause

The Bunyip-as-OP Resource-Server path (src/modules/auth/middleware.rs) called bunyip's /oauth2/userinfo over the network on every authenticated request. ensure_user_from_bunyip ran unconditionally after the at+jwt verified (JWKS is cached, so that part was fine) and always awaited verifier.userinfo(bearer) - no "user already exists" short-circuit. userinfo is only needed to JIT-provision a first-sight user, back-fill a user stuck in the legacy default tenant, or match a pending invite. For an already-provisioned, placed user it's a wasted round-trip on every request, so the dashboard (which fires /reports/dashboard, /tickets, /time-entries, /projects, ...) stalled for seconds. App-wide latency, not a UI bug.

Fix

bunyip_userinfo_needed gates the fetch: resolve the user by sub from local state and skip the hop when they exist, are placed in a real tenant, and have no pending invite (matched on their local verified email). Only fetch on first-sight JIT, stuck-in-default back-fill, or a pending invite. place_bunyip_user already resolves an existing user from local state with None email/name while still running the PMS-698 principal gate and the role reconcile, so the fast path passes None. All the checks in the gate are local DB reads - cheap next to the network round-trip they remove.

Behavior preserved

  • First-sight JIT provisioning still fetches userinfo (email/name).
  • Pending invites still honored (gate matches on the local verified email; a match takes the full path).
  • Stuck-in-default back-fill (PMS-245) still takes the full path.
  • The PMS-698 principal gate (deactivated user / suspended tenant) still runs on every request.
  • MAPPS-335 userinfo/sub-binding guard unchanged on the path that still fetches.

Tests

tests/bunyip_login.rs: gate skips for an existing placed user; still fetches for first-sight and for a pending-invite match; and place_bunyip_user resolves an existing user with no userinfo (fast path) keeping tenant + cached name. Full bunyip_login (20), bunyip_principal_gate + auth (36+2) suites pass; clippy -D warnings and fmt clean.

Not yet verified live; the acceptance item on PMS-723 is confirming the dashboard Refresh no longer stalls after deploy.

## PMS-723: skip the per-request bunyip `/oauth2/userinfo` round-trip Split from PMS-713 (the "clicking Refresh freezes the UI" report). A browser Performance capture proved the freeze is **not** a Dioxus render issue: ~3.6s total with only ~16ms scripting and an idle main thread on a 1-ticket dataset - pure I/O wait. ### Root cause The Bunyip-as-OP Resource-Server path (`src/modules/auth/middleware.rs`) called bunyip's `/oauth2/userinfo` over the network on **every** authenticated request. `ensure_user_from_bunyip` ran unconditionally after the at+jwt verified (JWKS *is* cached, so that part was fine) and always awaited `verifier.userinfo(bearer)` - no "user already exists" short-circuit. userinfo is only needed to JIT-provision a first-sight user, back-fill a user stuck in the legacy default tenant, or match a pending invite. For an already-provisioned, placed user it's a wasted round-trip on every request, so the dashboard (which fires `/reports/dashboard`, `/tickets`, `/time-entries`, `/projects`, ...) stalled for seconds. App-wide latency, not a UI bug. ### Fix `bunyip_userinfo_needed` gates the fetch: resolve the user by `sub` from local state and skip the hop when they exist, are placed in a real tenant, and have no pending invite (matched on their **local** verified email). Only fetch on first-sight JIT, stuck-in-default back-fill, or a pending invite. `place_bunyip_user` already resolves an existing user from local state with `None` email/name while still running the PMS-698 principal gate and the role reconcile, so the fast path passes `None`. All the checks in the gate are local DB reads - cheap next to the network round-trip they remove. ### Behavior preserved - First-sight JIT provisioning still fetches userinfo (email/name). - Pending invites still honored (gate matches on the local verified email; a match takes the full path). - Stuck-in-default back-fill (PMS-245) still takes the full path. - The PMS-698 principal gate (deactivated user / suspended tenant) still runs on **every** request. - MAPPS-335 userinfo/sub-binding guard unchanged on the path that still fetches. ### Tests `tests/bunyip_login.rs`: gate skips for an existing placed user; still fetches for first-sight and for a pending-invite match; and `place_bunyip_user` resolves an existing user with no userinfo (fast path) keeping tenant + cached name. Full `bunyip_login` (20), `bunyip_principal_gate` + `auth` (36+2) suites pass; `clippy -D warnings` and `fmt` clean. Not yet verified live; the acceptance item on PMS-723 is confirming the dashboard Refresh no longer stalls after deploy.
The Bunyip-as-OP Resource-Server path called bunyip's /oauth2/userinfo over the network on EVERY authenticated request: `ensure_user_from_bunyip` ran unconditionally after the at+jwt verified (JWKS is cached, so that part was fine) and always awaited `verifier.userinfo(bearer)`, with no "user already exists" short-circuit. userinfo is only needed to JIT-provision a first-sight user, back-fill a user stuck in the legacy default tenant, or match a pending invite - all keyed on the IdP email/name. For an already-provisioned, placed user it is a wasted round-trip paid on every request, so a page that fires several API calls (the dashboard) stalled for seconds waiting on bunyip. A browser Performance capture on PMS-713 proved this: ~3.6s total with ~16ms scripting and an idle main thread on a 1-ticket dataset - pure I/O wait, not a Dioxus render freeze.

Gate the fetch with `bunyip_userinfo_needed`: resolve the user by sub from local state and skip the hop when they exist, are placed in a real tenant, and have no pending invite (matched on their LOCAL verified email); only fetch on first-sight JIT, stuck-in-default back-fill, or a pending invite. `place_bunyip_user` already resolves an existing user from local state with `None` email/name while still running the PMS-698 principal gate and the role reconcile, so the fast path passes `None`. Removes a network hop from every authenticated request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9DhtRyWubFuzzKohE3JBt
test(auth): cover the userinfo short-circuit gate (PMS-723)
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m42s
E2E / Playwright against staging (pull_request) Successful in 2m52s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 4m48s
61f3b5a816
Assert `bunyip_userinfo_needed` skips the round-trip for an already-placed user with no invite, still fetches for a first-sight user and when a pending invite matches the user's verified email, and that place_bunyip_user resolves an existing user with no userinfo (the fast path) keeping their tenant and cached name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9DhtRyWubFuzzKohE3JBt
longjacksonle deleted branch fix/PMS-713-skip-userinfo-per-request 2026-08-03 03:42:03 +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!487
No description provided.