mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
fb82748296
* fix: make on_behalf_of control permissions for scripts and flows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep an omitted permissioned_as from re-versioning an unchanged script Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: derive the on-behalf-of principal from the email and reject mismatched pairs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop workspace deploys from carrying a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the onBehalfOfPermissionedAs param doc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin that workspace deploys never carry a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the omitted-principal contract and refresh generated prompts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep external-superadmin principals on email-only redeploys Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope the recorded principal to its workspace and prefer real accounts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry the recorded principal correctly through drafts and set-permissioned-as Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: sweep draft identity pairs on email change and offboarding Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: leave group identities alone when sweeping a user's email Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: treat only g/ without an email as a group, and match the offboard preview Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop the group guard from skipping rows with no recorded principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state the group guard once instead of restating it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: make the permissioned_as the only stored on-behalf-of identity Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: skip resolving the on-behalf-of address for sync clients that discard it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address the local review of the identity refactor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys * test: pin that a fork keeps only the on-behalf-of identities that resolve in it * fix: decide a principal prefix-first everywhere and canonicalize bare addresses * fix: prefix a slash-containing address so a reader cannot take it for a group * fix: read an address as a username before the group- convention * fix: rewrite the canonical principal when an account's address moves * fix: keep the address form of a principal to accounts without a usr row * fix: reject an identity a job row cannot carry and read it uncached at dispatch * fix: count characters against the job identity width and cap the backfill * refactor: name the script/flow principal on_behalf_of, as apps do * docs: state the caller-must-authorize contract on the identity resolvers * fix: keep writing on_behalf_of_email until every worker reads the principal * fix: err high on the compatibility version and document the last resolver * fix: keep the compatibility address current through identity mutations * fix: carry the compatibility address with the principal on every copy path * chore: re-pin the EE ref to the companion branch merged with EE main * fix: key the dbt retry lookup on the stored principal * fix: keep a mixed-version address recoverable through a fork * fix: read a round-tripped address uncached so a redeploy is not rejected * fix: refuse an email change that would make a principal unenqueueable * chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4 This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private. Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036 New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1161 lines
37 KiB
Rust
1161 lines
37 KiB
Rust
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<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);
|
|
// Same form the mutation reassigns, so preview and execution cannot disagree. `usr.username`
|
|
// is constrained to `[\w-]+`, so a member is always named `u/{username}` — the address form a
|
|
// principal can also take names an account with no `usr` row, which is nobody offboardable.
|
|
let departing = windmill_common::users::username_to_permissioned_as(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",
|
|
"amqp_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 = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted",
|
|
&departing, &user_prefix, w_id
|
|
).fetch_all(db).await?;
|
|
|
|
let obo_flows = sqlx::query_scalar!(
|
|
"SELECT path FROM flow WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
|
|
&departing, &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,
|
|
&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,
|
|
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(email): Path<String>,
|
|
Json(req): Json<GlobalOffboardRequest>,
|
|
) -> Result<Json<OffboardResponse>> {
|
|
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<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,
|
|
&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",
|
|
"amqp_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,
|
|
reassign_to: &str,
|
|
new_permissioned_as: &str,
|
|
) -> Result<OffboardSummary> {
|
|
let new_prefix = reassign_to.to_string();
|
|
let departing = windmill_common::users::username_to_permissioned_as(username);
|
|
|
|
// The app policy stores an address beside its principal, and script/flow keep one for the
|
|
// workers that still read it, so the replacement's is resolved here.
|
|
// resolve_new_permissioned_as already validated the user exists.
|
|
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 = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
|
|
new_permissioned_as,
|
|
&departing,
|
|
w_id,
|
|
new_on_behalf_of_user_email
|
|
)
|
|
.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, 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, 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 = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
|
|
new_permissioned_as,
|
|
&departing,
|
|
w_id,
|
|
new_on_behalf_of_user_email
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
// Drafts hold both halves in their value and are not confined to the offboarded user's
|
|
// paths, so a draft on a shared path would keep running as them. Both are rewritten:
|
|
// `deployDraft` sends the pair, and one naming two people is rejected.
|
|
sqlx::query!(
|
|
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['on_behalf_of'], to_jsonb($1::text)), ARRAY['on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('script', 'flow') AND value->>'on_behalf_of' = $2 AND workspace_id = $3"#,
|
|
new_permissioned_as,
|
|
&departing,
|
|
w_id,
|
|
new_on_behalf_of_user_email
|
|
)
|
|
.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 10 types with path/permissioned_as) ----
|
|
let trigger_tables = [
|
|
"http_trigger",
|
|
"websocket_trigger",
|
|
"kafka_trigger",
|
|
"postgres_trigger",
|
|
"mqtt_trigger",
|
|
"amqp_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,
|
|
})
|
|
}
|