Files
windmill/backend/windmill-api-integration-tests/tests/fork_member_grant.rs
T
Ruben FiszelandClaude Opus 4.8 97f4477069 feat(forks): let a fork's creator manage developers on it without being an admin (#10166)
* feat(forks): let a fork's creator manage developers on it without being an admin

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

* fix(forks): bind the fork-creator grant to the member's parent username and lock the delete

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:13:36 +02:00

188 lines
5.4 KiB
Rust

use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
/// `test2` created the fork `wm-fork-test` but is only a developer in it.
const FORK_OWNER_TOKEN: &str = "SECRET_TOKEN_2";
/// `test3` is a developer of the parent and of the fork, but created neither.
const FORK_MEMBER_TOKEN: &str = "SECRET_TOKEN_3";
fn as_user(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
async fn add_user(
port: u16,
w_id: &str,
token: &str,
body: serde_json::Value,
) -> reqwest::Response {
as_user(
client().post(format!(
"http://localhost:{port}/api/w/{w_id}/workspaces/add_user"
)),
token,
)
.json(&body)
.send()
.await
.unwrap()
}
fn developer(email: &str) -> serde_json::Value {
json!({ "email": email, "is_admin": false, "operator": false })
}
/// The creator of a fork may manage developers on it without being an admin of it, and may do
/// nothing beyond that. A fork clones its parent wholesale (secrets included), so each of these
/// bounds is what keeps the grant from becoming a way for any developer to widen access to the
/// parent's data or to mint an admin.
#[sqlx::test(migrations = "../migrations", fixtures("base", "fork_member_grant"))]
async fn test_fork_creator_can_only_manage_developers_on_their_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Seating an eligible member on someone else's username is what would hand them that user's
// cloned private assets. `test-user-4` is the target that matters: a fork clones the parent's
// `u/test-user-4/` scripts, variables and secrets but not their membership, so the username is
// free of the unique constraint on `usr` and the squat would otherwise land.
let resp = add_user(
port,
"wm-fork-test",
FORK_OWNER_TOKEN,
json!({ "email": "test3@windmill.dev", "username": "test-user-4", "is_admin": false, "operator": false }),
)
.await;
assert_eq!(
resp.status(),
403,
"fork creator cannot choose the username a member joins under"
);
// The creator adds a developer of the parent as a developer of their fork.
let resp = add_user(
port,
"wm-fork-test",
FORK_OWNER_TOKEN,
developer("test3@windmill.dev"),
)
.await;
assert_eq!(resp.status(), 201, "fork creator can add a developer");
// They join under the username they hold in the parent, which is the `u/` namespace the fork
// cloned for them.
let username: String = sqlx::query_scalar(
"SELECT username FROM usr WHERE workspace_id = 'wm-fork-test' AND email = 'test3@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(
username, "test-user-3",
"added member keeps their parent username"
);
// ... but never as an admin.
let resp = add_user(
port,
"wm-fork-test",
FORK_OWNER_TOKEN,
json!({ "email": "test4@windmill.dev", "is_admin": true, "operator": false }),
)
.await;
assert_eq!(resp.status(), 403, "fork creator cannot add an admin");
// ... nor anyone who is only an operator of the parent, which would widen their access.
let resp = add_user(
port,
"wm-fork-test",
FORK_OWNER_TOKEN,
developer("test4@windmill.dev"),
)
.await;
assert_eq!(
resp.status(),
403,
"fork creator cannot add an operator of the parent"
);
// ... nor anyone from outside the parent workspace.
let resp = add_user(
port,
"wm-fork-test",
FORK_OWNER_TOKEN,
developer("outsider@windmill.dev"),
)
.await;
assert_eq!(
resp.status(),
403,
"fork creator cannot add a non-member of the parent"
);
// The grant covers the fork alone, not the workspace it was forked from.
let resp = add_user(
port,
"test-workspace",
FORK_OWNER_TOKEN,
developer("test4@windmill.dev"),
)
.await;
assert_eq!(
resp.status(),
403,
"fork creator gains nothing on the parent workspace"
);
// ... and belongs to the creator, not to every member of the fork.
let resp = add_user(
port,
"wm-fork-test",
FORK_MEMBER_TOKEN,
developer("test4@windmill.dev"),
)
.await;
assert_eq!(
resp.status(),
403,
"a fork member who did not create it gains nothing"
);
// Removing is the counterpart of adding: allowed for the developer they just added...
let resp = as_user(
client().delete(format!(
"http://localhost:{port}/api/w/wm-fork-test/users/delete/test-user-3"
)),
FORK_OWNER_TOKEN,
)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "fork creator can remove a developer");
// ... but not for an admin of the fork.
let resp = as_user(
client().delete(format!(
"http://localhost:{port}/api/w/wm-fork-test/users/delete/test-user"
)),
FORK_OWNER_TOKEN,
)
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
403,
"fork creator cannot remove an admin of the fork"
);
Ok(())
}