mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
cc37c45c3d
commit
d493eff237
@@ -1 +1 @@
|
||||
154cbccb6b8d47bf662b5507882917b5933d5a45
|
||||
065c2c64c3904d5c0b2766b424210d34f9e54d2d
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user