mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 00:03:08 +00:00
feat(apps): use the windmill-client SDK from raw app frontend code
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
044ce39e5f
commit
397f4a6e1a
@@ -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
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<chrono::Utc>)> {
|
||||
// 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<EmbedTokenResponse> {
|
||||
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<String>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
Query(sdk_query): Query<EmbedTokenQuery>,
|
||||
) -> JsonResult<EmbedTokenResponse> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
@@ -1417,6 +1555,15 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
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<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(sdk_query): Query<EmbedTokenQuery>,
|
||||
) -> JsonResult<EmbedTokenResponse> {
|
||||
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<String> = 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:<own path>` and `apps:read:<own path>`
|
||||
/// (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
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
{#if rawApp}
|
||||
<h2>Frontend API access</h2>
|
||||
<div class="my-6">
|
||||
<div class="text-xs text-secondary mb-3">
|
||||
Let the app's frontend code call the Windmill API through the <code>windmill-client</code>
|
||||
SDK, authenticated as <b>the viewer</b> (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.
|
||||
</div>
|
||||
{#each FRONTEND_SDK_SCOPES as scope (scope.value)}
|
||||
<div class="mb-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
options={{ right: scope.label }}
|
||||
checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false}
|
||||
on:change={(e) => {
|
||||
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}
|
||||
/>
|
||||
<div class="text-xs text-tertiary ml-10">{scope.description}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if newApp}
|
||||
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
|
||||
{/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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hideSecretUrl}
|
||||
<h2>Public URL</h2>
|
||||
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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) {
|
||||
|
||||
@@ -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<EmbedToken>
|
||||
* `.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<EmbedToken>
|
||||
/** 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 @@
|
||||
<a href={base}>Go to Windmill</a>
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if status === 'sdkConsent'}
|
||||
<!-- Raw-app frontend SDK: the app code would run same-origin with a
|
||||
viewer-scoped token, so nothing renders until the viewer accepts the
|
||||
declared permissions. -->
|
||||
<RawAppSdkConsent scopes={sdkScopes ?? []} onContinue={onSdkConsentContinue} />
|
||||
{:else if status === 'noPermission'}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
|
||||
@@ -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' }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { ShieldCheck } from 'lucide-svelte'
|
||||
import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes'
|
||||
|
||||
let {
|
||||
scopes,
|
||||
onContinue
|
||||
}: {
|
||||
/** Scopes the app policy declares for its frontend SDK token. */
|
||||
scopes: string[]
|
||||
/** Fired when the viewer accepts; `dontAskAgain` persists the consent for
|
||||
* this app path so the banner is skipped until the declared scopes grow. */
|
||||
onContinue: (dontAskAgain: boolean) => void
|
||||
} = $props()
|
||||
|
||||
let dontAskAgain = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="px-4 mt-20 max-w-xl mx-auto">
|
||||
<div class="border rounded-md p-6 bg-surface shadow-sm flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck size={20} class="text-primary" />
|
||||
<div class="text-lg font-semibold">This app requires the following permissions</div>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
The app's code will be able to call the Windmill API on your behalf, restricted to:
|
||||
</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each scopes as scope (scope)}
|
||||
<li class="text-sm">
|
||||
<span class="font-medium">{sdkScopeLabel(scope)}</span>
|
||||
{#if sdkScopeDescription(scope)}
|
||||
<span class="text-tertiary"> — {sdkScopeDescription(scope)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="flex items-center justify-between gap-4 pt-2">
|
||||
<Toggle bind:checked={dontAskAgain} size="xs" options={{ right: 'Do not ask again' }} />
|
||||
<Button variant="accent" onclick={() => onContinue(dontAskAgain)}>Continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -172,6 +178,7 @@ export function unsandboxedRawAppHtml(
|
||||
<title>App</title>
|
||||
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
|
||||
<script>
|
||||
${sdkEnv ? `window.process = { env: ${JSON.stringify(sdkEnv).replace(/</g, '\\u003c')} };` : ''}
|
||||
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
|
||||
(function () {
|
||||
// Keep the parent URL hash in sync for shareable URLs.
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
// Embedder side: validate access (main session cookie or shared JWT) and mint
|
||||
// a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
|
||||
if (parsedCustomPath.jwt) {
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
|
||||
}
|
||||
@@ -69,8 +69,9 @@
|
||||
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
|
||||
}
|
||||
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
|
||||
const res = await fetch(
|
||||
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}`,
|
||||
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}${consent}`,
|
||||
{ headers }
|
||||
)
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -29,12 +29,13 @@
|
||||
|
||||
// Embedder side: the logged-in member's session mints a scoped embed token for
|
||||
// the opaque iframe, isolating the in-workspace app from their full session.
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
|
||||
const headers: Record<string, string> = {}
|
||||
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) {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
// Embedder side: validate access (using the main session cookie or the shared
|
||||
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
|
||||
if (parsedSecret.jwt) {
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
|
||||
}
|
||||
@@ -49,8 +49,9 @@
|
||||
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
|
||||
}
|
||||
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
|
||||
const res = await fetch(
|
||||
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}`,
|
||||
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`,
|
||||
{ headers }
|
||||
)
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user