Skip to main content

ADR-027: Superset Embedding Strategy (Phase 8.5)

Status: Accepted (2026-04-20 at Phase 8.5 ship; was Proposed since 2026-04-19) — Phase 8.5 dispatch landed all 4 workstreams in 1 Architect-session; the Architect-at-dispatch deferred-decision dispositions are documented in §"Decisions deferred to Phase 8.5 Architect — Architect-at-dispatch resolutions" below. Date: 2026-04-19 (Proposed); 2026-04-20 (Accepted) Supersedes: N/A (replaces W2's interim "View in Superset" anchor-link pattern at Phase 8.5 dispatch) Related: ADR-013 (Identity Strategy — Proposed; this ADR commits PSSaaS to Keycloak via oauth2-proxy for the W2 React UI surface), ADR-020 (Shared K8s Namespaces — pss-platform is now Superset's home per Backlog #30 closure), ADR-026 (Frontend Framework + Build — Vite static build is the structural reason oauth2-proxy fits)

Context

Phase 8 W2 shipped the React operator UI at https://pssaas.staging.powerseller.com/app/ with anchor-link "View in Superset" deep-links to the 8 PowerFill dashboards (IDs 13-20 at https://bi.staging.powerseller.com/superset/dashboard/{id}/). The PO surfaced two product-instinct upgrades for the Greg-demo readiness window:

  1. Embedded Superset (not new-tab anchor links) — narrative coherence in the demo; one app surface, not two
  2. Keycloak auth on /app/ — closes the public-staging-URL security debt; satisfies the "operator must authenticate" expectation

These belong together architecturally: the embedded-SDK guest-token-mint endpoint has very different security semantics behind auth (authorization-checked passthrough) than without it (public footgun). Phase 8.5 ships them as one unit.

PSX Collaborator's reply 2026-04-19 (file paths + 8 ranked gotchas + one architectural-mismatch correction) provides the empirical primary source for the design. The reply is archived at docs-site/docs/agents/cross-project-relays/2026-04-19-psx-superset-embedding-relay.md.

Decision (Proposed)

Phase 8.5 will adopt the PSX oauth2-proxy + static-site embedding pattern (NOT the PSX NextAuth + Next.js pattern) and the PSX FastAPI 3-step guest-token-mint pattern translated to .NET 8 + HttpClient.

Architecture summary

Browser → /app/* (Vite static bundle served by nginx)
↓ (auth check)
oauth2-proxy → Keycloak (pss-platform realm)
↓ (X-Forwarded-Access-Token header)
nginx upstream → React app
↓ (fetch guest-token-mint endpoint)
/api/superset/guest-token (.NET 8; checks user role; calls Superset)
↓ (3-step server-to-server: login → CSRF → guest_token POST)
bi.staging.powerseller.com /api/v1/security/guest_token/
↓ (returns scoped guest token)
React app → @superset-ui/embedded-sdk embedDashboard()
↓ (MessageChannel handshake; renders iframe)
bi.staging.powerseller.com /embedded/{uuid}/ (auth-gated by guest token)

Key architectural decisions

(D-8.5-1) PSX's oauth2-proxy pattern is the right reference, NOT PSX's NextAuth pattern. Per PSX Collaborator's Q3 caveat: the W2 React UI is a Vite-built static bundle served by nginx — structurally identical to PSX's Docs / Grafana / Superset-admin static-site surface, and structurally distinct from PSX's Next.js app. Translating NextAuth to a Vite static bundle is non-trivial because there's no server runtime to do the OIDC code exchange. oauth2-proxy is the correct shape for our build pattern. Reference files in PSX repo: infra/oauth2-proxy/oauth2-proxy.cfg and infra/azure/scripts/configure-keycloak.ps1 (search for docs-proxy — the canonical static-site Keycloak client).

(D-8.5-2) Guest-token-mint endpoint lives in the .NET 8 PSSaaS API. Same shape as PSX's FastAPI endpoint at api/routes/principal.py:242-313. The .NET 8 implementation translates the 3-step Superset handshake (login → CSRF → guest_token POST) to C# + HttpClient. The CSRF step is the most-missed piece — Superset's guest_token endpoint requires X-CSRFToken header even though it's an API endpoint. Auth check happens at the .NET endpoint via the X-Forwarded-Access-Token header forwarded by oauth2-proxy.

(D-8.5-3) Per-resource scoping: PSSaaS will start with one-token-per-dashboard (Option A from PSX), then evaluate one-token-covering-multiple-resources (Option B) if 8 separate token mints become a UX or performance issue. PSX picked Option A for simplicity; PSSaaS has 8 dashboards instead of 1, so the calculation may differ. Decision deferred to Phase 8.5 Architect with this ADR's Option B as documented escape hatch.

(D-8.5-4) Per-dashboard registration: PSSaaS will write the script PSX explicitly does not have. PSX registers each embed-enabled dashboard manually via Superset admin UI; PSSaaS has 8 dashboards, so manual click-through is friction-prone. Phase 8.5 ships an idempotent registration script using Superset's /api/v1/dashboard/{id}/embedded/ endpoint (POST to create; PUT to update). The captured UUIDs persist to a config map (likely infra/superset/powerfill-embed-uuids.json or similar) for the .NET API to read.

(D-8.5-5) Required Superset config changes are platform-Superset-side, owned by PSX Infra. Per PSX Collaborator's Q2 (lines 81-95 of infra/superset/superset_config.py):

  • HTTP_HEADERS = {"X-Frame-Options": "ALLOWALL"} + TALISMAN_ENABLED = False — without these, the iframe never renders
  • FEATURE_FLAGS["EMBEDDED_SUPERSET"] = True — embedded API endpoints don't exist without it
  • GUEST_TOKEN_JWT_SECRET — production override required; same secret across all instances of Superset that share a database (per gotcha #7)
  • PUBLIC_ROLE_LIKE = "Gamma" — applies at fresh DB init only (gotcha #1; see "Risks" below)

PSX's platform-Superset already has EMBEDDED_SUPERSET on (PSX uses it for their Principal dashboard). PSSaaS verifies + amends as needed via PSX Infra collaboration.

Decisions deferred to Phase 8.5 Architect — Architect-at-dispatch resolutions (2026-04-20)

  • Specific .NET 8 HttpClient implementation shape — RESOLVED: typed client via services.AddHttpClient<SupersetGuestTokenClient>(...) in Modules.Superset/SupersetExtensions.cs. No Polly retry in v1 (acceptable per the empirical-failure-bubble-up-to-embed-side-error pattern in W2's EmbeddedDashboard.tsx; banked as "implementation-shape decision deferred to dispatch time" pre-plan; v1 ships without retry to keep failure modes surfacing cleanly through the embed-side error block per A69 honesty pattern). CSRF token caching is implemented via process-local SemaphoreSlim-guarded session memo with LoginSessionCacheSeconds = 300 default, refreshed-on-401 (handles mid-session admin credential rotation cleanly).
  • Endpoint shape: /api/superset/guest-token vs /api/powerfill/guest-token vs per-dashboard endpoints — RESOLVED via Alternatives-First Gate (Phase 8.5 plan §3 (a)): chose cross-cutting /api/superset/guest-token with body { "dashboard_key": "hub|guide|recap|switching|poolCandidates|existingDisposition|poolingGuide|cashTradeSlotting" }. Phase 10+ surfaces (Pipeline UI, Risk UI) inherit the same endpoint shape; only the dashboard_key set extends. The endpoint lives in a new Modules.Superset module per ADR-004 modular-monolith pattern. Per-dashboard endpoints (chatty; 9 endpoints) and per-module endpoints (forces parallel /api/risk/guest-token etc. duplication at Phase 10+) both rejected.
  • React component shape — RESOLVED: <EmbeddedDashboard> shared component at src/frontend/src/components/EmbeddedDashboard.tsx (mirrors PSX web/app/principal/components/SupersetEmbed.tsx shape). Dynamic-import of @superset-ui/embedded-sdk so it lazy-loads (matches PSX pattern; verified via Vite build chunk separation at lib-Cb7LCDYX.js). iframe-sizing trick is the PSX containerRef-polling approach (poll 100ms until containerRef.current.querySelector('iframe') returns non-null, then apply width/height/border-radius styles). Cleanup contract: AbortController on initial fetchGuestToken + clearInterval on poll + embedded.unmount() on unmount, mirroring the AGENTS.md async-leak countermeasure shape from useApi.ts / useReportFetch.ts.
  • Anchor-link replacement strategy: full-replace atomic vs feature-flag per-page migrate — RESOLVED via Alternatives-First Gate (Phase 8.5 plan §3 (c)): chose full-replace atomically. The header "View in Superset" anchor link in reportShell.tsx lines 103-113 became a small data-test-id="superset-embed-marker" indicator + the embedded iframe rendered ABOVE the GenericTable. The Hub-anchor in Home.tsx lines 42-54 + the per-report Superset anchors in RunStatus.tsx lines 190-198 + the Hub-anchor in RunStatus.tsx lines 213-220 all replaced with inline <EmbeddedDashboard> instances. A66 BLUE banner UX preserved above embeds (FreshnessBanner renders BEFORE the embed; for TerminalEmpty verdict, the embed is intentionally NOT rendered to avoid confusing-empty-iframe). Feature-flag rejected per ADR-027 + kickoff non-negotiable; embed-fail-fallback rejected per A66/A69 honesty pattern.
  • Per-resource scoping (Option A vs Option B) — RESOLVED: stayed on Option A (one token per dashboard) for v1 per Phase 8.5 plan §3 (b). Matches PSX reference exactly; minimal blast radius if a token leaks. 9-mint-per-session chattiness flagged for Phase 10+ revisit if it surfaces as a UX issue.

Consequences

Positive

  • Demo narrative coherence. Greg sees one app, not "PSSaaS that links out to Superset."
  • Branding/credibility. Embedded dashboards strip Superset's chrome — looks like PSSaaS analytics, not "third-party tool we forwarded to."
  • Filter pre-population. Embedded dashboards take URL state at mount time; the React UI can pre-set "show me the run I just ran" or "filter to this loan" — much harder to coordinate via new-tab navigation.
  • Auth lands. Public-staging-URL security debt closed; A68 long-term decoupling (TenantId from Keycloak claim) gets natural fold-in.
  • Pattern composability. Future PSSaaS surfaces (Pipeline Management UI, Risk Manager UI, etc.) inherit the same auth + embed shape.

Negative

  • Phase 8.5 is 2-3 Architect-sessions of focused work. Faster than the original "auth-deferred-to-Phase-9+" path would have been; slower than "ship anchor links and call it done."
  • Coupling to platform-Superset configuration. PSSaaS now depends on EMBEDDED_SUPERSET feature flag, GUEST_TOKEN_JWT_SECRET, PUBLIC_ROLE_LIKE, TALISMAN_ENABLED=False, HTTP_HEADERS=ALLOWALL all being correctly set on platform-Superset. Mitigated by PSX Infra ownership + PSX Collab having already debugged these for their Principal dashboard.
  • Per-dashboard UUID lifecycle. If a dashboard is re-created (vs updated), the UUID changes. Need to verify our deploy-powerfill.py idempotent re-run preserves UUIDs, OR document the re-registration step.
  • 24-hour-rollback-window from Backlog #30 is gone — by Phase 8.5 dispatch, the old psx-staging Superset deployment will have been fully decommissioned. Rollback to the old Superset is no longer possible. Not a Phase 8.5 risk; flagged for completeness.

Operational

  • PSSaaS oauth2-proxy Deployment + Service in pssaas-staging namespace, fronting /app/ and /api/ paths. Replaces W2's current direct ingress-to-frontend / direct-ingress-to-API pattern.
  • New Keycloak client in pss-platform realm — call it pssaas-app (analog of PSX's docs-proxy). Confidential client; Vault-managed secret; standard flow only.
  • New ingress rules that delegate /app/ and /api/ paths to oauth2-proxy upstream first, with oauth2-proxy forwarding to the existing frontend and api services after auth.
  • GitHub Actions deploy workflow extends to deploy oauth2-proxy alongside the existing api/docs/frontend pipeline.
  • Cross-cutting: A68 (tenant-id-vs-config-slot conflation) gets natural closure when Keycloak's tenant_id claim becomes the canonical TenantId source.

Risks (PSX Collaborator's gotchas, ranked by cost)

  1. PUBLIC_ROLE_LIKE silent no-op on existing Superset (PSX gotcha #1). On platform-Superset (existing DB, not fresh init), this config does nothing. Embedded dashboards fail with cryptic "embedded authentication" errors that don't obviously point to "Public role is empty." Mitigation: at Phase 8.5 dispatch, run copy_gamma_to_public.py script OR manually grant Public role Gamma's permissions via Settings → Security → List Roles → Public. PSX Infra collaboration required.
  2. TALISMAN_ENABLED + X-Frame-Options nginx override (gotchas #2-3). Superset's default CSP blocks all framing; nginx-side X-Frame-Options can override Superset's permissive setting and re-block. Mitigation: explicit X-Frame-Options unset in PSSaaS nginx + verify TALISMAN_ENABLED=False on platform-Superset.
  3. CORS on guest-token endpoint (gotcha #4). Avoided by our pattern (React fetches token from same-origin .NET API; .NET fetches from Superset server-to-server). Flagged so future PSSaaS surfaces don't accidentally cross origins.
  4. Superset 3+ MessageChannel-only (gotcha #5). URL-param guest token delivery is dead in Superset 3+. Old StackOverflow answers showing ?guest_token={token} won't work. Mitigation: SDK 0.3.0+ handles MessageChannel; we just use the SDK.
  5. can_log on Superset v6 permission (gotcha #6). Same family as #1 (Public role missing perms). Mitigation: include in the Public-role-permission audit.
  6. GUEST_TOKEN_JWT_SECRET coordination across instances (gotcha #7). Rotation invalidates existing tokens mid-session. Mitigation: PSX Infra handles secret rotation; PSSaaS doesn't rotate independently.
  7. UUID ≠ dashboard ID (gotcha #8). UUID generated when embedding is enabled per dashboard; not the integer ID we already know. Mitigation: capture UUIDs at registration time; persist to config map; .NET API reads from there.

Decision provenance

  • PO direction 2026-04-19: "I'd like to do it in staging, and I should have to authenticate via Keycloak to access /app" + product-instinct on embedded-vs-anchor-links
  • PSX Collaborator reply 2026-04-19 (archived at docs-site/docs/agents/cross-project-relays/2026-04-19-psx-superset-embedding-relay.md): authoritative file-path references + the load-bearing Q3 architectural-mismatch correction (oauth2-proxy + static-site, not NextAuth + Next.js) + 8 ranked gotchas
  • Backlog #30 closure (commit f920668): platform-Superset operational; embedding can target the stable bi.staging.powerseller.com hostname

Architect-at-dispatch checklist

When Phase 8.5 is dispatched (post-Phase-9-completion per PO sequence preference; Phase 9 currently in flight), the Architect should:

  1. Re-read this ADR + the cross-project-relays archive entry
  2. Verify the pssaas-app Keycloak client doesn't already exist; if not, create it (or coordinate with PSX Infra to create it)
  3. Verify platform-Superset's EMBEDDED_SUPERSET flag, PUBLIC_ROLE_LIKE actual-state-on-existing-DB, and TALISMAN_ENABLED setting via PSX Infra collaboration
  4. Decide the deferred items (D-8.5 deferred list above) per Alternatives-First Gate
  5. Implement the 8-dashboard registration script as the first deliverable (lowest-risk; isolatable; lets the per-page React work proceed in parallel)
  6. The PSSaaS oauth2-proxy + nginx config + Keycloak client setup are the load-bearing infrastructure pieces; the React component changes are lighter follow-on