Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ STACKABLE_COCKPIT_SESSION_SECRET=change-me-to-a-long-random-secret-min-32-chars
# The publicly accessible base URL of this application
STACKABLE_COCKPIT_BASE_URL=http://localhost:5173

# OIDC discovery URL (Keycloak, Entra ID, or any compliant OIDC provider)
# OIDC discovery URL (dex, Keycloak, Entra ID, or any compliant OIDC provider).
#
# Recommended: point this at a dex instance (https://dexidp.io) that fronts the
# customer's OIDC provider. Stackable controls dex, so external services (e.g.
# Airflow) can be embedded via dex without loosening the upstream provider's
# frame/CSP headers, and one dex session can be shared between the cockpit and
# those services (SSO). The dev stack uses `http://localhost:5556` (see
# ~/airflow-kc).
STACKABLE_COCKPIT_OIDC_DISCOVERY_URL=https://your-idp.example.com/realms/your-realm/.well-known/openid-configuration

# The client ID and secret registered in your OIDC provider
Expand All @@ -14,6 +21,36 @@ STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret
# OIDC claim used as the username for Trino impersonation (default: preferred_username)
# STACKABLE_COCKPIT_OIDC_USERNAME_CLAIM=preferred_username

# Optional embedded external services. Any bookmark product id can be embedded
# through the same-origin service proxy (`/api/services/<id>/`) by configuring
# its upstream URL with flat environment variables:
#
# STACKABLE_COCKPIT_<ID>_URL upstream base URL (presence enables the service)
# STACKABLE_COCKPIT_<ID>_AUTH_MODE "none" (default) | "all-admins" | "bearer" | "sso" | "simple-users"
# STACKABLE_COCKPIT_<ID>_BEARER_TOKEN required when AUTH_MODE=bearer
# STACKABLE_COCKPIT_<ID>_SIMPLE_USERS "user:password,..." required when AUTH_MODE=simple-users
#
# Auth modes:
# - "none": plain same-origin reverse proxy (works for most web UIs).
# - "simple-users": per-user login on stock Airflow. The proxy exchanges the
# cockpit session identity (from dex/Keycloak) for a real Airflow token via
# SimpleAuthManager's /auth/token, using the matching entry from
# _SIMPLE_USERS. The embedded UI is logged in as that user, not anonymous.
# - "all-admins": fetch an anonymous admin token from Airflow's all-admins
# endpoint (no identity).
# - "bearer": sends a dedicated static token upstream.
# - "sso": forwards the cockpit session identity as X-Forwarded-* headers;
# requires an upstream auth layer that trusts those headers.
#
# Example — local dev stack (~airflow-proxy defines the same users):
# STACKABLE_COCKPIT_AIRFLOW_URL=http://127.0.0.1:8089
# STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=simple-users
# STACKABLE_COCKPIT_AIRFLOW_SIMPLE_USERS=alice:pipeline-alice,bob:pipeline-bob
#
# Example — any other product UI (Superset, Spark, NiFi, ...):
# STACKABLE_COCKPIT_SUPERSET_URL=http://superset.example.com
# STACKABLE_COCKPIT_SPARK_URL=http://spark-history.example.com


# Logging
# LOG_LEVEL=debug # Minimum log level (debug, info, warn, error). Default: debug in dev, info in prod.
Expand Down
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ PUBLIC_STACKABLE_COCKPIT_MAX_RECENT_FILES=15
PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3
GARAGE_ADMIN_URL=http://localhost:30902
GARAGE_ADMIN_TOKEN=stackable-cockpit-e2e-admin-token
STACKABLE_COCKPIT_AIRFLOW_URL=http://localhost:8089
STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=all-admins
STACKABLE_COCKPIT_SUPERSET_URL=http://localhost:8088
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,54 @@ The application is configured via environment variables. Create a `.env` file at
| `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES` | `integer` (bytes) | `26214400` (25 MiB) | Maximum bytes fetched when previewing PDF files. | `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=52428800` |
| `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS` | `integer` (rows) | `250` | Maximum number of rows included in a tabular file preview (e.g. Parquet converted to CSV). | `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=500` |

### Embedded Services

Embedded services are routed through authenticated, same-origin SvelteKit routes under
`/api/services/<service>/`. This removes browser CORS and iframe-cookie concerns without
modifying the service or deploying a separate reverse proxy. Service credentials are used
only by the Cockpit server and never reach the browser.

Any bookmark product id can be embedded through the proxy by configuring its upstream URL
with flat environment variables (`STACKABLE_COCKPIT_<ID>_URL`, `_AUTH_MODE`,
`_BEARER_TOKEN`). A bookmark for a configured product is then served from
`/api/services/<id>/` rather than its raw URL; unconfigured products keep using the raw URL.

| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `STACKABLE_COCKPIT_<ID>_URL` | Direct HTTP(S) URL of the service's web UI or API server. Setting it enables the proxy for that product. |
| `STACKABLE_COCKPIT_<ID>_AUTH_MODE` | `none` (default, plain reverse proxy), `simple-users` (per-user Airflow login: the proxy exchanges the dex-sourced cockpit session identity for a real Airflow token via stock `SimpleAuthManager`), `all-admins` (anonymous admin token, stock dev fallback), `bearer` (static token), `sso` (forwards the cockpit session identity as `X-Forwarded-*` headers to an upstream that trusts them). |
| `STACKABLE_COCKPIT_<ID>_BEARER_TOKEN` | Required when `AUTH_MODE=bearer`; remains server-side. |
| `STACKABLE_COCKPIT_<ID>_SIMPLE_USERS` | Required when `AUTH_MODE=simple-users`; comma-separated `user:password` pairs matching the upstream's named users. |

For example, to embed Superset and Spark alongside Airflow:

```text
STACKABLE_COCKPIT_AIRFLOW_URL=http://127.0.0.1:8089
STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=simple-users
STACKABLE_COCKPIT_AIRFLOW_SIMPLE_USERS=alice:pipeline-alice,bob:pipeline-bob
STACKABLE_COCKPIT_SUPERSET_URL=http://superset.example.com
STACKABLE_COCKPIT_SPARK_URL=http://spark-history.example.com
```

`~/airflow-proxy` contains the default **stock** Airflow 3.3.0 development stack — no image
patches, no SSO services inside Airflow. It defines the dev realm's users (alice/bob) as
stock `SimpleAuthManager` named users so the Cockpit proxy can log the embedded UI in as the
actual session user; production deployments must use a real OIDC auth manager or `bearer`
with a least-privilege service token.

## dex (development SSO broker)

`dev/docker-compose.dex.yml` runs a shared [dex](https://dexidp.io) instance on
`http://localhost:5556` (`dev/dex.yaml`). It fronts the kind-cluster Keycloak deployed by
`dev/setup.sh` (realm `stackable`) and serves as the single OIDC issuer for the cockpit, so
one browser session covers the cockpit login and any embedded services. Start the cluster
first, then:

```bash
docker compose -f dev/docker-compose.dex.yml up -d
./dev/setup.sh # auto-detects dex on :5556 and configures .env.development accordingly
```

## Contributing

1. Make your changes
Expand Down
24 changes: 24 additions & 0 deletions TECH_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ All server-side query state (progress, rows, status) is held in a module-level `

---

### Bookmark tools embedded via unsandboxed iframe

**File:** `src/routes/(app)/bookmark/[id]/+page.svelte`

Bookmarks are embedded as full-page iframes without a `sandbox` attribute, so the embedded tool can run scripts, navigate the top frame, and read cookies in its own origin context. A `sandbox` attribute would break legitimate tools that need scripts/forms, and most external services will refuse framing anyway via `X-Frame-Options`/CSP. Acceptable for the current stage; long-term, consider a configurable sandbox policy per bookmark and validation of the URL scheme (http/https only).

---

### Airflow's startup version check misses the proxy prefix

**File:** `src/lib/server/embedded-services.ts`

Airflow's OpenAPI client derives its base URL from the document `<base>` tag at `queryClient` initialisation (`OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href")`). One call, `VersionService.getVersion()` in the i18n bootstrap (`src/i18n/config.ts`), fires at module import time — before `queryClient.ts` sets `OpenAPI.BASE` from the rewritten `<base href="/api/services/airflow/">` — so it requests the origin-rooted `/api/v2/version` and 404s through the proxy. The failure is swallowed by the UI (`.catch(() => fut(''))`), so only the version badge in the top bar is affected; nothing else breaks. Acceptable while embedding the stock UI; the robust fix would be rewriting the response to inject the service prefix into the version call or patching the upstream bundle.

---

### Single-file download limit

**File:** `src/lib/storage/download.ts`, `src/lib/components/storage/FileExplorer.svelte`
Expand Down Expand Up @@ -149,3 +165,11 @@ The upload endpoint imposes no maximum file size. S3's 5 TB single-object limit
**File:** `src/routes/healthz/+server.ts`, `deploy/helm/cockpit/values.yaml`

Both `livenessProbe` and `readinessProbe` point at `/healthz`, which always returns 200. There is currently nothing meaningful to gate readiness on (better-auth uses an in-memory session store, OIDC discovery is fetched lazily on first auth call), so a separate `/readyz` would just be a placeholder. Once one of these lands — a real session store / DB, eager OIDC discovery, or a startup-time cache warm — split into `/healthz` (liveness, trivial) and `/readyz` (readiness, checking the new dependency), and update the helm probes accordingly.

---

### Embedded-service `sso` auth mode trusts forwarded identity headers

**File:** `src/lib/server/embedded-services.ts`

In `AUTH_MODE=sso` the proxy derives `X-Forwarded-Preferred-Username`/`X-Forwarded-Email` from the cockpit session and forwards them upstream, where an identity-trusting auth layer turns them into a per-user session without verifying them again. Inbound `X-Forwarded-*` headers from the browser are always stripped by the proxy, so the trust boundary is the cockpit server itself. This is safe for loopback-only dev stacks; a production deployment should instead terminate at an authenticating proxy (e.g. oauth2-proxy) on the service side or use a real OIDC auth manager, and treat the forwarded headers as untrusted input.
67 changes: 67 additions & 0 deletions dev/dex.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# dex — the OIDC identity broker for the Stackable Cockpit dev stack.
#
# dex fronts the "customer" OIDC provider — the Keycloak deployed by
# dev/setup.sh into the kind/k3s cluster (realm `stackable`, NodePort :30080) —
# and presents itself as the single OIDC issuer for the cockpit and any
# embedded external services. Because Stackable controls this dex instance,
# external service login flows can run inside iframe embeddings without
# loosening the upstream provider's frame/CSP headers, and one dex session is
# shared across the whole surface (SSO).
#
# It is deliberately served on `http://localhost:5556` — the SAME site as the
# cockpit (`http://localhost:5173`). That keeps dex's session cookie at
# SameSite=Lax and still lets Chrome send it on cross-site iframe navigations
# (SameSite is evaluated against the top-level site), so embedded services can
# silently reuse the SSO session.
#
# dev/setup.sh creates the matching `dex` client in the Keycloak realm and
# points the cockpit at this broker automatically when it detects
# http://localhost:5556/.well-known/openid-configuration.
#
# NOTE: Keycloak must be up before dex starts — dex opens its connector at
# startup and exits if discovery fails.
issuer: http://localhost:5556

storage:
type: memory

web:
http: 0.0.0.0:5556

expiry:
idTokens: '24h'
signingKeys: '6h'

oauth2:
# Never show dex's consent/approval screen — login implies consent, which
# would otherwise render inside cockpit iframe embeddings.
skipApprovalScreen: true
# Single connector, so dex redirects straight to Keycloak (no connector
# selection page) unless a session already exists.
alwaysShowLoginScreen: false

staticClients:
# The Stackable Cockpit (better-auth genericOAuth callback).
- id: stackable-cockpit
name: Stackable Cockpit
secret: lY7rCsg4Ae0Gj1L119CRt1sGw2Z2yEBT
redirectURIs:
- 'http://localhost:5173/api/auth/oauth2/callback/oidc'

connectors:
# The real "customer" OIDC provider — the kind-cluster Keycloak, `stackable`
# realm. Keycloak keeps its strict security headers (frame-ancestors 'self',
# X-Frame-Options: SAMEORIGIN) — the browser never needs to frame Keycloak
# because dex (same-site with the cockpit) brokers the login and reuses the
# Keycloak SSO session silently.
- type: oidc
id: keycloak
name: Keycloak
config:
issuer: http://localhost:30080/realms/stackable
clientID: dex
clientSecret: dex-secret
redirectURI: http://localhost:5556/callback
insecureSkipEmailVerified: true

enablePasswordDB: false
30 changes: 30 additions & 0 deletions dev/docker-compose.dex.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Runs the shared dex OIDC identity broker for the Stackable Cockpit dev stack.
#
# docker compose -f dev/docker-compose.dex.yml up -d
#
# Start the Keycloak/Trino cluster (dev/setup.sh) FIRST — dex opens its
# Keycloak connector at startup and exits if discovery fails. dev/setup.sh
# auto-detects dex on localhost:5556 and uses it as the cockpit's OIDC provider,
# so one dex session covers the cockpit login and any embedded services (SSO).
name: stackable-cockpit-dex

services:
dex:
image: ghcr.io/dexidp/dex:v2.45.1
command: ['dex', 'serve', '/etc/dex/config.yaml']
volumes:
- ./dex.yaml:/etc/dex/config.yaml:ro
ports:
- '5556:5556'
healthcheck:
test:
['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:5556/.well-known/openid-configuration']
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
extra_hosts:
# dex reaches Keycloak (cluster NodePort on the host) server-to-server via
# the host gateway; Go's resolver falls through the container loopback to
# this mapping when Keycloak is reachable there.
- 'localhost:host-gateway'
Loading