use std::collections::HashMap; use crate::db::{ApiAuthed, OptJobAuthed}; use crate::secret_backend_ext::rename_vault_secrets_with_prefix; use axum::{ extract::{Extension, Path}, Json, }; use serde::{Deserialize, Serialize}; use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin}; use windmill_api_users::users::delete_workspace_user_internal; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::utils::require_admin; use windmill_common::{ error::{Error, JsonResult, Result}, DB, }; use windmill_git_sync::handle_deployment_metadata; // ---- Types ---- #[derive(Serialize, Deserialize)] pub(crate) struct OffboardPreview { /// Objects under u/{username}/ that will be reassigned owned: OffboardAffectedPaths, /// Objects NOT under the user's path but that execute on behalf of this user /// (will have their permissioned_as/on_behalf_of updated) executing_on_behalf: OffboardAffectedPaths, /// Scripts/flows/apps/resources whose content/value references u/{username}/ paths /// (these references may break after path changes — admin should review) referencing: OffboardAffectedPaths, /// Tokens owned by this user (will be deleted) tokens: Vec, /// HTTP triggers under the user's path (webhook URLs will change) http_triggers: i64, /// Email triggers under the user's path (email addresses will change) email_triggers: i64, } #[derive(Serialize, Deserialize, Default)] struct OffboardAffectedPaths { #[serde(skip_serializing_if = "Vec::is_empty")] scripts: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] flows: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] apps: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] resources: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] variables: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] schedules: Vec, #[serde(skip_serializing_if = "HashMap::is_empty")] triggers: HashMap>, } #[derive(Serialize, Deserialize)] struct OffboardTokenInfo { label: String, scopes: Vec, expiration: Option, } #[derive(Deserialize)] pub(crate) struct OffboardRequest { reassign_to: String, /// Required when reassign_to is a folder (f/...). The username whose identity /// will be used as permissioned_as for schedules and triggers. new_on_behalf_of_user: Option, #[serde(default = "default_true")] delete_user: bool, } fn default_true() -> bool { true } #[derive(Serialize)] pub(crate) struct OffboardResponse { #[serde(skip_serializing_if = "Vec::is_empty")] conflicts: Vec, #[serde(skip_serializing_if = "Option::is_none")] summary: Option, } #[derive(Serialize)] struct OffboardSummary { scripts_reassigned: i64, flows_reassigned: i64, apps_reassigned: i64, resources_reassigned: i64, variables_reassigned: i64, schedules_reassigned: i64, triggers_reassigned: i64, drafts_deleted: i64, } #[derive(Serialize)] pub(crate) struct GlobalOffboardPreview { workspaces: Vec, } #[derive(Serialize)] struct WorkspaceOffboardPreview { workspace_id: String, username: String, preview: OffboardPreview, } #[derive(Deserialize)] pub(crate) struct GlobalOffboardRequest { #[serde(default)] reassignments: HashMap, #[serde(default = "default_true")] delete_user: bool, } #[derive(Deserialize)] struct WorkspaceReassignment { reassign_to: String, new_on_behalf_of_user: Option, } // ---- Preview helpers ---- async fn get_offboard_preview( db: impl sqlx::PgExecutor<'_> + Copy, w_id: &str, username: &str, email: &str, ) -> Result { let user_prefix = format!("u/{}/%", username); let user_owner = format!("u/{}", username); // ---- Owned objects (under u/{username}/) ---- let scripts = sqlx::query_scalar!( "SELECT path FROM script WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived AND NOT deleted", &user_prefix, w_id ).fetch_all(db).await?; let flows = sqlx::query_scalar!( "SELECT path FROM flow WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived", &user_prefix, w_id ) .fetch_all(db) .await?; let apps = sqlx::query_scalar!( "SELECT path FROM app WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_all(db) .await?; let resources = sqlx::query_scalar!( "SELECT path FROM resource WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_all(db) .await?; let variables = sqlx::query_scalar!( "SELECT path FROM variable WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_all(db) .await?; let schedules = sqlx::query_scalar!( "SELECT path FROM schedule WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_all(db) .await?; let trigger_tables = [ "http_trigger", "websocket_trigger", "kafka_trigger", "postgres_trigger", "mqtt_trigger", "nats_trigger", "sqs_trigger", "gcp_trigger", "azure_trigger", "email_trigger", ]; let mut triggers = HashMap::new(); for table in &trigger_tables { // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input. let paths: Vec = sqlx::query_scalar(&format!( "SELECT path FROM {table} WHERE path LIKE $1 AND workspace_id = $2" )) .bind(&user_prefix) .bind(w_id) .fetch_all(db) .await?; if !paths.is_empty() { triggers.insert(table.to_string(), paths); } } // ---- Tokens (all workspace-scoped tokens that will be deleted) ---- let token_rows = sqlx::query!( "SELECT label, scopes, expiration FROM token WHERE email = $1 AND workspace_id = $2", email, w_id ) .fetch_all(db) .await?; let tokens: Vec = token_rows .into_iter() .map(|r| OffboardTokenInfo { label: r.label.unwrap_or_default(), scopes: r.scopes.unwrap_or_default(), expiration: r.expiration.map(|e| e.to_string()), }) .collect(); // ---- Operator references (not under user's path) ---- let obo_scripts = sqlx::query_scalar!( "SELECT path FROM script WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", email, &user_prefix, w_id ).fetch_all(db).await?; let obo_flows = sqlx::query_scalar!( "SELECT path FROM flow WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", email, &user_prefix, w_id ).fetch_all(db).await?; let obo_apps = sqlx::query_scalar!( "SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3", &user_owner, &user_prefix, w_id ).fetch_all(db).await?; let obo_schedules = sqlx::query_scalar!( "SELECT path FROM schedule WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3", &user_owner, &user_prefix, w_id ).fetch_all(db).await?; let mut obo_triggers = HashMap::new(); // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input. for table in &trigger_tables { let paths: Vec = sqlx::query_scalar(&format!( "SELECT path FROM {table} WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3" )) .bind(&user_owner) .bind(&user_prefix) .bind(w_id) .fetch_all(db) .await?; if !paths.is_empty() { obo_triggers.insert(table.to_string(), paths); } } // ---- Objects whose content/value references u/{username}/ paths ---- let ref_pattern = format!("%u/{}/%", username); let ref_scripts = sqlx::query_scalar!( "SELECT DISTINCT path FROM script WHERE content LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", &ref_pattern, &user_prefix, w_id ).fetch_all(db).await?; let ref_flows = sqlx::query_scalar!( "SELECT DISTINCT path FROM flow WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", &ref_pattern, &user_prefix, w_id ).fetch_all(db).await?; let ref_apps = sqlx::query_scalar!( "SELECT DISTINCT path FROM app WHERE (policy::text LIKE $1 OR extra_perms::text LIKE $1) AND NOT path LIKE $2 AND workspace_id = $3", &ref_pattern, &user_prefix, w_id ).fetch_all(db).await?; let ref_resources = sqlx::query_scalar!( "SELECT DISTINCT path FROM resource WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3", &ref_pattern, &user_prefix, w_id ).fetch_all(db).await?; // ---- Specific trigger warnings ---- let http_triggers = sqlx::query_scalar!( "SELECT COUNT(*) FROM http_trigger WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_one(db) .await? .unwrap_or(0); let email_triggers = sqlx::query_scalar!( "SELECT COUNT(*) FROM email_trigger WHERE path LIKE $1 AND workspace_id = $2", &user_prefix, w_id ) .fetch_one(db) .await? .unwrap_or(0); Ok(OffboardPreview { owned: OffboardAffectedPaths { scripts, flows, apps, resources, variables, schedules, triggers, }, executing_on_behalf: OffboardAffectedPaths { scripts: obo_scripts, flows: obo_flows, apps: obo_apps, schedules: obo_schedules, triggers: obo_triggers, ..Default::default() }, referencing: OffboardAffectedPaths { scripts: ref_scripts, flows: ref_flows, apps: ref_apps, resources: ref_resources, ..Default::default() }, tokens, http_triggers, email_triggers, }) } // ---- Workspace-level endpoints ---- pub(crate) async fn offboard_preview( authed: ApiAuthed, Extension(db): Extension, Path((w_id, username)): Path<(String, String)>, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; let email = sqlx::query_scalar!( "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", &username, &w_id ) .fetch_optional(&db) .await? .ok_or_else(|| { Error::NotFound(format!( "user {} not found in workspace {}", &username, &w_id )) })?; let preview = get_offboard_preview(&db, &w_id, &username, &email).await?; Ok(Json(preview)) } pub(crate) async fn offboard_workspace_user( authed: ApiAuthed, Extension(db): Extension, Path((w_id, username)): Path<(String, String)>, Json(req): Json, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; let (target_kind, target_name) = parse_reassign_target(&req.reassign_to)?; validate_target(&db, &w_id, target_kind, target_name).await?; let new_permissioned_as = resolve_new_permissioned_as( target_kind, &req.reassign_to, req.new_on_behalf_of_user.as_deref(), &db, &w_id, ) .await?; let conflicts = check_path_conflicts(&db, &w_id, &username, &req.reassign_to).await?; if !conflicts.is_empty() { return Ok(Json(OffboardResponse { conflicts, summary: None })); } let email = sqlx::query_scalar!( "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", &username, &w_id ) .fetch_optional(&db) .await? .ok_or_else(|| { Error::NotFound(format!( "user {} not found in workspace {}", &username, &w_id )) })?; let mut tx = db.begin().await?; let summary = offboard_user_from_workspace( &mut tx, &db, &w_id, &username, &email, &req.reassign_to, &new_permissioned_as, ) .await?; if req.delete_user { delete_workspace_user_internal(&w_id, &username, &email, &mut tx, Some(&authed)).await?; } else { audit_log( &mut *tx, &authed, "users.offboard_reassign", ActionKind::Update, &w_id, Some(&username), Some([("reassign_to", req.reassign_to.as_str())].into()), ) .await?; } tx.commit().await?; if req.delete_user { handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::User { email: email.clone() }, Some(format!( "Offboarded user '{}' from workspace (objects reassigned to {})", &email, &req.reassign_to )), true, None, ) .await?; } Ok(Json(OffboardResponse { conflicts: vec![], summary: Some(summary), })) } // ---- Instance-level endpoints ---- pub(crate) async fn global_offboard_preview( authed: ApiAuthed, Extension(db): Extension, Path(email): Path, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let workspaces = sqlx::query!( "SELECT workspace_id, username FROM usr WHERE email = $1", &email ) .fetch_all(&db) .await?; let mut previews = Vec::new(); for w in workspaces { let preview = get_offboard_preview(&db, &w.workspace_id, &w.username, &email).await?; previews.push(WorkspaceOffboardPreview { workspace_id: w.workspace_id, username: w.username, preview, }); } Ok(Json(GlobalOffboardPreview { workspaces: previews })) } pub(crate) async fn offboard_global_user( authed: ApiAuthed, OptJobAuthed { job_id, .. }: OptJobAuthed, Extension(db): Extension, Path(email): Path, Json(req): Json, ) -> Result> { require_super_admin(&db, &authed.email).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let workspaces = sqlx::query!( "SELECT workspace_id, username FROM usr WHERE email = $1", &email ) .fetch_all(&db) .await?; // Validate all targets and resolve permissioned_as upfront let mut resolved_permissioned_as: HashMap = HashMap::new(); for (w_id, reassignment) in &req.reassignments { let (target_kind, target_name) = parse_reassign_target(&reassignment.reassign_to)?; validate_target(&db, w_id, target_kind, target_name).await?; let perm_as = resolve_new_permissioned_as( target_kind, &reassignment.reassign_to, reassignment.new_on_behalf_of_user.as_deref(), &db, w_id, ) .await?; resolved_permissioned_as.insert(w_id.clone(), perm_as); } // Check for conflicts in all workspaces let mut all_conflicts = Vec::new(); for ws in &workspaces { if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) { let conflicts = check_path_conflicts( &db, &ws.workspace_id, &ws.username, &reassignment.reassign_to, ) .await?; for c in conflicts { all_conflicts.push(format!("[{}] {}", &ws.workspace_id, c)); } } } if !all_conflicts.is_empty() { return Ok(Json(OffboardResponse { conflicts: all_conflicts, summary: None, })); } let mut tx = db.begin().await?; let mut total_summary = OffboardSummary { scripts_reassigned: 0, flows_reassigned: 0, apps_reassigned: 0, resources_reassigned: 0, variables_reassigned: 0, schedules_reassigned: 0, triggers_reassigned: 0, drafts_deleted: 0, }; for ws in &workspaces { if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) { let perm_as = resolved_permissioned_as .get(&ws.workspace_id) .ok_or_else(|| { Error::InternalErr("missing resolved permissioned_as".to_string()) })?; let ws_summary = offboard_user_from_workspace( &mut tx, &db, &ws.workspace_id, &ws.username, &email, &reassignment.reassign_to, perm_as, ) .await?; total_summary.scripts_reassigned += ws_summary.scripts_reassigned; total_summary.flows_reassigned += ws_summary.flows_reassigned; total_summary.apps_reassigned += ws_summary.apps_reassigned; total_summary.resources_reassigned += ws_summary.resources_reassigned; total_summary.variables_reassigned += ws_summary.variables_reassigned; total_summary.schedules_reassigned += ws_summary.schedules_reassigned; total_summary.triggers_reassigned += ws_summary.triggers_reassigned; total_summary.drafts_deleted += ws_summary.drafts_deleted; } if req.delete_user { delete_workspace_user_internal(&ws.workspace_id, &ws.username, &email, &mut tx, None) .await?; } } if req.delete_user { sqlx::query!("DELETE FROM token WHERE email = $1", &email) .execute(&mut *tx) .await?; sqlx::query!("DELETE FROM password WHERE email = $1", &email) .execute(&mut *tx) .await?; sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email) .execute(&mut *tx) .await?; sqlx::query!("DELETE FROM email_to_igroup WHERE email = $1", &email) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, if req.delete_user { "users.offboard_delete" } else { "users.offboard_reassign" }, if req.delete_user { ActionKind::Delete } else { ActionKind::Update }, "global", Some(&email), None, ) .await?; tx.commit().await?; Ok(Json(OffboardResponse { conflicts: vec![], summary: Some(total_summary), })) } // ---- Validation helpers ---- fn parse_reassign_target(target: &str) -> Result<(&str, &str)> { if let Some(name) = target.strip_prefix("u/") { if name.is_empty() { return Err(Error::BadRequest( "empty username in reassign_to".to_string(), )); } Ok(("user", name)) } else if let Some(name) = target.strip_prefix("f/") { if name.is_empty() { return Err(Error::BadRequest("empty folder in reassign_to".to_string())); } Ok(("folder", name)) } else { Err(Error::BadRequest( "reassign_to must start with 'u/' (user) or 'f/' (folder)".to_string(), )) } } async fn validate_target(db: &DB, w_id: &str, kind: &str, name: &str) -> Result<()> { match kind { "user" => { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", name, w_id ) .fetch_one(db) .await? .unwrap_or(false); if !exists { return Err(Error::BadRequest(format!( "target user '{}' not found in workspace '{}'", name, w_id ))); } } "folder" => { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)", name, w_id ) .fetch_one(db) .await? .unwrap_or(false); if !exists { return Err(Error::BadRequest(format!( "target folder '{}' not found in workspace '{}'", name, w_id ))); } } _ => unreachable!(), } Ok(()) } /// Resolve the permissioned_as value for schedules/triggers. /// If new_on_behalf_of_user is provided, use that user. /// Otherwise, default to the target user (for user targets) or error (for folder targets). async fn resolve_new_permissioned_as( target_kind: &str, reassign_to: &str, new_on_behalf_of_user: Option<&str>, db: &DB, w_id: &str, ) -> Result { let username = if let Some(user) = new_on_behalf_of_user { user.to_string() } else { match target_kind { "user" => reassign_to .strip_prefix("u/") .unwrap_or(reassign_to) .to_string(), "folder" => { return Err(Error::BadRequest( "new_on_behalf_of_user is required when reassigning to a folder".to_string(), )); } _ => unreachable!(), } }; let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", &username, w_id ) .fetch_one(db) .await? .unwrap_or(false); if !exists { return Err(Error::NotFound(format!( "new_on_behalf_of_user '{}' not found in workspace '{}'", username, w_id ))); } Ok(format!("u/{}", username)) } async fn check_path_conflicts( db: &DB, w_id: &str, username: &str, reassign_to: &str, ) -> Result> { let new_prefix = format!("{}/", reassign_to); let mut conflicts = Vec::new(); let tables = [ "script", "flow", "app", "resource", "variable", "schedule", "http_trigger", "websocket_trigger", "kafka_trigger", "postgres_trigger", "mqtt_trigger", "nats_trigger", "sqs_trigger", "gcp_trigger", "azure_trigger", "email_trigger", ]; for table_name in &tables { let extra_filter = match *table_name { "script" => " AND NOT t1.archived AND NOT t1.deleted", "flow" => " AND NOT t1.archived", _ => "", }; // SAFETY: `table_name` comes from a hardcoded allowlist `tables`, not user input. let rows: Vec = sqlx::query_scalar(&format!( "SELECT REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \ FROM {table} t1 \ WHERE t1.path LIKE ('u/' || $1 || '/%') AND t1.workspace_id = $2{extra_filter} \ AND EXISTS ( \ SELECT 1 FROM {table} t2 \ WHERE t2.path = REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \ AND t2.workspace_id = $2 \ )", table = table_name )) .bind(username) .bind(w_id) .bind(&new_prefix) .fetch_all(db) .await?; for path in rows { conflicts.push(format!("{}: {}", table_name, path)); } } Ok(conflicts) } // ---- Core reassignment logic ---- async fn offboard_user_from_workspace<'c>( tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, db: &DB, w_id: &str, username: &str, email: &str, reassign_to: &str, new_permissioned_as: &str, ) -> Result { let new_prefix = reassign_to.to_string(); // Resolve the new operator's email for on_behalf_of_email on scripts/flows/apps. // resolve_new_permissioned_as already validated the user exists, and usr.email is NOT NULL. let new_on_behalf_of_user_username = new_permissioned_as .strip_prefix("u/") .unwrap_or(new_permissioned_as); let new_on_behalf_of_user_email = sqlx::query_scalar!( "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", new_on_behalf_of_user_username, w_id ) .fetch_optional(&mut **tx) .await? .ok_or_else(|| { Error::NotFound(format!( "new on_behalf_of user '{}' not found in workspace '{}'", new_on_behalf_of_user_username, w_id )) })?; // ---- scripts ---- let scripts_reassigned = sqlx::query_scalar!( r#"WITH updated AS ( UPDATE script SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM updated"#, &new_prefix, username, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(0); sqlx::query!( "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", new_on_behalf_of_user_email, email, w_id ) .execute(&mut **tx) .await?; // ---- flows ---- let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM inserted"#, &new_prefix, username, w_id ).fetch_one(&mut **tx).await?.unwrap_or(0); sqlx::query!( r#"UPDATE flow_version SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE flow_node SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( "DELETE FROM flow WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2", username, w_id ) .execute(&mut **tx) .await?; sqlx::query!( "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", new_on_behalf_of_user_email, email, w_id ) .execute(&mut **tx) .await?; // ---- apps ---- let apps_reassigned = sqlx::query_scalar!( r#"WITH updated AS ( UPDATE app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM updated"#, &new_prefix, username, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(0); sqlx::query!( "UPDATE app SET policy = jsonb_set( jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)), ARRAY['on_behalf_of_email'], to_jsonb($4::text) ) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3", &new_permissioned_as, username, w_id, new_on_behalf_of_user_email ) .execute(&mut **tx) .await?; // ---- raw_app (mirrors app paths) ---- sqlx::query!( r#"UPDATE raw_app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ) .execute(&mut **tx) .await?; // ---- resources ---- let resources_reassigned = sqlx::query_scalar!( r#"WITH updated AS ( UPDATE resource SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM updated"#, &new_prefix, username, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(0); // ---- variables (with Vault secret handling) ---- let old_var_prefix = format!("u/{}/", username); let new_var_prefix = format!("{}/", reassign_to); let vault_secrets: Vec<(String, String)> = sqlx::query!( r#"SELECT path, value FROM variable WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 AND is_secret = true AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')"#, username, w_id ) .fetch_all(&mut **tx) .await? .into_iter() .map(|r| (r.path, r.value)) .collect(); let vault_updates = rename_vault_secrets_with_prefix(db, w_id, &old_var_prefix, &new_var_prefix, vault_secrets) .await?; for (old_path, new_value) in vault_updates { sqlx::query!( "UPDATE variable SET value = $1 WHERE path = $2 AND workspace_id = $3", new_value, old_path, w_id ) .execute(&mut **tx) .await?; } let variables_reassigned = sqlx::query_scalar!( r#"WITH updated AS ( UPDATE variable SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM updated"#, &new_prefix, username, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(0); // ---- schedules ---- let schedules_reassigned = sqlx::query_scalar!( r#"WITH updated AS ( UPDATE schedule SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1'), permissioned_as = $4 WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 ) SELECT COUNT(*) FROM updated"#, &new_prefix, username, w_id, &new_permissioned_as ) .fetch_one(&mut **tx) .await? .unwrap_or(0); sqlx::query!( r#"UPDATE schedule SET script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3", &new_permissioned_as, username, w_id ).execute(&mut **tx).await?; // ---- triggers (all 9 types with path/permissioned_as) ---- let trigger_tables = [ "http_trigger", "websocket_trigger", "kafka_trigger", "postgres_trigger", "mqtt_trigger", "nats_trigger", "sqs_trigger", "gcp_trigger", "azure_trigger", "email_trigger", ]; let mut triggers_reassigned: i64 = 0; // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input. for table in &trigger_tables { let count: i64 = sqlx::query_scalar(&format!( "WITH updated AS ( \ UPDATE {table} SET \ path = REGEXP_REPLACE(path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \ script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \ permissioned_as = $4 \ WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $3 \ RETURNING 1 \ ) SELECT COUNT(*) FROM updated" )) .bind(username) .bind(&new_prefix) .bind(w_id) .bind(&new_permissioned_as) .fetch_one(&mut **tx) .await?; triggers_reassigned += count; sqlx::query(&format!( "UPDATE {table} SET script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1') \ WHERE script_path LIKE ('u/' || $1 || '/%') AND workspace_id = $3" )) .bind(username) .bind(&new_prefix) .bind(w_id) .execute(&mut **tx) .await?; sqlx::query(&format!( "UPDATE {table} SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3" )) .bind(&new_permissioned_as) .bind(username) .bind(w_id) .execute(&mut **tx) .await?; } // NOTE: extra_perms cleanup, folder owners, favorites, inputs, captures, // and token deletion are handled by delete_workspace_user_internal when delete_user=true. // They are NOT done here for reassign-only (delete_user=false) since the user stays. // ---- Related path tables ---- sqlx::query!( r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE flow_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; // ---- dependency_map ---- sqlx::query!( r#"UPDATE dependency_map SET importer_path = REGEXP_REPLACE(importer_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE importer_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; sqlx::query!( r#"UPDATE dependency_map SET imported_path = REGEXP_REPLACE(imported_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE imported_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; // ---- Drafts (stale after path reassignment) ---- let drafts_deleted = sqlx::query_scalar!( r#"WITH deleted AS ( DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 RETURNING 1 ) SELECT COUNT(*) FROM deleted"#, username, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(0); Ok(OffboardSummary { scripts_reassigned, flows_reassigned, apps_reassigned, resources_reassigned, variables_reassigned, schedules_reassigned, triggers_reassigned, drafts_deleted, }) }