mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 16:05:42 +00:00
feat(apps): support the frontend SDK in sandboxed raw apps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
co-authored by
Claude Fable 5
parent
fd2735d241
commit
025069c41e
@@ -830,6 +830,13 @@ fn raw_app_wrapper_html(secret: &str) -> String {
|
||||
window.__wmStorageShim.local.__hydrate(d.storage.local);
|
||||
window.__wmStorageShim.session.__hydrate(d.storage.session);
|
||||
}
|
||||
// Frontend SDK: a bundled `windmill-client` reads window.process.env at
|
||||
// module load, so the env must exist before the bundle script below is
|
||||
// injected. BASE_URL is supplied by the embedder rather than derived —
|
||||
// location.origin is "null" on this opaque origin.
|
||||
if (d.sdk && d.sdk.token) {
|
||||
window.process = { env: { WM_TOKEN: d.sdk.token, BASE_URL: d.sdk.baseUrl, WM_WORKSPACE: d.sdk.workspace } };
|
||||
}
|
||||
if (d.initialHash && d.initialHash !== '#' && !window.location.hash) {
|
||||
try { history.replaceState(null, '', d.initialHash); } catch (_) {}
|
||||
}
|
||||
@@ -1322,11 +1329,13 @@ async fn mint_raw_app_sdk_token(
|
||||
/// apps whose policy declares `frontend_sdk_scopes` get the viewer-scoped SDK
|
||||
/// token once the viewer confirms the permission prompt (`sdk_consent`).
|
||||
///
|
||||
/// `sdk_consent` is the viewer's answer to that prompt, not a security boundary:
|
||||
/// an unsandboxed raw app's bundle runs same-origin with the viewer's session and
|
||||
/// can call this endpoint itself. The boundary is the scope set — the token can
|
||||
/// never exceed the policy-declared scopes, which are also capped by the viewer's
|
||||
/// own permissions. Sandbox isolation is what actually contains an app's code.
|
||||
/// For an unsandboxed app `sdk_consent` is the viewer's answer to that prompt,
|
||||
/// not a security boundary: its bundle runs same-origin with the viewer's session
|
||||
/// and can call this endpoint itself. The boundary is the scope set — the token
|
||||
/// can never exceed the policy-declared scopes, which are also capped by the
|
||||
/// viewer's own permissions. Sandbox isolation is what contains an app's code; a
|
||||
/// sandboxed app holds nothing but this token, so there the prompt is the only
|
||||
/// way it gains any reach at all.
|
||||
///
|
||||
/// The CALLER MUST verify that `opt_authed` may view `app_path` before calling:
|
||||
/// this mints on their behalf unconditionally and does no visibility check of its
|
||||
@@ -1342,15 +1351,9 @@ pub async fn build_embed_token_response(
|
||||
sdk_consent: bool,
|
||||
) -> Result<EmbedTokenResponse> {
|
||||
// Only advertise scopes where a token could actually be minted, so the viewer
|
||||
// never shows a permission prompt that can grant nothing: a sandboxed bundle
|
||||
// lives on an opaque origin and can't use the token, and an anonymous visitor
|
||||
// has no identity to mint against. The backend, not the viewer, decides this
|
||||
// (the editor shows the author the same sandbox rule).
|
||||
let sdk_scopes = if raw_app
|
||||
&& !policy.sandbox
|
||||
&& opt_authed.is_some()
|
||||
&& !policy.frontend_sdk_scopes.is_empty()
|
||||
{
|
||||
// is never shown a permission prompt that can grant nothing: an anonymous
|
||||
// visitor has no identity to mint against.
|
||||
let sdk_scopes = if raw_app && opt_authed.is_some() && !policy.frontend_sdk_scopes.is_empty() {
|
||||
Some(policy.frontend_sdk_scopes.clone())
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -655,7 +655,14 @@ pub async fn run_server(
|
||||
.layer(Extension(argon2.clone()))
|
||||
.layer(cors.clone()),
|
||||
)
|
||||
.nest("/variables", variables::workspaced_service())
|
||||
// CORS so a sandboxed raw app's opaque-origin bundle can
|
||||
// read variables with its frontend SDK token. Bearer-only:
|
||||
// the layer never allows credentials, so no cookie can ride
|
||||
// a cross-origin call. Consistent with resources/users.
|
||||
.nest(
|
||||
"/variables",
|
||||
variables::workspaced_service().layer(cors.clone()),
|
||||
)
|
||||
.nest("/volumes", volumes_oss::workspaced_service())
|
||||
.nest("/workers", windmill_api_workers::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
type OnBehalfOfChoice
|
||||
} from '$lib/components/OnBehalfOfSelector.svelte'
|
||||
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
|
||||
import {
|
||||
FRONTEND_SDK_SCOPES,
|
||||
MIN_SANDBOXED_SDK_VERSION
|
||||
} from '$lib/components/raw_apps/sdkScopes'
|
||||
|
||||
const WM_DEPLOYERS_GROUP = 'wm_deployers'
|
||||
|
||||
@@ -370,9 +373,10 @@
|
||||
{/if}
|
||||
{#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
|
||||
<div class="mt-2">
|
||||
<Alert type="warning" title="Not available with sandbox isolation" size="xs">
|
||||
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.
|
||||
<Alert type="info" title="Requires windmill-client {MIN_SANDBOXED_SDK_VERSION}" size="xs">
|
||||
A sandboxed app calls the API cross-origin, which earlier versions of the SDK cannot do.
|
||||
Make sure your app depends on <code>windmill-client@^{MIN_SANDBOXED_SDK_VERSION}</code> and
|
||||
redeploy — apps bundled against an older version fail with a CORS error.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
}
|
||||
|
||||
function respondCtx() {
|
||||
const sdkToken = sdkTokenCtx?.value
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'windmill:ctx',
|
||||
@@ -158,7 +159,14 @@
|
||||
// `window.ctx.workspace` works for anonymous viewers too.
|
||||
ctx: { ctx: user, workspace },
|
||||
initialHash,
|
||||
storage: { local: bundleStorage ?? {}, session: {} }
|
||||
storage: { local: bundleStorage ?? {}, session: {} },
|
||||
// The wrapper turns this into `window.process.env` before it injects
|
||||
// the bundle, which is what a bundled `windmill-client` reads at
|
||||
// module load. `baseUrl` must come from here: this component runs on
|
||||
// the real origin, the bundle's is opaque.
|
||||
...(sdkToken
|
||||
? { sdk: { token: sdkToken, baseUrl: window.location.origin, workspace } }
|
||||
: {})
|
||||
},
|
||||
'*'
|
||||
)
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side
|
||||
// consent persistence for the permission banner.
|
||||
|
||||
/** A sandboxed app calls the API from an opaque origin, which needs the
|
||||
* credential-free client shipped alongside this release: earlier versions force
|
||||
* `credentials: 'include'`, which CORS rejects. Tracks the monorepo version
|
||||
* because `windmill-client` is published from it in lockstep. */
|
||||
export const MIN_SANDBOXED_SDK_VERSION = __pkg__.version
|
||||
|
||||
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'jobs:run',
|
||||
|
||||
@@ -22,12 +22,16 @@ const baseUrl = getEnv("BASE_INTERNAL_URL") ?? getEnv("BASE_URL") ?? "http://loc
|
||||
const baseUrlApi = (baseUrl ?? '') + "/api";
|
||||
|
||||
EOF
|
||||
# WITH_CREDENTIALS mirrors setClient: cookies only when there is no bearer token.
|
||||
# A browser bundle that never calls setClient (a raw app, which gets its token
|
||||
# from window.process.env) must not send credentials — the API answers
|
||||
# `Access-Control-Allow-Origin: *`, so a credentialed cross-origin request fails.
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts
|
||||
sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
|
||||
sed -i '' 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
|
||||
sed -i '' "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts
|
||||
else
|
||||
sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts
|
||||
sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
|
||||
sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
|
||||
sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts
|
||||
fi
|
||||
|
||||
@@ -68,7 +68,11 @@ export function setClient(token?: string, baseUrl?: string) {
|
||||
if (token === undefined) {
|
||||
token = getEnv("WM_TOKEN") ?? "no_token";
|
||||
}
|
||||
OpenAPI.WITH_CREDENTIALS = true;
|
||||
// Cookies are the fallback for when there is no bearer token. Sending both is
|
||||
// not just redundant, it breaks the browser case outright: the API answers
|
||||
// `Access-Control-Allow-Origin: *` and so can never allow credentials, and a
|
||||
// credentialed cross-origin request is rejected before the token is looked at.
|
||||
OpenAPI.WITH_CREDENTIALS = token === "no_token";
|
||||
OpenAPI.TOKEN = token;
|
||||
OpenAPI.BASE = baseUrl + "/api";
|
||||
}
|
||||
@@ -79,8 +83,12 @@ function getPublicBaseUrl(): string {
|
||||
|
||||
export const getEnv = (key: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
// node
|
||||
return process?.env?.[key];
|
||||
// `process` may be undeclared entirely (web worker, browser-like runtimes
|
||||
// without a node shim), where `process?.env` still throws a ReferenceError.
|
||||
if (typeof process !== "undefined") {
|
||||
return process?.env?.[key];
|
||||
}
|
||||
return globalThis?.process?.env?.[key];
|
||||
}
|
||||
// browser
|
||||
return window?.process?.env?.[key];
|
||||
|
||||
Reference in New Issue
Block a user