diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f00c5de5f8..acc56ca254 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -154cbccb6b8d47bf662b5507882917b5933d5a45 +065c2c64c3904d5c0b2766b424210d34f9e54d2d diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs index 580dc1e8e2..6af694bff0 100644 --- a/backend/tests/job_token_scopes.rs +++ b/backend/tests/job_token_scopes.rs @@ -17,6 +17,15 @@ async fn insert_job( id: &str, parent: Option<&str>, scopes: &[&str], +) -> anyhow::Result<()> { + insert_job_with(db, id, parent, Some(scopes)).await +} + +async fn insert_job_with( + db: &Pool, + id: &str, + parent: Option<&str>, + scopes: Option<&[&str]>, ) -> anyhow::Result<()> { let id = Uuid::parse_str(id)?; sqlx::query( @@ -353,3 +362,53 @@ async fn test_deploy_without_the_field_keeps_the_restriction( assert_eq!(stored("u/test-user/renamed").await?, None); Ok(()) } + +/// The OIDC `job_token_scopes` claim states the scopes of the token that asked for it. +#[cfg(all(feature = "enterprise", feature = "private", feature = "openidconnect"))] +#[sqlx::test(fixtures("base"))] +async fn test_oidc_claim_carries_the_presented_scopes(db: Pool) -> anyhow::Result<()> { + use base64::Engine; + + initialize_tracing().await; + const OPEN_JOB: &str = "b0000000-0000-0000-0000-000000000005"; + insert_script(&db, "u/test-user-3/agent", 535353, None).await?; + insert_job(&db, OIDC_JOB, None, &["oidc:write"]).await?; + insert_job_with(&db, OPEN_JOB, None, None).await?; + sqlx::query("UPDATE v2_job SET runnable_id = 535353 WHERE id = ANY($1)") + .bind([Uuid::parse_str(OIDC_JOB)?, Uuid::parse_str(OPEN_JOB)?]) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, running, scheduled_for, tag) + SELECT id, workspace_id, true, now(), tag FROM v2_job WHERE id = ANY($1)", + ) + .bind([Uuid::parse_str(OIDC_JOB)?, Uuid::parse_str(OPEN_JOB)?]) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + // The issuer of the minted token. + windmill_common::BASE_URL.store(std::sync::Arc::new("http://localhost".to_string())); + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let client = reqwest::Client::new(); + for (job, expected) in [(OIDC_JOB, json!(["oidc:write"])), (OPEN_JOB, json!(null))] { + let resp = client + .post(format!("{base}/oidc/token/test-audience")) + .bearer_auth(job_token(&db, job).await?) + .send() + .await?; + let status = resp.status(); + let id_token = resp.text().await?; + assert_eq!(status, StatusCode::OK, "{id_token}"); + let payload = id_token.split('.').nth(1).expect("a JWT payload"); + let claims: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?, + )?; + assert_eq!(claims["job_token_scopes"], expected, "{job}"); + } + Ok(()) +} diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index b2488f14c0..c331008dff 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -79,6 +79,8 @@ pub struct JobClaim { pub tag: String, pub worker_group: Option, pub run_as_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, pub jti: String, } diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 38e5cefbf6..3b8cfbcfbf 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -71,7 +71,10 @@ Symbols, not line numbers, are cited: they drift less. unscoped token for every job. `$var:`/`$res:` args are resolved through the API with the job's own token, so they need read scopes. A deploy that omits the setting keeps the deployed value and `null` clears it, so an unaware client cannot drop a restriction; setting one is refused - until every worker supports it, since an older worker mints without it. The setting belongs to + until every worker supports it, since an older worker mints without it. The OIDC + `job_token_scopes` claim is the presented token's scopes, not the runnable's setting, so a + verifier sees what the job could reach and a token minted without the restriction shows as + unrestricted. The setting belongs to the deployed version: an older script hash run by hash runs with that version's setting (a restricted caller still caps it). Write scopes on scripts, flows, schedules or triggers let a job escape its restriction.