feat(cli): declare app sandbox and frontend api scopes in app yaml (#11703)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-12 17:28:38 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 14519884e4
commit 26afeecbd5
9 changed files with 254 additions and 10 deletions
+124 -6
View File
@@ -32,6 +32,8 @@ export interface AppFile {
guests?: boolean;
value: any;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
// Mirrors granular ACLs on the app path. Omitted from app.yaml when no
@@ -160,6 +162,100 @@ export function executionModeForPush(
}
return deployedPolicy?.execution_mode === "viewer" ? "viewer" : "publisher";
}
/** Mirrors `FRONTEND_SDK_ALLOWED_SCOPES` in the backend's `apps.rs`, which stays
* the authority: this copy only fails a push before it bundles. */
export const FRONTEND_API_SCOPES = [
"jobs:run",
"jobs:read",
"users:read",
"resources:read",
"variables:read",
"flow_conversations:read",
"flow_conversations:write",
];
export type AppIsolation = { sandbox: boolean; frontendApi: string[] };
/** Write `sandbox` and `frontend_api` on a pulled app, only when they are on, so
* an app using neither keeps the file it had. Scopes of an unsandboxed app are
* inert and stay out: a file stating them without `sandbox` does not push. */
export function markIsolationFromPolicy(app: any, rawApp: boolean) {
if (app?.["policy"]?.["sandbox"] !== true) {
return;
}
app.sandbox = true;
const scopes = app.policy.frontend_sdk_scopes;
if (rawApp && Array.isArray(scopes) && scopes.length > 0) {
app.frontend_api = scopes;
}
}
/** The isolation the tracked file states, or `undefined` when it has no `sandbox`
* key and the deployed policy carries over. A file that has the key states both
* fields: `sandbox: false` turns isolation off, and a missing `frontend_api`
* means no scopes. */
export function statedIsolation(
localApp: any,
rawApp: boolean,
): AppIsolation | undefined {
const sandbox = localApp?.["sandbox"];
const scopes = localApp?.["frontend_api"] ?? [];
if (sandbox !== undefined && typeof sandbox !== "boolean") {
throw new Error("'sandbox' must be true or false");
}
if (
!Array.isArray(scopes) ||
scopes.some((s: unknown) => typeof s !== "string")
) {
throw new Error("'frontend_api' must be a list of scopes");
}
if (scopes.length > 0 && !rawApp) {
throw new Error("'frontend_api' is only available to full-code apps");
}
const seen = new Set<string>();
for (const scope of scopes) {
if (!FRONTEND_API_SCOPES.includes(scope)) {
throw new Error(
`Invalid frontend_api scope '${scope}'. Allowed scopes: ${FRONTEND_API_SCOPES.join(", ")}`,
);
}
if (seen.has(scope)) {
throw new Error(`Duplicate frontend_api scope '${scope}'`);
}
seen.add(scope);
}
if (scopes.length > 0 && sandbox !== true) {
throw new Error(
"'frontend_api' requires 'sandbox: true': an unsandboxed app gets no frontend API token",
);
}
return sandbox === undefined ? undefined : { sandbox, frontendApi: scopes };
}
/** Whether the deployed policy already has the stated isolation. The file's two
* keys are kept out of the superset comparison against the deployed app, which
* could not see a key the file dropped. */
export function isolationUpToDate(
isolation: AppIsolation | undefined,
deployedPolicy: Policy | undefined,
rawApp: boolean,
): boolean {
if (!isolation) {
return true;
}
if ((deployedPolicy?.sandbox === true) !== isolation.sandbox) {
return false;
}
if (!rawApp) {
return true;
}
const deployed = deployedPolicy?.frontend_sdk_scopes ?? [];
return (
deployed.length === isolation.frontendApi.length &&
deployed.every((s, i) => s === isolation.frontendApi[i])
);
}
export async function pushApp(
workspace: string,
remotePath: string,
@@ -198,6 +294,7 @@ export async function pushApp(
}
const path = localPath + "app.yaml";
const localApp = (await yamlParseFile(path)) as AppFile;
const isolation = statedIsolation(localApp, false);
replaceInlineScripts(localApp.value, localPath, true);
// On create the backend applies folder defaults, so there is nothing to preserve.
@@ -210,17 +307,26 @@ export async function pushApp(
localApp,
remotePath,
executionModeForPush(localApp, deployedPolicy),
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of)
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of),
isolation
);
// extra_perms goes through /acls/* — strip from the body so a perms-only
// edit never bumps the app version (see applyExtraPermsDiff for details).
const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppBody
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
if (isSuperset(localAppBody, app)) {
if (
isSuperset(localAppBody, app) &&
isolationUpToDate(isolation, deployedPolicy, false)
) {
log.info(colors.green(`App ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
@@ -269,12 +375,13 @@ export async function generatingPolicy(
app: any,
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined
base: Policy | undefined,
isolation?: AppIsolation
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
@@ -332,10 +439,21 @@ export function preserveOnBehalfOfFields(
* deployed app would keep being able to run runnables this push removed. */
export function finalizeDerivedPolicy(
policy: Policy,
executionMode: AppExecutionMode
executionMode: AppExecutionMode,
isolation?: AppIsolation,
rawApp: boolean = false
) {
policy.triggerables = undefined;
policy.execution_mode = executionMode;
if (isolation) {
// `false` is sent rather than left out: the server reads an omitted value as
// "no opinion" and may sandbox the app anyway, hiding that the file was refused.
policy.sandbox = isolation.sandbox;
if (rawApp) {
policy.frontend_sdk_scopes =
isolation.frontendApi.length > 0 ? isolation.frontendApi : undefined;
}
}
}
async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) {
+7
View File
@@ -9,6 +9,7 @@ import { GlobalOptions } from "../../types.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { statedIsolation } from "./app.ts";
import {
getFolderSuffix,
hasFolderSuffix,
@@ -42,6 +43,12 @@ function validateRawAppYaml(appData: any): {
errors.push("Field 'summary' must be a string");
}
try {
statedIsolation(appData, true);
} catch (error: any) {
errors.push(error.message);
}
// Note: 'runnables' is no longer required in raw_app.yaml
// Runnables can be stored in separate files in the backend folder
+18 -3
View File
@@ -20,13 +20,16 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts";
import {
type AppExecutionMode,
type AppIsolation,
basePolicy,
executionModeForPush,
finalizeDerivedPolicy,
isolationUpToDate,
markAccessFromPolicy,
preserveOnBehalfOfFields,
replaceInlineScripts,
repopulateFields,
statedIsolation,
} from "./app.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { buildPermissionedAsContext } from "../../core/permissioned_as.ts";
@@ -49,6 +52,8 @@ export interface AppFile {
runnables?: any;
custom_path?: string;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
data?: {
@@ -402,6 +407,7 @@ export async function pushRawApp(
}
const appFilePath = localPath + "raw_app.yaml";
const localApp = (await yamlParseFile(appFilePath)) as AppFile;
const isolation = statedIsolation(localApp, true);
// Load runnables from separate YAML files in the backend folder
// Falls back to reading from raw_app.yaml if no separate files exist (backward compat)
@@ -454,6 +460,7 @@ export async function pushRawApp(
deployedPolicy,
!!preserveFields.preserve_on_behalf_of,
),
isolation,
);
const files = await collectAppFiles(localPath);
@@ -485,14 +492,21 @@ export async function pushRawApp(
// to `<path>.raw_app.json`, not `<path>.app.json`. The backend granular_acls
// handler routes "raw_app" to the `app` table (where v2 raw apps actually
// live) while still dispatching DeployedObject::RawApp for git-sync.
const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppNoPerms
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
// Check both metadata/runnables AND files for changes
// Files need separate comparison because isSuperset only checks if local keys exist in remote
const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app);
const metadataUpToDate =
isSuperset({ ...localAppNoPerms, runnables }, app) &&
isolationUpToDate(isolation, deployedPolicy, true);
const filesUpToDate = deepEqual(files, app.value?.files);
if (metadataUpToDate && filesUpToDate) {
log.info(colors.green(`App ${remotePath} is up to date`));
@@ -560,11 +574,12 @@ export async function generatingPolicy(
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined,
isolation?: AppIsolation,
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation, true);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+3 -1
View File
@@ -149,7 +149,7 @@ import {
extractCurrentMapping,
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { markAccessFromPolicy } from "../app/app.ts";
import { markAccessFromPolicy, markIsolationFromPolicy } from "../app/app.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,
@@ -1359,6 +1359,7 @@ export function ZipFSElement(
}
markAccessFromPolicy(app);
markIsolationFromPolicy(app, false);
app.policy = undefined;
yield {
isDirectory: false,
@@ -1377,6 +1378,7 @@ export function ZipFSElement(
throw error;
}
markAccessFromPolicy(rawApp);
markIsolationFromPolicy(rawApp, true);
// console.log("rawApp", rawApp);
rawApp.policy = undefined;
// custom_path is derived from the file path, don't store it
+12
View File
@@ -6840,6 +6840,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
\`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. 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.
In \`raw_app.yaml\`:
\`\`\`yaml
sandbox: true
frontend_api: [jobs:run, users:read]
\`\`\`
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.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import {
finalizeDerivedPolicy,
isolationUpToDate,
markIsolationFromPolicy,
statedIsolation,
} from "../src/commands/app/app.ts";
function pushed(file: any, deployed: any, rawApp = true) {
const policy = { ...deployed };
finalizeDerivedPolicy(policy, "publisher", statedIsolation(file, rawApp), rawApp);
return policy;
}
test("a pull writes isolation only when it is on, and a push deploys what was pulled", () => {
const off: any = { policy: { sandbox: false, frontend_sdk_scopes: ["jobs:run"] } };
markIsolationFromPolicy(off, true);
expect("sandbox" in off).toBe(false);
expect("frontend_api" in off).toBe(false);
const on: any = { policy: { sandbox: true, frontend_sdk_scopes: ["jobs:run"] } };
markIsolationFromPolicy(on, true);
expect(on.sandbox).toBe(true);
expect(on.frontend_api).toEqual(["jobs:run"]);
// Into a workspace where the app does not exist yet.
const fresh = pushed(on, undefined);
expect(fresh.sandbox).toBe(true);
expect(fresh.frontend_sdk_scopes).toEqual(["jobs:run"]);
});
test("the file wins when it states sandbox, the deployed policy carries over when it does not", () => {
const deployed = { sandbox: true, frontend_sdk_scopes: ["jobs:run"] };
const silent = pushed({}, deployed);
expect(silent.sandbox).toBe(true);
expect(silent.frontend_sdk_scopes).toEqual(["jobs:run"]);
expect(isolationUpToDate(statedIsolation({}, true), deployed, true)).toBe(true);
// A file with the key states the scopes too: none here.
const narrowed = pushed({ sandbox: true }, deployed);
expect(narrowed.sandbox).toBe(true);
expect(narrowed.frontend_sdk_scopes).toBeUndefined();
expect(isolationUpToDate(statedIsolation({ sandbox: true }, true), deployed, true)).toBe(false);
const opened = pushed({ sandbox: false }, deployed);
expect(opened.sandbox).toBe(false);
expect(opened.frontend_sdk_scopes).toBeUndefined();
});
test("scopes are refused without sandbox, outside the allowlist, or repeated", () => {
expect(() => statedIsolation({ frontend_api: ["jobs:run"] }, true)).toThrow("requires 'sandbox: true'");
expect(() => statedIsolation({ sandbox: false, frontend_api: ["jobs:run"] }, true)).toThrow(
"requires 'sandbox: true'",
);
expect(() => statedIsolation({ sandbox: true, frontend_api: ["apps:write"] }, true)).toThrow("Invalid");
expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run", "jobs:run"] }, true)).toThrow(
"Duplicate",
);
expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run"] }, false)).toThrow("full-code");
});
+4
View File
@@ -1116,6 +1116,10 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
When a deploy widens who may open the app, tell the user in plain words.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. 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.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -471,6 +471,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
`public: true` in `raw_app.yaml` deploys the app for anonymous users, and `guests: true` for guests. Remove the line and the next push closes the app again.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. 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.
In `raw_app.yaml`:
```yaml
sandbox: true
frontend_api: [jobs:run, users:read]
```
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.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
+14
View File
@@ -239,6 +239,20 @@ Either one lets those people run the app's backend runnables, so never open an a
When a deploy widens who may open the app, tell the user in plain words.
<!-- /chat-only -->
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. 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.
<!-- cli-only -->
In `raw_app.yaml`:
```yaml
sandbox: true
frontend_api: [jobs:run, users:read]
```
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.
<!-- /cli-only -->
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.