HydraIssues

hydraorganization: iamnim login and org-scoped session middleware for the dashboard
done feature Project: hydraorganization Parent: #589 Reporter: cederik 28 Aug 2026 07:24

Description

Part of #589.

CONTEXT
hydraorganization has no web routes and no user identity; auth is one static admin bearer token (internal/api/server.go:49-53). This issue adds iamnim login and per-request org authorization, using the one-time code handoff so the session token never appears in a URL. The ?token= pattern is NOT acceptable here; see the master issue's accepted-risk rule 6.

SCOPE

  1. iamnim CLIENT: copy /home/claude-user/hydraperforceprovision/internal/iamnim/client.go into internal/iamnim/ (it has Me, Memberships, IsMember, ErrUnauthorized). This is a deliberate third copy; shared-library extraction is separate tooling work. Add one method: Exchange(code) calling POST /api/session/exchange.
  2. LOGIN ROUTES (mirroring hydramancer/internal/api/handlers_experience.go:14-50, adjusted for the code flow):
    • GET /dashboard/login: redirect to /login?redirect_uri=https://hydraorganization.experiencenet.com/dashboard/authed with the code-flow opt-in.
    • GET /dashboard/authed: read ?code=, exchange it server-side for the session ID, set cookie iamnim_session (HttpOnly, Secure, SameSite=Lax, MaxAge 86400), then 302 immediately to /dashboard so nothing credential-shaped stays in the address bar or history. Set Referrer-Policy: no-referrer and Cache-Control: no-store on this response. Never log this route's query string.
    • GET /dashboard/logout: clear the cookie.
  3. MIDDLEWARE: for every /dashboard and org-scoped API request, read the cookie, call iamnim /api/me (401 redirect to login on failure) and /api/me/memberships, forwarding the session as the iamnim_session cookie on the outbound request (hydramancer/internal/iamnim/client.go:41-76 pattern). Authorize with IsMember(session, orgID): the Pantheon organization_slug equals the hydraorganization org ID by platform convention (visit-flanders). Non-members get 403 with a plain page. A user with multiple memberships gets an org selector (hydramancer experience.html:152-168 pattern); a single membership goes straight to that org.
  4. CONFIG: add iamnim.base_url to config.yaml and config.example.yaml. Keep the existing admin-token API untouched.
  5. Tests: exchange flow, middleware allow and deny, cookie handling.

ACCEPTANCE CRITERIA

  • Login round trip on the live service: the owner lands on /dashboard with a session cookie, and at no point does a session token appear in any URL. Verified by reading the Location headers and the access log during the test login.
  • /dashboard/authed responses carry Referrer-Policy: no-referrer and Cache-Control: no-store.
  • An iamnim user without the visit-flanders membership gets 403.
  • An unauthenticated request to /dashboard redirects to login.
  • Runbook updated: login flow, config keys, and the note that an iamnim restart invalidates sessions (accepted risk on the master issue).

FILES

  • /home/claude-user/hydraorganization/internal/api/server.go
  • /home/claude-user/hydraorganization/internal/api/handlers.go
  • /home/claude-user/hydraorganization/internal/cli/serve.go
  • /home/claude-user/hydraorganization/config.example.yaml
  • /home/claude-user/hydraorganization/docs/runbooks/runbook.md
  • /home/claude-user/hydramancer/internal/api/handlers_experience.go (pattern)
  • /home/claude-user/hydramancer/internal/iamnim/client.go (pattern)
  • /home/claude-user/hydraperforceprovision/internal/iamnim/client.go (copy source)

DEPLOY
Tag, push, CI publishes to releases.experiencenet.com, systemd host auto-updates within 6h (or run hydraorganization update). Verify with hydrarelease verify --project hydraorganization.

Comments (4)

claude 31 Aug 2026 21:13

RAISED IN PRIORITY by #597 shipping live data on 2026-08-31. /dashboard is still anonymous, and as of hydraorganization v0.5.1 it now serves live operational data: per-venue head and body counts, and which experiences are deployed where. The venue roster and the reliability figures were already public from their own services, but the per-venue fleet posture was not. This issue is the gate. Until it lands the fallback is to remove the dashboard block from /srv/scales/hydraorganization/config.yaml, which reverts the page to example data. Also note for the design here: hydraorganization currently holds full-power ADMIN tokens for hydracluster and hydraexperiencelibrary because neither offers a read-only scope, and it only ever issues GETs. A read scope on both would shrink the blast radius of this service.

claude 1 Sep 2026 05:29

CODE IMPLEMENTED in hydraorganization v0.6.0, currently OFF in production.

Setting iamnim.base_url turns it on. /dashboard requires a session and redirects to /dashboard/login, which sends the person to iamnim. /dashboard/authed catches the returning session, moves it into an HttpOnly Secure SameSite=Lax cookie and redirects in ONE hop with Referrer-Policy: no-referrer, so the token does not stay in the address bar, in history, or in a Referer; the query string is never logged. The one-time code exchange that removes the query parameter entirely is #591 and remains the real fix.

Org scoping: a person sees exactly the orgs they are a member of. ?org= is validated against membership and never trusted; more than one membership renders a switcher; zero renders a plain no-access page that deliberately names no organization. GET /api/v1/organizations/{id}/overview answers 401 rather than redirecting a machine client. The dashboard provider is now keyed by org with a per-org cache instead of one configured org. Tests cover: non-member org rejected, default org selection, unauthenticated redirect, JSON 401, and that the authed handler sets an HttpOnly+Secure cookie and strips the token from the redirect.

WHY IT IS OFF: enabling it without a Pantheon membership locks out the intended reader, and no membership exists yet (#590). Verified against live iamnim that a junk session is correctly rejected and the login redirect is well formed.

TO ENABLE, in order: (1) the owner signs into iamnim once so their user record exists in realm r_349832f91720; (2) create the Pantheon org with slug matching the hydraorganization org id (visit-flanders); (3) grant the membership; (4) add iamnim.base_url to /srv/scales/hydraorganization/config.yaml and restart the scale. I could not do step 2 or 3: Pantheon stores only a hash of its admin key, so the plaintext is not recoverable from the host.

claude 1 Sep 2026 08:50

ENABLED IN PRODUCTION 2026-09-01, now that #590 granted the membership. iamnim.base_url is set in /srv/scales/hydraorganization/config.yaml and the scale restarted on hydraorganization v0.6.1.

Verified live: GET /dashboard with no session returns 303 to /dashboard/login; /dashboard/login returns 303 to https://iamnim.com/login with redirect_uri pointing at /dashboard/authed; GET /api/v1/organizations/visit-flanders/overview with no session returns 401 with a JSON error rather than a redirect. The dashboard is no longer publicly readable, which closes the exposure raised when #597 shipped live data.

Remaining on this issue: the session still arrives from iamnim as a ?token= query parameter. It is scrubbed in one hop into an HttpOnly Secure SameSite=Lax cookie with Referrer-Policy: no-referrer and the query string is never logged, but the parameter itself only disappears when #591 lands the one-time code exchange.

claude 1 Sep 2026 11:16

TWO SIGN-IN REDIRECT LOOPS found and fixed after enabling auth (v0.7.1 and v0.7.2). Both presented as a page that never loads, with nothing in the log, which is why they were worth hunting rather than retrying.

LOOP 1 (v0.7.1): a session cookie that iamnim REJECTS. The dashboard redirected to login, login went to iamnim, iamnim returned a session, and the cycle repeated. Reachable in normal operation because iamnim keeps sessions in memory on a single replica, so any restart invalidates every session it has issued. Now the dead cookie is cleared and the page says the sign-in did not stick, with a manual link. No redirect.

LOOP 2 (v0.7.2): the cookie never arriving AT ALL. This is the tablet case: Safari and iPadOS restrict cookies written during a cross-site navigation, and private browsing or a cookie block does the same. With no cookie there was no way to tell a first visit from a failed sign-in, so it bounced to login forever. /dashboard/authed now redirects to /dashboard?signedin=1; that marker carries no secret and exists only to separate the two cases. A missing cookie plus the marker renders a page explaining the browser did not keep the cookie.

Both are covered by tests that assert the response is NOT a redirect, and both tests were confirmed to fail against the previous code. Testbook gained checks 9, 10 and 11, including a real iPad sign-in whose pass criterion is that it never spins forever.

DESIGN NOTE for #591: a one-time code exchange does not remove either loop. Any browser that refuses the cookie fails the same way, so the marker stays useful regardless.