HydraIssues

Self-service experience management for external creators (Cyborn / Gallo-Romeins) via hydramancer
closed unclassified Project: hydramancer Reporter: 15 Aug 2026 11:53

Description

Goal

Give external creators (Cyborn / Gallo-Romeins Museum) a self-service view + scoped lifecycle controls for their experiences, through the hydramancer creator portal — reusing the sign-in + org-scoped proxy pattern that already ships for Perforce access. Today a creator can push a build all the way to a registered development build, but only an operator (holding the library admin token) can see state or drive stage/promote/rollback/pause. This closes that last gap without handing creators the privileged token and without letting one org touch another org's experiences.

This issue is assessment + plan only. No code, deploy, or live-experience change is implied by filing it.

What already exists (verified, reuse these)

hydramancer (hydramancer.experiencenet.com)

  • iamnim sign-in on /experience: handleLogin -> iamnim /login?redirect_uri=.../experience/authed; handleAuthed stores the iamnim session in the portal's own iamnim_session cookie (internal/api/handlers_experience.go:14-36).
  • webExperience resolves identity + orgs via the iamnim client and renders the panel (internal/api/server.go:82-94).
  • iamnim client: Me(session) -> GET /api/me, Memberships(session) -> GET /api/me/memberships; Membership{OrganizationSlug, OrganizationName} (internal/iamnim/client.go:20-59).
  • Perforce proxy: handleProvisionPerforce forwards the session as X-Iamnim-Session to hydraperforceprovision and passes the upstream status/body straight back (internal/api/handlers_provision.go:16-55).

hydraperforceprovision — the reference for org scoping

  • handleProvision does the three-step gate: (1) authenticate s.iam.Me(session); (2) authorize s.iam.IsMember(session, req.OrgSlug) -> 403 "not a member of org" on miss; (3) provision for the authenticated identity, deriving login/email from /api/me, never from the body (internal/api/handlers.go:21-92).
  • IsMember = exact-match scan m.OrganizationSlug == orgSlug over the session's memberships (internal/iamnim/client.go:63-75).
  • Design principle (CLAUDE.md): "iamnim authenticates, this service provisions"; "provision for the authenticated identity only"; "authorization = org membership".

iamnim (github.com/nimsforest/iamnim)

  • GET /api/me, GET /api/me/memberships back the whole model; both read the session via cookie/?token= (internal/web/server.go:294-296, 771-817).
  • Session -> UserID -> resolveMemberships -> Pantheon ListMemberships; Membership{OrganizationSlug, OrganizationName, ...} (internal/web/server.go:681-688, internal/web/pantheon.go:26-33).
  • Its own org pages already gate every action with the same exact-slug membership scan (internal/web/server.go:549-561, 633-645).

hydraexperiencelibrary (hydraexperiencelibrary.experiencenet.com)

  • Full lifecycle API, all behind one bearer token: requireAuth compares Bearer <token> to s.adminToken, nothing more (internal/api/middleware.go:10-24). Not org-aware.
  • Routes: GET/PUT /api/v1/experiences[/{name}], POST .../{promote,rollback,pause,resume,retire,stream}, POST /api/v1/builds/notify (internal/api/server.go:122-155).
  • Experience record carries the org linkage: Owner, Developer, WatchName, plus Status, the three build pointers, Districts (internal/store/store.go:17-43). The gallo-romeins-museum experience has developer="cyborn", owner="gallo-romeins-museum".
  • A parallel operator web UI (RequireWebAuth, cookie) exists at /admin... — leave it untouched for operators.

The crux: authorization scoping

How a creator is authenticated + org-confirmed today (Perforce path): the portal never holds credentials. It forwards the person's iamnim session downstream; the downstream service calls iamnim /api/me (who is this) and /api/me/memberships (which orgs), and refuses any action whose org_slug is not among the caller's memberships. The org is the unit of authorization — Pantheon has no role model.

How that maps to experiences — the one important difference. In the Perforce flow the client names its own org (org_slug in the body) and gets a depot named after that same org: naming a foreign org buys nothing. Experiences invert this. The client names an experience ({name}), and the owning org is a property of the stored record (developer / owner), not of the request. So the scoping check cannot trust a client-supplied org; it must:

  1. Resolve the caller's membership slug set from iamnim (Memberships(session)).
  2. Load the target experience and read its developer (and/or owner).
  3. Allow the action only if the experience's org is in the caller's slug set.
  4. For list views, filter the library's full set down to experiences whose org is in the caller's slug set — never return the whole catalog.

Concretely for Cyborn: a signed-in Cyborn member has membership slug cyborn; they may see/control experiences where developer == "cyborn". That derivation — "which experiences may this creator touch" = "experiences whose org field is one of my membership slugs" — is the entire authorization model, and it lives in exactly one place (see recommendation).

developer vs owner (who gets what). developer="cyborn" is the studio that builds and ships; owner="gallo-romeins-museum" is the client that commissioned it. Recommendation: developer grants full lifecycle control (Cyborn drives stage/promote/rollback/pause — they are the pipeline actor). Optionally owner grants read-only visibility to museum staff (a member of the gallo-romeins-museum org sees status but cannot promote). Ship developer-scoped control first; owner read-only is an additive follow-up.

Two options for where scoping lives

(a) Scope in the hydramancer proxy; call the library with the privileged token. Mirrors the Perforce proxy exactly. hydramancer authenticates via iamnim, resolves memberships, loads the experience (or list), enforces the org check, and only then calls the library API with the admin bearer token, mapping the creator's intent to the specific library route. The library is unchanged.

  • Pros: smallest change; on-pattern; ships self-service now; library stays a single-token operator service; creator never sees the token.
  • Cons: scoping logic lives in the portal, not next to the data; if the admin token leaks it is still all-powerful; the library will still hand the proxy any experience, so a proxy bug could leak cross-org data.

(b) Make the library org-aware. The library itself validates iamnim sessions and filters by developer/owner.

  • Pros: single source of truth for scoping sits next to the data; defense in depth.
  • Cons: duplicates the iamnim-client + membership logic the library does not have today; larger change; the operator token/UI must still coexist. Overkill for the initial rollout.

Recommendation: (a) now, mirroring handleProvisionPerforce + handleProvision, with a small (b)-lite follow-up: add a library read endpoint that accepts a developer=/owner= filter so the proxy fetches only the caller's slice and can never accidentally surface another org's record. This keeps the proven pattern, ships fast, and adds a second scoping fence at the data layer without a full auth rebuild in the library.

Risks and how to close them

  1. Slug mismatch (the top risk). experience.developer is free text in YAML; Membership.OrganizationSlug is a Pantheon slug. IsMember compares them raw (==). "Cyborn" vs "cyborn", or the museum's owner="gallo-romeins-museum" vs a Pantheon slug that may be galloromeinsmuseum (note the Perforce depot already normalizes to galloromeinsmuseum), will silently deny — or, worse, a loose match could over-grant. Close it: define one canonical normalization (lowercase, strip non-[a-z0-9], reuse hydraperforceprovision's Slug() idea) applied to both sides before comparison, and add a startup/validation check that every experience's developer/owner resolves to a known Pantheon org slug. Do not ship until the cyborn and gallo-romeins-museum records are confirmed to match their real Pantheon slugs.

  2. Cross-org promotion (privilege escalation). A Cyborn member calling POST .../gallo-romeins-museum/promote for an experience that is really another studio's must be blocked. Close it: always resolve the org from the stored record, never the request path/body; check membership of that resolved org; 403 on miss (exactly handleProvision:57-60). Never let the client pass the org that authorizes it.

  3. Unscoped list leak. GET /api/v1/experiences returns everything. Close it: the proxy's list handler must filter to the caller's membership slugs before returning; pair with the (b)-lite server-side developer= filter so the full catalog never crosses the wire.

  4. Admin-token blast radius. The proxy holds a token that can act on every experience. Close it: keep the token server-side only (as the Perforce URL/creds are); scope every proxied call to a single named experience post-authorization; log actor email + experience + action (mirror handleProvision:89). Consider a dedicated, rotatable token distinct from the operator token.

  5. Empty / stale memberships or iamnim down. If /api/me/memberships errors or returns empty, fail closed — 403/502, never fall through to an unfiltered action (Perforce path returns 502 on membership error, 403 on empty: handlers.go:52-60).

  6. owner-vs-developer confusion granting the wrong side control. If both fields were treated as "grants control", museum staff could promote a build Cyborn has not blessed, or vice versa. Close it: control keyed on developer only; owner read-only (or explicitly configured), decided per the record, documented in the runbook.

  7. Session forwarding fidelity. The proxy must forward the creator's session (X-Iamnim-Session), never a service identity, so the downstream membership check is about the real person (as handleProvisionPerforce:41 already does). A bug that swaps in a service session would authorize the wrong subject.

Implementation plan (phased, on-pattern)

Phase 1 — read-only, developer-scoped (ship first)

  • hydramancer: add an "Experiences" panel to /experience (reuse experienceData + experience.html), shown when signed in, listing the caller's experiences.
  • New proxy handler GET /api/v1/experiences in hydramancer: authenticate Me, resolve Memberships, call the library GET /api/v1/experiences with the admin token, filter to records whose normalized developer is in the caller's normalized slug set, return the slice.
  • Canonical slug normalization helper shared by list + action checks.

Phase 2 — scoped lifecycle actions

  • Proxy handlers for promote|rollback|pause|resume on /api/v1/experiences/{name}/...: authenticate, load the single experience via the library, resolve its developer, enforce membership (403 on miss), then forward to the matching library route with the admin token; pass status/body straight back (mirror handleProvisionPerforce). Log actor+experience+action.
  • UI: per-experience action buttons in the panel; promote target (staging|production) via the existing body shape.

Phase 3 — hardening / defense in depth

  • (b)-lite: library gains a developer=/owner= query filter on the list endpoint so the proxy fetches only the caller's slice.
  • Optional owner-scoped read-only view for client-org staff.
  • Startup validation that every experience's developer/owner maps to a real Pantheon org slug; dedicated rotatable proxy token; audit-log review.

Acceptance

  • A signed-in Cyborn member sees only developer="cyborn" experiences and can promote/rollback/pause them; sees and can do nothing to any other org's experience (verified 403).
  • No creator ever receives the library admin token.
  • The operator /admin UI and API are unchanged.
  • cyborn / gallo-romeins-museum record fields are confirmed to match their Pantheon slugs before rollout.

Key files

  • hydramancer: internal/api/handlers_provision.go:16-67, handlers_experience.go:14-36, server.go:82-94, internal/iamnim/client.go:20-59
  • hydraperforceprovision (pattern): internal/api/handlers.go:21-92, internal/iamnim/client.go:63-75
  • iamnim: internal/web/server.go:294-296,549-561,681-688,771-817, internal/web/pantheon.go:26-33
  • hydraexperiencelibrary: internal/api/server.go:122-155, internal/api/middleware.go:10-24, internal/store/store.go:17-49

Comments (1)

claude-ops 15 Aug 2026 12:02

Duplicate — this is an intermediate investigation artifact from the assessment workflow. The consolidated findings + plan live in #490. Closing as duplicate.