From 397f4a6e1a35595af245adc54ee77d200fbbc7bb Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 28 Jul 2026 11:08:24 +0200 Subject: [PATCH] feat(apps): use the windmill-client SDK from raw app frontend code Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz --- backend/windmill-api/openapi.yaml | 33 +++ backend/windmill-api/src/apps.rs | 274 +++++++++++++++--- .../apps/editor/AppEditorHeaderDeploy.svelte | 50 ++++ .../apps/editor/InWorkspaceAppViewer.svelte | 5 +- .../apps/editor/PublicAppFrame.svelte | 90 +++++- .../components/raw_apps/RawAppPreview.svelte | 11 +- .../raw_apps/RawAppSdkConsent.svelte | 45 +++ .../src/lib/components/raw_apps/sdkScopes.ts | 61 ++++ .../src/lib/components/raw_apps/templates.ts | 6 +- frontend/src/lib/components/raw_apps/utils.ts | 9 +- frontend/src/routes/a/[...path]/+page.svelte | 5 +- .../[workspace]/[...path]/+page.svelte | 5 +- .../[workspace]/[...secret]/+page.svelte | 5 +- 13 files changed, 533 insertions(+), 66 deletions(-) create mode 100644 frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte create mode 100644 frontend/src/lib/components/raw_apps/sdkScopes.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index fc0a24352f..d4c20be35c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8313,6 +8313,7 @@ paths: - app parameters: - $ref: "#/components/parameters/CustomPath" + - $ref: "#/components/parameters/SdkConsent" responses: "200": description: embed token @@ -11838,6 +11839,7 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - $ref: "#/components/parameters/SdkConsent" responses: "200": description: embed token @@ -11976,6 +11978,7 @@ paths: required: true schema: type: string + - $ref: "#/components/parameters/SdkConsent" responses: "200": description: embed token @@ -23866,6 +23869,16 @@ components: required: true schema: type: string + SdkConsent: + name: sdk_consent + in: query + required: false + description: > + Raw apps: the viewer confirmed the frontend-SDK permissions consent + banner, so the viewer-scoped SDK token may actually be minted. Without + it the response only advertises the declared sdk_scopes. + schema: + type: boolean PathId: name: id in: path @@ -30700,6 +30713,17 @@ components: is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior). + frontend_sdk_scopes: + type: array + items: + type: string + description: > + Raw apps: author-declared scopes for the frontend SDK token. When + non-empty, viewers can mint (after consenting) a short-lived token + carrying their own identity restricted to these scopes, handed to + the app bundle so `windmill-client` calls run as the viewer. Must + be a subset of the server's curated allowlist (jobs:run, jobs:read, + users:read, resources:read, variables:read). ListableApp: type: object @@ -30945,6 +30969,15 @@ components: type: string nullable: true description: The resolved workspace; pairs with app_path so apps at the same path in different workspaces don't share a localStorage store. + sdk_scopes: + type: array + nullable: true + items: + type: string + description: > + Raw apps: scopes the app policy declares for the frontend SDK + token. The viewer renders these in the consent banner; token stays + absent until the endpoint is re-called with sdk_consent=true. required: - raw_app - sandbox diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index aa320c4167..8a820a1c47 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -329,6 +329,13 @@ pub struct Policy { // with the viewer's full session, the pre-isolation behavior. #[serde(skip_serializing_if = "Option::is_none")] pub sandbox: Option, + /// Author-declared scopes for the frontend SDK token (raw apps): when set and + /// non-empty, viewers can mint a short-lived token carrying THEIR identity + /// restricted to these scopes, handed to the app bundle so `windmill-client` + /// calls run as the viewer. Must be a subset of `FRONTEND_SDK_ALLOWED_SCOPES`. + /// Absent/empty means the bundle receives no credential (the default). + #[serde(skip_serializing_if = "Option::is_none")] + pub frontend_sdk_scopes: Option>, } #[derive(Deserialize)] @@ -1210,6 +1217,140 @@ pub const APP_EMBED_SCOPES: [&str; 5] = [ /// (e.g. after a `401` from the iframe) so this can stay short. const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12; +/// Scopes an app author may declare in `Policy::frontend_sdk_scopes` (the +/// viewer-identity token handed to a raw app's bundled `windmill-client`). +/// Deliberately excludes every `apps:*` scope: the embed/SDK mint routes live in +/// the Apps scope domain and scoped tokens are default-denied outside their +/// domains, so a minted SDK token can never reach the mint endpoints to renew +/// itself past its expiry. +pub const FRONTEND_SDK_ALLOWED_SCOPES: [&str; 5] = [ + "jobs:run", + "jobs:read", + "users:read", + "resources:read", + "variables:read", +]; + +/// Reject a policy declaring frontend SDK scopes outside the curated list. +/// Enforced on every policy write AND re-checked at mint time, so a policy +/// written by an older/foreign client can't broaden what gets minted. +fn validate_frontend_sdk_scopes_list(scopes: &[String]) -> Result<()> { + for s in scopes { + if !FRONTEND_SDK_ALLOWED_SCOPES.contains(&s.as_str()) { + return Err(Error::BadRequest(format!( + "Invalid frontend SDK scope '{}'. Allowed scopes: {}", + s, + FRONTEND_SDK_ALLOWED_SCOPES.join(", ") + ))); + } + } + Ok(()) +} + +fn validate_frontend_sdk_scopes(policy: &Policy) -> Result<()> { + if let Some(scopes) = &policy.frontend_sdk_scopes { + validate_frontend_sdk_scopes_list(scopes)?; + } + Ok(()) +} + +/// Mint the short-lived viewer-identity token a raw app's bundle uses for +/// `windmill-client` calls. Unlike the app-embed token (fixed narrow scopes + +/// route allowlist), its scopes are the policy-declared `frontend_sdk_scopes` — +/// the viewer consented to them before this is called — and confinement is the +/// regular default-deny scope system. +pub async fn mint_raw_app_sdk_token( + db: &DB, + w_id: &str, + app_path: &str, + authed: &ApiAuthed, + scopes: &[String], +) -> Result<(String, chrono::DateTime)> { + // An embed token represents untrusted app JS; it must not bootstrap a + // broader SDK credential (same guard as `mint_app_embed_token`). + if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) { + return Err(Error::NotAuthorized( + "App embed tokens cannot mint SDK tokens".to_string(), + )); + } + validate_frontend_sdk_scopes_list(scopes)?; + let scopes = scopes.to_vec(); + // A scope-restricted caller token must not bootstrap a broader-scoped SDK + // token. No-op for unscoped browser sessions. + ensure_scopes_within_caller(authed, Some(&scopes))?; + let expiration = chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS); + let token_config = NewToken::new( + Some(format!("sdk_app:{app_path}")), + Some(expiration), + None, + Some(scopes), + Some(w_id.to_string()), + // Never let an SDK token gain write capability the caller's own + // session lacks. + Some(authed.read_only), + ); + let mut tx = db.begin().await?; + let token = create_token_internal(&mut *tx, db, authed, token_config).await?; + tx.commit().await?; + Ok((token, expiration)) +} + +/// Shared tail of the three embed-token endpoints (by secret, by path, and the +/// EE by-custom-path variant): resolves which credential — if any — the viewer +/// gets for this app. Sandboxed low-code apps get the narrow embed token; raw +/// apps whose policy declares `frontend_sdk_scopes` get the viewer-scoped SDK +/// token, but only once the viewer consented (`sdk_consent`) — without it the +/// response only advertises `sdk_scopes` so the viewer can render the consent +/// banner and re-request. +pub async fn build_embed_token_response( + db: &DB, + w_id: &str, + app_path: &str, + raw_app: bool, + policy: &EmbedPolicyView, + opt_authed: Option<&ApiAuthed>, + sdk_consent: bool, +) -> Result { + let sdk_scopes = if raw_app && !policy.frontend_sdk_scopes.is_empty() { + Some(policy.frontend_sdk_scopes.clone()) + } else { + None + }; + let (token, expiration) = if raw_app { + match (&sdk_scopes, opt_authed) { + (Some(scopes), Some(authed)) if sdk_consent => { + let (t, e) = mint_raw_app_sdk_token(db, w_id, app_path, authed, scopes).await?; + (Some(t), Some(e)) + } + _ => (None, None), + } + } else if policy.sandbox { + let resp = mint_app_embed_token(db, w_id, app_path, opt_authed).await?; + (resp.token, resp.expiration) + } else { + (None, None) + }; + Ok(EmbedTokenResponse { + token, + expiration, + raw_app, + sandbox: policy.sandbox, + app_path: Some(app_path.to_string()), + workspace_id: Some(w_id.to_string()), + sdk_scopes, + }) +} + +/// Query for the embed-token endpoints. +#[derive(Deserialize)] +pub struct EmbedTokenQuery { + /// Raw apps: the viewer confirmed the SDK-permissions consent banner, so the + /// viewer-scoped SDK token may actually be minted. Defaults to false — the + /// first fetch only advertises the declared scopes. + #[serde(default)] + pub sdk_consent: bool, +} + #[derive(Serialize)] pub struct EmbedTokenResponse { /// Narrowly-scoped token for the iframe. `None` for fully anonymous access @@ -1237,6 +1378,11 @@ pub struct EmbedTokenResponse { /// share a store. For custom-path apps the viewer can't derive this itself. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, + /// Raw apps: scopes the app policy declares for the frontend SDK token. The + /// viewer renders these in the consent banner; `token` stays `None` until the + /// endpoint is re-called with `sdk_consent=true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sdk_scopes: Option>, } /// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller @@ -1313,6 +1459,7 @@ pub async fn mint_app_embed_token( sandbox: false, app_path: Some(app_path.to_string()), workspace_id: Some(w_id.to_string()), + sdk_scopes: None, }) } @@ -1325,6 +1472,7 @@ async fn get_app_embed_token( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, secret)): Path<(String, String)>, + Query(sdk_query): Query, ) -> JsonResult { let id = get_id_from_secret(&db, &w_id, secret, None).await?; @@ -1374,29 +1522,16 @@ async fn get_app_embed_token( Some(authed) }; - // The token is only consumed by the sandboxed low-code render. Raw apps - // render single-iframe with the page credential (WIN-2006 Variant A), and - // unsandboxed apps render same-origin with the viewer's own session — minting - // for those would write a useless token row per view and, worse, could fail - // the whole render for a scope-restricted caller (`ensure_scopes_within_caller`) - // even though no token is needed. The access check above still gates - // visibility in every case. - let mut resp = if raw_app || !policy.sandbox { - EmbedTokenResponse { - token: None, - expiration: None, - raw_app, - sandbox: policy.sandbox, - app_path: None, - workspace_id: None, - } - } else { - mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await? - }; - resp.raw_app = raw_app; - resp.sandbox = policy.sandbox; - resp.app_path = Some(app.path); - resp.workspace_id = Some(w_id.to_string()); + let resp = build_embed_token_response( + &db, + &w_id, + &app.path, + raw_app, + &policy, + authed_for_token.as_ref(), + sdk_query.sdk_consent, + ) + .await?; Ok(Json(resp)) } @@ -1410,6 +1545,9 @@ async fn get_app_embed_token( pub struct EmbedPolicyView { pub anonymous_execution: bool, pub sandbox: bool, + /// Raw apps: author-declared scopes for the frontend SDK token; empty when + /// the app doesn't use the frontend SDK (non-string entries are ignored). + pub frontend_sdk_scopes: Vec, } pub fn parse_embed_policy(policy_str: &str) -> Result { @@ -1417,6 +1555,15 @@ pub fn parse_embed_policy(policy_str: &str) -> Result { Ok(EmbedPolicyView { anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"), sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false), + frontend_sdk_scopes: v + .get("frontend_sdk_scopes") + .and_then(|s| s.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), }) } @@ -1430,6 +1577,7 @@ async fn get_app_embed_token_for_path( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(sdk_query): Query, ) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; @@ -1450,26 +1598,19 @@ async fn get_app_embed_token_for_path( let policy_str = app .policy .ok_or_else(|| Error::internal_err("App policy missing".to_string()))?; - // Lenient parse + mint only for the sandboxed low-code render — see - // [`get_app_embed_token`] for the rationale (identical here). + // Lenient parse — see [`get_app_embed_token`] for the rationale (identical here). let policy = parse_embed_policy(&policy_str)?; - let mut resp = if raw_app || !policy.sandbox { - EmbedTokenResponse { - token: None, - expiration: None, - raw_app, - sandbox: policy.sandbox, - app_path: None, - workspace_id: None, - } - } else { - mint_app_embed_token(&db, &w_id, path, Some(&authed)).await? - }; - resp.raw_app = raw_app; - resp.sandbox = policy.sandbox; - resp.app_path = Some(path.to_string()); - resp.workspace_id = Some(w_id.to_string()); + let resp = build_embed_token_response( + &db, + &w_id, + path, + raw_app, + &policy, + Some(&authed), + sdk_query.sdk_consent, + ) + .await?; Ok(Json(resp)) } @@ -1854,6 +1995,7 @@ async fn create_app_internal<'a>( // inside process_app_multipart!, so checking after this call would leave a // denied app committed in the DB. check_scopes(&authed, || format!("apps:write:{}", &app.path))?; + validate_frontend_sdk_scopes(&app.policy)?; if *CLOUD_HOSTED { let nb_apps = sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id) @@ -2492,6 +2634,7 @@ async fn update_app_internal<'a>( } if let Some(mut npolicy) = ns.policy { + validate_frontend_sdk_scopes(&npolicy)?; if matches!(npolicy.execution_mode, ExecutionMode::Anonymous) && !authed.is_admin { // Restricted users may keep deploying an app that is already // public, but flipping an app to anonymous (public) access is @@ -4724,6 +4867,55 @@ mod embed_token_tests { .includes(&required)); } + /// The raw-app frontend SDK token's confinement rests on one property: the + /// curated scope list contains no `apps:*` scope, so the (Apps-domain, + /// default-denied) mint endpoints are unreachable and a captured SDK token + /// cannot renew itself past its expiry. Lock that, plus the intended reach: + /// the viewer-permissioned surface each curated scope grants. + #[test] + fn frontend_sdk_scopes_reach_declared_domains_but_never_mint_routes() { + let scopes: Vec = super::FRONTEND_SDK_ALLOWED_SCOPES + .iter() + .map(|s| s.to_string()) + .collect(); + let scopes = Some(scopes.as_slice()); + + let allowed = [ + ("/api/w/test/jobs/run/p/u/admin/script", "POST"), + ("/api/w/test/jobs/run_wait_result/p/u/admin/script", "POST"), + ("/api/w/test/jobs_u/completed/get_result/some-uuid", "GET"), + ("/api/w/test/users/whoami", "GET"), + // Unlike the embed token, resource VALUE reads are intended here — + // the author declared it and the viewer consented. + ("/api/w/test/resources/get_value/u/admin/r", "GET"), + ("/api/w/test/variables/get_value/u/admin/v", "GET"), + ]; + for (path, method) in allowed { + assert!( + check_scopes_for_route(scopes, path, method).is_ok(), + "SDK token should allow {method} {path}" + ); + } + + let denied = [ + // No apps scope → every mint endpoint (and the Apps domain at large) + // is unreachable: no self-renewal, no embed-token bootstrap. + ("/api/w/test/apps_u/embed_token/secret", "GET"), + ("/api/w/test/apps/embed_token/p/u/admin/app", "GET"), + ("/api/w/test/apps_u/embed_token_by_custom_path/foo", "GET"), + ("/api/w/test/apps/get/p/u/admin/app", "GET"), + // Read-level scopes must not grant writes. + ("/api/w/test/resources/update/u/admin/r", "POST"), + ("/api/w/test/variables/create", "POST"), + ]; + for (path, method) in denied { + assert!( + check_scopes_for_route(scopes, path, method).is_err(), + "SDK token should deny {method} {path}" + ); + } + } + /// The token carries path-scoped `apps:run:` and `apps:read:` /// (NOT unqualified `apps:run`). Every handler that resolves an app and acts on /// its behalf re-checks the requested path via `ScopeDefinition::includes`, so the diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 880c24810d..c17814ff11 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -21,6 +21,7 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' + import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -329,6 +330,55 @@ {/if} +{#if rawApp} +

Frontend API access

+
+
+ Let the app's frontend code call the Windmill API through the windmill-client + SDK, authenticated as the viewer (unlike runnables, which run on behalf of the publisher). + Each viewer is asked to approve the scopes below before the app runs. Grant only what the app needs: + its code — or an XSS bug in it — can use them as that viewer. +
+ {#each FRONTEND_SDK_SCOPES as scope (scope.value)} +
+ { + const current: string[] = policy.frontend_sdk_scopes ?? [] + const next = e.detail + ? [...current, scope.value] + : current.filter((s) => s !== scope.value) + // Keep the curated order so the consent banner and the stored + // consent compare stably across deploys. + const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s)) + policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined + // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the + // scopes ride along in the first deploy's policy instead. + if (savedApp && !newApp) { + setPublishState('Frontend API access updated') + } + }} + disabled={!savedApp} + /> +
{scope.description}
+
+ {/each} + {#if newApp} +
Takes effect when you first deploy this app.
+ {/if} + {#if policy.sandbox == true && policy.frontend_sdk_scopes?.length} +
+ + A sandboxed app's bundle runs on an opaque origin and cannot use the SDK token. Turn off + sandbox isolation for these scopes to take effect. + +
+ {/if} +
+{/if} + {#if !hideSecretUrl}

Public URL

diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index cd50455a18..d5839c6766 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -50,12 +50,13 @@ const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true' // Embedder side: mint a scoped embed token (by path) from the member's session. - async function fetchEmbedToken(): Promise<{ token?: string }> { + async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> { const headers: Record = {} if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) { headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}` } - const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, { + const consent = opts?.sdkConsent ? '?sdk_consent=true' : '' + const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}${consent}`, { headers }) if (!res.ok) { diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 43dc937651..c0b8f5de1e 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -32,6 +32,8 @@ import Login from '$lib/components/Login.svelte' import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils' import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' + import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' + import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' type EmbedToken = { token?: string | null @@ -39,6 +41,7 @@ sandbox?: boolean app_path?: string | null workspace_id?: string | null + sdk_scopes?: string[] | null } let { @@ -48,8 +51,10 @@ viewerUrl }: { /** Embedder-side: validate access + mint the scoped token. Throws with a - * `.status` of 401 (login required) or 404 (not found). */ - fetchEmbedToken: () => Promise + * `.status` of 401 (login required) or 404 (not found). Pass + * `sdkConsent` once the viewer accepted the frontend-SDK permission + * banner — only then does the backend mint the raw-app SDK token. */ + fetchEmbedToken: (opts?: { sdkConsent?: boolean }) => Promise /** Viewer-side: fired (once per received token) when the embed token is * available, before the app renders. Use it to kick off data loading. * `requestTokenRefresh` asks the embedder for a fresh token on a 401. */ @@ -188,10 +193,17 @@ } // ---------------------------- embedder mode ---------------------------- - let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading') + let status: 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkConsent' = $state('loading') let embedToken: string | null = $state(null) let iframeEl: HTMLIFrameElement | undefined = $state(undefined) + // Raw-app frontend SDK (viewer-permissioned windmill-client): the scopes the + // app policy declares, and the viewer-identity token minted after consent. + // RawAppPreview reads the token via context and exposes it to the bundle as + // `window.process.env` so a bundled `windmill-client` auto-configures. + let sdkScopes: string[] | undefined = $state(undefined) + let sdkToken: string | undefined = $state(undefined) + // WIN-2006: publisher opted this app into sandbox isolation (alpha). When false // (the default) the app runs same-origin with the viewer's full session — the // pre-isolation behavior. @@ -224,6 +236,14 @@ } }) + // Read by RawAppPreview: the consented viewer-scoped SDK token to expose to + // the bundle (unsandboxed raw apps only). + setContext('RAW_APP_SDK_TOKEN', { + get value() { + return sdkToken + } + }) + function buildViewerUrl(): string { // Default: embed the current route. The in-workspace viewer overrides this // with a dedicated cookieless, chrome-less viewer route (`/app_embed`). @@ -243,22 +263,61 @@ isRaw = resp.raw_app ?? false appPath = resp.app_path ?? undefined workspaceId = resp.workspace_id ?? undefined - status = 'ready' - - if (unsandboxed || isRaw) { - // Render the app directly on this origin: same-origin when unsandboxed - // (the default), or a single opaque bundle iframe when it's a sandboxed - // raw app. - onViewerReady?.(undefined, requestTokenRefresh) - } else { - // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. - postTokenToIframe() + // SDK tokens only exist for unsandboxed raw apps: the sandboxed (opaque + // origin) bundle can't reach the API cross-origin with this credential yet. + sdkScopes = + resp.raw_app && !resp.sandbox && resp.sdk_scopes?.length ? resp.sdk_scopes : undefined + if (sdkScopes) { + if (!hasStoredSdkConsent(workspaceId ?? '', appPath ?? '', sdkScopes)) { + // Block the app render behind the permission banner: the app's own + // code must not run before the viewer decided (it runs same-origin). + status = 'sdkConsent' + return + } + await mintSdkToken() } + finishReady() } catch (e: any) { status = e?.status === 401 ? 'noPermission' : 'notExists' } } + function finishReady() { + status = 'ready' + if (unsandboxed || isRaw) { + // Render the app directly on this origin: same-origin when unsandboxed + // (the default), or a single opaque bundle iframe when it's a sandboxed + // raw app. + onViewerReady?.(undefined, requestTokenRefresh) + } else { + // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. + postTokenToIframe() + } + } + + /** Mint the viewer-scoped SDK token (consent already given). Tolerant: a + * failed mint (e.g. a scope-restricted caller session that can't satisfy the + * declared scopes) must not block the app render — the bundle then simply + * gets no credential and its SDK calls fail with 401. */ + async function mintSdkToken() { + try { + const resp = await fetchEmbedToken({ sdkConsent: true }) + sdkToken = resp.token ?? undefined + } catch (e) { + console.warn('Failed to mint the frontend SDK token', e) + sdkToken = undefined + } + } + + async function onSdkConsentContinue(dontAskAgain: boolean) { + if (dontAskAgain) { + storeSdkConsent(workspaceId ?? '', appPath ?? '', sdkScopes ?? []) + } + status = 'loading' + await mintSdkToken() + finishReady() + } + function postTokenToIframe() { // The iframe is an opaque origin ("null"), which cannot be named as a // targetOrigin, so we use '*'. This only relaxes the receiver-origin @@ -390,6 +449,11 @@ Go to Windmill +{:else if status === 'sdkConsent'} + + {:else if status === 'noPermission'} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 9884881717..765e06cd02 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -36,6 +36,11 @@ // same-origin with full access (the default); otherwise the opaque-origin sandbox. const unsandboxedCtx = getContext<{ value: boolean }>('IS_APP_UNSANDBOXED') let unsandboxed = $derived(unsandboxedCtx?.value ?? false) + + // Viewer-scoped frontend SDK token (PublicAppFrame mints it after the viewer + // consents to the app's declared scopes). Exposed to the bundle as + // `window.process.env` so a bundled `windmill-client` auto-configures. + const sdkTokenCtx = getContext<{ value: string | undefined }>('RAW_APP_SDK_TOKEN') // Unsandboxed (the default) must match the pre-isolation viewer exactly: NO // sandbox attribute (a same-origin blob with full session — an attribute would // only break leftover features like unsandboxed popups for OAuth flows, while @@ -69,12 +74,16 @@ // Always pass the wrapper object — pre-sandbox bundles rely on // `window.ctx.workspace` even for anonymous viewers (ctx.ctx undefined). const u = untrack(() => user) + const sdkToken = sdkTokenCtx?.value const html = unsandboxedRawAppHtml( workspace, secret, { ctx: u, workspace }, window.location.origin, - window.location.hash || '' + window.location.hash || '', + sdkToken + ? { WM_TOKEN: sdkToken, BASE_URL: window.location.origin, WM_WORKSPACE: workspace } + : undefined ) return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } diff --git a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte new file mode 100644 index 0000000000..2bbd206e3a --- /dev/null +++ b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte @@ -0,0 +1,45 @@ + + +
+
+
+ +
This app requires the following permissions
+
+

+ The app's code will be able to call the Windmill API on your behalf, restricted to: +

+
    + {#each scopes as scope (scope)} +
  • + {sdkScopeLabel(scope)} + {#if sdkScopeDescription(scope)} + — {sdkScopeDescription(scope)} + {/if} +
  • + {/each} +
+
+ + +
+
+
diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts new file mode 100644 index 0000000000..942285e9ea --- /dev/null +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -0,0 +1,61 @@ +// Frontend-SDK permissions for raw apps: the curated scopes an app author may +// declare in `policy.frontend_sdk_scopes` (mirrors FRONTEND_SDK_ALLOWED_SCOPES +// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side +// consent persistence for the permission banner. + +export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [ + { + value: 'jobs:run', + label: 'Run scripts and flows', + description: 'Execute any script or flow the viewer can run, and read jobs' + }, + { + value: 'jobs:read', + label: 'Read jobs and results', + description: 'Poll jobs by id and read their results' + }, + { + value: 'users:read', + label: 'Read your identity', + description: 'Read the viewer username and email (whoami)' + }, + { + value: 'resources:read', + label: 'Read resources', + description: 'Read resource values the viewer can access, including credentials' + }, + { + value: 'variables:read', + label: 'Read variables', + description: 'Read variable values the viewer can access' + } +] + +export function sdkScopeLabel(scope: string): string { + return FRONTEND_SDK_SCOPES.find((s) => s.value === scope)?.label ?? scope +} + +export function sdkScopeDescription(scope: string): string | undefined { + return FRONTEND_SDK_SCOPES.find((s) => s.value === scope)?.description +} + +function sdkConsentKey(workspace: string, path: string): string { + return `wm_sdk_consent:${workspace}:${path}` +} + +/** True when a previously stored "do not ask again" consent covers every + * declared scope. A later deploy that adds scopes re-triggers the banner. */ +export function hasStoredSdkConsent(workspace: string, path: string, scopes: string[]): boolean { + try { + const stored = JSON.parse(localStorage.getItem(sdkConsentKey(workspace, path)) ?? 'null') + return Array.isArray(stored) && scopes.every((s) => stored.includes(s)) + } catch (_) { + return false + } +} + +export function storeSdkConsent(workspace: string, path: string, scopes: string[]): void { + try { + localStorage.setItem(sdkConsentKey(workspace, path), JSON.stringify(scopes)) + } catch (_) {} +} diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index 335ae4d8e8..582f848297 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -121,7 +121,8 @@ export const react18Template = { '/package.json': `{ "dependencies": { "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "windmill-client": "^1" }, "devDependencies": { "@types/react-dom": "^19.0.0", @@ -149,7 +150,8 @@ export const vueTemplate = { '/package.json': `{ "dependencies": { "core-js": "3.26.1", - "vue": "3.5.13" + "vue": "3.5.13", + "windmill-client": "^1" } }` } diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 12dc7d87d4..59cea7e37c 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -163,7 +163,13 @@ export function unsandboxedRawAppHtml( secret: string, ctx: any, baseUrl: string, - initialHash: string + initialHash: string, + /** When the viewer consented to the app's declared frontend-SDK scopes, the + * minted viewer token + connection settings, exposed as `window.process.env` + * (WM_TOKEN/BASE_URL/WM_WORKSPACE) BEFORE the bundle runs so a bundled + * `windmill-client` auto-configures at module load. Absent otherwise — the + * bundle then receives no credential and no API base. */ + sdkEnv?: Record ) { return ` @@ -172,6 +178,7 @@ export function unsandboxedRawAppHtml( App