mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
68debab877
* feat(triggers): add AMQP (RabbitMQ) trigger using the lapin library Fixes WIN-2214 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(triggers): defer AMQP cross-workspace deploy pending utils-internal publish Revert the amqp_trigger additions to the shared windmill-utils-internal TriggerDeployKind and the frontend cross-workspace deploy adapter: the frontend installs the published npm package, which lacks the new kind until a release is cut. AMQP create/edit/delete/list/sync/capture are unaffected (they use local types); only cross-workspace deploy/merge of AMQP triggers waits on the package bump. Also document the at-most-once ack in the consumer loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): address AMQP review — at-least-once ack, workspace cascade, contracts - ack AMQP deliveries only after successful dispatch; nack+requeue on failure - add ON DELETE CASCADE workspace FK so amqp_trigger rows are cleaned on workspace deletion (and the listener stops) - fix the /amqp_triggers/test OpenAPI body and add amqp_trigger to WorkspaceDiffRow.kind - register AMQP in the generated workspace trigger tool (create_trigger) - drop banned $bindable defaults on optional props in the config section - add build_uri unit tests (encoding, ports, vhost) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): stop AMQP poison-message loop and reconnect on transient drops Chaos testing against a live RabbitMQ broker showed the previous nack(requeue) + immediate re-poll spun a tight redelivery loop (~1000 critical-error reports/sec) on a poison message, and any connection blip permanently disabled the trigger (lapin has no built-in reconnect). - on dispatch failure: nack+requeue then stop consuming; the listener framework re-lists the trigger after its ping goes stale (~15s), backing redelivery off to that cadence instead of a tight loop (verified: rate dropped from ~1000/s to ~1 per ~26s, message preserved) - on connection/stream error: stop and let the framework reconnect instead of disabling; persistent failures are still disabled via get_consumer (verified: a forced connection close now auto-reconnects and resumes) - finish the AI create-trigger action wiring for AMQP: add amqp to CreatedResourceTriggerKind, the action-card registry, and the drawer registry so the result card renders and its "Open" action works Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP frontend registries and defer merge rows - add amqp to capturableTriggerTypes (so AmqpCapture mounts), the Runs jobTriggerKinds filter, and CLOUD_DISABLED_TRIGGER_TYPES - wire AMQP into global AI chat mode: TRIGGER_KINDS, the request union, writeTriggerSchema, triggerServices, and the draft adapter - stop emitting actionable AMQP fork-comparison rows (revert amqp_trigger from TRIGGER_OR_SCHEDULE_TABLES) since cross-workspace deploy is deferred until windmill-utils-internal is published — avoids a deploy that fails with "Unknown kind: amqp_trigger" - use design-system TextInput instead of raw <input> in the config section Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP session/draft registries and constrain prefetch - add amqp to the session-deploy, draft-compare, preview-router, and copilot workspace-item registries so AMQP drafts/deploys/nav/path resolution work - include amqp_count in the MoveDrawer attached-trigger rename warning - replace the raw prefetch <input> with a design-system TextInput bounded to an integer 1-65535 (backend u16) and block save on invalid values Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): make AMQP disconnect/reconnect consistent with the Kafka trigger lapin, like rdkafka, has no transparent reconnect, so the AMQP listener now mirrors the Kafka trigger's explicit reconnect loop instead of relying on the framework re-list (which disabled the trigger once get_consumer failed on a sustained outage): - get_consumer returns cheaply; consume owns a (re)connect loop that retries with a 30s backoff, reports a critical error every 10 failed attempts, and reports a recovered critical error once it reconnects — never disabling the trigger on a connectivity failure - a consumer/stream error breaks out to reconnect rather than disabling - dispatch failure still nacks+requeues (at-least-once) with a short backoff to avoid a tight poison-message loop, keeping the connection alive Verified against a live RabbitMQ broker: killing the broker keeps the trigger enabled and retrying (attempt N), and restarting it auto-reconnects (logs "reconnected after N attempts") and resumes dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP capture registries and constrain prefetch contract - add the 'amqp' case to triggerKindToTriggerType so opening the AMQP editor from a capture button no longer throws "Unknown TriggerKind: amqp" - register AmqpIcon in CaptureTable's icon map and add an AMQP entry to the script/flow CaptureButton menu - bound the OpenAPI prefetch_count to an integer 1-65535 (matches the Rust u16) and regenerate clients/prompts - require a non-empty exchange name when the exchange binding is enabled - build_uri: fall back to "/" on a blank vhost and bracket IPv6 hosts (+ tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(triggers): wire AMQP into pipeline graph, git-sync, and preprocessor types - asset_graph: discover attached amqp_trigger rows and emit an AMQP TriggerEdge so AMQP triggers render (and can be opened/deleted) on the data-pipeline canvas - frontend pipeline graph: add amqp to NativeTriggerKind, the add-trigger menu, node presentation, event-trigger set, annotation keywords, and the editor/service registrations - git-sync: add the amqp_trigger include pattern (+ test) so an AMQP git-sync deployment stages only its .amqp_trigger.* file, not an unrelated same-path object - preprocessor starters: add the AMQP event to the generated TS/Python/PHP trigger event types (kind/payload/exchange/routing_key/queue_name/redelivered/ delivery_tag) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): finish AMQP pipeline/parser wiring, prefetch validation, source lists - fix a stray edit that corrupted the pre-existing MqttTriggerEditor import ($lib/... path) in PipelineTriggerEditors.svelte - reject prefetch_count = 0 server-side in validate_config (RabbitMQ treats 0 as unlimited) and defensively skip basic_qos(0) in build_consumer (covers the capture path that bypasses CRUD validation) - recognize `// on amqp` in the canonical parser (TriggerSpec::Amqp) and add amqp to the CLI non-autorun/event-trigger sets so a pipeline cascade never runs an AMQP-only node as a manual root without an event - add amqp to the preprocessor intro lists and both pipeline AI instructions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): reject zero AMQP prefetch in all paths and finish guidance lists - extract a shared validate_amqp_options used by both CRUD validate_config and build_consumer, so capture configs (which bypass CRUD validation) also reject prefetch 0 instead of silently connecting with an unlimited buffer (+ unit tests for 0/1/65535/None) - add AMQP to the main script-writing preprocessor-sources prompt and the CLI triggers-skill guidance list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(triggers): de-duplicate AMQP prefetch comment and fix GET response text - keep the zero-prefetch rationale only on the shared validate_amqp_options doc; drop the redundant call-site comments - correct the getAmqpTrigger OpenAPI 200 description ("deleted" -> "retrieved") Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to aaa6cb89b05b76139252c64f057e53b94d12ac60 This commit updates the EE repository reference after PR #680 was merged in windmill-ee-private. Previous ee-repo-ref: 5da5fd65aca9594b2611837a52e4677b544b0380 New ee-repo-ref: aaa6cb89b05b76139252c64f057e53b94d12ac60 Automated by sync-ee-ref workflow. * chore(migrations): consolidate the four AMQP migrations into one The table and the three enum ADD VALUE statements (trigger_kind, job_trigger_kind, draft_kind) are one atomic feature. ALTER TYPE ... ADD VALUE runs inside the migration transaction on PG >= 14 (Windmill's minimum) since the amqp_trigger table doesn't reference those enum types, so they can share a single migration instead of four. Verified applying cleanly in a single transaction on a fresh DB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1143 lines
36 KiB
Rust
1143 lines
36 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);
|
|
|
|
// ---- 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_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,
|
|
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,
|
|
&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",
|
|
"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,
|
|
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, 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 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,
|
|
})
|
|
}
|