Files
windmill/backend/tests/app_s3_onbehalf.rs
T
fb82748296 fix: make on_behalf_of control permissions for scripts and flows (#10438)
* fix: make on_behalf_of control permissions for scripts and flows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep an omitted permissioned_as from re-versioning an unchanged script

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive the on-behalf-of principal from the email and reject mismatched pairs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop workspace deploys from carrying a source-workspace principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the onBehalfOfPermissionedAs param doc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that workspace deploys never carry a source-workspace principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the omitted-principal contract and refresh generated prompts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep external-superadmin principals on email-only redeploys

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the recorded principal to its workspace and prefer real accounts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the recorded principal correctly through drafts and set-permissioned-as

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sweep draft identity pairs on email change and offboarding

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: leave group identities alone when sweeping a user's email

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: treat only g/ without an email as a group, and match the offboard preview

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the group guard from skipping rows with no recorded principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the group guard once instead of restating it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: make the permissioned_as the only stored on-behalf-of identity

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: skip resolving the on-behalf-of address for sync clients that discard it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address the local review of the identity refactor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys

* test: pin that a fork keeps only the on-behalf-of identities that resolve in it

* fix: decide a principal prefix-first everywhere and canonicalize bare addresses

* fix: prefix a slash-containing address so a reader cannot take it for a group

* fix: read an address as a username before the group- convention

* fix: rewrite the canonical principal when an account's address moves

* fix: keep the address form of a principal to accounts without a usr row

* fix: reject an identity a job row cannot carry and read it uncached at dispatch

* fix: count characters against the job identity width and cap the backfill

* refactor: name the script/flow principal on_behalf_of, as apps do

* docs: state the caller-must-authorize contract on the identity resolvers

* fix: keep writing on_behalf_of_email until every worker reads the principal

* fix: err high on the compatibility version and document the last resolver

* fix: keep the compatibility address current through identity mutations

* fix: carry the compatibility address with the principal on every copy path

* chore: re-pin the EE ref to the companion branch merged with EE main

* fix: key the dbt retry lookup on the stored principal

* fix: keep a mixed-version address recoverable through a fork

* fix: read a round-tripped address uncached so a redeploy is not rejected

* fix: refuse an email change that would make a principal unenqueueable

* chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4

This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private.

Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036

New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-01 20:37:21 +02:00

721 lines
29 KiB
Rust

//! Deployed-app S3 reads authorize on-behalf of the app author and are confined to
//! app provenance (declared keys or recent job outputs): an anonymous viewer cannot
//! read an arbitrary `file_key` as the author. A viewer on a full (unscoped) session
//! instead falls back to reading as THEMSELVES (bounded by their own S3 perms), so the
//! gate is exercised here through the anonymous identity it still fully protects.
//! Requires the `parquet` feature — the real `apps_u/*` S3 handlers are gated on it.
//!
//! `base` fixture: test-user (admin, SECRET_TOKEN); test-user-2 (non-admin,
//! SECRET_TOKEN_2, no S3 folder permission).
#![cfg(feature = "parquet")]
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const USER_TOKEN: &str = "SECRET_TOKEN_2";
const APP: &str = "u/test-user/s3onbehalf";
const DECLARED: &str = "provenance/allowed.csv";
const NON_PROVENANCE: &str = "evil/secret.csv";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
/// Mint an API token for test-user (admin) restricted to `scopes`.
async fn mint_scoped_token(port: u16, scopes: Vec<&str>) -> anyhow::Result<String> {
let resp = authed(
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
ADMIN_TOKEN,
)
.json(&json!({ "label": "scoped", "scopes": scopes, "workspace_id": "test-workspace" }))
.send()
.await?;
assert_eq!(resp.status(), 201, "mint scoped token");
Ok(resp.text().await?)
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
#[sqlx::test(fixtures("base"))]
async fn test_deployed_app_s3_onbehalf_provenance(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
// `on_behalf_of` is auto-set to the creator (admin) for an anonymous app, so
// the app reads S3 as that author; `DECLARED` is the only allowlisted key.
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP,
"summary": "s3 onbehalf test",
"value": {},
"policy": {
"execution_mode": "anonymous",
"triggerables": {},
"allowed_s3_keys": [{ "s3_path": DECLARED }]
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
// GET an app-scoped S3 route ANONYMOUSLY. Anonymous callers have no viewer
// identity to fall back to, so the provenance gate still fully applies to them
// (unlike logged-in viewers, who now read as themselves — see the union test).
// No workspace storage is configured, so a request that clears the gate fails
// later at the storage lookup (or the CE OSS stub), never with the denial
// message — which is what lets these assertions distinguish pass from deny.
let get = |route: &str| {
let url = format!("{ws}/apps_u/{route}");
client().get(url).send()
};
let denied = |body: &str| body.contains("is not accessible from this app");
// download_s3_file: allowed for the declared key, denied for a key the app never
// declared (the confused-deputy guard).
let body = get(&format!("download_s3_file/{APP}?s3={DECLARED}"))
.await?
.text()
.await?;
assert!(!denied(&body), "declared key must clear the gate: {body}");
let body = get(&format!("download_s3_file/{APP}?s3={NON_PROVENANCE}"))
.await?
.text()
.await?;
assert!(denied(&body), "non-provenance key must be denied: {body}");
// load_table_count and load_csv_preview enforce the same gate. The preview's
// numeric `limit`/`offset` must deserialize (regression: a flattened query
// struct 400s on them under serde_urlencoded).
let body = get(&format!("load_table_count/{APP}?file_key={DECLARED}"))
.await?
.text()
.await?;
assert!(
!denied(&body),
"table_count declared key must clear the gate: {body}"
);
let body = get(&format!("load_table_count/{APP}?file_key={NON_PROVENANCE}"))
.await?
.text()
.await?;
assert!(
denied(&body),
"table_count non-provenance key must be denied: {body}"
);
let resp = get(&format!(
"load_csv_preview/{APP}?file_key={DECLARED}&limit=5&offset=0"
))
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_ne!(status, 400, "numeric limit/offset must deserialize: {body}");
assert!(
!denied(&body),
"csv_preview declared key must clear the gate: {body}"
);
// load_file_preview: `read_bytes_from` / `read_bytes_length` are required.
let resp = get(&format!("load_file_preview/{APP}?file_key={DECLARED}")).await?;
assert_eq!(
resp.status(),
400,
"file_preview without byte range must 400: {}",
resp.text().await?
);
let body = get(&format!(
"load_file_preview/{APP}?file_key={DECLARED}&read_bytes_from=0&read_bytes_length=4096"
))
.await?
.text()
.await?;
assert!(
!denied(&body),
"file_preview declared key must clear the gate: {body}"
);
Ok(())
}
/// The viewer-perm union: a viewer on a full (unscoped) session is no longer hard-denied
/// by the provenance gate for a pre-existing file. It falls back to reading as ITSELF
/// (bounded by its own S3 perms downstream), while an anonymous caller (no identity) and
/// a scope-restricted token (can hit `apps_u/*` but not `job_helpers/*`, so the fallback
/// would be a new capability) both stay fully gated with the actionable denial.
#[sqlx::test(fixtures("base"))]
async fn test_deployed_app_s3_viewer_union(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP,
"summary": "s3 viewer union test",
"value": {},
"policy": {
"execution_mode": "anonymous",
"triggerables": {},
"allowed_s3_keys": [{ "s3_path": DECLARED }]
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
let url = format!("{ws}/apps_u/download_s3_file/{APP}?s3={NON_PROVENANCE}");
// Anonymous: still gated. The denial is the actionable message and echoes the key.
let body = client().get(&url).send().await?.text().await?;
assert!(
body.contains("is not accessible from this app"),
"anonymous viewer must stay gated with the actionable denial: {body}"
);
assert!(
body.contains(NON_PROVENANCE),
"denial must echo the requested key: {body}"
);
// Logged-in viewer: no longer hard-denied — the gate delegates to reading as the
// viewer, so the request falls through to the storage read (no gate denial in
// EITHER the old or new form). No workspace storage is configured here, so it
// surfaces a downstream storage/OSS error, not a gate denial.
let body = authed(client().get(&url), USER_TOKEN)
.send()
.await?
.text()
.await?;
assert!(
!body.contains("is not accessible from this app") && !body.contains("File restricted"),
"logged-in viewer must delegate to its own read, not be gate-denied: {body}"
);
// Scope-restricted token: an `apps:read:<app>` token reaches this route but is
// REJECTED by the route-scope middleware on `job_helpers/*`, so it must NOT get the
// viewer fallback (that would be a capability it cannot obtain directly). It stays
// gated with the denial, unlike the unscoped session above.
let apps_read_scope = format!("apps:read:{APP}");
let scoped = mint_scoped_token(port, vec![apps_read_scope.as_str()]).await?;
let body = authed(client().get(&url), &scoped)
.send()
.await?
.text()
.await?;
assert!(
body.contains("is not accessible from this app"),
"scope-restricted token must stay gated, not get the viewer fallback: {body}"
);
// A filter-tags-only token carries no real scope restriction (the route-scope
// middleware treats it as unscoped), so it can read via job_helpers directly and
// MUST get the viewer fallback here — not be gated like a genuinely scoped token.
let tag_only = mint_scoped_token(port, vec!["if_jobs:filter_tags:default"]).await?;
let body = authed(client().get(&url), &tag_only)
.send()
.await?
.text()
.await?;
assert!(
!body.contains("is not accessible from this app") && !body.contains("File restricted"),
"filter-tags-only token is effectively unscoped and must delegate, not be gated: {body}"
);
Ok(())
}
/// Mint a presigned bearer (`exp=..&sig=..`) exactly as `sign_s3_objects` does:
/// `HMAC-SHA256(workspace_key, "file_key={s3}&exp={exp}")` (no storage param, since
/// these routes send none). `validate_s3_signature` is `private`-gated, so this test
/// only runs with the `private` feature.
#[cfg(feature = "private")]
fn mint_presigned(workspace_key: &str, s3: &str, exp: i64) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(workspace_key.as_bytes()).unwrap();
mac.update(format!("file_key={s3}&exp={exp}").as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
format!("exp={exp}&sig={sig}")
}
/// A presigned S3 object (bearer minted by `signS3Objects`) bypasses the provenance
/// gate on EVERY app-scoped display route, not just the raw `download_s3_file`
/// download: a valid signature clears the gate on preview/count/metadata/csv routes,
/// while an unsigned key stays denied and a forged/expired signature is rejected.
#[cfg(feature = "private")]
#[sqlx::test(fixtures("base"))]
async fn test_deployed_app_s3_presigned_bypasses_gate(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP,
"summary": "s3 presigned test",
"value": {},
"policy": {
"execution_mode": "anonymous",
"triggerables": {},
"allowed_s3_keys": [{ "s3_path": DECLARED }]
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
let workspace_key: String = sqlx::query_scalar(
"SELECT key FROM workspace_key WHERE workspace_id = 'test-workspace' AND kind = 'cloud'",
)
.fetch_one(&db)
.await?;
let exp = chrono::Utc::now().timestamp() + 3600;
let presigned = mint_presigned(&workspace_key, NON_PROVENANCE, exp);
let get = |route: String, token: &'static str| {
let url = format!("{ws}/apps_u/{route}");
authed(client().get(url), token).send()
};
let denied = |body: &str| body.contains("is not accessible from this app");
// Control: NON_PROVENANCE without a signature is denied by the gate. Sent
// anonymously — a logged-in viewer would instead fall back to reading as
// themselves, so anonymous is the identity that isolates the presigned bypass.
let body = client()
.get(format!(
"{ws}/apps_u/download_s3_file/{APP}?s3={NON_PROVENANCE}"
))
.send()
.await?
.text()
.await?;
assert!(
denied(&body),
"unsigned non-provenance key must be denied: {body}"
);
// Every display route: a valid presigned key clears the gate (falls through to
// the storage read, which fails with a storage error, never "File restricted").
// `read_bytes_*` are required on load_file_preview.
let routes = [
format!("download_s3_file/{APP}?s3={NON_PROVENANCE}&{presigned}"),
format!("load_table_count/{APP}?file_key={NON_PROVENANCE}&{presigned}"),
format!("load_csv_preview/{APP}?file_key={NON_PROVENANCE}&limit=5&offset=0&{presigned}"),
format!("load_parquet_preview/{APP}?file_key={NON_PROVENANCE}&limit=5&offset=0&{presigned}"),
format!("load_file_metadata/{APP}?file_key={NON_PROVENANCE}&{presigned}"),
format!(
"load_file_preview/{APP}?file_key={NON_PROVENANCE}&read_bytes_from=0&read_bytes_length=4096&{presigned}"
),
format!("download_s3_parquet_file_as_csv/{APP}?file_key={NON_PROVENANCE}&{presigned}"),
];
for route in routes {
let body = get(route.clone(), USER_TOKEN).await?.text().await?;
assert!(
!denied(&body),
"presigned key must bypass the gate on {route}: {body}"
);
}
// A tampered signature must NOT bypass: presence of `sig` commits to validation,
// so a wrong sig is rejected outright ("Invalid signature") rather than falling
// back to the provenance gate.
let forged = format!("exp={exp}&sig={}", "00".repeat(32));
let body = get(
format!("download_s3_file/{APP}?s3={NON_PROVENANCE}&{forged}"),
USER_TOKEN,
)
.await?
.text()
.await?;
assert!(
body.contains("Invalid signature"),
"forged signature must be rejected: {body}"
);
// An expired-but-valid signature is rejected on expiry, not bypassed.
let past = chrono::Utc::now().timestamp() - 10;
let expired = mint_presigned(&workspace_key, NON_PROVENANCE, past);
let body = get(
format!("download_s3_file/{APP}?s3={NON_PROVENANCE}&{expired}"),
USER_TOKEN,
)
.await?
.text()
.await?;
assert!(
body.contains("Signature expired"),
"expired signature must be rejected: {body}"
);
Ok(())
}
/// Seed a completed job whose result carries an s3 object. `app_trigger` sets the
/// app-origination marker exactly as `execute_component` stamps it: `Some(app_path)`
/// => `trigger_kind = 'app'` + `trigger = <app_path>` (an app-launched run);
/// `None` => an ordinary direct `/jobs/run` (no app marker). `created_by` is the user
/// the job ran as (the isolation key the gate confines downloads to).
async fn seed_completed_job(
db: &Pool<Postgres>,
created_by: &str,
app_trigger: Option<&str>,
s3_key: &str,
) -> anyhow::Result<()> {
let result = format!(r#"{{"s3":"{s3_key}"}}"#);
sqlx::query(
r#"
WITH j AS (
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, created_by,
permissioned_as, trigger_kind, trigger)
VALUES (gen_random_uuid(), 'test-workspace', 'script', 'u/test-user/query_to_s3',
$1, 'u/test-user',
CASE WHEN $2::text IS NULL THEN NULL ELSE 'app'::job_trigger_kind END, $2)
RETURNING id
)
INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, result, started_at)
SELECT id, 'test-workspace', 1, 'success', $3::jsonb, now() FROM j
"#,
)
.bind(created_by)
.bind(app_trigger)
.bind(&result)
.execute(db)
.await?;
Ok(())
}
/// A deployed app that renders S3 files it produced (e.g. a SQL query persisted to
/// S3 by a component) must clear the provenance gate for the caller whose own app run
/// produced them, while (a) provenance cannot be forged by running a runnable directly
/// (no app marker), (b) another app's outputs stay denied, and (c) another caller's
/// outputs stay denied (per-caller isolation). Provenance is keyed on the
/// app-origination marker (`trigger_kind='app'` + `trigger=<app path>`) that
/// `execute_component` stamps, plus `created_by = <this caller>` for isolation.
/// Exercised anonymously: the gate still fully governs anonymous callers, whereas a
/// logged-in viewer would instead fall back to reading as themselves.
#[sqlx::test(fixtures("base"))]
async fn test_deployed_app_s3_onbehalf_flow_script_provenance(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
const FS_APP: &str = "u/test-user/s3flowscript";
const OTHER_APP: &str = "u/test-user/other_app";
// Produced by the anonymous caller's own app run of THIS app.
const OWN_KEY: &str = "results/own_output.parquet";
// Produced by a DIFFERENT caller's app run of THIS app → isolation, must stay denied.
const OTHER_CALLER_KEY: &str = "results/user2_output.parquet";
// Produced by an app run of a DIFFERENT app → must stay denied.
const OTHER_APP_KEY: &str = "results/other_app_output.parquet";
// Produced by a DIRECT run (no app marker) → the forgery attempt, must stay denied.
const FORGED_KEY: &str = "results/author_only_secret.parquet";
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": FS_APP,
"summary": "s3 app-origination provenance test",
"value": {},
"policy": { "execution_mode": "anonymous", "triggerables": {} }
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
// Seed the produced-file jobs (all within the 3h window). The gate's `created_by`
// filter uses "anonymous" for an unauthenticated caller.
seed_completed_job(&db, "anonymous", Some(FS_APP), OWN_KEY).await?;
seed_completed_job(&db, "test-user-2", Some(FS_APP), OTHER_CALLER_KEY).await?;
seed_completed_job(&db, "anonymous", Some(OTHER_APP), OTHER_APP_KEY).await?;
seed_completed_job(&db, "anonymous", None, FORGED_KEY).await?;
let denied = |body: &str| body.contains("is not accessible from this app");
// Anonymous GET (borrows `ws`, reusable across calls: the URL is built before the
// `async move` so only the owned `url` is moved into the future, not `ws`).
let anon_body = |route: String| {
let url = format!("{ws}/apps_u/{route}");
async move {
client()
.get(url)
.send()
.await
.unwrap()
.text()
.await
.unwrap()
}
};
// The caller's own app run's output clears the gate (the case that regressed to
// a hard denial).
let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OWN_KEY}")).await;
assert!(
!denied(&body),
"caller's own app-produced key must clear the gate: {body}"
);
// Per-caller isolation: another caller's result stays denied even though it is a
// genuine app-marked job of the same app.
let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OTHER_CALLER_KEY}")).await;
assert!(
denied(&body),
"another caller's app-produced key must stay denied (isolation): {body}"
);
// A key produced by a direct run (no app marker) stays denied — the forgery the
// app-origination marker closes.
let body = anon_body(format!("download_s3_file/{FS_APP}?s3={FORGED_KEY}")).await;
assert!(
denied(&body),
"key from a direct run (no app marker) must stay denied: {body}"
);
// A key produced by a DIFFERENT app stays denied — provenance is scoped to THIS
// app's path.
let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OTHER_APP_KEY}")).await;
assert!(
denied(&body),
"key produced by a different app must stay denied: {body}"
);
Ok(())
}
/// Seed a minimal deployed script so `execute_component` can resolve `script/<path>`.
/// The script is given its OWN `on_behalf_of` (created_by test-user-2), distinct from
/// any app author, so a test can assert an app component runs as the app's identity,
/// not the referenced script's on_behalf.
async fn seed_script(db: &Pool<Postgres>, path: &str, content: &str) -> anyhow::Result<()> {
let mut h = 0i64;
for b in path.bytes().chain(content.bytes()) {
h = h.wrapping_mul(31).wrapping_add(b as i64);
}
sqlx::query(
r#"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, on_behalf_of, language, tag, lock)
VALUES ('test-workspace', $1, $2, '', '', $3, 'test-user-2', 'u/test-user-2',
'deno'::script_lang, 'deno', '')
ON CONFLICT DO NOTHING"#,
)
.bind(h)
.bind(path)
.bind(content)
.execute(db)
.await?;
// #[sqlx::test] isolated DBs share one workspace id and reuse script paths; the
// process-global deployed-script cache is keyed by (workspace, path), so disable
// it here so `execute_component` resolves against this test's own DB.
windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED
.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
/// End-to-end: `execute_component` must stamp the job it enqueues with
/// `trigger_kind = 'app'` + `trigger = <app path>`. This is the marker the S3
/// provenance gate relies on; the gate tests seed it directly, so this test proves
/// the runtime actually produces it. `execute_component` commits the job row and
/// returns its id, so we assert on the row without needing a worker to run it.
#[sqlx::test(fixtures("base"))]
async fn test_execute_component_stamps_app_trigger(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
const APP_PATH: &str = "u/test-user/trigger_marker_app";
const SCRIPT_PATH: &str = "u/test-user/query_to_s3";
seed_script(&db, SCRIPT_PATH, "export function main() { return 1 }").await?;
// Anonymous app wired to run the deployed script; keys use the production
// component-prefixed triggerable form.
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP_PATH,
"summary": "trigger marker test",
"value": {},
"policy": {
"execution_mode": "anonymous",
"triggerables_v2": {
format!("comp1:script/{SCRIPT_PATH}"): { "static_inputs": {}, "one_of_inputs": {} }
}
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
// Run the script component through the app runtime.
let resp = authed(
client().post(format!("{ws}/apps_u/execute_component/{APP_PATH}")),
ADMIN_TOKEN,
)
.json(&json!({
"component": "comp1",
"path": format!("script/{SCRIPT_PATH}"),
"args": {}
}))
.send()
.await?;
let status = resp.status();
let job_id = resp.text().await?;
assert_eq!(status, 200, "execute_component: {job_id}");
let job_id = job_id.trim().trim_matches('"');
// The enqueued job must carry the app-origination marker (trigger_kind = 'app',
// trigger = the app path, NOT the runnable path) and must run on-behalf of the
// APP's identity (u/test-user, the anonymous app's author), NOT the referenced
// script's own on_behalf (u/test-user-2).
let (trigger_kind, trigger, permissioned_as): (Option<String>, Option<String>, String) =
sqlx::query_as(
"SELECT trigger_kind::text, trigger, permissioned_as FROM v2_job \
WHERE id = $1::uuid AND workspace_id = 'test-workspace'",
)
.bind(job_id)
.fetch_one(&db)
.await?;
assert_eq!(
trigger_kind.as_deref(),
Some("app"),
"execute_component must stamp trigger_kind = 'app' (got {trigger_kind:?})"
);
assert_eq!(
trigger.as_deref(),
Some(APP_PATH),
"trigger must be the app path, not the runnable path (got {trigger:?})"
);
assert_eq!(
permissioned_as, "u/test-user",
"component must run on-behalf of the APP identity, not the referenced script's on_behalf (got {permissioned_as})"
);
Ok(())
}
/// `JobTriggerKind::App` (added for the app-origination S3 marker) is now a valid
/// value for the suspended-trigger reassignment routes, but there is no
/// `app_trigger` table. The handler must reject it with a clean 400 rather than
/// failing on a missing-relation database error (500).
#[sqlx::test(fixtures("base"))]
async fn test_app_trigger_kind_rejected_for_reassignment(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
let resp = authed(
client().post(format!(
"{ws}/trigger/app/resume_suspended_trigger_jobs/u/test-user/x"
)),
ADMIN_TOKEN,
)
.json(&json!({}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"app reassignment must be a clean 400, not 500: {body}"
);
assert!(
body.contains("do not support job reassignment"),
"expected reassignment-unsupported message, got: {body}"
);
Ok(())
}
/// A preview run is NEVER app-provenanced. Preview executes as the *caller* (Viewer
/// mode), so its results are read back as the caller via the viewer-scoped
/// job_helpers endpoint — never author-mode. Marking a preview would let any
/// `jobs:run` caller supply arbitrary `raw_code` against a victim app path and forge
/// the marker the S3 gate trusts; and it is never needed. Even the app owner's own
/// preview stays unmarked.
#[sqlx::test(fixtures("base"))]
async fn test_preview_is_not_app_provenanced(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
const APP: &str = "u/test-user/preview_app";
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP,
"summary": "preview marker test",
"value": {},
"policy": { "execution_mode": "anonymous", "triggerables": {} }
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?);
// Preview arbitrary inline code against the app (force_viewer_static_fields =>
// preview mode; raw_code with no path/id skips all app authorization), as `token`.
let preview_trigger_kind = |token: &'static str| {
let ws = ws.clone();
let db = db.clone();
async move {
let resp = authed(
client().post(format!("{ws}/apps_u/execute_component/{APP}")),
token,
)
.json(&json!({
"component": "comp1",
"raw_code": { "content": "export function main() { return 1 }", "language": "deno" },
"force_viewer_static_fields": {},
"args": {}
}))
.send()
.await
.unwrap();
let status = resp.status();
let job_id = resp.text().await.unwrap();
assert_eq!(status, 200, "preview execute_component: {job_id}");
let job_id = job_id.trim().trim_matches('"').to_string();
let trigger_kind: Option<String> = sqlx::query_scalar(
"SELECT trigger_kind::text FROM v2_job WHERE id = $1::uuid AND workspace_id = 'test-workspace'",
)
.bind(job_id)
.fetch_one(&db)
.await
.unwrap();
trigger_kind
}
};
// The app owner (test-user, admin) previewing their own app → still NOT marked.
let trigger_kind = preview_trigger_kind(ADMIN_TOKEN).await;
assert_eq!(
trigger_kind, None,
"the app owner's own preview must NOT be app-provenanced (got {trigger_kind:?})"
);
// A non-editor (test-user-2) previewing a victim app → NOT marked.
let trigger_kind = preview_trigger_kind(USER_TOKEN).await;
assert_eq!(
trigger_kind, None,
"a non-editor's preview must NOT be app-provenanced (got {trigger_kind:?})"
);
Ok(())
}