mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
392888d113
* docs: add SAFETY comments to all dynamic SQL call sites Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: address review feedback on SAFETY comments - Fix missed comment for obo_triggers loop in offboarding.rs - Fix variable name in comment (table -> table_name) in offboarding.rs - Fix api-settings comment to reference inline VALID_NAME regex, not validate_dbname() - Add SAFETY comments to batch_execute calls in api-settings - Fix db.rs comment: PG_SCHEMA is env var, not compile-time constant - Add doc comments on RunnableSettingsTraitInternal constants * docs: remove misleading SAFETY comment on static SQL --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1138 lines
36 KiB
Rust
1138 lines
36 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use crate::db::ApiAuthed;
|
|
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
|
|
use axum::{
|
|
extract::{Extension, Path},
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use windmill_api_auth::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<OffboardTokenInfo>,
|
|
/// 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<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
flows: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
apps: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
resources: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
variables: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
schedules: Vec<String>,
|
|
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
|
triggers: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct OffboardTokenInfo {
|
|
label: String,
|
|
scopes: Vec<String>,
|
|
expiration: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
summary: Option<OffboardSummary>,
|
|
}
|
|
|
|
#[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<WorkspaceOffboardPreview>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct WorkspaceOffboardPreview {
|
|
workspace_id: String,
|
|
username: String,
|
|
preview: OffboardPreview,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct GlobalOffboardRequest {
|
|
#[serde(default)]
|
|
reassignments: HashMap<String, WorkspaceReassignment>,
|
|
#[serde(default = "default_true")]
|
|
delete_user: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct WorkspaceReassignment {
|
|
reassign_to: String,
|
|
new_on_behalf_of_user: Option<String>,
|
|
}
|
|
|
|
// ---- Preview helpers ----
|
|
|
|
async fn get_offboard_preview(
|
|
db: impl sqlx::PgExecutor<'_> + Copy,
|
|
w_id: &str,
|
|
username: &str,
|
|
email: &str,
|
|
) -> Result<OffboardPreview> {
|
|
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<String> = 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<OffboardTokenInfo> = 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<String> = 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<DB>,
|
|
Path((w_id, username)): Path<(String, String)>,
|
|
) -> JsonResult<OffboardPreview> {
|
|
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<DB>,
|
|
Path((w_id, username)): Path<(String, String)>,
|
|
Json(req): Json<OffboardRequest>,
|
|
) -> JsonResult<OffboardResponse> {
|
|
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<DB>,
|
|
Path(email): Path<String>,
|
|
) -> JsonResult<GlobalOffboardPreview> {
|
|
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,
|
|
Extension(db): Extension<DB>,
|
|
Path(email): Path<String>,
|
|
Json(req): Json<GlobalOffboardRequest>,
|
|
) -> Result<Json<OffboardResponse>> {
|
|
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?;
|
|
|
|
// Validate all targets and resolve permissioned_as upfront
|
|
let mut resolved_permissioned_as: HashMap<String, String> = 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<String> {
|
|
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<Vec<String>> {
|
|
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<String> = 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<OffboardSummary> {
|
|
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, draft_only, 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, draft_only, 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,
|
|
})
|
|
}
|