mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
fix: let raw app SDK tokens read the viewer's identity without a toggle (#11790)
* feat: grant raw app SDK tokens identity access without a toggle * test: pin the implicit identity scope on raw app SDK tokens * refactor: let raw app SDK tokens reach whoami at the route check
This commit is contained in:
@@ -73,7 +73,12 @@ pub fn check_route_access(
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if required_domain == ScopeDomain::Users && suffix != "users/whoami" {
|
||||
if required_domain == ScopeDomain::Users {
|
||||
// Whoami returns what the bundle already has in `window.ctx`, so it
|
||||
// needs no declared scope.
|
||||
if suffix == "users/whoami" {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::PermissionDenied(
|
||||
"Access denied. A raw app frontend SDK token can only read the viewer's own identity."
|
||||
.to_string(),
|
||||
@@ -418,8 +423,9 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Ve
|
||||
])
|
||||
}
|
||||
|
||||
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
||||
/// to narrow the declared scopes to what the viewer's prompt promised.
|
||||
/// Sentinel in raw-app SDK tokens. `check_route_access` uses it to narrow the
|
||||
/// declared scopes to what the viewer's prompt promised; the only thing it grants
|
||||
/// is the bearer's own `users/whoami`.
|
||||
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
||||
|
||||
pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
|
||||
@@ -36871,7 +36871,8 @@ components:
|
||||
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, flow_conversations:read,
|
||||
flow_conversations:write).
|
||||
flow_conversations:write). Every such token can call whoami without
|
||||
consent, whether `users:read` is declared or not.
|
||||
|
||||
ListableApp:
|
||||
type: object
|
||||
|
||||
@@ -6779,8 +6779,8 @@ mod embed_token_tests {
|
||||
"POST",
|
||||
),
|
||||
("/api/w/test/jobs/restart/f/some-uuid", "POST"),
|
||||
// `users:read` is presented to the viewer as "read your identity", so
|
||||
// the workspace member directory must stay out of reach.
|
||||
// `users:read` reaches whoami only: the workspace member directory must
|
||||
// stay out of reach.
|
||||
("/api/w/test/users/list", "GET"),
|
||||
("/api/w/test/users/list_usage", "GET"),
|
||||
("/api/w/test/users/username_to_email/admin", "GET"),
|
||||
@@ -6791,6 +6791,15 @@ mod embed_token_tests {
|
||||
"SDK token should deny {method} {path}"
|
||||
);
|
||||
}
|
||||
|
||||
// Whoami needs no declared scope.
|
||||
let jobs_only = [
|
||||
"jobs:run".to_string(),
|
||||
windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string(),
|
||||
];
|
||||
assert!(
|
||||
check_scopes_for_route(Some(&jobs_only[..]), "/api/w/test/users/whoami", "GET").is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// The token carries path-scoped `apps:run:<own path>` and `apps:read:<own path>`
|
||||
|
||||
@@ -599,6 +599,10 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n"
|
||||
},
|
||||
"starred_only": {
|
||||
"type": "boolean",
|
||||
"description": "Only the `ai_agent` resources the caller starred"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -1336,7 +1340,7 @@ is, a different one moves it there and archives the old path"),
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write).\n"
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1457,7 @@ is, a different one moves it there and archives the old path"),
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write).\n"
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -6926,14 +6926,14 @@ An app's custom URL is not in \`raw_app.yaml\`: each workspace has its own file
|
||||
|
||||
## Sandbox isolation and frontend API access
|
||||
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls.
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them.
|
||||
|
||||
Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.
|
||||
In \`raw_app.yaml\`:
|
||||
|
||||
\`\`\`yaml
|
||||
sandbox: true
|
||||
frontend_api: [jobs:run, users:read]
|
||||
frontend_api: [jobs:run]
|
||||
\`\`\`
|
||||
|
||||
A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise.
|
||||
|
||||
@@ -24,7 +24,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'
|
||||
import { FRONTEND_SDK_SCOPES, sdkScopesToConfirm } from '$lib/components/raw_apps/sdkScopes'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import {
|
||||
useOperatingUser,
|
||||
@@ -436,7 +436,7 @@
|
||||
tooltip="Each viewer approves these scopes before the app runs. The app's code, or an XSS bug in it, can use them as that viewer."
|
||||
>
|
||||
{#snippet badge()}
|
||||
{#if policy.frontend_sdk_scopes?.length}
|
||||
{#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
|
||||
<div class="flex grow min-w-0 flex-wrap gap-1 ps-2">
|
||||
{#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)}
|
||||
<Badge twBgColor="bg-surface-sunken" twTextColor="text-primary"
|
||||
@@ -464,7 +464,9 @@
|
||||
? [...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.
|
||||
// consent compare stably across deploys. A leftover implicit scope is
|
||||
// not in the list, so it drops here rather than keep minting a token
|
||||
// for an app whose toggles all read off.
|
||||
const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) =>
|
||||
next.includes(s)
|
||||
)
|
||||
@@ -481,7 +483,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if policy.frontend_sdk_scopes?.length}
|
||||
{#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
|
||||
<div class="mt-3">
|
||||
<Alert type="info" title="Redeploy to use the SDK from a sandboxed app" size="xs">
|
||||
A sandboxed app calls the API cross-origin, which older <code>windmill-client</code>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { ShieldCheck } from 'lucide-svelte'
|
||||
import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes'
|
||||
import { sdkScopeDescription, sdkScopeLabel, sdkScopesToConfirm } from './sdkScopes'
|
||||
|
||||
let {
|
||||
scopes,
|
||||
@@ -35,7 +35,7 @@
|
||||
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)}
|
||||
{#each sdkScopesToConfirm(scopes) as scope (scope)}
|
||||
<li class="text-sm">
|
||||
<span class="font-medium">{sdkScopeLabel(scope)}</span
|
||||
>{#if sdkScopeDescription(scope)}<span class="text-tertiary"
|
||||
|
||||
@@ -6,33 +6,41 @@ describe('stored frontend SDK consent', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('only covers scopes the viewer actually approved', () => {
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(true)
|
||||
// The app added a scope after the viewer consented: it must ask again
|
||||
// rather than silently minting a broader token.
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'jobs:run'])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not leak one viewer or app to another', () => {
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
|
||||
expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])
|
||||
expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['jobs:read'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['jobs:read'])).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the editor preview and the deployed app apart, and moves preview consent', () => {
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'], true)).toBe(false)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'])).toBe(false)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'], true)).toBe(false)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'])).toBe(false)
|
||||
|
||||
movePreviewSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', 'u/a/real')
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['users:read'], true)).toBe(true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['jobs:read'], true)).toBe(true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true)).toBe(false)
|
||||
})
|
||||
|
||||
it('never asks for the identity scope every token carries', () => {
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'users:read'])).toBe(true)
|
||||
})
|
||||
|
||||
it('treats unreadable storage as no consent', () => {
|
||||
localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json')
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false)
|
||||
// Nothing to ask for: storage must not turn that into an empty prompt.
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// 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.
|
||||
// in the backend `apps.rs` — both lists must stay in sync, except for the
|
||||
// implicit scope below), plus the viewer-side consent persistence for the
|
||||
// permission banner.
|
||||
|
||||
/** Never toggled nor asked for: every SDK token reaches whoami without it, and whoami
|
||||
* returns what the bundle already gets in `window.ctx`. */
|
||||
export const IMPLICIT_SDK_SCOPE = 'users:read'
|
||||
|
||||
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
|
||||
{
|
||||
@@ -14,11 +19,6 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s
|
||||
label: 'Read jobs and results',
|
||||
description: 'Read jobs and their results, and list the runs the viewer can see'
|
||||
},
|
||||
{
|
||||
value: 'users:read',
|
||||
label: 'Read your identity',
|
||||
description: 'Call whoami (Read the viewer username, email, groups, is_super_admin...)'
|
||||
},
|
||||
{
|
||||
value: 'resources:read',
|
||||
label: 'Read resources',
|
||||
@@ -59,10 +59,18 @@ function sdkConsentKey(viewer: string, workspace: string, path: string, preview:
|
||||
return `wm_sdk_consent:${preview ? 'p:' : ''}${viewer}:${workspace}:${path}`
|
||||
}
|
||||
|
||||
/** The declared scopes the viewer is asked to approve. */
|
||||
export function sdkScopesToConfirm(scopes: string[]): string[] {
|
||||
return scopes.filter((s) => s !== IMPLICIT_SDK_SCOPE)
|
||||
}
|
||||
|
||||
/** True when `approved` (a consent the viewer gave) covers every scope the app now
|
||||
* declares. Fewer scopes need no new consent; one more does. */
|
||||
export function sdkConsentCovers(approved: unknown, scopes: string[]): boolean {
|
||||
return Array.isArray(approved) && scopes.every((s) => approved.includes(s))
|
||||
const needed = sdkScopesToConfirm(scopes)
|
||||
return (
|
||||
needed.length === 0 || (Array.isArray(approved) && needed.every((s) => approved.includes(s)))
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a previously stored "do not ask again" consent covers every
|
||||
@@ -80,7 +88,7 @@ export function hasStoredSdkConsent(
|
||||
)
|
||||
return sdkConsentCovers(stored, scopes)
|
||||
} catch (_) {
|
||||
return false
|
||||
return sdkConsentCovers(null, scopes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -608,6 +608,10 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n"
|
||||
},
|
||||
"starred_only": {
|
||||
"type": "boolean",
|
||||
"description": "Only the `ai_agent` resources the caller starred"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -1343,7 +1347,7 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write).\n"
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1460,7 +1464,7 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write).\n"
|
||||
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers 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, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1148,7 +1148,7 @@ When a deploy widens who may open the app, tell the user in plain words.
|
||||
|
||||
## Sandbox isolation and frontend API access
|
||||
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls.
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them.
|
||||
|
||||
Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.
|
||||
You cannot turn isolation off; the user does it in the app's deploy settings.
|
||||
|
||||
@@ -483,14 +483,14 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne
|
||||
|
||||
## Sandbox isolation and frontend API access
|
||||
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls.
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.
|
||||
|
||||
Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.
|
||||
In `raw_app.yaml`:
|
||||
|
||||
```yaml
|
||||
sandbox: true
|
||||
frontend_api: [jobs:run, users:read]
|
||||
frontend_api: [jobs:run]
|
||||
```
|
||||
|
||||
A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.
|
||||
|
||||
@@ -251,7 +251,7 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne
|
||||
|
||||
## Sandbox isolation and frontend API access
|
||||
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls.
|
||||
New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.
|
||||
|
||||
Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.
|
||||
<!-- chat-only -->
|
||||
@@ -262,7 +262,7 @@ In `raw_app.yaml`:
|
||||
|
||||
```yaml
|
||||
sandbox: true
|
||||
frontend_api: [jobs:run, users:read]
|
||||
frontend_api: [jobs:run]
|
||||
```
|
||||
|
||||
A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.
|
||||
|
||||
Reference in New Issue
Block a user