mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
* fix: only let an oauth account's creator or readers link it to a variable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop an oauth account link the caller may not hold instead of failing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover relinking an oauth account through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover a permitted relink through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an oauth account's creator in step with the user's username Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: follow a non-member superadmin's instance username on the OAuth accounts they created Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: link a variable to its OAuth account only in the connect flow The variable endpoints no longer take an account link from the caller. The connect flows create the token variable first and pass its path to create_account, which links it server-side. This replaces the creator check and the created_by bookkeeping, along with both migrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: keep the MCP secret variable test's formatting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link the account to the token variable AppConnect created, even if the path is edited mid-save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop exporting the OAuth account a variable links, and regenerate the chat tool schema Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: pin the EE refresh and linking checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete AppConnect's token variable when linking its account fails, so Save can be retried Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the resource AppConnect fills when linking its account fails Deleting a variable also deletes the resource at its path, so the rollback is skipped when filling an existing resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: create an OAuth token variable and its account in one request createAccount takes the variable and the store creates both in one transaction, so a connect is either fully saved or not at all. The connect flows make that single call instead of creating the variable and linking it afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop listing the ignored account input in the chat's write_variable instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: drop the account visibility helper the oauth refresh route no longer uses Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let native triggers act through per-user or shared connections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bind native trigger oauth state to its user and follow connection renames Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: authorize oauth account links and scope native trigger disconnects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: order the native trigger connection migration after account ownership Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete only the scope-checked triggers when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: skip triggers moved since their scope check when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope-check the connection path when connecting, keep a connection still in use Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key token write-back by account and serialize trigger creation with disconnect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let an admin share one native trigger connection with the whole workspace Members can use the shared connection without reading its token. It reuses workspace_integrations.resource_path, so existing workspace connections stay usable by members after upgrading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: label the disconnect action in the connection picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop the own-account note from the workspace connection field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let admins share any connection, serialize service removal with trigger creation An admin can already read every connection, so restricting sharing to their own added nothing. Removing a service now takes each connection's lock and keeps everything if a trigger was created meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show each native trigger service's OAuth app status and keep removal apart Remove now only appears with a workspace OAuth app, since a workspace using the instance app has nothing of its own to remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: configure a native trigger service's OAuth app in a dialog The card states once which OAuth app is in use, with a single Configure action, and the form follows the design system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a stray brace from the OAuth app dialog binding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: lead the OAuth app row with which app is in use, with a link to change it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: compact the native trigger service card Edit and Remove sit on the workspace OAuth app row, and the workspace connection reads as part of the same section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show the OAuth app status in each native trigger card's header App actions move into a menu, the card body holds only the shared account, and service descriptions name what fires the trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say who configured each native trigger service, and name removal for what it removes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: say what members can do with each native trigger service, keep the shared account optional Nextcloud, whose webhooks need an admin account, offers connecting and sharing one directly instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pick the shared account with an inline select, and keep Nextcloud's own-account path in its wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: one status line per native trigger service, a Nextcloud admin hint, and clearer refusals The shared account is a regular form field, Nextcloud shows an alert while no admin account is shared, a non-admin Nextcloud account gets a message saying why it cannot create triggers, and the OAuth dialog only accepts a full server URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: point native trigger error hints at the account picker and the shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say members can browse what a shared native trigger account can access Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: make the OAuth app dialog's open prop required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep pre-connection triggers working, and guard reconnects and service removal - The migration recovers a service's connection when the workspace row lost its path, which saving the OAuth app or an abandoned instance connect used to do. - A trigger created without a connection uses the shared account, as older clients and trigger specs expect. - Only whoever connected a path, or an admin, may reconnect over it. - Removing a service or sharing an account checks a path-scoped token against what it touches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: connect native trigger accounts at a chosen path through the resource drawer, and reconnect from the picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say a disconnect deletes the triggers other members created on the account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer Reconnect next to an existing trigger's account and point the refresh hints at it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to da49c12e3006fc77de789277f1462a4d8d7d92ce This commit updates the EE repository reference after PR #829 was merged in windmill-ee-private. Previous ee-repo-ref: 58681a2e7eafb5689ed739cd2560fa0f5d82c3dc New ee-repo-ref: da49c12e3006fc77de789277f1462a4d8d7d92ce Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1278 lines
42 KiB
Rust
1278 lines
42 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 = "Vec::is_empty")]
|
|
eval_datasets: 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,
|
|
eval_datasets_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 ----
|
|
|
|
/// The principal a departing member's runnables run as, or `None` when none of them are theirs
|
|
/// to hand over. `usr.username` is constrained to `[\w-]+`, so a member is `u/{username}`, except
|
|
/// a legacy `group-*` username, which canonicalizes to the group it names: what runs under that
|
|
/// principal runs as the group, which outlives the member. `None` binds NULL, which the
|
|
/// `on_behalf_of = $n` queries then match nowhere.
|
|
fn departing_principal(username: &str) -> Option<String> {
|
|
use windmill_common::users::{username_to_permissioned_as, PERMISSIONED_AS_GROUP_PREFIX};
|
|
let principal = username_to_permissioned_as(username);
|
|
(!principal.starts_with(PERMISSIONED_AS_GROUP_PREFIX)).then_some(principal)
|
|
}
|
|
|
|
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.
|
|
let departing = departing_principal(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 eval_datasets = sqlx::query_scalar!(
|
|
"SELECT path FROM eval_dataset 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.as_deref(), &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.as_deref(), &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",
|
|
departing.as_deref(), &user_prefix, w_id
|
|
).fetch_all(db).await?;
|
|
|
|
// An agent's identity sits in its value, under any path, the user's own included: those
|
|
// move with the path but keep running as the departing user until re-pointed.
|
|
let obo_agents: Vec<String> = sqlx::query_scalar(
|
|
"SELECT path FROM resource WHERE resource_type = 'ai_agent'
|
|
AND value->>'on_behalf_of' = $1 AND workspace_id = $2",
|
|
)
|
|
.bind(departing.as_deref())
|
|
.bind(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_eval_datasets = sqlx::query_scalar!(
|
|
"SELECT path FROM eval_dataset
|
|
WHERE scorers::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,
|
|
eval_datasets,
|
|
triggers,
|
|
},
|
|
executing_on_behalf: OffboardAffectedPaths {
|
|
scripts: obo_scripts,
|
|
flows: obo_flows,
|
|
apps: obo_apps,
|
|
resources: obo_agents,
|
|
schedules: obo_schedules,
|
|
triggers: obo_triggers,
|
|
..Default::default()
|
|
},
|
|
referencing: OffboardAffectedPaths {
|
|
scripts: ref_scripts,
|
|
flows: ref_flows,
|
|
apps: ref_apps,
|
|
resources: ref_resources,
|
|
eval_datasets: ref_eval_datasets,
|
|
..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).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).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,
|
|
eval_datasets_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.eval_datasets_reassigned += ws_summary.eval_datasets_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?;
|
|
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email).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",
|
|
"eval_dataset",
|
|
"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 = departing_principal(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.as_deref(),
|
|
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, job_token_scopes)
|
|
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, job_token_scopes
|
|
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.as_deref(),
|
|
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.as_deref(),
|
|
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' = $2 AND workspace_id = $3",
|
|
&new_permissioned_as,
|
|
departing.as_deref(),
|
|
w_id,
|
|
new_on_behalf_of_user_email
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
// An app draft carries a copy of the deployed policy and is deployed from it, so it needs
|
|
// the same pair rewritten — the draft sweep above only covers scripts and flows.
|
|
sqlx::query!(
|
|
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3"#,
|
|
new_permissioned_as,
|
|
departing.as_deref(),
|
|
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);
|
|
|
|
sqlx::query(
|
|
"UPDATE resource SET value = jsonb_set(value, '{on_behalf_of}', to_jsonb($1::text))
|
|
WHERE resource_type = 'ai_agent' AND value->>'on_behalf_of' = $2 AND workspace_id = $3",
|
|
)
|
|
.bind(new_permissioned_as)
|
|
.bind(departing.as_deref())
|
|
.bind(w_id)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
// ---- eval datasets ----
|
|
// The foreign keys cascade the rename onto cases and experiments; the paths held inside JSONB
|
|
// (an experiment's subject, a dataset's scorers) are rewritten separately since the cascade
|
|
// cannot reach them and those runnables move with the user.
|
|
let eval_datasets_reassigned = sqlx::query_scalar!(
|
|
r#"WITH updated AS (
|
|
UPDATE eval_dataset 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!(
|
|
r#"UPDATE eval_experiment SET subject = jsonb_set(subject, '{path}', to_jsonb(REGEXP_REPLACE(subject->>'path', 'u/' || $2 || '/(.*)', $1 || '/\1'))) WHERE subject->>'path' LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
|
&new_prefix,
|
|
username,
|
|
w_id
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
r#"UPDATE eval_dataset SET scorers = COALESCE((
|
|
SELECT jsonb_agg(
|
|
CASE WHEN elem->>'path' LIKE ('u/' || $2 || '/%')
|
|
THEN jsonb_set(elem, '{path}', to_jsonb(REGEXP_REPLACE(elem->>'path', 'u/' || $2 || '/(.*)', $1 || '/\1')))
|
|
ELSE elem END)
|
|
FROM jsonb_array_elements(scorers) elem), '[]'::jsonb)
|
|
WHERE workspace_id = $3
|
|
AND EXISTS (SELECT 1 FROM jsonb_array_elements(scorers) e WHERE e->>'path' LIKE ('u/' || $2 || '/%'))"#,
|
|
&new_prefix,
|
|
username,
|
|
w_id
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
// ---- 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 native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
|
&new_prefix, username, w_id
|
|
).execute(&mut **tx).await?;
|
|
|
|
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,
|
|
eval_datasets_reassigned,
|
|
variables_reassigned,
|
|
schedules_reassigned,
|
|
triggers_reassigned,
|
|
drafts_deleted,
|
|
})
|
|
}
|