mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* refactor: make the app policy's principal the authority for its identity * fix: align the app backfill with the sibling migration and audit the uncached address * chore: refresh the sqlx cache after rebasing onto the merged base * fix: resolve the app execution address uncached, it decides the job's authorization * chore: cache the EE queries at the ref this branch pins * chore: cache the EE queries at the ref this branch pins * fix: derive the app draft's on-behalf-of address on read * chore: cache the query the draft derivation test added * fix: derive the app identity on the draft-table and version reads too * docs: state the draft resolver's authorization contract * fix: resolve a draft's principal against workspace membership only * chore: cache the membership lookup the draft resolver added * fix: drop an unresolvable draft's address instead of leaving it stale * perf: evict the address cache on change so app dispatch can read it * fix: evict on superadmin role changes, not only address changes * refactor: make the app policy's address optional instead of derived on read * fix: follow an external superadmin's rename into the apps that name them * docs: state the removal gate once, and correctly * refactor: drop the app-policy version constant that gated nothing * docs: drop the last reference to the removed constant * perf: read the address cache everywhere now that eviction reaches every replica * fix: keep persisted addresses off the cache the poller evicts asynchronously * docs: state where the cached address is accepted and where it is not * docs: keep the cache rule in one place and drop the stale premise * docs: sort the two lookups by how long a wrong answer lives * fix: resolve the schedule address uncached where it is written to the row * docs: name the release this actually ships in * perf: evict a superadmin's key per workspace instead of the whole cache * fix: evict every alias a superadmin principal can be spelled as * docs: describe the trigger as it is * docs: cover the round-tripped read in the cache rule * docs: record why a stale dispatch address cannot escalate * fix: validate a dispatch address against the principal's live binding * fix: carry the validated address through to the job row and token * fix: record the validated address on the job row, not the one handed in * test: run the substep tag check as the non-superadmin it means to test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: rewrite a stored app address that disagrees with its principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the accepted staleness window of the cached dispatch address Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: record the validated address on the job's audit row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the accepted rename race of pre-transaction identity resolution Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: separate the app's stored address from the derived one in the resolver doc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: describe the job identity fast path the push comments skipped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: backfill a legacy group-prefixed username as the group it names Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: resolve a schedule edit's identity before opening its transaction Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: never resolve a disabled member to a same-named superadmin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: state what the email-change notify buys, and rewrap two comment lines Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: keep a group's runnables when offboarding a legacy group-prefixed member Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * fix: read the app author from the stored address, as execution does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the rename race's full consequence as a known, accepted limitation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc * docs: record the keep-target group address case as a known, accepted limitation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
658 lines
23 KiB
Rust
658 lines
23 KiB
Rust
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
|
}
|
|
|
|
fn ws_url(port: u16, endpoint: &str) -> String {
|
|
format!("http://localhost:{port}/api/w/test-workspace/users/{endpoint}")
|
|
}
|
|
|
|
fn global_url(port: u16, endpoint: &str) -> String {
|
|
format!("http://localhost:{port}/api/users/{endpoint}")
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().get(ws_url(port, "offboard_preview/test-user-2")))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert_eq!(body["owned"]["scripts"].as_array().unwrap().len(), 3); // script_a, script_b, conflict_script
|
|
assert_eq!(body["owned"]["flows"].as_array().unwrap().len(), 1); // flow_a
|
|
assert_eq!(body["owned"]["resources"].as_array().unwrap().len(), 1); // res_a
|
|
assert_eq!(body["owned"]["variables"].as_array().unwrap().len(), 1); // var_a
|
|
assert_eq!(body["owned"]["schedules"].as_array().unwrap().len(), 1); // sched_a
|
|
assert_eq!(
|
|
body["owned"]["triggers"]["http_trigger"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
); // webhook_a (dynamic trigger query)
|
|
assert_eq!(body["tokens"].as_array().unwrap().len(), 1); // OFFBOARD_TOKEN_1
|
|
assert_eq!(body["tokens"][0]["label"], "offboard test");
|
|
assert_eq!(body["http_triggers"], 1); // webhook_a
|
|
assert_eq!(body["email_triggers"], 0);
|
|
// Operator references: schedule + trigger at f/test-folder/ run as u/test-user-2
|
|
assert_eq!(
|
|
body["executing_on_behalf"]["schedules"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
body["executing_on_behalf"]["triggers"]["http_trigger"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1,
|
|
"shared trigger permissioned_as should be detected"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_to_user(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so offboard can proceed
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// A shared-path script that runs as the departing user. Both halves of its identity have to
|
|
// move: a worker predating MIN_VERSION_SUPPORTS_ON_BEHALF_OF_PRINCIPAL reads the address and
|
|
// nothing else, so a stale one keeps running it under someone who has just been offboarded.
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, on_behalf_of, on_behalf_of_email)
|
|
VALUES ('test-workspace', 'f/shared/obo', 1099, 'def main(): pass', '', '', 'python3', 'test-user', NOW(), 'u/test-user-2', 'test2@windmill.dev')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty()));
|
|
assert!(body["summary"].is_object());
|
|
|
|
let summary = &body["summary"];
|
|
assert!(summary["scripts_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["flows_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["resources_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["variables_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["schedules_reassigned"].as_i64().unwrap() > 0);
|
|
|
|
let obo = sqlx::query!(
|
|
"SELECT on_behalf_of, on_behalf_of_email FROM script WHERE path = 'f/shared/obo' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
(obo.on_behalf_of.as_deref(), obo.on_behalf_of_email.as_deref()),
|
|
(Some("u/test-user"), Some("test@windmill.dev")),
|
|
"the reassignment moves the whole identity, not just the half this release reads"
|
|
);
|
|
|
|
// Verify scripts moved
|
|
let moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(moved > 0, "scripts should be under u/test-user now");
|
|
|
|
let old = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert_eq!(old, 0, "no scripts should remain under u/test-user-2");
|
|
|
|
// Verify schedule permissioned_as updated
|
|
let perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'u/test-user/sched_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(perm.as_deref(), Some("u/test-user"));
|
|
|
|
// Verify user deleted from workspace
|
|
let user_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!user_exists, "user should be removed from workspace");
|
|
|
|
// Verify tokens revoked (default behavior when no reassign_tokens_to)
|
|
let token_count = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(token_count, 0, "tokens should be revoked");
|
|
|
|
// Verify HTTP trigger reassigned (dynamic trigger query)
|
|
let trigger_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert_eq!(trigger_moved, 1, "http trigger should be reassigned");
|
|
|
|
let trigger_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
trigger_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"trigger permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify extra_perms cleaned up (dynamic extra_perms query)
|
|
let extra_perms = sqlx::query_scalar!(
|
|
"SELECT extra_perms::text FROM script WHERE path = 'f/test-folder/shared_script' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert!(
|
|
!extra_perms
|
|
.unwrap_or_default()
|
|
.unwrap_or_default()
|
|
.contains("test-user-2"),
|
|
"extra_perms should no longer reference test-user-2"
|
|
);
|
|
|
|
// Verify operator schedule updated (schedule not under user's path)
|
|
let shared_sched_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
shared_sched_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"shared schedule permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify shared trigger permissioned_as updated (line 951 - dynamic query)
|
|
let shared_trigger_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
shared_trigger_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"shared trigger permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify extra_perms cleaned on trigger (line 983 - dynamic query on trigger table)
|
|
let trigger_extra_perms = sqlx::query_scalar!(
|
|
"SELECT extra_perms::text FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert!(
|
|
!trigger_extra_perms
|
|
.unwrap_or_default()
|
|
.unwrap_or_default()
|
|
.contains("test-user-2"),
|
|
"trigger extra_perms should no longer reference test-user-2"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_to_folder(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/test-folder",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty()));
|
|
|
|
// Verify specific script moved to folder (not just pre-existing shared_script)
|
|
let script_a_moved = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM script WHERE path = 'f/test-folder/script_a' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
assert!(script_a_moved, "script_a should be moved to f/test-folder/");
|
|
|
|
// Verify no scripts remain under old path
|
|
let old_scripts = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(
|
|
old_scripts, 0,
|
|
"no scripts should remain under u/test-user-2"
|
|
);
|
|
|
|
// Verify schedule permissioned_as is u/test-user (operator), not g/f/test-folder
|
|
let perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(perm.as_deref(), Some("u/test-user"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_reassign_only(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so reassignment can proceed
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": false
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(
|
|
body["conflicts"].as_array().map_or(true, |c| c.is_empty()),
|
|
"should have no conflicts"
|
|
);
|
|
assert!(body["summary"].is_object(), "should have a summary");
|
|
|
|
// Verify user still exists in workspace
|
|
let user_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
assert!(
|
|
user_exists,
|
|
"user should still exist when delete_user=false"
|
|
);
|
|
|
|
// Verify items were actually reassigned
|
|
let moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(moved > 0, "scripts should be under u/test-user now");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_conflicts(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// conflict_script exists under both u/test-user-2/ and u/test-user/
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let conflicts = body["conflicts"]
|
|
.as_array()
|
|
.expect("conflicts should be array");
|
|
assert!(!conflicts.is_empty(), "should have path conflicts");
|
|
assert!(
|
|
conflicts
|
|
.iter()
|
|
.any(|c| c.as_str().unwrap().contains("conflict_script")),
|
|
"conflict should mention conflict_script"
|
|
);
|
|
// No summary when conflicts exist
|
|
assert!(body["summary"].is_null());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_tokens_deleted(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict_script so offboard can proceed
|
|
sqlx::query!("DELETE FROM script WHERE hash = 1004 AND workspace_id = 'test-workspace'")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
// Verify tokens are deleted
|
|
let token_count = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(token_count, 0, "tokens should be deleted after offboarding");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_folder_requires_operator(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Missing new_on_behalf_of_user when reassigning to folder should fail
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/test-folder",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_global_offboard_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().get(global_url(port, "offboard_preview/test2@windmill.dev")))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let workspaces = body["workspaces"]
|
|
.as_array()
|
|
.expect("should have workspaces");
|
|
assert_eq!(workspaces.len(), 2, "user is in two workspaces");
|
|
|
|
// Both workspaces should have the user's items
|
|
for ws in workspaces {
|
|
assert_eq!(ws["username"], "test-user-2");
|
|
assert!(
|
|
!ws["preview"]["owned"]["scripts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty(),
|
|
"workspace {} should have owned scripts",
|
|
ws["workspace_id"]
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_global_offboard_execution(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so offboard can proceed in test-workspace
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(global_url(port, "offboard/test2@windmill.dev")))
|
|
.json(&json!({
|
|
"reassignments": {
|
|
"test-workspace": {
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user"
|
|
},
|
|
"test-workspace-2": {
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user"
|
|
}
|
|
},
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(
|
|
body["conflicts"].as_array().map_or(true, |c| c.is_empty()),
|
|
"should have no conflicts"
|
|
);
|
|
assert!(body["summary"].is_object(), "should have a summary");
|
|
let summary = &body["summary"];
|
|
assert!(
|
|
summary["scripts_reassigned"].as_i64().unwrap() > 0,
|
|
"should have reassigned scripts across workspaces"
|
|
);
|
|
|
|
// Verify items moved in workspace 1
|
|
let ws1_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(ws1_moved > 0, "scripts should be moved in test-workspace");
|
|
|
|
// Verify items moved in workspace 2
|
|
let ws2_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace-2' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(ws2_moved > 0, "scripts should be moved in test-workspace-2");
|
|
|
|
// Verify user deleted from both workspaces
|
|
let ws1_user = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!ws1_user, "user should be removed from test-workspace");
|
|
|
|
let ws2_user = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace-2')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!ws2_user, "user should be removed from test-workspace-2");
|
|
|
|
// Verify global auth deleted (password row)
|
|
let password_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM password WHERE email = 'test2@windmill.dev')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!password_exists, "password should be deleted from instance");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_invalid_target(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Reassign to nonexistent user
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/nonexistent-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"should reject reassignment to nonexistent user"
|
|
);
|
|
|
|
// Reassign to nonexistent folder
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/nonexistent-folder",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"should reject reassignment to nonexistent folder"
|
|
);
|
|
|
|
// Invalid target format
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "invalid-format",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "should reject invalid target format");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A legacy member named `group-ops` canonicalizes to `g/ops`, the principal the real `ops` group
|
|
/// runs as. Offboarding the member must not hand the group's runnables to the replacement.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_offboard_group_prefixed_member_keeps_group_identities(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
sqlx::raw_sql(
|
|
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
|
VALUES ('ops-bot@windmill.dev', 'x', 'password', false, true, 'Ops bot', 'group-ops');
|
|
INSERT INTO usr(workspace_id, email, username, is_admin, role)
|
|
VALUES ('test-workspace', 'ops-bot@windmill.dev', 'group-ops', false, 'User');
|
|
INSERT INTO group_(workspace_id, name, summary) VALUES ('test-workspace', 'ops', '');
|
|
INSERT INTO app(workspace_id, path, summary, policy, versions, extra_perms)
|
|
VALUES ('test-workspace', 'f/shared/ops_app', '',
|
|
'{\"execution_mode\": \"publisher\", \"on_behalf_of\": \"g/ops\",
|
|
\"on_behalf_of_email\": \"group-ops@windmill.dev\"}', '{}', '{}');",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let preview: serde_json::Value =
|
|
authed(client().get(ws_url(port, "offboard_preview/group-ops")))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert!(
|
|
preview["executing_on_behalf"]["apps"].is_null(),
|
|
"the group's apps are not the member's to reassign: {preview}"
|
|
);
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/group-ops")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": false
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let principal: Option<String> = sqlx::query_scalar(
|
|
"SELECT policy->>'on_behalf_of' FROM app
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/ops_app'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(principal.as_deref(), Some("g/ops"));
|
|
|
|
Ok(())
|
|
}
|