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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPCW1WB5QeYrgJmgwcywNA
This commit is contained in:
Diego Imbert
2026-07-31 14:57:03 +02:00
parent faa1bb3190
commit 81905e455b
5 changed files with 29 additions and 143 deletions
+3 -5
View File
@@ -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] = [
-37
View File
@@ -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
+4 -40
View File
@@ -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:<path>`.
/// 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<String>,
}
/// 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<DB>,
Path(w_id): Path<String>,
Json(req): Json<PreviewSdkTokenRequest>,
) -> Result<String> {
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 {
@@ -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
@@ -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(/</g, '\\u003c')} };\n`
)
// The preview shell reuses one window across builds, so a workspace switch has
// to be pushed into the running preview rather than waiting for a rebuild.
$effect(() => {
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(/</g, '\\u003c')} };\n`
)
} catch (e) {
console.warn('Could not mint a preview SDK token', e)
if (key !== previewSdkKey) return
setPreviewSdkEnv(NO_SDK_ENV_JS)
// The key stays set, so a failed mint is not retried until the scopes or
// workspace actually change — which is the only thing this effect reacts to.
}
}
function syncExternalPreview() {
if (lastBuild) {
postToExternalPreview({
@@ -1357,8 +1320,8 @@
function feedPreviewIframe(build: { css: string; js: string }) {
runtimeError = undefined
emptyRender = false
// Same-origin app-preview.html, and the payload now carries a token — address
// it to our origin rather than '*', as the detached preview already does.
// Same-origin app-preview.html — address it to our origin rather than '*',
// as the detached preview already does.
previewIframe?.contentWindow?.postMessage(
{ type: 'preview', css: build.css, js: previewSdkEnvJs + build.js },
window.location.origin