mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Claude Opus 5.5
parent
14519884e4
commit
26afeecbd5
+124
-6
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+12
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user