Skip to content

fix(dash0): send a session that lands on an unusable org to one it can use - #346

Merged
fclairamb merged 10 commits into
mainfrom
batch/2026-09-08
Sep 8, 2026
Merged

fclairamb merged 10 commits into
mainfrom
batch/2026-09-08

Conversation

@fclairamb

Copy link
Copy Markdown
Owner

Spec 2026-09-08-01-redirect-to-an-accessible-org.

The bug

On production v0.26.0, entering the live demo from solidping.io/dash0/orgs/default/login left the visitor in /orgs/default — an org the demo user is not a member of. Every request 403s, the layout shows "Permission Denied", and that card's only button links back to the same org. A dead end with no way into the demo.

Two paths produced it. The "Try the live demo" button raced its own sign-in: login() flips isAuthenticated, the already-authenticated effect fires with the URL's org and replaces over the in-flight navigation to the demo. Spec 2026-09-07-02 silenced that effect for the ?demo flag and left the button path with it. Separately, a returning visitor still holding a demo session hit the same wall with no race at all, just by opening a plain login link.

The existing E2E caught neither: it waited for /orgs/[^/]+/, which the login page's own URL already matches, then asserted a banner that renders on every org page.

The general problem underneath

Any session can land on an org URL it cannot use — a bookmark to an org you were removed from, a colleague's link, a slug from an old email. The layout already re-mints the token when the URL org is one of yours; when it is not, the comment said "non-members fall through to the normal 403 handling". Nothing ever sent you somewhere usable, even though the client already holds the full list.

What changed

  • lib/accessible-org.ts — one pure, unit-tested helper both call sites share, so the two redirects cannot disagree: super admin → URL org; URL org if you're a member; else the session's org (which is "last accessed", since every login and switch re-mints the token); else organizations[0]; else /no-org. This mirrors what the backend already does at login time in resolveDefaultOrg.
  • Login page resolves through the helper instead of the raw URL org, which fixes both demo paths.
  • Org layout sends a non-member to the picked org's root (sub-paths don't survive an org change) with a toast, loop-guarded per org and skipped while a switch is in flight.

The audit caught a regression this introduced

The independent audit found that completeLoginAfter2FA was the one login path returning no organization list, so applyLoginResponse fell back to /auth/me — and that await ends React's auto-batch, committing a render with isAuthenticated, !isLoading and an empty list. The new effect read that as "belongs to no org" and bounced TOTP users through /no-org. Fixed at both layers: the 2FA response now carries the list like every other login shape, and isLoading is held up across the fallback so no render exposes a half-built session.

The existing 2FA E2E could not have caught it — it waits for a URL merely not containing /login, which /no-org satisfies. The new test records pushState/replaceState instead, because a final-URL assertion cannot see a redirect that a later one corrects.

Verification

  • The tightened button test failed 5 runs out of 5 pre-fix — deterministic, not timing-lucky — and passes 3/3 after.
  • A negative control for the layout guard: neutered, the two non-member tests fail and the positive controls still pass.
  • The new 2FA test confirmed failing pre-fix.
  • test@test.com turned out to be a superadmin, so the spec's suggested "sign in as the test user" would have asserted nothing; the general E2E provisions a real ordinary member and keeps the superadmin as the "URL org kept" variant.
  • Backend build/lint/test green; dash0 lint at its documented 42-error baseline with no new errors in touched files; test:unit 1505 passing; 27/27 Playwright across the three affected specs.

🤖 Generated with Claude Code

fclairamb and others added 9 commits September 8, 2026 07:19
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pickAccessibleOrg mirrors the backend's login-time resolution — super admin
keeps the URL's org, then the URL's org when it is a membership, then the
session's own org (last accessed), then organizations[0], then null for a
user with no organization at all.

Pure and unit-tested, so the two redirect call sites that follow can never
disagree and bounce a user between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… to the URL's

The login page's already-authenticated effect resolved its destination from
the URL's org. That org is only a hint: /orgs/<slug>/login is reachable from
a marketing link, a bookmark, or the "Try the live demo" button offered on
every org's login page. A visitor who is not a member of that org landed on
"Permission Denied", whose only button links back to the same org.

It now resolves through pickAccessibleOrg, with the session fields it reads
in the dependency list so it evaluates against the session applyLoginResponse
just stored. A session with no organization at all goes to /no-org.

This also closes the second half of the demo race spec 2026-09-07-02 left
open: the button path. The tightened E2E test failed 5 runs out of 5 before
the fix (the visitor reached /orgs/demo, then a replace() to /orgs/test
overrode it) and passes after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d-end 403

The org layout switched the session's token when the URL named another org
the user belongs to, and let everything else "fall through to the normal 403
handling": every child query answered 403, QueryErrorView rendered
"Permission Denied", and that card's only button linked back to the very
same org. A bookmark to an org you were removed from, or a link pasted from
a colleague's org, had no way out but editing the URL by hand.

It now redirects to the org pickAccessibleOrg names (or /no-org when there is
none), with a toast saying which org was refused and which is being shown.
The destination is the org ROOT, never the current sub-path: check, status
page and incident uids do not carry across organizations.

Children are gated behind the redirect the same way the switch case gates
them, so no org-scoped request is fired — and refused — on the way out.
Super admins are untouched, and a member merely lacking a role for one page
still gets Permission Denied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- npx tsc -b: clean
- bun run test:unit: 1505 passed (92 files), incl. locale parity + translated
  defaults for the four new org.accessRedirect.toast keys
- make build-dash0 + bun run lint: 42 errors, byte-for-byte the pre-existing
  base; the touched files show the same 2 errors / 12 warnings as their
  baseline versions, so no new finding was introduced
- Playwright (side-car SP_RUNMODE=test server on :4010, postgres):
  accessible-org-redirect + demo-account + the 14 auth/org specs — 90 passed.
  The one failure, confirm-registration-no-org, is the documented
  "Registration is not enabled" environment limitation create-org.spec.ts
  already calls out, unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the audit's blocking finding: a 2FA login flashed through /no-org.

The new login-page effect (spec §B) picks the destination from
auth.organizations. Every login shape carried that list except 2FA:
completeLoginAfter2FA built its LoginResponse without the field, so
applyLoginResponse fell back to /auth/me — and that await ends React's
auto-batch, committing a render where the session is authenticated with an
empty list. The effect read "no organization at all" and sent an ordinary TOTP
user to /no-org, which has no "you do have orgs" guard, before the ordinary
post-login navigation corrected the URL.

Fixed at both layers:

- Backend (root cause): completeLoginAfter2FA now returns Organizations from
  getOrganizationsForUser, exactly as the invitation-accept path does, in both
  its org and no-org branches. The list is decorative enough that a failure to
  read it is logged and swallowed rather than failing an otherwise valid login
  — the same call it already makes for last_active_at. The spec expected no
  backend change, but it also asked the implementer to verify this shape, and
  it did not hold.

- Client (defence in depth): applyLoginResponse holds isLoading up across the
  /auth/me fallback, and the login effect gates on it. isLoading is the
  session's existing "still resolving" signal rather than a new global flag,
  and it is raised in the same batch as setUser so no render ever exposes an
  authenticated session with a half-built org list. That also makes the org
  layout's twin branch ($org.tsx), which already gated on !auth.isLoading,
  correct for this window for free — both call sites now key on one signal.

Tests: login_organizations_test.go asserts the 2FA response carries the user's
WHOLE list (two memberships, so an echo of the signed-into org would not pass),
on both branches. e2e/login-2fa.spec.ts gains a test that records
pushState/replaceState navigations — the existing tests wait for a URL merely
not containing "/login", which /no-org satisfies, and a final-URL assertion
cannot see a redirect that a later one corrects. Verified failing pre-fix with
"/dash0/no-org" in the trail while the other three 2FA tests stayed green.

Cosmetic: accessible-org-redirect.spec.ts's seed helper now skips through
base.skip rather than mixing in the fixtures test object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb enabled auto-merge (squash) September 8, 2026 06:03
The wrong-org live-socket regression test used "park a non-member on a
foreign org" as its vehicle for getting a wrong-org token onto a page
that dials the WebSocket. Spec 2026-09-08-01 §C deliberately makes that
state unreachable — a non-member is now redirected to an org they can
use — so the socket connected fine and the sidebar dot never reached
the terminal "disabled" state.

Re-point the test at the wrong-org state that IS still reachable, and
was always the likelier production cause: a genuine *member* of the
URL's org whose token is scoped to another of their orgs, for whom the
automatic `switchOrg()` re-mint fails. $org.tsx drops its loading gate
in exactly that case ("better to render and let per-request 403s
surface than to hang"), so the page mounts holding the other org's
token — the same (token-org × target-org) mismatch the server answers
with a 4403 (backend twin: realtimews TestServe_TwoOrgMemberMatchesREST).

The end-to-end coverage is unchanged: real cookie, real upgrade, real
4403, no hello, no subscribed, dot on "disabled". Added assertions pin
the vehicle itself down — the switch-org call was attempted and refused,
and the page stayed on the URL's org — so the test cannot degrade into
passing vacuously on a page that never held a wrong-org token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb merged commit 0defd69 into main Sep 8, 2026
13 checks passed
@fclairamb
fclairamb deleted the batch/2026-09-08 branch September 8, 2026 07:47
@fclairamb fclairamb mentioned this pull request Sep 8, 2026
fclairamb added a commit that referenced this pull request Sep 8, 2026
release-please renders one line per squashed PR; #346 carried three
distinct user-visible fixes. Folds in the prose written alongside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb fclairamb mentioned this pull request Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant