mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user