mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* refactor: make the app policy's principal the authority for its identity * fix: align the app backfill with the sibling migration and audit the uncached address * chore: refresh the sqlx cache after rebasing onto the merged base * fix: resolve the app execution address uncached, it decides the job's authorization * chore: cache the EE queries at the ref this branch pins * chore: cache the EE queries at the ref this branch pins * fix: derive the app draft's on-behalf-of address on read * chore: cache the query the draft derivation test added * fix: derive the app identity on the draft-table and version reads too * docs: state the draft resolver's authorization contract * fix: resolve a draft's principal against workspace membership only * chore: cache the membership lookup the draft resolver added * fix: drop an unresolvable draft's address instead of leaving it stale * perf: evict the address cache on change so app dispatch can read it * fix: evict on superadmin role changes, not only address changes * refactor: make the app policy's address optional instead of derived on read * fix: follow an external superadmin's rename into the apps that name them * docs: state the removal gate once, and correctly * refactor: drop the app-policy version constant that gated nothing * docs: drop the last reference to the removed constant * perf: read the address cache everywhere now that eviction reaches every replica * fix: keep persisted addresses off the cache the poller evicts asynchronously * docs: state where the cached address is accepted and where it is not * docs: keep the cache rule in one place and drop the stale premise * docs: sort the two lookups by how long a wrong answer lives * fix: resolve the schedule address uncached where it is written to the row * docs: name the release this actually ships in * perf: evict a superadmin's key per workspace instead of the whole cache * fix: evict every alias a superadmin principal can be spelled as * docs: describe the trigger as it is * docs: cover the round-tripped read in the cache rule * docs: record why a stale dispatch address cannot escalate * fix: validate a dispatch address against the principal's live binding * fix: carry the validated address through to the job row and token * fix: record the validated address on the job row, not the one handed in * test: run the substep tag check as the non-superadmin it means to test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: rewrite a stored app address that disagrees with its principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the accepted staleness window of the cached dispatch address Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: record the validated address on the job's audit row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the accepted rename race of pre-transaction identity resolution Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: separate the app's stored address from the derived one in the resolver doc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: describe the job identity fast path the push comments skipped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: backfill a legacy group-prefixed username as the group it names Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: resolve a schedule edit's identity before opening its transaction Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: never resolve a disabled member to a same-named superadmin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: state what the email-change notify buys, and rewrap two comment lines Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: keep a group's runnables when offboarding a legacy group-prefixed member Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: read the app author from the stored address, as execution does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the rename race's full consequence as a known, accepted limitation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the keep-target group address case as a known, accepted limitation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
775 lines
23 KiB
Rust
775 lines
23 KiB
Rust
//! Integration tests for folder `default_permissioned_as` rules.
|
|
//!
|
|
//! These tests exhaustively verify the feature across every surface where a
|
|
//! `permissioned_as` / `on_behalf_of_email` default is applied at create-time:
|
|
//!
|
|
//! - folder CRUD (persistence, validation)
|
|
//! - audit log emission
|
|
//! - schedules, flows, scripts, apps — all entry points
|
|
//! - admin / wm_deployers / regular-user behavior
|
|
//! - create-only semantics (updates never rewrite)
|
|
//! - explicit preserve beats folder default
|
|
//! - first-match-wins ordering for overlapping rules
|
|
//! - stale rule rejection
|
|
//! - paths outside folders are untouched
|
|
//!
|
|
//! The tests share one workspace and one folder because ApiServer::start has
|
|
//! significant setup cost; keeping everything in one test minimizes total run
|
|
//! time while still exercising every branch.
|
|
|
|
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", format!("Bearer {}", token))
|
|
}
|
|
|
|
fn new_script(path: &str) -> serde_json::Value {
|
|
json!({
|
|
"path": path,
|
|
"summary": "Test script",
|
|
"description": "",
|
|
"content": "export async function main() { return 42; }",
|
|
"language": "deno",
|
|
"schema": {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": []
|
|
}
|
|
})
|
|
}
|
|
|
|
fn new_flow(path: &str) -> serde_json::Value {
|
|
json!({
|
|
"path": path,
|
|
"summary": "Test flow",
|
|
"description": "",
|
|
"value": { "modules": [] },
|
|
"schema": {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": []
|
|
}
|
|
})
|
|
}
|
|
|
|
fn new_app(path: &str) -> serde_json::Value {
|
|
json!({
|
|
"path": path,
|
|
"summary": "Test app",
|
|
"value": {
|
|
"type": "rawapp",
|
|
"inline_script": null
|
|
},
|
|
"policy": {
|
|
"execution_mode": "anonymous",
|
|
"triggerables": {}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn new_schedule(path: &str, script_path: &str) -> serde_json::Value {
|
|
json!({
|
|
"path": path,
|
|
"schedule": "0 0 */6 * * *",
|
|
"timezone": "UTC",
|
|
"script_path": script_path,
|
|
"is_flow": false,
|
|
"enabled": false,
|
|
})
|
|
}
|
|
|
|
/// Exhaustive create-time folder default_permissioned_as coverage.
|
|
#[sqlx::test(fixtures("folder_default_permissioned_as"))]
|
|
async fn test_folder_default_permissioned_as(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
// ========================================================================
|
|
// 0. Setup — create the folder with no rules (admin becomes owner)
|
|
// and grant the deployer + regular users writer access so they can
|
|
// deploy items inside it.
|
|
// ========================================================================
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"name": "prodfolder",
|
|
"extra_perms": {
|
|
"u/deployer-user": true,
|
|
"u/test-user-2": true
|
|
}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should create folder: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// A helper script that schedules reference
|
|
let resp = authed(
|
|
client().post(format!("{base}/scripts/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_script("f/prodfolder/helper"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Admin should create helper script: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// ========================================================================
|
|
// 1. Rule validation — bad inputs are rejected with 400
|
|
// ========================================================================
|
|
|
|
// Non-array
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "default_permissioned_as": { "not": "an array" } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "non-array should 400");
|
|
|
|
// Invalid glob
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"default_permissioned_as": [{ "path_glob": "[unclosed", "permissioned_as": "u/original-user" }]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "invalid glob should 400");
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
body.contains("path_glob is not a valid glob"),
|
|
"error should mention glob: {body}"
|
|
);
|
|
|
|
// Invalid permissioned_as format
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"default_permissioned_as": [{ "path_glob": "**", "permissioned_as": "bogus" }]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "invalid permissioned_as should 400");
|
|
|
|
// Missing required field
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"default_permissioned_as": [{ "path_glob": "**" }]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "missing permissioned_as should 400");
|
|
|
|
// ========================================================================
|
|
// 2. Rule persistence — valid rules round-trip through GET folder
|
|
// ========================================================================
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"default_permissioned_as": [
|
|
{ "path_glob": "jobs/critical/**", "permissioned_as": "u/original-user" },
|
|
{ "path_glob": "jobs/**", "permissioned_as": "g/wm_deployers" },
|
|
{ "path_glob": "reports/*", "permissioned_as": "original@windmill.dev" }
|
|
]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Valid rules should update: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
let resp = authed(
|
|
client().get(format!("{base}/folders/get/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let folder: serde_json::Value = resp.json().await?;
|
|
let rules = folder
|
|
.get("default_permissioned_as")
|
|
.and_then(|v| v.as_array())
|
|
.expect("rules should be an array");
|
|
assert_eq!(rules.len(), 3, "should have 3 rules");
|
|
assert_eq!(rules[0]["path_glob"], "jobs/critical/**");
|
|
assert_eq!(rules[0]["permissioned_as"], "u/original-user");
|
|
assert_eq!(rules[1]["permissioned_as"], "g/wm_deployers");
|
|
assert_eq!(rules[2]["permissioned_as"], "original@windmill.dev");
|
|
|
|
// ========================================================================
|
|
// 4. Schedules — the core matrix
|
|
// ========================================================================
|
|
|
|
// 4a. Admin, matching path — folder default wins
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/jobs/sched_admin_match",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should create schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/sched_admin_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "g/wm_deployers",
|
|
"matching rule should apply (jobs/** wins over critical since path is not critical)"
|
|
);
|
|
|
|
// 4b. Admin, path matching the MORE SPECIFIC rule which is listed first
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/jobs/critical/prod_run",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should create critical schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/critical/prod_run",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/original-user",
|
|
"first matching rule wins (critical/** listed first)"
|
|
);
|
|
|
|
// 4c. Admin, non-matching path — acting user identity
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/dev/sched_no_match",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should create non-matching schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/dev/sched_no_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/test-user",
|
|
"non-matching path falls back to acting user"
|
|
);
|
|
assert_eq!(sched.email, "test@windmill.dev");
|
|
|
|
// 4d. Deployer (non-admin in wm_deployers), matching path — default applies
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"DEPLOYER_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/jobs/sched_deployer",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Deployer should create schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/sched_deployer",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "g/wm_deployers",
|
|
"deployer gets folder default"
|
|
);
|
|
assert_eq!(
|
|
sched.edited_by, "deployer-user",
|
|
"edited_by is still the acting user"
|
|
);
|
|
|
|
// 4e. Non-admin/non-deployer, matching path — default is NOT applied
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/jobs/sched_regular",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Regular user should create schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/sched_regular",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/test-user-2",
|
|
"regular user never gets folder default"
|
|
);
|
|
|
|
// 4f. Explicit preserve beats folder default
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"path": "f/prodfolder/jobs/sched_preserve",
|
|
"schedule": "0 0 */6 * * *",
|
|
"timezone": "UTC",
|
|
"script_path": "f/prodfolder/helper",
|
|
"is_flow": false,
|
|
"enabled": false,
|
|
"permissioned_as": "u/original-user",
|
|
"preserve_permissioned_as": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should create preserved schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/sched_preserve",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/original-user",
|
|
"explicit preserve beats folder default (even though folder default would be g/wm_deployers)"
|
|
);
|
|
|
|
// 4g. Update does NOT rewrite permissioned_as to folder default
|
|
// Edit the non-matching schedule created in 4c, with no permissioned_as in the payload.
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"{base}/schedules/update/f/prodfolder/dev/sched_no_match"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"schedule": "0 0 */12 * * *",
|
|
"timezone": "UTC",
|
|
"script_path": "f/prodfolder/helper",
|
|
"is_flow": false,
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should update schedule: {}",
|
|
resp.text().await?
|
|
);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/dev/sched_no_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/test-user",
|
|
"update still shows acting user — the existing schedule wasn't rewritten by a folder rule change (this would be defaults-on-update, which we don't do)"
|
|
);
|
|
|
|
// ========================================================================
|
|
// 5. Flows — on_behalf_of_email variant
|
|
// ========================================================================
|
|
|
|
// 5a. Admin, matching path
|
|
let resp = authed(
|
|
client().post(format!("{base}/flows/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_flow("f/prodfolder/jobs/flow_admin_match"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Admin should create flow: {}",
|
|
resp.text().await?
|
|
);
|
|
let flow = sqlx::query!(
|
|
"SELECT on_behalf_of FROM flow WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/flow_admin_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
flow.on_behalf_of.as_deref(),
|
|
Some("g/wm_deployers"),
|
|
"flow should resolve folder default to group email"
|
|
);
|
|
// The group email is synthetic and resolves back to nobody, so the group can
|
|
// only survive as the stored permissioned_as.
|
|
assert_eq!(
|
|
flow.on_behalf_of.as_deref(),
|
|
Some("g/wm_deployers"),
|
|
"flow should run as the group the folder rule names"
|
|
);
|
|
|
|
// 5b. Admin, reports/* rule (email directly as permissioned_as)
|
|
let resp = authed(
|
|
client().post(format!("{base}/flows/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_flow("f/prodfolder/reports/weekly"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Admin should create reports flow: {}",
|
|
resp.text().await?
|
|
);
|
|
let flow = sqlx::query!(
|
|
"SELECT on_behalf_of FROM flow WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/reports/weekly",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
flow.on_behalf_of.as_deref(),
|
|
Some("u/original-user"),
|
|
"a rule naming an address stores the principal it names, which is what carries that user's groups and folders"
|
|
);
|
|
|
|
// 5c. Non-matching flow path — no default
|
|
let resp = authed(
|
|
client().post(format!("{base}/flows/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_flow("f/prodfolder/dev/flow_no_match"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
let flow = sqlx::query!(
|
|
"SELECT on_behalf_of FROM flow WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/dev/flow_no_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
flow.on_behalf_of, None,
|
|
"no folder default ⇒ no on_behalf_of_email written"
|
|
);
|
|
|
|
// 5d. Path outside any folder (user folder) — default never applies
|
|
let resp = authed(
|
|
client().post(format!("{base}/flows/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_flow("u/test-user/outside_flow"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
let flow = sqlx::query!(
|
|
"SELECT on_behalf_of FROM flow WHERE path = $1 AND workspace_id = $2",
|
|
"u/test-user/outside_flow",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
flow.on_behalf_of, None,
|
|
"paths outside folders are never touched"
|
|
);
|
|
|
|
// ========================================================================
|
|
// 6. Scripts — first-time create applies default; subsequent updates don't
|
|
// ========================================================================
|
|
|
|
// 6a. Admin creates a new script at matching path
|
|
let resp = authed(
|
|
client().post(format!("{base}/scripts/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_script("f/prodfolder/jobs/new_script"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Admin should create script: {}",
|
|
resp.text().await?
|
|
);
|
|
let script = sqlx::query!(
|
|
"SELECT on_behalf_of FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
|
|
"f/prodfolder/jobs/new_script",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
script.on_behalf_of.as_deref(),
|
|
Some("g/wm_deployers"),
|
|
"new script at matching path gets folder default"
|
|
);
|
|
|
|
// Note: Windmill scripts require the previous non-archived version to be
|
|
// explicitly archived before a new hash can be created at the same path.
|
|
// We rely on the existence check inside `create_script_internal`
|
|
// (`path_already_exists` → skip folder default) which is unit-level logic
|
|
// exercised on every subsequent deploy at an existing path. The flow 5c
|
|
// case above covers create-only semantics end-to-end through the API.
|
|
|
|
// ========================================================================
|
|
// 7. Apps — policy.on_behalf_of variant
|
|
// ========================================================================
|
|
|
|
// 7a. Admin, matching path
|
|
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
|
|
.json(&new_app("f/prodfolder/jobs/app_match"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Admin should create app: {}",
|
|
resp.text().await?
|
|
);
|
|
let app = sqlx::query!(
|
|
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/app_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let policy: serde_json::Value = app.policy;
|
|
assert_eq!(
|
|
policy["on_behalf_of"], "g/wm_deployers",
|
|
"app policy.on_behalf_of gets folder default"
|
|
);
|
|
assert_eq!(
|
|
policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev",
|
|
"the stored address is derived from that principal"
|
|
);
|
|
|
|
// 7b. Admin, non-matching path — acting user
|
|
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
|
|
.json(&new_app("f/prodfolder/dev/app_no_match"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
let app = sqlx::query!(
|
|
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/dev/app_no_match",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let policy: serde_json::Value = app.policy;
|
|
assert_eq!(policy["on_behalf_of"], "u/test-user");
|
|
|
|
// 7c. Regular user, matching path — acting user (not folder default)
|
|
let resp = authed(
|
|
client().post(format!("{base}/apps/create")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&new_app("f/prodfolder/jobs/app_regular"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"Regular user should create app: {}",
|
|
resp.text().await?
|
|
);
|
|
let app = sqlx::query!(
|
|
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/app_regular",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let policy: serde_json::Value = app.policy;
|
|
assert_eq!(
|
|
policy["on_behalf_of"], "u/test-user-2",
|
|
"regular user never gets folder default in app policy"
|
|
);
|
|
|
|
// ========================================================================
|
|
// 8. Stale rule — rule resolves to a user that does not exist
|
|
// ========================================================================
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"default_permissioned_as": [
|
|
{ "path_glob": "stale/**", "permissioned_as": "u/ghost" }
|
|
]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"Admin should update folder rules: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/stale/should_fail",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"stale rule should return 400 when a new schedule matches it"
|
|
);
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
body.contains("u/ghost") && body.contains("does not exist"),
|
|
"error should identify the stale user: {body}"
|
|
);
|
|
|
|
// Non-matching paths in the same folder still work fine.
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule("f/prodfolder/safe/ok", "f/prodfolder/helper"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"non-matching path should not hit the stale rule: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// ========================================================================
|
|
// 9. Clearing rules by passing an empty array
|
|
// ========================================================================
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/folders/update/prodfolder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "default_permissioned_as": [] }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/schedules/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&new_schedule(
|
|
"f/prodfolder/jobs/cleared",
|
|
"f/prodfolder/helper",
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let sched = sqlx::query!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2",
|
|
"f/prodfolder/jobs/cleared",
|
|
"test-workspace"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
sched.permissioned_as, "u/test-user",
|
|
"after rules cleared, new schedule uses acting user again"
|
|
);
|
|
|
|
Ok(())
|
|
}
|