feat: job_token_scopes OIDC claim (#11551)

* feat: refuse unscoped tokens of restricted jobs, add OIDC scopes claim

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

* fix: keep restricted job_perms rows by a stamp that survives retention

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

* chore: log refused unscoped job tokens at warn

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

* fix: key the unrestricted cache by token, keep restricted rows on bulk delete

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

* fix: clear a sweep stamp raced onto a queued job

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

* fix: stamp restricted job_perms rows under a row lock

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

* test: give the memo test's job token scopes

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

* refactor: keep only the OIDC claim, drop the server-side refusal

A relying party can already tell a restricted job from the claim, which is
absent when the presented token carries no scopes. The auth-layer refusal,
its lookup cache and the job_perms retention it needed are removed.

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

* chore: update ee-repo-ref to 065c2c64c3904d5c0b2766b424210d34f9e54d2d

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

Previous ee-repo-ref: ea5354477cdf281598a816a5e16ad1f476d42afb

New ee-repo-ref: 065c2c64c3904d5c0b2766b424210d34f9e54d2d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-06 15:32:12 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent cc37c45c3d
commit d493eff237
4 changed files with 66 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
154cbccb6b8d47bf662b5507882917b5933d5a45
065c2c64c3904d5c0b2766b424210d34f9e54d2d
+59
View File
@@ -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<Postgres>,
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<Postgres>) -> 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(())
}
+2
View File
@@ -79,6 +79,8 @@ pub struct JobClaim {
pub tag: String,
pub worker_group: Option<String>,
pub run_as_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_token_scopes: Option<Vec<String>>,
pub jti: String,
}
+4 -1
View File
@@ -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.