From 81905e455b2dfa5995687b0eb398acba09f9d4de Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 31 Jul 2026 14:57:03 +0200 Subject: [PATCH] refactor(apps): authenticate the raw app preview by session instead of a token The editor preview is same-origin and unsandboxed, so app code there already holds the editing user's session cookie. Minting a scoped bearer for it added an endpoint and a portable 12h credential without containing anything. Inject only BASE_URL and WM_WORKSPACE: `windmill-client` falls back to credentialed same-origin requests when it finds no token, so the SDK runs as the editing user. Drops POST /apps/preview_sdk_token and the mint/race handling in the editor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KPCW1WB5QeYrgJmgwcywNA --- backend/windmill-api-auth/src/scopes.rs | 8 +- backend/windmill-api/openapi.yaml | 37 --------- backend/windmill-api/src/apps.rs | 44 +---------- .../apps/editor/PublicAppFrame.svelte | 8 +- .../components/raw_apps/RawAppEditor.svelte | 75 +++++-------------- 5 files changed, 29 insertions(+), 143 deletions(-) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index ccf02db7dd..6b3609b80f 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -728,11 +728,9 @@ pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool { scopes.is_some_and(|s| s.iter().any(|x| x == RAW_APP_SDK_SENTINEL)) } -/// Endpoints that run code the caller supplies, or names by job id — the latter -/// re-run code a named job already holds (`workflow_as_code` copies its -/// `raw_code`, `restart/f` its `raw_flow`), with no ownership check. Their jobs -/// get an unscoped credential as the viewer, so reaching any of them would make a -/// captured SDK token a full account takeover. +/// Endpoints that run code the caller supplies or names by job id (the latter +/// with no ownership check). Their jobs get an unscoped credential as the viewer, +/// so reaching one would make a captured SDK token a full account takeover. fn is_request_supplied_code_route(suffix: &str) -> bool { // Prefixes, so the `_async` variants are covered too. const CODE_ROUTES: [&str; 10] = [ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bef6c84c8b..4aedd0b45e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11908,43 +11908,6 @@ paths: - $ref: "#/components/schemas/AppWithLastVersion" - $ref: "#/components/schemas/UserDraftOverlay" - /w/{workspace}/apps/preview_sdk_token: - post: - summary: mint a frontend SDK token for the raw app editor's preview - operationId: mintPreviewSdkToken - tags: - - app - parameters: - - $ref: "#/components/parameters/WorkspaceId" - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - description: App being edited; may not be deployed yet. - scopes: - type: array - items: - type: string - description: > - Scopes from the policy being edited. Capped by the curated - allowlist and by the caller's own scopes, and minted as the - caller, so it grants nothing they could not mint themselves. - required: - - path - - scopes - responses: - "200": - description: the token - content: - text/plain: - schema: - type: string - /w/{workspace}/apps/embed_token/p/{path}: get: summary: get app embed token by path diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 2432c11e4a..4a8208a836 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -102,7 +102,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { .route("/list_search", get(list_search_apps)) .route("/get/p/{*path}", get(get_app)) .route("/embed_token/p/{*path}", get(get_app_embed_token_for_path)) - .route("/preview_sdk_token", post(mint_preview_sdk_token)) .route("/get/lite/{*path}", get(get_app_lite)) .route("/secret_of/{*path}", get(get_secret_id)) .route( @@ -1281,9 +1280,8 @@ fn validate_frontend_sdk_scopes(policy: &Policy) -> Result<()> { /// Mint the viewer-identity token a raw app's bundle uses for `windmill-client`. /// -/// The CALLER MUST establish that `authed` may hold this app's credential: the -/// viewer endpoints verify read access, `mint_preview_sdk_token` requires -/// `apps:write:`. +/// The CALLER MUST establish that `authed` may hold this app's credential; the +/// viewer endpoints verify read access first. async fn mint_raw_app_sdk_token( db: &DB, w_id: &str, @@ -1328,9 +1326,8 @@ async fn mint_raw_app_sdk_token( /// Shared tail of the three embed-token endpoints: which credential the viewer /// gets. Sandboxed low-code gets the embed token; a raw app declaring -/// `frontend_sdk_scopes` gets the SDK token once `sdk_consent` is set. -/// `sdk_consent` is the viewer's answer, not a boundary — the boundary is the -/// scope set, capped by the viewer's own permissions. +/// `frontend_sdk_scopes` gets the SDK token once `sdk_consent` is set — the +/// viewer's answer, not a boundary; the boundary is the scope set. /// /// The CALLER MUST verify that `opt_authed` may view `app_path`. pub async fn build_embed_token_response( @@ -1382,39 +1379,6 @@ pub async fn build_embed_token_response( }) } -#[derive(Deserialize)] -pub struct PreviewSdkTokenRequest { - /// The app being edited. May not exist yet (a draft), so this is only used for - /// the write-scope check and the token label. - pub path: String, - /// Scopes from the policy currently being edited, so the preview behaves like - /// the deployed app instead of the last-deployed policy. - pub scopes: Vec, -} - -/// Mint an SDK token for the raw-app editor's own preview. The author grants it -/// to themselves — minted from their session and capped the same way, so it -/// conveys nothing `users/tokens/create` wouldn't. Scopes come from the request -/// so the preview matches an app whose scopes aren't deployed yet, as -/// `execute_component` already does for editor previews. -async fn mint_preview_sdk_token( - authed: ApiAuthed, - OptJobAuthed { job_id, .. }: OptJobAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(req): Json, -) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot preview raw apps".to_string(), - )); - } - check_scopes(&authed, || format!("apps:write:{}", req.path))?; - let (token, _expiration) = - mint_raw_app_sdk_token(&db, &w_id, &req.path, &authed, &req.scopes, job_id).await?; - Ok(token) -} - /// Query for the embed-token endpoints. #[derive(Deserialize)] pub struct EmbedTokenQuery { diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 4e2f3d0248..690cb16ea7 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -383,11 +383,9 @@ } } - /** Declined: render the app anyway, with no credential for its frontend code - * (its SDK calls then fail unauthorized). Never stored, so the next visit asks - * again. Re-init rather than rendering straight away: the app may have been - * redeployed while the prompt was open, and rendering from the pre-prompt mode - * could put a now-sandboxed bundle on the same-origin path. */ + /** Declined: render credential-less, never stored. Re-init first — a redeploy + * during the prompt can have enabled sandboxing, and the pre-prompt mode would + * put that bundle on the same-origin path. */ function onSdkConsentDecline() { sdkToken = undefined sdkTokenless = true diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8d3ba4c9f3..e52864ce3b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -7,7 +7,7 @@ import RawAppYamlEditor, { type RawAppYamlUpdate } from './RawAppYamlEditor.svelte' import type Drawer from '../common/drawer/Drawer.svelte' import Alert from '../common/alert/Alert.svelte' - import { AppService, type Policy, WorkspaceService } from '$lib/gen' + import { type Policy, WorkspaceService } from '$lib/gen' import DiffDrawer from '../DiffDrawer.svelte' import { deepEqual } from 'fast-equals' @@ -1284,63 +1284,26 @@ // `app-preview.html` evaluates the js we post, so prefixing the env is what a // bundled `windmill-client` needs — it reads `window.process.env` at module - // load. Scoped to the policy being edited, so preview hits the same 403s. - - // Stated on every payload, tokenless included: the preview shell reuses one - // window across builds, so omitting it would leave an old token in place. - // Deleting rather than blanking matches a deployed app with no scopes. - const NO_SDK_ENV_JS = 'try { delete window.process } catch (_) {}\n' - let previewSdkEnvJs = $state(NO_SDK_ENV_JS) - // Identifies the request whose answer is still wanted. Toggling scopes starts a - // new mint while an older one is in flight, and an out-of-order answer would - // otherwise hand the preview the wrong scope set — or restore a token after all - // scopes were removed. - let previewSdkKey: string | undefined = undefined - - /** Assign the prologue and re-feed, so a running preview stops using a - * credential the policy no longer grants. */ - function setPreviewSdkEnv(js: string) { - previewSdkEnvJs = js - if (lastBuild) feedPreviewIframe(lastBuild) - syncExternalPreview() - } + // load. No token: the preview is same-origin, so the client falls back to the + // editing user's session cookie and the SDK runs with their own permissions, + // not the policy's. Preview therefore never mirrors a viewer's 403s. + const previewSdkEnvJs = $derived( + `window.process = { env: ${JSON.stringify({ + BASE_URL: window.location.origin, + WM_WORKSPACE: opWorkspace ?? '' + }).replace(/ { - const scopes = policy?.frontend_sdk_scopes ?? [] - const ws = opWorkspace - const key = `${ws ?? ''}|${scopes.join(',')}` - if (key === previewSdkKey) return - previewSdkKey = key - if (scopes.length === 0 || !ws) { - setPreviewSdkEnv(NO_SDK_ENV_JS) - return - } - mintPreviewSdkToken(scopes, ws, key) + previewSdkEnvJs + untrack(() => { + if (lastBuild) feedPreviewIframe(lastBuild) + syncExternalPreview() + }) }) - async function mintPreviewSdkToken(scopes: string[], ws: string, key: string) { - try { - const token = await AppService.mintPreviewSdkToken({ - workspace: ws, - requestBody: { path, scopes } - }) - if (key !== previewSdkKey) return - setPreviewSdkEnv( - `window.process = { env: ${JSON.stringify({ - WM_TOKEN: token, - BASE_URL: window.location.origin, - WM_WORKSPACE: ws - }).replace(/