mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-04 16:03:06 +00:00
25d9a20630
* fix: require an unscoped token to read the workspace encryption key Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: hold the encryption key's write path to the same token bar Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: audit a workspace export only once nothing can still reject it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: carry the new audit operation into the served openapi spec Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
119 lines
3.9 KiB
Rust
119 lines
3.9 KiB
Rust
//! The workspace encryption key must never reach a scope-restricted token.
|
|
//!
|
|
//! The route that serves it and the tarball export that embeds it both resolve
|
|
//! to `workspaces:read`, while the key decrypts every secret variable of the
|
|
//! workspace offline — so a `workspaces:read` token would recover, past its own
|
|
//! scopes, what `variables:read` gates on the same handler (GHSA-g3x2-mwm6-jrc3).
|
|
//! Replacing the key reaches the same secrets, so it is held to the same bar.
|
|
|
|
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", format!("Bearer {}", token))
|
|
}
|
|
|
|
/// Mint an API token for test-user (a workspace admin) restricted to `scopes`.
|
|
async fn mint_scoped_token(port: u16, scopes: Vec<&str>) -> anyhow::Result<String> {
|
|
let resp = authed(
|
|
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "label": "scoped", "scopes": scopes, "workspace_id": "test-workspace" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "mint scoped token");
|
|
Ok(resp.text().await?)
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_workspace_key_denied_to_scoped_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
let scoped = mint_scoped_token(port, vec!["workspaces:read"]).await?;
|
|
|
|
// 403 has other producers on these routes (`require_admin`, a route-scope denial),
|
|
// so every case pins the guard's own message rather than the status alone.
|
|
let resp = authed(
|
|
client().get(format!("{ws}/workspaces/encryption_key")),
|
|
&scoped,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 403, "workspaces:read must not read the key");
|
|
assert!(
|
|
resp.text().await?.contains("without scopes"),
|
|
"the 403 must come from the encryption-key guard"
|
|
);
|
|
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{ws}/workspaces/tarball?archive_type=tar&include_key=true"
|
|
)),
|
|
&scoped,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"workspaces:read must not export the key"
|
|
);
|
|
assert!(
|
|
resp.text().await?.contains("without scopes"),
|
|
"the 403 must come from the encryption-key guard"
|
|
);
|
|
|
|
// Replacing the key is the same capability: the server re-encrypts every secret
|
|
// under a key the caller chose. `workspaces:write` reaches the route, so a 403 here
|
|
// is the guard rather than the route's own scope check.
|
|
let scoped_write = mint_scoped_token(port, vec!["workspaces:write"]).await?;
|
|
let resp = authed(
|
|
client().post(format!("{ws}/workspaces/encryption_key")),
|
|
&scoped_write,
|
|
)
|
|
.json(&json!({ "new_key": "a".repeat(64) }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"workspaces:write token must not replace the encryption key"
|
|
);
|
|
assert!(
|
|
resp.text().await?.contains("without scopes"),
|
|
"the 403 must come from the encryption-key guard"
|
|
);
|
|
|
|
// The same admin's unscoped token keeps both read paths working.
|
|
let resp = authed(
|
|
client().get(format!("{ws}/workspaces/encryption_key")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "unscoped admin token reads the key");
|
|
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{ws}/workspaces/tarball?archive_type=tar&include_key=true"
|
|
)),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "unscoped admin token exports the key");
|
|
|
|
Ok(())
|
|
}
|