Files
windmill/backend/tests/on_behalf_of.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

392 lines
13 KiB
Rust

//! A script's on-behalf-of identity must drive the permissions of the jobs it produces, not
//! just their address. The principal is the only stored half; the address is derived from it,
//! so a request naming one, the other, or a mismatched pair all resolve to one account.
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 script_body(path: &str, on_behalf_of: Option<&str>) -> serde_json::Value {
let mut body = json!({
"path": path,
"summary": "",
"description": "",
"content": "export async function main() { return 42; }",
"language": "deno",
"on_behalf_of_email": "original@windmill.dev",
"preserve_on_behalf_of": true,
"auto_parent": true,
});
if let Some(permissioned_as) = on_behalf_of {
body["on_behalf_of"] = json!(permissioned_as);
}
body
}
/// Deploys as `test-user` (admin) so the recorded identity is nobody's default: neither
/// the caller's nor the deployer's. Returns the hex hash the run-by-hash route parses.
async fn create_script(
base: &str,
path: &str,
on_behalf_of: Option<&str>,
) -> anyhow::Result<String> {
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&script_body(path, on_behalf_of))
.send()
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert_eq!(status, 201, "creating {path}: {hash}");
Ok(hash.trim().trim_matches('"').to_string())
}
async fn run_by_hash(base: &str, hash: &str) -> anyhow::Result<uuid::Uuid> {
let resp = authed(
client().post(format!("{base}/jobs/run/h/{hash}")),
"SECRET_TOKEN",
)
.json(&json!({}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 201, "running {hash}: {body}");
Ok(uuid::Uuid::parse_str(body.trim().trim_matches('"'))?)
}
async fn stored_permissioned_as(
db: &Pool<Postgres>,
table: &str,
path: &str,
) -> anyhow::Result<Option<String>> {
// `table` is a literal from this test, never caller input.
Ok(sqlx::query_scalar(&format!(
"SELECT on_behalf_of FROM {table} \
WHERE path = $1 AND workspace_id = 'test-workspace' AND NOT archived"
))
.bind(path)
.fetch_one(db)
.await?)
}
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_on_behalf_of_drives_job_identity(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let recorded =
create_script(&base, "u/test-user/obo_recorded", Some("u/original-user")).await?;
assert_eq!(
stored_permissioned_as(&db, "script", "u/test-user/obo_recorded")
.await?
.as_deref(),
Some("u/original-user")
);
// Workers predating this release read only the address, and they are expected to lag the
// server, so a deploy keeps filling it in until every live worker is new.
assert_eq!(
sqlx::query_scalar!(
"SELECT on_behalf_of_email FROM script WHERE path = 'u/test-user/obo_recorded' AND workspace_id = 'test-workspace'"
)
.fetch_one(&db)
.await?
.as_deref(),
Some("original@windmill.dev"),
);
// A client that predates the field names only the email; deriving the principal from
// it is what stops a routine redeploy from handing the script to whoever deploys it.
let derived = create_script(&base, "u/test-user/obo_derived", None).await?;
assert_eq!(
stored_permissioned_as(&db, "script", "u/test-user/obo_derived")
.await?
.as_deref(),
Some("u/original-user")
);
let recorded_job = run_by_hash(&base, &recorded).await?;
let derived_job = run_by_hash(&base, &derived).await?;
let jobs = sqlx::query!(
"SELECT id, permissioned_as, permissioned_as_email FROM v2_job WHERE id = ANY($1)",
&[recorded_job, derived_job][..]
)
.fetch_all(&db)
.await?;
let identity = |id: uuid::Uuid| {
let job = jobs.iter().find(|j| j.id == id).expect("job was pushed");
(
job.permissioned_as.clone(),
job.permissioned_as_email.clone(),
)
};
assert_eq!(
identity(recorded_job),
(
"u/original-user".to_string(),
"original@windmill.dev".to_string()
),
"a recorded permissioned_as must be what the job runs as"
);
assert_eq!(
identity(derived_job),
(
"u/original-user".to_string(),
"original@windmill.dev".to_string()
),
"a principal derived from the address drives the job the same way an explicit one does"
);
// A superadmin acting outside their workspaces has no `usr` row. Dropping them on an
// email-only redeploy would keep their superadmin email next to the deployer's
// permissions — the hybrid identity this whole change exists to remove.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/obo_superadmin",
"summary": "",
"description": "",
"content": "export async function main() { return 42; }",
"language": "deno",
"on_behalf_of_email": "superadmin-external@windmill.dev",
"preserve_on_behalf_of": true,
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "creating: {}", resp.text().await?);
assert_eq!(
stored_permissioned_as(&db, "script", "u/test-user/obo_superadmin")
.await?
.as_deref(),
Some("u/superadmin-external")
);
// The synthetic group namespace is not reserved, so a real account holding such an
// address must win over the like-named group — otherwise an email-only deploy would
// hand the runnable that group's folder access.
sqlx::query!(
"INSERT INTO group_ (workspace_id, name, summary, extra_perms) \
VALUES ('test-workspace', 'ops', '', '{}') ON CONFLICT DO NOTHING"
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE usr SET email = 'group-ops@windmill.dev' WHERE workspace_id = 'test-workspace' \
AND username = 'test-user-2'"
)
.execute(&db)
.await?;
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/obo_group_collision",
"summary": "",
"description": "",
"content": "export async function main() { return 42; }",
"language": "deno",
"on_behalf_of_email": "group-ops@windmill.dev",
"preserve_on_behalf_of": true,
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "creating: {}", resp.text().await?);
assert_eq!(
stored_permissioned_as(&db, "script", "u/test-user/obo_group_collision")
.await?
.as_deref(),
Some("u/test-user-2"),
"a real account must win over the like-named group"
);
// An address is the principal only for an account whose username is that address; for
// anybody else it is canonicalized, since the bare form carries neither their groups nor
// their folders.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&script_body(
"u/test-user/obo_bare_address",
Some("original@windmill.dev"),
))
.send()
.await?;
assert_eq!(resp.status(), 201, "creating: {}", resp.text().await?);
assert_eq!(
stored_permissioned_as(&db, "script", "u/test-user/obo_bare_address")
.await?
.as_deref(),
Some("u/original-user")
);
// A job row carries a narrower identity column than the runnable it comes from, so an
// address too long to be enqueued is refused at deploy rather than at the first run —
// whether it is preserved from someone else or is the deployer's own.
const LONG_ADDRESS: &str = "a-very-long-superadmin-address-for-this-test@windmill.dev";
sqlx::query!(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ($1, '', 'password', true, true, '')",
LONG_ADDRESS
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('LONG_TOKEN'::bytea), 'hex'), 'LONG_TOKEN', 'LONG_TOKEN', $1, 'long', true)",
LONG_ADDRESS
)
.execute(&db)
.await?;
let too_long = |path: &str| {
json!({
"path": path,
"summary": "",
"description": "",
"content": "export async function main() { return 42; }",
"language": "deno",
"on_behalf_of_email": LONG_ADDRESS,
"preserve_on_behalf_of": true,
})
};
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&too_long("u/test-user/obo_too_long"))
.send()
.await?;
assert_eq!(resp.status(), 400, "an unenqueueable identity must be rejected");
// Picking "me" does not preserve anyone, so it takes the branch that stores the caller's
// own principal — which for an account acting without a `usr` row is their address.
let mut own = too_long("u/test-user/obo_too_long_self");
own["preserve_on_behalf_of"] = json!(false);
let resp = authed(
client().post(format!("{base}/scripts/create")),
"LONG_TOKEN",
)
.json(&own)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains("characters a job can carry"),
"the caller's own identity has to be refused by the same check, not by a column \
overflow further down: {body}"
);
// A pair naming two different principals would run as a composite of both.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&script_body(
"u/test-user/obo_mismatch",
Some("u/test-user-2"),
))
.send()
.await?;
assert_eq!(resp.status(), 400, "a mismatched pair must be rejected");
// The identity a no-op push is compared against is the stored principal, so a push that
// names it by address alone still has to read as unchanged — otherwise every idempotent
// CLI push of a configured script would cut a version and a phantom git-sync commit.
async fn push_noop_guarded(base: &str, body: serde_json::Value) -> anyhow::Result<String> {
let resp = authed(
client().post(format!("{base}/scripts/create?skip_if_noop=true")),
"SECRET_TOKEN",
)
.json(&body)
.send()
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert_eq!(status, 201, "creating: {hash}");
Ok(hash.trim().trim_matches('"').to_string())
}
// The no-op check compares every field, so the body has to carry the values a deploy
// fills in by itself, or it would be rejected before reaching the identity comparison.
let noop_body = |permissioned_as| {
let mut body = script_body("u/test-user/obo_noop", permissioned_as);
body["ws_error_handler_muted"] = json!(false);
body["assets"] = json!([]);
body
};
let first = push_noop_guarded(&base, noop_body(Some("u/original-user"))).await?;
let again = push_noop_guarded(&base, noop_body(None)).await?;
assert_eq!(
first, again,
"an identical push naming the same identity by address must not cut a new version"
);
// Flows resolve the same way, but through their own UPDATE — which must not drop the
// principal when the body names only the email.
let flow = json!({
"path": "u/test-user/obo_flow",
"summary": "",
"value": { "modules": [] },
"on_behalf_of_email": "original@windmill.dev",
"on_behalf_of": "u/original-user",
"preserve_on_behalf_of": true,
});
let resp = authed(
client().post(format!("{base}/flows/create")),
"SECRET_TOKEN",
)
.json(&flow)
.send()
.await?;
assert_eq!(resp.status(), 201, "creating flow: {}", resp.text().await?);
let mut update = flow.clone();
update["summary"] = json!("edited");
update
.as_object_mut()
.unwrap()
.remove("on_behalf_of");
let resp = authed(
client().post(format!("{base}/flows/update/u/test-user/obo_flow")),
"SECRET_TOKEN",
)
.json(&update)
.send()
.await?;
assert_eq!(resp.status(), 200, "updating flow: {}", resp.text().await?);
assert_eq!(
stored_permissioned_as(&db, "flow", "u/test-user/obo_flow")
.await?
.as_deref(),
Some("u/original-user"),
"an update that names only the email must not drop the flow's principal"
);
Ok(())
}