LOOM_1B_EMBED_AUTH_DESIGN

1b Design — Embed-Token Auth Path (Galaxy)

Status: Implemented & green (page + dataset + collection + viz slices; 17 API tests pass). Reviewed (subagent) — token RNG hardened to CSPRNG; see “Review hardening” below. Parent: LOOM_PLAN.md §4 Phase 1.3. Prereq done: 1a (PageEmbedToken model + migration f0e1d2c3b4a5 + mint endpoint) — green.

⚠️ Model pivot — per-resource scope DROPPED → full-user + route allow-list

This doc was originally written for a per-resource id-scope model (embed_allowed=(kind, param_name), EmbedScope, in-scope/out-of-scope checks). That model was dropped during implementation. The sections below are kept for the strategy rationale (A/B/C) and Divergence 2 research, but where they describe the scope mechanism they are superseded by this section.

What changed and why. The token already binds to a (user, page). Recording the page’s referenced ids and gating each request against that set was redundant and brittle: Galaxy ids are not a security boundary (exposed in URLs/responses/ logs, easy to mint or guess), so an id-membership check buys little. Accepted decision: if someone hijacks the token they get the marked read routes as the owning user — that residual is acceptable for short-TTL embed tokens.

Implemented model:


The three enforcement strategies

A. Central default-deny gate (the original recommendation)

A FastAPI middleware / global dependency: when the request carries an embed token, consult a single allow-list of (method, path-pattern) + do per-resource id checks there; reject everything else.

B. Per-endpoint manager checks

Resolve the token to user + scope, then thread scope checks into each access path (security_check, can_access_dataset, viz security_check, the legacy dataset controller).

Add an embed_allowed=True kwarg to Galaxy’s overridden verb decorators, handled in FrameworkRouter._handle_galaxy_kwdthe exact mechanism Galaxy already uses for require_admin=True and public=True (lib/galaxy/webapps/galaxy/api/__init__.py:548-571). A route opts in; the embed token authenticates only on opted-in routes.

# _handle_galaxy_kwd already does this for require_admin:
require_admin = kwd.pop("require_admin", False)
if require_admin:
    kwd.setdefault("dependencies", []).append(self.admin_user_dependency)
# add, identically:
embed_allowed = kwd.pop("embed_allowed", False)
if embed_allowed:
    kwd.setdefault("dependencies", []).append(self.embed_token_dependency)

Recommendation: C, with the per-resource id check co-located in the embed dependency.


Why C is default-deny by construction (the key insight)

The embed token is not added to the global get_api_user credential chain. It is validated only by the embed_token_dependency, which is attached to a route only when embed_allowed=True. Therefore:

No global gate, no path-matching, no “remember to deny.” Denial is the absence of opt-in. This is strictly safer than A (which must enumerate denies correctly) and B (which fails open on omission).

What checks which resource (SUPERSEDED — no per-resource check)

Superseded by the pivot. There is no per-resource id check. embed_allowed=True admits the route; Galaxy’s existing ACLs (get_accessible, history ownership) decide which resources the recovered user may read. The original text below described the dropped membership check.

embed_allowed=True admits the route; the dependency must verify the requested id is in scope (page_id / dataset_ids / collection_ids), raising 403 on miss.

Fewer routes than feared (public bootstrap)

The SPA’s bootstrap reads (/api/configuration, /api/datatypes, /api/version, …) are already public=True (anonymous) — verified for configuration.py:82 and datatypes.py. They need no embed credential. So the set to mark embed_allowed=True is small: page show, dataset show/display, dataset-collection show, visualization data. (Enumerate the exact list empirically — load /published/page?id=X&embed=true against a page with a viz and capture the network calls that 401 without auth.)


Scope computation (SUPERSEDED — removed)

Superseded by the pivot. There is no scope computation. EmbedScope, embed_scope, and the page-content parse (with its raw-vs-encoded id handling) were removed from managers/pages.py; only resolve_embed_token remains. The token recovers record.user directly.

Wiring — SETTLED by spike (FastAPI 0.136.3)

Spike (/tmp/spike2.py) result, against the venv’s actual FastAPI:

Chosen mechanism (as implemented, post-pivot): the embed_allowed=True flag attaches a single shared embed_token_dependency that, on a valid token, sets request.state.embed_user = record.user; get_api_user reads getattr(request.state, "embed_user", None) first and returns it. The full chain is get_api_user → get_user → get_trans(user=…), so the full owning user flows into trans.user with no change to get_trans/managers.

No per-resource check in the dependency. Absent token → no-op; present-but- invalid/expired → AuthenticationFailed (401). Resource access is left to Galaxy’s ACLs on the handler. Account endpoints stay safe because they’re never marked → the dependency never runs → the token is ignored → 401.

Decorator shape (implemented)

@router.get("/api/pages/{id}", embed_allowed=True)   # page show
@router.get("/api/datasets/{history_content_id}/display", embed_allowed=True)
@router.get("/api/dataset_collections/{hdca_id}", embed_allowed=True)

_handle_galaxy_kwd pops embed_allowed and appends Depends(embed_token_dependency) — identical machinery to require_admin. One shared dependency, no per-kind factory.


Tests (red→green) — cross-user / ACL matrix (post-pivot)

Denial is now exercised cross-user (the token’s user vs a different user’s private resource), not in-scope/out-of-scope, since there is no scope.

CaseExpectStatus
embed token → its own page200✅ pass
embed token → a different user’s page403 (history ownership)✅ pass
embed token → a dataset (own/public)200✅ pass
embed token → a different user’s private dataset403 (dataset ACL)✅ pass
embed token → a collection (own)200✅ pass
embed token → a different user’s collection403 (history ownership)✅ pass
embed token → plugin metadata (/api/plugins/{id})200 (anonymous fallback)✅ pass
embed token → plugin ?history_id= enumerationrefused (not 200)✅ pass
embed token → dataset metadata (own)200✅ pass
embed token → a different user’s private dataset metadata403✅ pass
embed token → a different user’s private metadata_file403✅ pass
embed token → HEAD on display200✅ pass
embed token → POST mint (pages/{id}/embed_token)401/403 (can’t escalate)✅ pass
embed token → GET /api/users/{id}/api_key401/403 (proves Q5 closed)✅ pass
embed token → any other mutation (PUT/DELETE)401/403⏳ (covered-by-not-marked)
malformed/unknown token401✅ pass
expired token → resolve_embed_token returns NoneNone✅ pass (unit)

17 API tests pass in test_pages_history_attached.py (1a mint + page/dataset/ collection/viz reads + cross-user denials + plugin-enumeration denial + HEAD + mint-escalation denial + Q5), plus 5 unit tests: test/unit/app/managers/test_PageManager.py (4: valid / expired→None / unknown / empty, exercising the resolve_embed_token expiry branch directly without HTTP or time-mocking) and test/unit/webapps/api/test_embed_allowed_routes.py (1: allow-list snapshot, below). Key nuance: the dataset denial test must call make_private(history, dataset_id) on the other user’s dataset — Galaxy datasets are public by default, so without privatizing, the owning user legitimately reads it (200) and the test would false-fail. Page and collection denials need no privatizing — they are gated by history ownership. The api-key-endpoint test is the headline: it passes for free under C (route unmarked → token ignored), which is the whole argument for C.


Open questions

Divergence 2 — legacy dataset-display route — RESOLVED → (a) repoint client

The page’s dataset element fetched from the legacy /datasets/{id}/display/ controller route (controllers/dataset.py), not a FastAPI route — so embed_allowed could not decorate it. Resolved by subagent research:

Confirmation folded into implementation: the API display uses DependsOnTrans + get_accessible; under the full-user model the recovered trans.user passes that for any dataset the user can access (same model as legacy _can_access_dataset). (The earlier param_name == history_content_id concern is moot — the boolean flag takes no param name.)

Tracked as Divergence 2 in LOOM_PLAN.md §0.5.

Visualization slice — RESOLVED & green (5 tests)

VisualizationFrame.vue (client/src/components/Visualizations/VisualizationFrame.vue) does not load a server URL into the iframe src. Its render():

  1. axios.get(/api/plugins/{name}) → the FastAPI show route (api/plugins.py:333, GET /api/plugins/{id}) for plugin metadata — no ?history_id=.
  2. injects the plugin’s static JS/CSS (pluginPath/entry) into a blank iframe — static files, no API auth.
  3. the plugin JS, running inside the iframe, fetches the dataset itself using visualization_config.dataset_url / dataset_id + root.

(P1) Plugin metadata — RESOLVED, zero server change. The metadata branch (registry.get_plugin(id).to_dict()) is static, non-user config and is anonymous-readable. The embed iframe carries the token, but the route is not marked → token ignored → request falls through to anonymous → metadata loads. The ?history_id= enumeration branch (plugins.py:350-364, get_owned(history_id, trans.user)) is closed by the same construction: unmarked route → anonymous → get_owned(history_id, None) refused. So the “never mark a listing/enumeration route” invariant holds trivially — we simply never mark /api/plugins/{id}. Two regression tests pin both halves (test_embed_token_reads_plugin_metadata, …_cannot_enumerate_plugin_history). Note: the ?history_id= branch is used only by fetchPluginHistoryItems (api/plugins.ts) → VisualizationCreate.vue, the standalone viz-creation UI, never by the embed frame.

(P2) In-iframe data fetches — RESOLVED via empirical capture. Real embeddable plugins live in the external galaxy-visualizations repo (only example ships in the Galaxy tree). Static capture of their fetch URLs (clone at ~/projects/repositories/galaxy-visualizations):

Route the plugin JS fetchesPlugins (examples)Action
api/datasets/{id}/display?to_ext=json)pca, seqviz, aladin, nora, annotateimage, tiffviewer, unipept, katex, drawrna, aceeditor, openseadragon, hyphyvisionalready marked (dataset slice)
api/datasets/{id} (metadata show)seqviz, aladin, nora, openseadragon, aceeditor, chiraviz, jupyterlite, plotly, igvnow marked
api/datasets/{id}/metadata_file?metadata_file=igvnow marked (GET+HEAD)
api/histories/{id}/contents?…igv, jupyterlitedeliberately NOT marked (enumeration — invariant). Those plugins lose history-browsing in embed mode; expected — the embed shows the referenced dataset, not a history browser.

Both newly-marked routes are single-resource reads keyed on a specific dataset_id, gated by get_accessible — safe per the invariant. Marked in api/datasets.py: GET /api/datasets/{dataset_id} (show) and GET|HEAD /api/datasets/{history_content_id}/metadata_file. Tests: test_embed_token_reads_dataset_metadata (200), …_denies_other_users_dataset_metadata (403), …_denies_other_users_metadata_file (403, access check fires before file lookup).

Residual: a positive metadata_file read test would need a datatype that generates a metadata file (e.g. BAM/.bai); deferred — the route shares the show/ display auth model, and the cross-user denial test proves it does not leak.

Review hardening (subagent pass)

A read-only review confirmed the model holds (correct dep ordering, default-deny, no marked enumeration route, data_type/**extra_params on dataset show stay single-resource, mint owner-gated, expiry comparison correct). Acted on:

Follow-ups from the review — 1–3 now done:

  1. Expired-token testtest/unit/app/managers/test_PageManager.py. A manager unit test (mock app) inserts a PageEmbedToken with a past expiration_time and asserts resolve_embed_token returns None; also covers valid / unknown / empty. Covers the previously-untested expiry branch without DB time-mocking. (Mock app omits workflow_manager; stubbed to None since resolve_embed_token never uses it.)
  2. Allow-list snapshot testtest/unit/webapps/api/test_embed_allowed_routes.py. Builds the real app via include_all_package_routers and pins the exact 7 (method, path) routes carrying embed_token_dependency, so any future addition (esp. an enumeration route) trips the test — a deliberate, reviewed change rather than silent. (Galaxy registers each route with/without a trailing slash; the test normalizes the slash to compare the 7 logical routes.)
  3. Migration tracked. f0e1d2c3b4a5_create_page_embed_token_table.py is now git added (staged). Revision id is hand-authored, not alembic-generated — cosmetic.
  4. No revocation / expired-row cleanup — acceptable v1 (matches PasswordResetToken); consider a future revoke endpoint + periodic cleanup.
  5. Collection show (view=element) exposes child dataset IDs — within the full-user-read-by-ID model, but modestly widens the ID-discovery surface; conscious acceptance.