/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use windmill_api_auth::{ build_scope_path_predicate, check_scopes, require_devops_role, require_instance_admin, require_is_writer, require_super_admin, ApiAuthed, }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; use windmill_common::webhook::WebhookShared; use windmill_common::{BASE_URL, DB}; use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace; use axum::{ extract::{Extension, Path, Query}, routing::{delete, get, post}, Json, Router, }; use chrono::Utc; use regex::Regex; use hex; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use strum::IntoEnumIterator; use uuid::Uuid; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ can_use_datatable_role, check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db, get_datatable_resource_from_db_unchecked, redact_datatable_settings_for_export, validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType, DataTableForkBehavior, DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, ADMIN_DATATABLE_ROLE, DEV_WORKSPACE_LOCK_RULE_NAME, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; use windmill_common::{ error::{pg_error_message, Error, JsonResult, Result}, global_settings::{ AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING, }, oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, utils::{paginate, rd_string, require_admin, Pagination}, }; use windmill_dep_map::scoped_dependency_map::{ DependencyDependent, DependencyMap, ScopedDependencyMap, }; use windmill_git_sync::{ handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, DeployedObject, }; use windmill_types::s3::LargeFileStorage; use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Postgres, Row, Transaction}; use windmill_common::oauth2::InstanceEvent; use windmill_common::secret_backend::{get_secret_backend, is_vault_backend_configured}; use windmill_common::utils::not_found_if_none; lazy_static::lazy_static! { static ref WORKSPACE_KEY_REGEXP: Regex = Regex::new("^[a-zA-Z0-9]{64}$").unwrap(); } pub fn workspaced_service() -> Router { Router::new() .route("/get_as_superadmin", get(get_workspace_as_superadmin)) .route("/list_pending_invites", get(list_pending_invites)) .route("/update", post(edit_workspace)) .route("/archive", post(archive_workspace)) .route("/invite_user", post(invite_user)) .route("/add_user", post(add_user)) .route("/create_service_account", post(create_service_account)) .route("/delete_invite", post(delete_invite)) .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) .route("/get_dependents/{*imported_path}", get(get_dependents)) .route("/get_imports/{*importer_path}", get(get_imports)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) .route("/get_public_settings", get(get_public_settings)) .route( "/get_copilot_settings_state", get(get_copilot_settings_state), ) .route("/get_deploy_to", get(get_deploy_to)) .route("/edit_slack_command", post(edit_slack_command)) .route( "/run_slack_message_test_job", post(run_slack_message_test_job), ) .route("/slack_oauth_config", get(get_slack_oauth_config)) .route("/slack_oauth_config", post(set_slack_oauth_config)) .route("/slack_oauth_config", delete(delete_slack_oauth_config)) .route("/edit_webhook", post(edit_webhook)) .route("/edit_auto_invite", post(edit_auto_invite)) .route("/edit_instance_groups", post(edit_instance_groups)) .route( "/get_secondary_storage_names", get(get_secondary_storage_names), ) .route("/is_premium", get(is_premium)) .route("/billable_seats", get(get_billable_seats)) .route("/edit_error_handler", post(edit_error_handler)) .route("/edit_success_handler", post(edit_success_handler)) .route( "/edit_large_file_storage_config", post(edit_large_file_storage_config), ) .route("/edit_dbt_warehouses", post(edit_dbt_warehouses)) .route("/edit_ducklake_config", post(edit_ducklake_config)) .route("/list_ducklakes", get(list_ducklakes)) .route("/list_datatables", get(list_datatables)) .route("/list_datatable_schemas", get(list_datatable_schemas)) .route("/list_datatable_tables", get(list_datatable_tables)) .route( "/get_datatable_table_schema", get(get_datatable_table_schema), ) .route("/edit_datatable_config", post(edit_datatable_config)) .route( "/test_datatable_connection/{datatable_name}", get(test_datatable_connection), ) .merge(crate::datatable_migrations::routes()) .merge(crate::datatable_permissions::routes()) .merge(crate::datatable_acl::routes()) .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode)) .route("/edit_git_sync_config", post(edit_git_sync_config)) .route("/edit_git_sync_repository", post(edit_git_sync_repository)) .route( "/delete_git_sync_repository", delete(delete_git_sync_repository), ) .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) .route("/edit_default_app", post(edit_default_app)) .route("/default_app", get(get_default_app)) .route( "/default_scripts", post(edit_default_scripts).get(get_default_scripts), ) .route("/set_environment_variable", post(set_environment_variable)) .route( "/encryption_key", get(get_encryption_key).post(set_encryption_key), ) .route("/leave", post(leave_workspace)) .route("/get_workspace_name", get(get_workspace_name)) .route("/create_fork", post(create_workspace_fork)) .route("/attach_dev_workspace", post(attach_dev_workspace)) .route("/detach_dev_workspace", post(detach_dev_workspace)) .route("/get_dev_workspace", get(get_dev_workspace)) .route("/change_workspace_name", post(change_workspace_name)) .route("/change_workspace_color", post(change_workspace_color)) .route( "/change_workspace_id", post(crate::workspaces_extra::change_workspace_id), ) .route("/usage", get(get_usage)) .route("/used_triggers", get(get_used_triggers)) .route("/public_app_rate_limit", post(edit_public_app_rate_limit)) .route("/operator_settings", post(update_operator_settings)) .route( "/create_workspace_fork_branch", post(create_workspace_fork_branch), ) .route( "/reset_diff_tally/{fork_workspace_id}", post(reset_workspace_diffs), ) .route("/compare/{target_workspace_id}", get(compare_workspaces)) .route( "/seed_full_diff/{target_workspace_id}", post(seed_full_diff_scan), ) .route("/create_pg_database", post(create_pg_database)) .route("/import_pg_database", post(import_pg_database)) .route("/export_pg_schema", post(export_pg_schema)) .route( "/drop_forked_datatable_databases", post(crate::workspaces_extra::drop_forked_datatable_databases), ) .route( "/drop_forked_ducklake_namespaces", post(crate::workspaces_extra::drop_forked_ducklake_namespaces), ) .route( "/get_datatable_full_schema", post(get_datatable_full_schema), ) .route("/protection_rules", get(list_protection_rules)) .route("/protection_rules", post(create_protection_rule)) .route( "/protection_rules/{rule_name}", post(update_protection_rule).delete(delete_protection_rule), ) .route("/log_feature_usage", post(log_feature_usage)) .route("/cloud_quotas", get(get_cloud_quotas)) .route("/prune_versions", post(prune_versions)) .route("/list_ws_specific", get(list_ws_specific)) .route("/list_ws_specific_versions", get(list_ws_specific_versions)) .route("/set_ws_specific", post(set_ws_specific)) } pub fn global_service() -> Router { Router::new() .route("/list_as_superadmin", get(list_workspaces_as_super_admin)) .route("/list", get(list_workspaces)) .route("/users", get(user_workspaces)) .route("/session_workspace_status", post(session_workspace_status)) .route("/create", post(create_workspace)) .route("/create_fork", post(deprecated_create_workspace_fork)) .route("/exists", post(exists_workspace)) .route("/exists_username", post(exists_username)) .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) .route("/unarchive/{workspace}", post(unarchive_workspace)) .route( "/delete/{workspace}", delete(crate::workspaces_extra::delete_workspace), ) .route( "/create_workspace_require_superadmin", get(create_workspace_require_superadmin), ) } #[derive(FromRow, Serialize)] struct Workspace { id: String, name: String, owner: String, deleted: bool, premium: bool, color: Option, parent_workspace_id: Option, is_dev_workspace: bool, dev_workspace_label: Option, } #[derive(FromRow, Serialize, Debug)] pub struct WorkspaceSettings { pub workspace_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_guid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub slack_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub slack_command_script: Option, pub teams_command_script: Option, pub slack_email: String, #[serde(skip_serializing_if = "Option::is_none")] pub slack_oauth_client_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub slack_oauth_client_secret: Option, #[serde(skip_serializing_if = "Option::is_none")] pub customer_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub plan: Option, #[serde(skip_serializing_if = "Option::is_none")] pub webhook: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ai_config: Option, /// Pointers to the resources dbt scripts run against by default. #[serde(skip_serializing_if = "Option::is_none")] pub dbt_warehouses: Option, #[serde(skip_serializing_if = "Option::is_none")] pub large_file_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ducklake: Option, #[serde(skip_serializing_if = "Option::is_none")] pub datatable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub git_sync: Option, #[serde(skip_serializing_if = "Option::is_none")] pub deploy_ui: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_app: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mute_critical_alerts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub operator_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub git_app_installations: Option, // Grouped config fields #[serde(skip_serializing_if = "Option::is_none")] pub auto_invite: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] pub success_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] pub public_app_execution_limit_per_minute: Option, pub error_handler_fallback_to_instance_alerts: bool, } /// Subset of `WorkspaceSettings` that is safe to return to any workspace /// member. Adding a field here means it will be readable by every authed user /// in the workspace — anything sensitive (OAuth secrets, GitHub App tokens, /// billing/customer info, integration credentials, etc.) must NOT be added. /// The full `WorkspaceSettings` struct is admin-only via `get_settings`. #[derive(FromRow, Serialize, Debug)] pub struct WorkspacePublicSettings { pub workspace_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub slack_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub teams_team_guid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mute_critical_alerts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub deploy_ui: Option, #[serde(skip_serializing_if = "Option::is_none")] pub large_file_storage: Option, /// Carries each data table role's generated login as stored, so it only /// leaves the server through `redact_datatable_settings_for_export`. #[serde(skip_serializing_if = "Option::is_none")] pub datatable: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CopilotSettingsState { pub has_instance_ai_config: bool, pub uses_instance_ai_config: bool, #[serde(skip_serializing_if = "Option::is_none")] pub instance_ai_summary: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InstanceAIProviderSummary { pub provider: String, pub models: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InstanceAIModelSummary { pub provider: String, pub model: String, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct InstanceAISummary { pub providers: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata_model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub code_completion_model: Option, } /// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] // #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] // pub enum WorkspaceKeyKind { // Cloud, // } #[derive(Deserialize)] struct EditCommandScript { slack_command_script: Option, } #[derive(Deserialize)] struct RunSlackMessageTestJobRequest { hub_script_path: String, channel: String, test_msg: String, } #[derive(Serialize)] struct RunSlackMessageTestJobResponse { job_uuid: String, } #[allow(dead_code)] #[derive(Deserialize)] pub struct EditAutoInvite { pub operator: Option, pub invite_all: Option, pub auto_add: Option, } #[derive(Deserialize)] struct EditWebhook { webhook: Option, } #[derive(Deserialize, Serialize, Debug)] struct LargeFileStorageWithSecondary { #[serde(flatten)] large_file_storage: LargeFileStorage, #[serde(default)] secondary_storage: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] volume_storage: Option, } #[derive(Deserialize, Debug)] struct EditLargeFileStorageConfig { large_file_storage: Option, } #[derive(Deserialize, Debug)] struct EditDucklakeConfig { settings: DucklakeSettings, } #[derive(Deserialize, Serialize, Debug)] pub struct DucklakeSettings { pub ducklakes: HashMap, } #[derive(Deserialize, Debug)] struct EditDataTableConfig { settings: DataTableSettings, // Data table renames (old -> new) and deletions, tracked client-side by a // stable id, so we can cascade or drop each data table's migrations. #[serde(default)] renames: Vec, #[serde(default)] deleted_datatables: Vec, } #[derive(Deserialize, Serialize, Debug)] pub struct DataTableSettings { pub datatables: HashMap, } #[derive(Deserialize)] struct CreateWorkspace { id: String, name: String, username: Option, color: Option, #[serde(default)] error_handler_fallback_to_instance_alerts: bool, } #[derive(Deserialize)] struct CreateWorkspaceFork { id: String, name: String, color: Option, /// Datatable names that were forked. For each, the backend will update the /// forked workspace's datatable config to point to the new database. #[serde(default)] forked_datatables: Vec, /// Lakes the user explicitly chose to SHARE with the parent (the fork then reads and /// writes the parent's lake directly). Every lake not listed gets the default isolated /// fork namespace + read-defer. #[serde(default)] shared_ducklakes: Vec, /// Create the fork as a persistent dev workspace: the id is not required to carry the /// `wm-fork-` prefix, and at most one dev workspace may exist per parent. #[serde(default)] is_dev_workspace: bool, /// When creating a dev workspace, lock the parent ("prod") against direct deployment and/or /// ad-hoc forking, so edits are funneled through the dev workspace. #[serde(default)] lock_prod_deploy: bool, #[serde(default)] lock_prod_forking: bool, /// Copy the parent's members (usr rows + group memberships) into the fork so /// the team can work in it. Defaults off; the dev-workspace UI defaults it on. #[serde(default)] copy_members: bool, /// Environment label for the dev workspace, e.g. 'dev' or 'staging': its badge text and the /// branch it deploys to. Ignored for non-dev forks. None defaults to 'dev'. #[serde(default)] dev_workspace_label: Option, } #[derive(Deserialize)] struct ForkedDatatableInfo { name: String, new_dbname: String, } #[derive(Deserialize)] struct EditWorkspace { name: String, owner: String, } #[derive(Serialize)] struct WorkspaceList { pub email: String, pub workspaces: Vec, } #[derive(Serialize)] struct UserWorkspace { pub id: String, pub name: String, pub username: String, pub color: Option, pub operator_settings: Option>, pub parent_workspace_id: Option, pub is_dev_workspace: bool, pub dev_workspace_label: Option, /// Creator of the workspace (`workspace.owner`). On a fork it identifies the forker, who gets a /// narrow membership grant over it even without being an admin — the UI keys the fork members /// screen off this. pub created_by: Option, pub disabled: bool, } #[derive(Deserialize)] struct WorkspaceId { pub id: String, } #[derive(Deserialize)] struct ValidateUsername { pub id: String, pub username: String, } #[derive(Deserialize)] pub struct NewWorkspaceInvite { pub email: String, pub is_admin: bool, pub operator: bool, } #[derive(Deserialize)] pub struct NewWorkspaceUser { pub email: String, pub username: Option, pub is_admin: bool, pub operator: bool, } // New format for error handler (grouped) #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct EditErrorHandlerNew { pub path: Option, pub extra_args: Option, #[serde(default)] pub muted_on_cancel: bool, #[serde(default)] pub muted_on_user_path: bool, /// Left as `None` by clients that predate the setting (the CLI among them), which must /// keep the stored value rather than silently reset it on every settings push. pub fallback_to_instance_alerts: Option, } // Legacy format for error handler (flat fields from old CLI) #[derive(Deserialize)] pub struct EditErrorHandlerLegacy { pub error_handler: Option, pub error_handler_extra_args: Option, #[serde(default)] pub error_handler_muted_on_cancel: bool, } // Accepts both old and new formats #[derive(Deserialize)] #[serde(untagged)] pub enum EditErrorHandler { New(EditErrorHandlerNew), Legacy(EditErrorHandlerLegacy), } impl EditErrorHandler { pub fn into_normalized(self) -> EditErrorHandlerNew { match self { EditErrorHandler::New(new) => new, EditErrorHandler::Legacy(legacy) => EditErrorHandlerNew { path: legacy.error_handler, extra_args: legacy.error_handler_extra_args, muted_on_cancel: legacy.error_handler_muted_on_cancel, muted_on_user_path: false, // Old format doesn't have this field fallback_to_instance_alerts: None, }, } } } // New format for success handler (grouped) #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct EditSuccessHandlerNew { pub path: Option, pub extra_args: Option, } // Legacy format for success handler (flat fields from old CLI) #[derive(Deserialize)] pub struct EditSuccessHandlerLegacy { pub success_handler: Option, pub success_handler_extra_args: Option, } // Accepts both old and new formats #[derive(Deserialize)] #[serde(untagged)] pub enum EditSuccessHandler { New(EditSuccessHandlerNew), Legacy(EditSuccessHandlerLegacy), } impl EditSuccessHandler { pub fn into_normalized(self) -> EditSuccessHandlerNew { match self { EditSuccessHandler::New(new) => new, EditSuccessHandler::Legacy(legacy) => EditSuccessHandlerNew { path: legacy.success_handler, extra_args: legacy.success_handler_extra_args, }, } } } lazy_static::lazy_static! { pub static ref EMAIL_REGEXP: Regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); } async fn list_pending_invites( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, ) -> JsonResult> { require_admin(authed.is_admin, &authed.username)?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as!( WorkspaceInvite, "SELECT workspace_invite.workspace_id, workspace_invite.email, workspace_invite.is_admin, workspace_invite.operator, workspace.parent_workspace_id FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE workspace_id = $1 ORDER BY workspace_invite.email", w_id ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(rows)) } async fn is_premium( _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, ) -> JsonResult { // Any workspace member (not just admins) may read whether the workspace is on a paid plan: it's a // single boolean, and the frontend needs it to decide whether to surface premium-gated affordances // (e.g. forking) to non-admin developers too. The `_authed` extractor still enforces membership. #[cfg(feature = "cloud")] let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id) .await? .premium; #[cfg(not(feature = "cloud"))] let premium = false; Ok(Json(premium)) } #[derive(Serialize)] struct BillableSeatsResponse { /// Both omitted when the seats counted are another workspace's: a fork member need not be a /// member of the billing root, so the root's headcount is not theirs to read. The total is, /// since it is the divisor of the quota their own executions draw on. #[serde(skip_serializing_if = "Option::is_none")] developers: Option, #[serde(skip_serializing_if = "Option::is_none")] operators: Option, seats: i64, } async fn get_billable_seats( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { // Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter // divides by, and that meter is shown to non-admin developers too. // // On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is // measured against are the root's. Resolved here rather than by the caller: a fork member need // not be a member of that root, and so cannot count its seats from the member list. Off cloud // a fork is not billed through a root at all, so the workspace answers for itself. #[cfg(feature = "cloud")] let billing_w_id = if *CLOUD_HOSTED { windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await? } else { w_id.clone() }; #[cfg(not(feature = "cloud"))] let billing_w_id = w_id.clone(); let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?; let own = billing_w_id == w_id; Ok(Json(BillableSeatsResponse { developers: own.then_some(counted.developers), operators: own.then_some(counted.operators), seats: counted.seats, })) } async fn exists_workspace( authed: ApiAuthed, Extension(user_db): Extension, Json(WorkspaceId { id }): Json, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)", id ) .fetch_one(&mut *tx) .await? .unwrap_or(false); tx.commit().await?; Ok(Json(exists)) } /// Whether this workspace already has an active canonical dev workspace. The create-fork UI can't /// rely on the caller's workspace list to decide this — a dev paired to this prod may exist that the /// caller isn't a member of — so it asks the server, which sees all children. #[derive(Serialize)] struct DevWorkspaceInfo { id: String, name: String, dev_workspace_label: Option, } /// The environment labels a dev workspace may carry, ordered dev -> prod. Each names the git branch /// that workspace deploys to (`dev_workspace_branch`), and every dev workspace in a chain must /// carry a distinct one (`reject_dev_label_taken_in_chain`) — so the length of this list is also /// the deepest promotion chain. A fixed list rather than free text: the label has to be a usable /// single-segment branch name, must not collide with the `wm-fork/**` and `wm_deploy/**` namespaces /// git-sync already writes, and must not be a repository's default branch (`main`, `master`). pub const DEV_WORKSPACE_LABELS: [&str; 8] = [ "dev", "qa", "test", "uat", "staging", "demo", "sandbox", "preprod", ]; /// Normalize/validate the dev-workspace environment label. Unset defaults to 'dev', which is also /// what a NULL column reads as; any supplied value must be one of `DEV_WORKSPACE_LABELS` exactly, /// so the accepted set is what the OpenAPI enum advertises — no trimming, no empty-string alias. fn normalize_dev_workspace_label(label: Option) -> Result> { let Some(label) = label else { return Ok(Some("dev".to_string())); }; if !DEV_WORKSPACE_LABELS.contains(&label.as_str()) { return Err(Error::BadRequest(format!( "invalid dev workspace label '{label}' (expected one of: {})", DEV_WORKSPACE_LABELS.join(", ") ))); } Ok(Some(label)) } #[cfg(test)] mod dev_workspace_label_tests { use super::{normalize_dev_workspace_label, tracked_branch_blocks_dev_label}; #[test] fn tracked_branch_blocks_its_own_name_and_its_namespace() { assert!(tracked_branch_blocks_dev_label("uat", "uat")); // The label would have to be a ref and a ref directory at once. assert!(tracked_branch_blocks_dev_label("release", "release/main")); assert!(!tracked_branch_blocks_dev_label("release", "release-main")); assert!(!tracked_branch_blocks_dev_label("release", "main")); assert!(!tracked_branch_blocks_dev_label("uat", "pre/uat")); } fn norm(label: &str) -> Option { normalize_dev_workspace_label(Some(label.to_string())) .ok() .flatten() } #[test] fn unset_defaults_to_dev() { assert_eq!( normalize_dev_workspace_label(None).unwrap().as_deref(), Some("dev") ); } #[test] fn accepts_every_offered_label_and_nothing_else() { for label in super::DEV_WORKSPACE_LABELS { assert_eq!(norm(label).as_deref(), Some(label), "rejected '{label}'"); } // Off-list names are refused whether or not they would make a usable branch: the list is // what keeps a label off `main`/`master` and out of the `wm-fork/**` and `wm_deploy/**` // namespaces git-sync writes. Padded and empty values are refused too, so the accepted set // is exactly the OpenAPI enum rather than a superset a validating client would reject. for label in [ " uat ", "", "main", "master", "wm-fork", "wm_deploy", "UAT", "feature/uat", ] { assert!( normalize_dev_workspace_label(Some(label.to_string())).is_err(), "accepted '{label}'" ); } } } /// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace /// settings tab can't rely on the caller's workspace list — a dev paired to this prod may exist that /// the caller isn't a member of — so they ask the server, which sees all children. Returns its id/name /// so a prod admin who isn't a dev member can still see the pairing and detach it. async fn get_dev_workspace( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let dev = sqlx::query_as!( DevWorkspaceInfo, "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", &w_id ) .fetch_optional(&db) .await?; Ok(Json(dev)) } async fn list_workspaces( authed: ApiAuthed, Extension(user_db): Extension, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; let workspaces = sqlx::query_as!( Workspace, "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label FROM workspace LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id JOIN usr ON usr.workspace_id = workspace.id WHERE usr.email = $1 AND workspace.deleted = false", authed.email ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(workspaces)) } /// Strip the server-only webhook HMAC secret from a `git_sync` blob before it is /// returned to a client. The UI never needs it; it stays (encrypted) in the DB. fn redact_git_sync_webhook_secrets(git_sync: &mut serde_json::Value) { if let Some(repos) = git_sync .get_mut("repositories") .and_then(|r| r.as_array_mut()) { for repo in repos { if let Some(auto_pull) = repo.get_mut("auto_pull").and_then(|a| a.as_object_mut()) { auto_pull.remove("webhook_secret"); } } } } /// Zero the server-owned auto-pull fields (webhook id/secret/url/error, synced /// sha, last pull status) on a client-supplied `AutoPullSettings`. The client only /// controls `enabled` / `mode` / `poll_interval_s`; the rest is written by the /// server (webhook creation, poller) and must never be trusted from the request — /// otherwise a caller could inject a webhook id/secret or fake sync state. fn clear_client_supplied_auto_pull_state( auto_pull: &mut windmill_common::workspaces::AutoPullSettings, ) { auto_pull.webhook_id = None; auto_pull.webhook_secret = None; auto_pull.webhook_url = None; auto_pull.webhook_error = None; auto_pull.last_synced_sha = std::collections::HashMap::new(); auto_pull.last_pull_status = None; } /// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's /// deploy branch. Two ways it can: /// /// - the same name: dev deploys would write straight into the branch the workspace (or its prod) /// syncs from, and the CLI refuses that push; /// - `label_branch` is the namespace `tracked` sits under (label `release`, tracked /// `release/main`): git stores refs hierarchically, so `refs/heads/release` cannot exist /// alongside `refs/heads/release/main`. /// /// Either way every deploy job from that workspace would fail. fn tracked_branch_blocks_dev_label(label_branch: &str, tracked: &str) -> bool { tracked == label_branch || tracked.starts_with(&format!("{label_branch}/")) } /// Reject a label whose branch clashes with a tracked branch of any git-sync repository on /// `workspace_ids`, before the pairing is created. (`wm-fork` and `wm_deploy`, whose namespaces /// exist whatever a repo tracks, are reserved unconditionally in `normalize_dev_workspace_label`.) async fn reject_dev_label_matching_tracked_branch( db: &DB, label: Option<&str>, workspace_ids: &[&str], ) -> Result<()> { let label_branch = windmill_common::workspaces::dev_workspace_branch(label); for w_id in workspace_ids { let Some(settings) = sqlx::query_scalar!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", w_id ) .fetch_optional(db) .await? .flatten() .and_then(|v| serde_json::from_value::(v).ok()) else { continue; }; for repo in &settings.repositories { let path = repo.git_repo_resource_path.trim_start_matches("$res:"); let branch: Option = sqlx::query_scalar!( "SELECT value->>'branch' FROM resource WHERE workspace_id = $1 AND path = $2", w_id, path ) .fetch_optional(db) .await? .flatten(); // A repository that pins no branch tracks the remote's default, which cannot be // resolved here without a network call — but no offered label is a plausible default // (`main`/`master` are off the list), so there is nothing to compare against. let Some(tracked) = branch.as_deref().filter(|b| !b.is_empty()) else { continue; }; if !tracked_branch_blocks_dev_label(&label_branch, tracked) { continue; } return Err(Error::BadRequest(if tracked == label_branch { format!( "The environment label '{label_branch}' matches the tracked branch of git-sync \ repository '{path}' in workspace '{w_id}': deploys from the dev workspace go \ to the '{label_branch}' branch and would overwrite the branch that repository \ syncs from. Use a different label or change the repository's branch." ) } else { format!( "The environment label '{label_branch}' is the namespace of the tracked branch \ '{tracked}' of git-sync repository '{path}' in workspace '{w_id}': git cannot \ hold a branch named '{label_branch}' alongside '{tracked}', so every deploy \ from the dev workspace would fail. Use a different label or change the \ repository's branch." ) })); } } Ok(()) } /// Reject parent-only git-sync settings on a fork workspace. Auto-pull and fork /// PRs are configured at the parent: repo → fork sync is routed by the parent's /// webhook/poller (`sync_forks`), and a fork-owned auto-pull would register a /// second webhook on the same GitHub repo per fork. Promotion mode is rejected /// on throwaway forks (their deploys always go to their `wm-fork/**` branch, so /// a promotion repo could never take effect) but allowed on a **dev workspace**, /// which deploys per-item `wm_deploy/**` branches that promote into the parent. async fn reject_parent_only_git_sync_settings_on_fork<'a>( db: &DB, w_id: &str, repos: impl Iterator, ) -> Result<()> { let row = sqlx::query!( "SELECT parent_workspace_id, is_dev_workspace FROM workspace WHERE id = $1", w_id ) .fetch_optional(db) .await?; let is_fork = row .as_ref() .and_then(|r| r.parent_workspace_id.as_ref()) .is_some() || w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX); if !is_fork { return Ok(()); } let is_dev = row.map(|r| r.is_dev_workspace).unwrap_or(false); let offending = repos.into_iter().find_map(|r| { if r.auto_pull.as_ref().is_some_and(|a| a.enabled) { Some("Auto-pull") } else if r.use_individual_branch.unwrap_or(false) && !is_dev { Some("Promotion mode") } else if r.fork_open_prs { Some("Opening PRs for fork deploys") } else { None } }); if let Some(offending) = offending { return Err(Error::BadRequest(format!( "{offending} cannot be configured on a fork workspace: it is managed from the parent workspace's git sync settings" ))); } Ok(()) } async fn get_settings( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult { // Admin-only: this struct contains OAuth secrets, GitHub App tokens, billing // info, and other admin-managed integration credentials. Non-admin callers // should use `get_public_settings`. require_admin(authed.is_admin, &authed.username)?; let mut tx = user_db.begin(&authed).await?; let settings = sqlx::query_as!( WorkspaceSettings, r#" SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, teams_team_guid, slack_name, slack_command_script, teams_command_script, slack_email, slack_oauth_client_id, slack_oauth_client_secret, customer_id, plan, webhook, ai_config, dbt_warehouses, large_file_storage, datatable, ducklake, git_sync, deploy_ui, default_app, default_scripts, mute_critical_alerts, color, operator_settings, git_app_installations, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $1 "#, &w_id ) .fetch_optional(&mut *tx) .await .map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?; let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; if let Some(git_sync) = settings.git_sync.as_mut() { redact_git_sync_webhook_secrets(git_sync); } settings.datatable = redact_datatable_settings_for_export(settings.datatable); Ok(Json(settings)) } async fn get_public_settings( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let settings = sqlx::query_as!( WorkspacePublicSettings, r#" SELECT workspace_id, slack_team_id, slack_name, teams_team_id, teams_team_name, teams_team_guid, mute_critical_alerts, deploy_ui, large_file_storage, datatable FROM workspace_settings WHERE workspace_id = $1 "#, &w_id ) .fetch_optional(&mut *tx) .await .map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?; let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; // Every workspace member reads this one, so the generated role logins go // through the same redaction as the admin settings and the tarball. settings.datatable = redact_datatable_settings_for_export(settings.datatable); Ok(Json(settings)) } #[derive(Deserialize)] pub struct GitSyncDeployModeQuery { /// The branch the caller would push. pub branch: Option, } #[derive(Serialize, Debug)] pub struct GitSyncDeployMode { /// At least one git-sync repository is configured for this workspace. pub configured: bool, /// Pushing `branch` deploys via server-side auto-pull: exactly one licensed, /// deliverable auto-pull repository tracks it. False (deploy via `git push` /// through CI, or `wmill sync push`) when unlicensed, no repo tracks the /// branch, or several do — with multiple synced repos we can't tell which the /// local checkout is, so the caller asks the user instead. pub deploy_on_push: bool, } /// Whether an enabled auto-pull repo actually has a delivery path that fires, so /// a push really deploys — mirroring the poller/webhook. Polling needs an HTTP(S) /// URL (SSH is rejected in the background); webhook-only mode needs an active /// hook; `auto` needs either. With neither, `enabled` alone never deploys (e.g. a /// webhook that failed to register). fn has_runnable_delivery( auto_pull: &windmill_common::workspaces::AutoPullSettings, resource: &serde_json::Value, ) -> bool { let webhook_active = auto_pull.webhook_id.is_some(); let is_app = resource .get("is_github_app") .and_then(|v| v.as_bool()) .unwrap_or(false); let is_http_url = resource .get("url") .and_then(|v| v.as_str()) .map(|u| { let u = u.trim_start(); u.starts_with("https://") || u.starts_with("http://") }) .unwrap_or(false); // App repos also have a GitHub-API poll fallback, but require an active // webhook here — conservative (errs toward `wmill sync push`) rather than // asserting the app installation can mint a token. let can_poll = !is_app && is_http_url; match auto_pull.mode { windmill_common::workspaces::AutoPullMode::Webhook => webhook_active, windmill_common::workspaces::AutoPullMode::Polling => can_poll, windmill_common::workspaces::AutoPullMode::Auto => webhook_active || can_poll, } } /// Whether pushing `pushed_branch` matches a repo directly tracking /// `tracked_branch` (the non-fork case). A blank tracked branch (repo default) is /// unresolvable without a network call, so it never matches and the caller falls /// back to `wmill sync push`. Fork/dev routing goes through /// `windmill_common::workspaces::resolve_fork_branch_target` instead. fn deploys_on_push_branch(pushed_branch: &str, tracked_branch: &str) -> bool { !tracked_branch.is_empty() && pushed_branch == tracked_branch } /// Non-admin endpoint so the CLI/agent can pick the deploy path (git push vs /// `wmill sync push`) without reading the admin-only workspace settings. Takes /// only the branch and returns booleans — no repository URLs, credentials, or /// webhook config ever leave the backend. async fn get_git_sync_deploy_mode( _authed: ApiAuthed, Path(w_id): Path, Query(q): Query, Extension(db): Extension, ) -> JsonResult { // A fork clears its own auto-pull; its pushes deploy through the root // ancestor's repo (which owns `sync_forks`), so evaluate the root's settings. let ancestors = windmill_common::workspaces::fork_ancestor_chain(&db, &w_id).await?; let is_fork = !ancestors.is_empty(); let root_id = ancestors.last().cloned().unwrap_or_else(|| w_id.clone()); // Polling and webhook delivery both exclude deleted roots, so an archived // root (or anything beneath one) can't deploy on push — treat a missing row // as archived too. let root_deleted = sqlx::query_scalar!("SELECT deleted FROM workspace WHERE id = $1", &root_id) .fetch_optional(&db) .await? .unwrap_or(true); // Read on the plain pool: a fork member may not be a member of the root // workspace, and only derived booleans are returned (never the settings). let git_sync = sqlx::query_scalar!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", &root_id ) .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting git_sync settings: {e:#}")))?; let settings = git_sync.flatten().and_then(|v| { serde_json::from_value::(v) .map_err(|e| { tracing::warn!( "git_sync deploy mode: settings deserialize failed for {root_id}: {e}" ) }) .ok() }); // Missing settings row / null git_sync means nothing is configured, not a 404. let Some(settings) = settings else { return Ok(Json(GitSyncDeployMode { configured: false, deploy_on_push: false, })); }; let configured = !settings.repositories.is_empty(); // Auto-pull runs only on Enterprise-licensed instances (see poll_git_auto_pull); // without a caller branch there is nothing to match. Either way deploy_on_push // stays false and the caller falls back (git push via CI, or wmill sync push). let Some(branch) = q.branch.as_deref() else { return Ok(Json(GitSyncDeployMode { configured, deploy_on_push: false, })); }; let licensed = matches!( windmill_common::ee_oss::get_license_plan().await, windmill_common::ee_oss::LicensePlan::Enterprise ); // Count the auto-pull repos that would deploy this branch. We deliberately do // not check the caller's remote URL: with exactly one such repo the local // checkout is unambiguously it, and with several we can't tell which is the // caller's, so we report false and let the CLI ask the user. let mut matches = 0u32; if licensed && !root_deleted { for repo in &settings.repositories { let Some(auto_pull) = repo.auto_pull.as_ref() else { continue; }; if !auto_pull.enabled { continue; } // A fork deploys only through the root's sync_forks repos. if is_fork && !auto_pull.sync_forks { continue; } // Interpolates `$var:`/`$res:` as the auto-pull poller does. // allow_cache=false: an on-demand status must reflect the current // repo config, not a value cached by an earlier poll. let Some(value) = windmill_store::resources::resolve_git_repository_resource( &db, &root_id, &repo.git_repo_resource_path, false, ) .await? else { continue; }; // `enabled` isn't enough: without a runnable delivery path (active // webhook, or pollable non-app HTTPS repo) the push never deploys. if !has_runnable_delivery(auto_pull, &value) { continue; } let tracked_branch = value.get("branch").and_then(|v| v.as_str()).unwrap_or(""); let deploys = if is_fork { // Fork/dev routing (wm-fork/* or an env-label branch) resolved by // the same logic the auto-pull reconciler uses; this repo counts // only if the branch routes to *this* workspace. windmill_common::workspaces::resolve_fork_branch_target( &db, &root_id, &repo.git_repo_resource_path, branch, tracked_branch, ) .await? .is_some_and(|(fork_id, _)| fork_id == w_id) } else { deploys_on_push_branch(branch, tracked_branch) }; if deploys { matches += 1; } } } Ok(Json(GitSyncDeployMode { configured, deploy_on_push: matches == 1, })) } #[cfg(test)] mod git_sync_deploy_mode_tests { use super::{deploys_on_push_branch, has_runnable_delivery}; use serde_json::json; use windmill_common::workspaces::{AutoPullMode, AutoPullSettings}; fn auto_pull(mode: AutoPullMode, webhook_id: Option) -> AutoPullSettings { AutoPullSettings { enabled: true, mode, webhook_id, ..Default::default() } } #[test] fn runnable_delivery_requires_a_firing_path() { let https = json!({ "url": "https://github.com/o/r.git" }); let ssh = json!({ "url": "git@github.com:o/r.git" }); let app = json!({ "url": "https://github.com/o/r.git", "is_github_app": true }); // Polling serves only non-app HTTPS repos (SSH is rejected in background). assert!(has_runnable_delivery( &auto_pull(AutoPullMode::Auto, None), &https )); assert!(has_runnable_delivery( &auto_pull(AutoPullMode::Polling, None), &https )); assert!(!has_runnable_delivery( &auto_pull(AutoPullMode::Polling, None), &ssh )); assert!(!has_runnable_delivery( &auto_pull(AutoPullMode::Auto, None), &ssh )); // Webhook-only mode needs an active hook. assert!(!has_runnable_delivery( &auto_pull(AutoPullMode::Webhook, None), &https )); assert!(has_runnable_delivery( &auto_pull(AutoPullMode::Webhook, Some(1)), &https )); // App repos are gated on an active webhook here (conservative): their // API poll-fallback may still deploy, so this is a safe under-report. assert!(!has_runnable_delivery( &auto_pull(AutoPullMode::Auto, None), &app )); assert!(has_runnable_delivery( &auto_pull(AutoPullMode::Auto, Some(1)), &app )); } #[test] fn non_fork_matches_tracked_branch_only() { assert!(deploys_on_push_branch("main", "main")); assert!(!deploys_on_push_branch("dev", "main")); // An unresolved default (blank) tracked branch never matches. assert!(!deploys_on_push_branch("main", "")); } } async fn get_copilot_settings_state( _authed: ApiAuthed, Path(w_id): Path, Extension(db): Extension, ) -> JsonResult { let workspace_ai_config = sqlx::query_scalar!( "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?; let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?; let instance_ai_config: Option = sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'") .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?; Ok(Json(build_copilot_settings_state( has_ai_providers(workspace_ai_config.as_ref()), instance_ai_config.as_ref(), ))) } pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool { config .and_then(|value| value.get("providers")) .and_then(|providers| providers.as_object()) .map(|providers| !providers.is_empty()) .unwrap_or(false) } pub fn build_copilot_settings_state( has_workspace_ai_config: bool, instance_ai_config: Option<&serde_json::Value>, ) -> CopilotSettingsState { let has_instance_ai_config = has_ai_providers(instance_ai_config); CopilotSettingsState { has_instance_ai_config, uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config, instance_ai_summary: build_instance_ai_summary(instance_ai_config), } } pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option { let config = config?; if !has_ai_providers(Some(config)) { return None; } let providers = config.get("providers")?.as_object()?; let mut provider_summaries = providers .iter() .map(|(provider, provider_config)| InstanceAIProviderSummary { provider: provider.clone(), models: provider_config .get("models") .and_then(|models| models.as_array()) .map(|models| { models .iter() .filter_map(|model| model.as_str().map(ToOwned::to_owned)) .collect::>() }) .unwrap_or_default(), }) .collect::>(); provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider)); Some(InstanceAISummary { providers: provider_summaries, default_model: extract_instance_ai_model_summary(config, "default_model"), metadata_model: extract_instance_ai_model_summary(config, "metadata_model"), code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"), }) } fn extract_instance_ai_model_summary( config: &serde_json::Value, key: &str, ) -> Option { let model_config = config.get(key)?.as_object()?; Some(InstanceAIModelSummary { provider: model_config.get("provider")?.as_str()?.to_owned(), model: model_config.get("model")?.as_str()?.to_owned(), }) } #[derive(Serialize)] struct DeployTo { deploy_to: Option, } /// The workspace this one deploys into: its fork parent, or nothing when it is a root. The response /// field keeps the `deploy_to` name that predates the fork lineage, so existing clients (the deploy /// drawer, the resource/variable editors) read it unchanged. async fn get_deploy_to( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let deploy_to = sqlx::query_scalar!( "SELECT parent_workspace_id FROM workspace WHERE id = $1", &w_id ) .fetch_optional(&mut *tx) .await .map_err(|e| Error::internal_err(format!("getting deploy target: {e:#}")))? .flatten(); tx.commit().await?; Ok(Json(DeployTo { deploy_to })) } async fn edit_slack_command( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(es): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; if es.slack_command_script.is_some() { let exists_slack_command_with_team_id = sqlx::query_scalar!( r#" SELECT EXISTS (SELECT 1 FROM workspace_settings WHERE workspace_id <> $1 AND slack_command_script IS NOT NULL AND slack_team_id IS NOT NULL AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1)) "#, &w_id ) .fetch_one(&mut *tx) .await?.unwrap_or(false); if exists_slack_command_with_team_id { return Err(Error::BadRequest( "A workspace connected to the same slack team already has a command script. Please remove it first." .to_string(), )); } } sqlx::query!( "UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2", es.slack_command_script, &w_id ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.edit_command_script", ActionKind::Update, &w_id, Some(&authed.email), Some( [( "script", es.slack_command_script .unwrap_or("NO_SCRIPT".to_string()) .as_str(), )] .into(), ), ) .await?; tx.commit().await?; Ok(format!("Edit command script {}", &w_id)) } async fn run_slack_message_test_job( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> JsonResult { let mut fake_result = HashMap::new(); fake_result.insert("error".to_string(), to_raw_value(&req.test_msg)); fake_result.insert("success_result".to_string(), to_raw_value(&req.test_msg)); let mut extra_args = HashMap::new(); extra_args.insert("channel".to_string(), to_raw_value(&req.channel)); extra_args.insert( "slack".to_string(), to_raw_value(&format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}")), ); let uuid = windmill_queue::push_error_handler( &db, Uuid::parse_str("00000000-0000-0000-0000-000000000000")?, None, Some("slack_message_test".to_string()), false, w_id.as_str(), &format!("script/{}", req.hub_script_path.as_str()), sqlx::types::Json(&fake_result), None, Some(Utc::now()), Some(sqlx::types::Json(to_raw_value(&extra_args))), authed.email.as_str(), false, false, None, // Note: we could mark it as high priority to return result quickly to the user ) .await?; Ok(Json(RunSlackMessageTestJobResponse { job_uuid: uuid.to_string(), })) } #[derive(Deserialize)] struct SetSlackOAuthConfigRequest { slack_oauth_client_id: String, slack_oauth_client_secret: String, } #[derive(Serialize)] struct GetSlackOAuthConfigResponse { slack_oauth_client_id: Option, slack_oauth_client_secret: Option, } async fn get_slack_oauth_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; let settings = sqlx::query!( "SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&db) .await?; // Mask the secret if it exists let masked_secret = settings .slack_oauth_client_secret .map(|_| "***".to_string()); Ok(Json(GetSlackOAuthConfigResponse { slack_oauth_client_id: settings.slack_oauth_client_id, slack_oauth_client_secret: masked_secret, })) } async fn set_slack_oauth_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; if req.slack_oauth_client_id.is_empty() || req.slack_oauth_client_secret.is_empty() { return Err(Error::BadRequest( "Both client ID and client secret are required".to_string(), )); } let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace_settings SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2 WHERE workspace_id = $3", &req.slack_oauth_client_id, &req.slack_oauth_client_secret, &w_id ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.set_slack_oauth_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("client_id", req.slack_oauth_client_id.as_str())].into()), ) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: "slack_oauth_config".to_string() }, Some("Slack OAuth config set".to_string()), false, None, ) .await?; Ok(format!("Slack OAuth config set for workspace {}", &w_id)) } async fn delete_slack_oauth_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace_settings SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL WHERE workspace_id = $1", &w_id ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.delete_slack_oauth_config", ActionKind::Delete, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: "slack_oauth_config".to_string() }, Some("Slack OAuth config deleted".to_string()), false, None, ) .await?; Ok(format!( "Slack OAuth config deleted for workspace {}", &w_id )) } #[derive(Deserialize)] struct GetSecondaryStorageNamesQuery { #[serde(default)] include_default: bool, } async fn get_secondary_storage_names( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(query): Query, ) -> JsonResult> { let mut result: Vec = sqlx::query_scalar!( "SELECT jsonb_object_keys(large_file_storage->'secondary_storage') AS \"secondary_storage_name!: _\" FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_all(&db) .await?; // If include_default is true, check if primary storage is set and add "_default_" if query.include_default { let has_primary_storage: Option = sqlx::query_scalar!( "SELECT (large_file_storage IS NOT NULL AND large_file_storage != 'null'::jsonb AND jsonb_typeof(large_file_storage) = 'object') AS \"has_primary!\" FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_optional(&db) .await?; if has_primary_storage.unwrap_or(false) { result.insert(0, "_default_".to_string()); } } Ok(Json(result)) } pub const BANNED_DOMAINS: &str = include_str!("../../windmill-api/banned_domains.txt"); pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000; async fn is_allowed_auto_domain(ApiAuthed { email, .. }: ApiAuthed) -> JsonResult { let domain = email.split('@').last().unwrap(); return Ok(Json(!BANNED_DOMAINS.contains(domain))); } async fn edit_auto_invite( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(ea): Json, ) -> Result { crate::workspaces_oss::edit_auto_invite(authed, db, w_id, ea).await } #[cfg(feature = "private")] async fn edit_instance_groups( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(config): Json, ) -> Result { crate::workspaces_ee::edit_instance_groups(authed, db, w_id, config).await } #[cfg(not(feature = "private"))] async fn edit_instance_groups( _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, Json(_config): Json, ) -> Result { Err(Error::BadRequest( "Instance groups are only available on Windmill Enterprise Edition".to_string(), )) } async fn edit_webhook( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ew): Json, ) -> Result { require_admin(is_admin, &username)?; if *CLOUD_HOSTED { return Err(Error::BadRequest( "Workspace webhooks are not available on cloud-hosted instances".to_string(), )); } let mut tx = db.begin().await?; if let Some(webhook) = &ew.webhook { sqlx::query!( "UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2", webhook, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "workspaces.edit_webhook", ActionKind::Update, &w_id, Some(&authed.email), Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()), ) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: "webhook".to_string() }, None, false, None, ) .await?; Ok(format!("Edit webhook for workspace {}", &w_id)) } /// A warehouse's resource, checked at the one place a warehouse is written. /// /// Same grammar as any Windmill resource path, with the `$res:` prefix optional /// because the storage configs are inconsistent about it. Traversal and control /// characters are refused here rather than at the far end: an unusable path /// stored now surfaces as every dbt run in the workspace failing later, with /// nothing pointing back at the settings page that accepted it. fn validate_dbt_resource_path(name: &str, path: &str) -> Result<()> { let bare = path.strip_prefix("$res:").unwrap_or(path); let parts: Vec<&str> = bare.split('/').collect(); let ok = (bare.starts_with("f/") || bare.starts_with("u/")) && parts.len() >= 3 && parts .iter() .all(|p| !p.is_empty() && *p != "." && *p != "..") // `resource.path` is VARCHAR(255), so a longer path names a resource that // cannot exist. && bare.chars().count() <= 255 && !bare.chars().any(|c| c.is_control() || "?#%\\ ".contains(c)); if !ok { return Err(Error::BadRequest(format!( "the dbt warehouse `{name}` names `{path}`, which is not a resource path \ (`f//` or `u//`, optionally prefixed with `$res:`)" ))); } Ok(()) } #[derive(serde::Deserialize, serde::Serialize)] struct DbtWarehouseConfig { resource_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] target: Option, } #[derive(serde::Deserialize)] struct EditDbtWarehouses { /// Typed rather than free JSON: a shape the resolver cannot read (a list, a /// number where a config belongs) would be accepted here and then fail every /// dbt run in the workspace, far from the request that caused it. dbt_warehouses: Option>, } /// The workspace's dbt warehouses, by name, each a POINTER to a resource rather /// than credentials — like `large_file_storage`. A dbt project can then carry no /// connection at all, and the resource keeps its own ACL. Admin-only, because it /// decides what every dbt script in the workspace reaches. async fn edit_dbt_warehouses( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; let new_config = new_config.dbt_warehouses; if let Some(map) = new_config.as_ref() { for (name, cfg) in map.iter() { windmill_common::workspaces::validate_dbt_warehouse_name(name)?; validate_dbt_resource_path(name, &cfg.resource_path)?; } } let new_config = new_config .map(|m| serde_json::to_value(m)) .transpose() .map_err(|e| Error::internal_err(format!("serializing the dbt warehouses: {e}")))?; let mut tx = db.begin().await?; audit_log( &mut *tx, &authed, "workspaces.edit_dbt_warehouses", ActionKind::Update, &w_id, Some(&authed.email), Some([("dbt_warehouses", format!("{new_config:?}").as_str())].into()), ) .await?; let value = new_config.filter(|v| !v.is_null()); sqlx::query!( "UPDATE workspace_settings SET dbt_warehouses = $1 WHERE workspace_id = $2", value, &w_id ) .execute(&mut *tx) .await?; tx.commit().await?; Ok("Updated the workspace's dbt warehouses".to_string()) } async fn edit_large_file_storage_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.large_file_storage); audit_log( &mut *tx, &authed, "workspaces.edit_large_file_storage_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("large_file_storage", args_for_audit.as_str())].into()), ) .await?; if let Some(lfs_config) = new_config.large_file_storage { // `_default_` names the primary storage everywhere else — `get_secondary_storage_names` // hands it out, the s3-proxy URL carries it, the clients fall back to it — so a secondary // storage of that name is unreachable by design and would shadow the primary for anything // that resolves the name. Reject it at the only route that creates one. if lfs_config .secondary_storage .contains_key(windmill_types::s3::DEFAULT_STORAGE) { return Err(Error::BadRequest(format!( "`{}` is reserved for the primary storage and cannot name a secondary one", windmill_types::s3::DEFAULT_STORAGE ))); } if !windmill_common::workspaces::filesystem_storage_allowed() { let named = std::iter::once(("primary storage", &lfs_config.large_file_storage)).chain( lfs_config .secondary_storage .iter() .map(|(name, storage)| (name.as_str(), storage)), ); for (name, storage) in named { if matches!(storage, LargeFileStorage::FilesystemStorage(_)) { return Err(Error::BadRequest(format!( "{name}: {}", windmill_common::workspaces::FILESYSTEM_STORAGE_DEV_ONLY_MSG ))); } } } let serialized_lfs_config = serde_json::to_value::(lfs_config) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", serialized_lfs_config, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET large_file_storage = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } tx.commit().await?; // Trigger git sync for large file storage changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "large_file_storage".to_string(), }, Some("Large file storage configuration updated".to_string()), false, None, ) .await?; Ok(format!( "Edit large file storage config for workspace {}", &w_id )) } async fn list_ducklakes( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let ducklakes = sqlx::query_scalar!( r#" SELECT jsonb_object_keys(ws.ducklake->'ducklakes') AS ducklake_name FROM workspace_settings ws WHERE ws.workspace_id = $1 "#, &w_id ) .fetch_all(&db) .await? .into_iter() .filter_map(|s| s) .collect(); Ok(Json(ducklakes)) } #[derive(Serialize)] struct DataTableListItem { name: String, resource_type: String, resource_path: String, /// The workspace this data table belongs to, when it is not this one. Its /// database is not this workspace's to name, so the two fields above are /// blank for it. #[serde(skip_serializing_if = "Option::is_none")] shared_from: Option, } async fn list_datatables( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let authed_ref = authed.to_authed_ref(); let items = windmill_common::workspaces::usable_datatables(&db, &authed_ref, &w_id) .await? .into_iter() .map(|(name, usable)| { if usable.owner_w_id == w_id { DataTableListItem { name, resource_type: usable.datatable.database.resource_type.as_ref().to_string(), resource_path: usable.datatable.database.resource_path, shared_from: None, } } else { DataTableListItem { name, resource_type: String::new(), resource_path: String::new(), shared_from: Some(usable.owner_w_id), } } }) .collect(); Ok(Json(items)) } /// Compact column representation: "type" or "type?" for nullable, with "=default" suffix if has default type CompactColumn = String; /// Columns mapped by name to their compact type type ColumnMap = HashMap; /// Tables mapped by name to their columns type TableMap = HashMap; /// Schemas mapped by name to their tables type SchemaMap = HashMap; /// Schemas mapped by name to their table names type TableListMap = HashMap>; #[derive(Serialize, Debug)] struct DataTableSchema { datatable_name: String, /// Hierarchical schema: schema_name -> table_name -> column_name -> "type[?][=default]" schemas: SchemaMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize, Debug)] struct DataTableTables { datatable_name: String, /// Hierarchical metadata: schema_name -> table_names schemas: TableListMap, /// The schemas the connection's role may create tables in — a subset of /// `schemas`, since reaching one says nothing about writing to it. #[serde(default)] creatable_schemas: Vec, /// Whether that role may create schemas in the database at all. #[serde(default)] can_create_schema: bool, /// The schemas whose owner this role is a member of, and the tables, as /// `schema.table`. Only those can have their access changed, so the rest do /// not offer it. #[serde(default)] manageable_schemas: Vec, #[serde(default)] manageable_tables: Vec, #[serde(skip_serializing_if = "Option::is_none")] error: Option, /// The roles the caller may run this data table as. Empty when it has no /// permissions, which is also what tells the UI it has no role to show. #[serde(default)] usable_roles: Vec, /// The role the caller gets without naming one. default_role: String, } #[derive(Deserialize)] struct GetDataTableSchemaQuery { datatable_name: String, schema_name: String, table_name: String, } #[derive(Serialize, Debug)] struct DataTableTableSchema { datatable_name: String, schema_name: String, table_name: String, columns: ColumnMap, } #[derive(Serialize, Debug)] struct DataTableConnectionCheck { /// The role the data table actually connects as, and the schema its /// unqualified statements resolve to. Both are read from the server rather /// than the resource, which need not spell either of them out. user: String, schema: Option, /// Whether that role can create tables in `schema` / schemas in the database. can_create_table: bool, can_create_schema: bool, /// Whether the migration bookkeeping table is already present. Informative /// only: it explains why migration *tracking* can work without CREATE, and /// grants nothing beyond that. migrations_table_exists: bool, /// Statements to run for the privileges that are missing, empty when there /// are none. Windmill connects as the role that lacks them, so it can only /// name them for a schema owner to run. suggested_grants: Vec, /// Statement that gives the session a schema to work in, when `search_path` /// resolves to none. Rendered here rather than by the caller so identifier /// quoting stays in one place. #[serde(skip_serializing_if = "Option::is_none")] suggested_search_path: Option, } /// Report what the data table's own database lets its role do. Surfacing this /// from the settings page is the difference between finding out here and finding /// out on a first schema change, when the failure reads as a Postgres refusal /// deep inside a migration. async fn test_datatable_connection( authed: ApiAuthed, Extension(db): Extension, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; // The data table's own connection, not whichever role the caller resolves to: // this reports what the data table itself can do, and the page is admin-only. let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(&db)).await?; let join_handle = tokio::spawn(async move { connection.await }); // One round trip, no side effects: `has_*_privilege` answers for the // connected role without attempting the operation. let rows = client .simple_query( "SELECT current_user AS usr, \ current_schema() AS sch, \ current_database() AS db, \ has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, \ has_database_privilege(current_database(), 'CREATE') AS can_create_schema, \ to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table", ) .await .map_err(|e| { Error::internal_err(format!( "Failed to inspect data table privileges: {}", pg_error_message(&e) )) }); drop(client); let _ = windmill_common::shutdown_pg_connection(join_handle).await; let row = rows? .into_iter() .find_map(|msg| match msg { tokio_postgres::SimpleQueryMessage::Row(row) => Some(row), _ => None, }) .ok_or_else(|| Error::internal_err("Privilege query returned no row".to_string()))?; let user = row.get("usr").unwrap_or_default().to_string(); let schema = row.get("sch").map(str::to_string); let can_create_table = row.get("can_create_table") == Some("t"); let can_create_schema = row.get("can_create_schema") == Some("t"); let migrations_table_exists = row.get("has_migrations_table") == Some("t"); let quoted_user = render_db_quoted_identifier(&user, DbType::Postgresql); let mut suggested_grants = Vec::new(); // Suggest on the capability alone: an existing `_wm_migrations` spares only // that one table, and says nothing about the tables a migration will create. // A NULL `current_schema()` means search_path resolves to nothing, and no // grant fixes that — an unqualified CREATE fails with `no schema has been // selected to create in` whoever holds the privilege — so suggest nothing // and let `schema: null` carry the diagnosis. if let (false, Some(target)) = (can_create_table, schema.as_deref()) { suggested_grants.push(format!( "GRANT CREATE ON SCHEMA {} TO {}", render_db_quoted_identifier(target, DbType::Postgresql), quoted_user )); } if !can_create_schema { // Named from the server like every other identifier here: behind a // pooler the resource's dbname can be an alias for another database. let dbname = row.get("db").unwrap_or(pg_db.dbname.as_str()); suggested_grants.push(format!( "GRANT CREATE ON DATABASE {} TO {}", render_db_quoted_identifier(dbname, DbType::Postgresql), quoted_user )); } // An empty search_path is not a privilege problem, so it gets a statement of // its own rather than a grant. let suggested_search_path = schema .is_none() .then(|| format!("ALTER ROLE {quoted_user} SET search_path = public")); Ok(Json(DataTableConnectionCheck { user, schema, can_create_table, can_create_schema, migrations_table_exists, suggested_grants, suggested_search_path, })) } async fn list_datatable_schemas( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let datatable_names = list_datatable_names(&db, &authed, &w_id).await?; let mut results = Vec::new(); for datatable_name in datatable_names { let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await { Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None }, Err(e) => DataTableSchema { datatable_name, schemas: HashMap::new(), error: Some(e.to_string()), }, }; results.push(schema); } Ok(Json(results)) } /// The role the caller is on, and the data table it belongs to. The tree covers /// every data table, and a role name means nothing outside the one it was /// picked on, so it is named rather than applied to all of them. #[derive(Deserialize)] struct ListDataTableTablesQuery { role_for: Option, role: Option, } async fn list_datatable_tables( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(query): Query, ) -> JsonResult> { let mut roles = list_datatable_roles(&db, &authed, &w_id).await?; let datatable_names: Vec = roles.keys().cloned().collect(); let mut results = Vec::new(); for datatable_name in datatable_names { let (usable_roles, default_role) = roles .remove(&datatable_name) .unwrap_or_else(|| (vec![], ADMIN_DATATABLE_ROLE.to_string())); // Privileges are the connected role's, so the answer for the data table // the caller is on has to be read as the role they picked, not as the // one it defaults to. let role = query .role .as_deref() .filter(|_| query.role_for.as_deref() == Some(datatable_name.as_str())); let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name, role).await { Ok(contents) => DataTableTables { datatable_name, schemas: contents.tables, creatable_schemas: contents.creatable_schemas, can_create_schema: contents.can_create_schema, manageable_schemas: contents.manageable_schemas, manageable_tables: contents.manageable_tables, error: None, usable_roles, default_role, }, Err(e) => DataTableTables { datatable_name, schemas: HashMap::new(), creatable_schemas: vec![], can_create_schema: false, manageable_schemas: vec![], manageable_tables: vec![], error: Some(e.to_string()), usable_roles, default_role, }, }; results.push(tables); } Ok(Json(results)) } /// Which roles the caller may use on each data table of the workspace, and the /// one they get by default. Read in one go: the tree lists every data table, and /// this is config only, so it costs a single query rather than one per table. async fn list_datatable_roles( db: &DB, authed: &ApiAuthed, w_id: &str, ) -> Result, String)>> { let authed_ref = authed.to_authed_ref(); let usable = windmill_common::workspaces::usable_datatables(db, &authed_ref, w_id).await?; Ok(usable .into_iter() .map(|(name, usable)| { // A shared data table's tenancy is its owner's question, so it is // asked of who the caller is there. let who = usable.who(&authed_ref); let permissions = usable.datatable.permissions.as_ref().filter(|p| p.enabled); let info = match permissions { Some(p) => ( p.roles .iter() .filter(|(_, role)| can_use_datatable_role(role, &who)) .map(|(role_name, _)| role_name.clone()) .collect(), p.default_role().to_string(), ), None => (vec![], ADMIN_DATATABLE_ROLE.to_string()), }; (name, info) }) .collect()) } async fn get_datatable_table_schema( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(query): Query, ) -> JsonResult { let columns = get_datatable_table_columns( &db, &authed, &w_id, &query.datatable_name, &query.schema_name, &query.table_name, ) .await?; Ok(Json(DataTableTableSchema { datatable_name: query.datatable_name, schema_name: query.schema_name, table_name: query.table_name, columns, })) } /// Resolve a data table for an API caller as the role it defaults to — schema /// browsing, the database manager and copy-between-databases name none — so a /// permissioned data table is reachable from the UI only by a tenant of that /// role (and by workspace admins, who may use any of them). async fn get_datatable_resource_as_default_role( db: &DB, authed: &ApiAuthed, w_id: &str, datatable_name: &str, ) -> Result { get_datatable_resource_from_db( db, w_id, datatable_name, None, DatatableAccess::Authed(authed.to_authed_ref()), ) .await } /// The data tables the caller can reach here, shared ones included. async fn list_datatable_names(db: &DB, authed: &ApiAuthed, w_id: &str) -> Result> { Ok( windmill_common::workspaces::usable_datatables(db, &authed.to_authed_ref(), w_id) .await? .into_keys() .collect(), ) } async fn get_datatable_schema( db: &DB, authed: &ApiAuthed, w_id: &str, datatable_name: &str, ) -> Result { // Get the datatable resource (connection credentials) let db_resource = get_datatable_resource_as_default_role(db, authed, w_id, datatable_name).await?; // Parse the resource as PgDatabase let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; // Connect to the datatable database let (client, connection) = pg_db.connect(Some(db)).await?; // Spawn the connection handler tokio::spawn(async move { if let Err(e) = connection.await { tracing::error!("Datatable connection error: {}", e); } }); // First, get all non-system schemas (including empty ones) let schema_rows = client .query( r#" SELECT nspname::text AS schema_name FROM pg_namespace WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') AND nspname NOT LIKE 'pg_%' -- Only what this connection's role can reach: a schema it cannot -- enter would list no tables and read as an empty one. AND has_schema_privilege(oid, 'USAGE, CREATE') ORDER BY nspname "#, &[], ) .await .map_err(|e| { Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e))) })?; // Build hierarchical structure: schema -> table -> column -> compact_type let mut schema_map: SchemaMap = HashMap::new(); // Collect schema names and initialize map let schema_names: Vec = schema_rows .iter() .map(|row| { let name: String = row.get(0); schema_map.entry(name.clone()).or_default(); name }) .collect(); // Query column information only for the schemas we found let rows = client .query( r#" SELECT table_schema::text, table_name::text, column_name::text, udt_name::text, is_nullable::text, column_default::text FROM information_schema.columns WHERE table_schema = ANY($1) AND table_name IS NOT NULL ORDER BY table_schema, table_name, ordinal_position "#, &[&schema_names], ) .await .map_err(|e| { Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e))) })?; for row in rows { let table_schema: String = row.get(0); let table_name: String = row.get(1); let column_name: String = row.get(2); let udt_name: String = row.get(3); let is_nullable: String = row.get(4); let column_default: Option = row.get(5); schema_map .entry(table_schema) .or_default() .entry(table_name) .or_default() .insert( column_name, compact_column_type(udt_name, is_nullable, column_default), ); } Ok(schema_map) } /// What exists in a data table, and what its role may add to it. struct DataTableContents { tables: TableListMap, creatable_schemas: Vec, can_create_schema: bool, manageable_schemas: Vec, manageable_tables: Vec, } async fn get_datatable_tables( db: &DB, authed: &ApiAuthed, w_id: &str, datatable_name: &str, role: Option<&str>, ) -> Result { let db_resource = get_datatable_resource_from_db( db, w_id, datatable_name, role, DatatableAccess::Authed(authed.to_authed_ref()), ) .await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::error!("Datatable connection error: {}", e); } }); let schema_rows = client .query( r#" SELECT nspname::text AS schema_name, has_schema_privilege(oid, 'CREATE') AS can_create, pg_has_role(nspowner, 'USAGE') AS can_manage FROM pg_namespace WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') AND nspname NOT LIKE 'pg_%' -- Only what this connection's role can reach: a schema it cannot -- enter would list no tables and read as an empty one. AND has_schema_privilege(oid, 'USAGE, CREATE') ORDER BY nspname "#, &[], ) .await .map_err(|e| { Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e))) })?; let mut table_map: TableListMap = HashMap::new(); let mut creatable_schemas: Vec = Vec::new(); let mut manageable_schemas: Vec = Vec::new(); let schema_names: Vec = schema_rows .iter() .map(|row| { let name: String = row.get(0); table_map.entry(name.clone()).or_default(); if row.get::<_, bool>(1) { creatable_schemas.push(name.clone()); } if row.get::<_, bool>(2) { manageable_schemas.push(name.clone()); } name }) .collect(); let can_create_schema: bool = client .query_one( "SELECT has_database_privilege(current_database(), 'CREATE')", &[], ) .await .map_err(|e| { Error::internal_err(format!( "Failed to read the database privileges: {}", pg_error_message(&e) )) })? .get(0); let rows = client .query( r#" SELECT DISTINCT table_schema::text, table_name::text FROM information_schema.columns WHERE table_schema = ANY($1) AND table_name IS NOT NULL ORDER BY table_schema, table_name "#, &[&schema_names], ) .await .map_err(|e| { Error::internal_err(format!("Failed to query tables: {}", pg_error_message(&e))) })?; for row in rows { let table_schema: String = row.get(0); let table_name: String = row.get(1); table_map.entry(table_schema).or_default().push(table_name); } // Ownership of a table is its own: a schema you own can hold one you do not. let manageable_tables = client .query( "SELECT n.nspname || '.' || c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = ANY($1) AND c.relkind = ANY(ARRAY['r','p','v','m','f']::\"char\"[]) AND pg_has_role(c.relowner, 'USAGE')", &[&schema_names], ) .await .map_err(|e| { Error::internal_err(format!( "Failed to read table ownership: {}", pg_error_message(&e) )) })? .into_iter() .map(|row| row.get::<_, String>(0)) .collect(); Ok(DataTableContents { tables: table_map, creatable_schemas, can_create_schema, manageable_schemas, manageable_tables, }) } async fn get_datatable_table_columns( db: &DB, authed: &ApiAuthed, w_id: &str, datatable_name: &str, schema_name: &str, table_name: &str, ) -> Result { if is_system_pg_schema(schema_name) { return Err(Error::BadRequest(format!( "Schema '{}' is not available for datatable schema lookup", schema_name ))); } let db_resource = get_datatable_resource_as_default_role(db, authed, w_id, datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::error!("Datatable connection error: {}", e); } }); let rows = client .query( r#" SELECT column_name::text, udt_name::text, is_nullable::text, column_default::text FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position "#, &[&schema_name, &table_name], ) .await .map_err(|e| { Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e))) })?; if rows.is_empty() { return Err(Error::NotFound(format!( "Table '{}.{}' not found in datatable '{}'", schema_name, table_name, datatable_name ))); } let mut columns: ColumnMap = HashMap::new(); for row in rows { let column_name: String = row.get(0); let udt_name: String = row.get(1); let is_nullable: String = row.get(2); let column_default: Option = row.get(3); columns.insert( column_name, compact_column_type(udt_name, is_nullable, column_default), ); } Ok(columns) } fn is_system_pg_schema(schema_name: &str) -> bool { // Match the datatable listing filter: PostgreSQL reserves pg_* schemas for system use. matches!( schema_name, "information_schema" | "pg_toast" | "pg_catalog" ) || schema_name.starts_with("pg_") } fn compact_column_type( udt_name: String, is_nullable: String, column_default: Option, ) -> String { let mut compact = udt_name; if is_nullable == "YES" { compact.push('?'); } if let Some(default) = column_default { compact.push('='); compact.push_str(&truncate_column_default(default)); } compact } fn truncate_column_default(default: String) -> String { const MAX_DEFAULT_CHARS: usize = 30; const TRUNCATED_DEFAULT_CHARS: usize = 27; if default.chars().count() > MAX_DEFAULT_CHARS { format!( "{}...", default .chars() .take(TRUNCATED_DEFAULT_CHARS) .collect::() ) } else { default } } #[cfg(test)] mod tests { use super::*; /// The header of a pg_dump, followed by an object whose body also holds a `SET`. const DUMP: &str = "--\n\ -- PostgreSQL database dump\n\ --\n\ \n\ \\restrict aBcD\n\ \n\ SET statement_timeout = 0;\n\ SET transaction_timeout = 0;\n\ SET client_encoding = 'UTF8';\n\ SELECT pg_catalog.set_config('search_path', '', false);\n\ \n\ SET default_table_access_method = heap;\n\ \n\ CREATE FUNCTION public.f() RETURNS void LANGUAGE plpgsql AS $$\n\ BEGIN\n\ SET transaction_timeout = 0;\n\ END;\n\ $$;\n"; #[test] fn replayable_dump_keeps_everything_but_meta_commands_and_session_timeouts() { let replayable = strip_unreplayable_dump_lines(DUMP); assert!(!replayable.contains("\\restrict")); assert!(!replayable.contains("SET statement_timeout")); assert!(!replayable.contains("SET transaction_timeout = 0;\nSET client_encoding")); assert!(replayable.contains("SET client_encoding = 'UTF8';")); assert!(replayable.contains("SET default_table_access_method = heap;")); // Past the preamble the dump is an object's own text: left exactly as it is. assert!(replayable.contains("BEGIN\nSET transaction_timeout = 0;\nEND;")); } #[tokio::test] async fn dump_preamble_only_drops_settings_the_server_lacks() { let dump_file = DumpFile::new().unwrap(); tokio::fs::write(&dump_file.path, DUMP).await.unwrap(); let supported = [ "statement_timeout", "client_encoding", "default_table_access_method", ] .map(String::from) .into_iter() .collect(); comment_out_unsupported_settings(&dump_file, &supported) .await .unwrap(); let patched = tokio::fs::read_to_string(&dump_file.path).await.unwrap(); // Rewriting the header must not shift the rest of the dump. assert_eq!(patched.len(), DUMP.len()); assert!(patched.contains("-- transaction_timeout = 0;")); assert!(patched.contains("SET statement_timeout = 0;")); assert!(patched.contains("SET default_table_access_method = heap;")); // The `SET` inside the function body is past the preamble: never touched. assert!(patched.contains("BEGIN\nSET transaction_timeout = 0;\nEND;")); } #[test] fn compact_column_type_truncates_multibyte_defaults_safely() { let default = "é".repeat(31); assert_eq!( compact_column_type("text".to_string(), "NO".to_string(), Some(default)), format!("text={}...", "é".repeat(27)) ); } // A real NUL can't live in Rust source, so build `{"k":"u0000"}` // by repeating backslashes: an ODD run before `u0000` is a genuine NUL escape, // an EVEN run is an escaped backslash then the literal text "u0000". fn nul_json(backslashes: usize) -> String { format!(r#"{{"k":"{}u0000"}}"#, "\\".repeat(backslashes)) } #[test] fn nul_escape_detected_only_for_odd_backslash_runs() { // 1 backslash: `\u0000` — a genuine NUL escape. assert!(json_text_has_nul_escape(&nul_json(1))); // 3 backslashes: escaped backslash + genuine NUL escape. assert!(json_text_has_nul_escape(&nul_json(3))); // 2 backslashes: escaped backslash then literal "u0000" (e.g. minified JS) — safe. assert!(!json_text_has_nul_escape(&nul_json(2))); // 0 backslashes: the bare token "u0000" — safe. assert!(!json_text_has_nul_escape(&nul_json(0))); } #[test] fn dbt_resource_path_length_bound() { let at_limit = format!("f/dbt/{}", "a".repeat(255 - "f/dbt/".len())); assert_eq!(at_limit.chars().count(), 255); assert!(validate_dbt_resource_path("main", &at_limit).is_ok()); // The `$res:` prefix is not stored, so it does not count against the column. assert!(validate_dbt_resource_path("main", &format!("$res:{at_limit}")).is_ok()); assert!(validate_dbt_resource_path("main", &format!("{at_limit}a")).is_err()); } #[test] fn nul_escape_ignores_clean_values() { assert!(!json_text_has_nul_escape( r#"{"files":{"/index.tsx":"hello"}}"# )); assert!(!json_text_has_nul_escape("")); // A later genuine NUL is still caught even after an earlier even (safe) run. assert!(json_text_has_nul_escape(&format!( r#"{{"a":"x{b}{b}u0000y","b":"z{b}u0000"}}"#, b = "\\" ))); } } /// Resolve a source string to PgDatabase credentials with user-scoped permission checks. /// For `datatable://name`: accessible to everyone (variables are resolved internally). /// For `$res:path`: uses UserDB (row-level security) to verify the user can see the resource, /// then interpolates `$var:` references in the resource value. pub(crate) async fn resolve_pg_source_checked( db: &DB, user_db: &UserDB, authed: &ApiAuthed, w_id: &str, source: &str, ) -> Result { resolve_pg_source_as(db, user_db, authed, w_id, source, false).await } /// As [`resolve_pg_source_checked`], but a workspace admin reaches a data table /// as `admin` rather than as the role it defaults to. /// /// For the paths that copy a whole database: dumping as a restricted default /// role silently leaves out every table that role cannot read, which is a /// truncated copy rather than an error. A non-admin still resolves as their own /// role — `admin` is not theirs to ask for — so this hands out nothing. pub(crate) async fn resolve_pg_source_for_copy( db: &DB, user_db: &UserDB, authed: &ApiAuthed, w_id: &str, source: &str, ) -> Result { resolve_pg_source_as(db, user_db, authed, w_id, source, authed.is_admin).await } async fn resolve_pg_source_as( db: &DB, user_db: &UserDB, authed: &ApiAuthed, w_id: &str, source: &str, as_admin: bool, ) -> Result { let db_resource = if let Some(name) = source.strip_prefix("datatable://") { if as_admin { get_datatable_resource_from_db( db, w_id, name, Some(ADMIN_DATATABLE_ROLE), DatatableAccess::Authed(authed.to_authed_ref()), ) .await? } else { get_datatable_resource_as_default_role(db, authed, w_id, name).await? } } else if let Some(path) = source.strip_prefix("$res:") { let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed( authed, db.clone(), Some(user_db.clone()), ); let value = windmill_store::resources::get_resource_value_interpolated_internal( &db_with_authed, w_id, path, None, None, false, ) .await?; match value { Some(v) => v, None => { return Err(Error::NotAuthorized(format!( "Resource '{}' not found or you do not have access to it", path ))); } } } else { return Err(Error::BadRequest(format!( "Invalid source format: '{}'. Expected 'datatable://name' or '$res:path'", source ))); }; serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) } /// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL /// rather than a user resource. pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result { let config = sqlx::query_scalar!( "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1", w_id, name ) .fetch_optional(db) .await? .flatten(); Ok(config .and_then(|v| { v.get("database") .and_then(|d| d.get("resource_type")) .and_then(|r| r.as_str()) .map(|s| s == "instance") }) .unwrap_or(false)) } /// Same, for the `datatable://` / `$res:` form the import endpoints take. /// Refuse to clone a data table whose role permissions are enabled. /// /// A clone lands in a brand-new database where none of the roles exist, and the /// fork's copy of the config is stripped of its permissions — so every member of /// the fork resolves to the copy's own owner connection and reads, in full, the /// data the roles existed to divide. Reproducing the roles in the copy is a /// separate piece of work; until it exists, a fork shares the original, which /// keeps the parent's restrictions, or goes without. pub(crate) async fn refuse_clone_of_permissioned_datatable( db: &DB, w_id: &str, source: &str, ) -> Result<()> { let Some(name) = source.strip_prefix("datatable://") else { return Ok(()); }; let enabled = sqlx::query_scalar!( "SELECT COALESCE((datatable->'datatables'->$2->'permissions'->>'enabled')::boolean, false) FROM workspace_settings WHERE workspace_id = $1", w_id, name, ) .fetch_optional(db) .await? .flatten() .unwrap_or(false); if enabled { return Err(Error::BadRequest(format!( "Data table '{name}' has role permissions enabled and cannot be cloned into a fork: \ the copy cannot carry its roles, so it would be readable in full by every member of \ the fork. Keep the original instead — the fork shares it with the same restrictions." ))); } Ok(()) } async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result { match source.strip_prefix("datatable://") { Some(name) => is_instance_datatable(db, w_id, name).await, None => Ok(false), } } /// A temporary file for pg_dump output that is automatically deleted when dropped. pub(crate) struct DumpFile { pub(crate) path: std::path::PathBuf, } impl DumpFile { fn new() -> Result { let dir = std::path::Path::new("/tmp/windmill"); std::fs::create_dir_all(dir) .map_err(|e| Error::internal_err(format!("Failed to create /tmp/windmill: {}", e)))?; // Set directory permissions to owner-only #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); } let path = dir.join(format!("datatable_dump_{}", uuid::Uuid::new_v4())); // Create the file with restrictive permissions before pg_dump writes to it #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .write(true) .create(true) .mode(0o600) .open(&path) .map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?; } #[cfg(not(unix))] { std::fs::File::create(&path) .map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?; } Ok(Self { path }) } } impl Drop for DumpFile { fn drop(&mut self) { if self.path.exists() { if let Err(e) = std::fs::remove_file(&self.path) { tracing::warn!("Failed to remove dump file {:?}: {}", self.path, e); } } } } #[derive(Default)] pub(crate) struct PgDumpOptions<'a> { pub(crate) schema_only: bool, pub(crate) exclude_tables: &'a [&'a str], /// Leave out `ALTER ... OWNER TO`. pub(crate) no_owner: bool, /// Leave out `GRANT`, `REVOKE` and `ALTER DEFAULT PRIVILEGES`. pub(crate) no_acl: bool, } /// Run pg_dump against a PgDatabase, writing output to a temp file on disk. /// Returns a DumpFile handle; the file is deleted when the handle is dropped. pub(crate) async fn pg_dump_database( pg_db: &PgDatabase, opts: PgDumpOptions<'_>, ) -> Result { let dump_file = DumpFile::new()?; let host = &pg_db.host; let port = pg_db.port.unwrap_or(5432).to_string(); let user = pg_db.login_name(); let dbname = &pg_db.dbname; let mut cmd = tokio::process::Command::new("pg_dump"); cmd.arg("--format=plain").arg("--file").arg(&dump_file.path); if opts.schema_only { cmd.arg("--schema-only"); } if opts.no_owner { cmd.arg("--no-owner"); } if opts.no_acl { cmd.arg("--no-privileges"); } for table in opts.exclude_tables { cmd.arg(format!("--exclude-table={table}")); } cmd.arg("--host") .arg(host) .arg("--port") .arg(&port) .arg("--username") .arg(user) .arg(dbname); if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } let output = cmd .output() .await .map_err(|e| Error::internal_err(format!("Failed to execute pg_dump: {}", e)))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(Error::internal_err(format!("pg_dump failed: {}", stderr))); } Ok(dump_file) } /// Whether `line` still belongs to the preamble pg_dump emits before the first /// dumped object: comments, blank lines, psql meta-commands and the session `SET`s. fn is_dump_preamble_line(line: &[u8]) -> bool { let line = line.trim_ascii_start(); line.is_empty() || line.starts_with(b"--") || line.starts_with(b"\\") || line.starts_with(b"SET ") || line.starts_with(b"SELECT pg_catalog.set_config(") } /// The GUCs pg_dump's preamble sets only to keep the dumping session out of the way. /// They are also the ones that come and go across versions (`transaction_timeout` is /// PG 17+), so they are what a dump replayed on an older server trips over first. const DUMP_SESSION_TIMEOUTS: [&str; 4] = [ "statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "transaction_timeout", ]; /// Turn a dump into SQL that can be replayed on another database: drop pg_dump's psql /// meta-commands (`\restrict` / `\unrestrict`, not valid SQL) and the session timeouts /// its preamble sets, which the replaying server may not have as GUCs at all. Only the /// preamble is filtered, so an object's body keeps whatever it holds. pub(crate) fn strip_unreplayable_dump_lines(dump: &str) -> String { let mut in_preamble = true; dump.lines() .filter(|line| { in_preamble = in_preamble && is_dump_preamble_line(line.as_bytes()); if line.trim_start().starts_with('\\') { return false; } !(in_preamble && preamble_setting_name(line.as_bytes()) .is_some_and(|name| DUMP_SESSION_TIMEOUTS.contains(&name))) }) .collect::>() .join("\n") } /// The GUC a preamble `SET = ...;` line assigns, if the line is one. fn preamble_setting_name(line: &[u8]) -> Option<&str> { let name = line.strip_prefix(b"SET ")?.split(|c| *c == b' ').next()?; std::str::from_utf8(name).ok() } /// The preamble Windmill's postgres client writes can set GUCs an older server does not /// have — harmless session tuning, but one failing statement aborts a restore that stops /// on the first error. Comment those out in place, three bytes each, so the data /// section's offsets stay put. async fn comment_out_unsupported_settings( dump_file: &DumpFile, supported_settings: &HashSet, ) -> Result<()> { use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; let file = tokio::fs::File::open(&dump_file.path) .await .map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?; let mut reader = tokio::io::BufReader::new(file); let mut preamble: Vec = Vec::new(); let mut patched = false; loop { let start = preamble.len(); let read = reader .read_until(b'\n', &mut preamble) .await .map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))?; if read == 0 { break; } let line = &preamble[start..]; if !is_dump_preamble_line(line) { preamble.truncate(start); break; } if preamble_setting_name(line).is_some_and(|name| !supported_settings.contains(name)) { preamble[start..start + 3].copy_from_slice(b"-- "); patched = true; } } if !patched { return Ok(()); } let mut file = tokio::fs::OpenOptions::new() .write(true) .open(&dump_file.path) .await .map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?; file.write_all(&preamble) .await .map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?; file.flush() .await .map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?; Ok(()) } /// A psql invocation against `pg_db`, carrying the connection settings the CLI reads /// from the environment. fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { let mut cmd = tokio::process::Command::new("psql"); cmd.arg("--host") .arg(&pg_db.host) .arg("--port") .arg(pg_db.port.unwrap_or(5432).to_string()) .arg("--username") .arg(pg_db.login_name()) .arg("--dbname") .arg(&pg_db.dbname) .arg("--no-psqlrc") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } cmd } /// GUC names the server backing `pg_db` knows about. /// /// Asked through psql rather than a tokio-postgres connection so the lookup reaches /// exactly the servers the restore itself can: libpq negotiates TLS for `sslmode=prefer` /// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a /// plaintext socket and fail before the import ever starts. async fn server_setting_names(pg_db: &PgDatabase) -> Result> { let output = psql_command(pg_db) .arg("--tuples-only") .arg("--no-align") .arg("--command") .arg("SELECT name FROM pg_settings") .output() .await .map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(Error::internal_err(format!( "Failed to list the settings of the target server: {}", stderr ))); } Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(|name| name.trim().to_string()) .filter(|name| !name.is_empty()) .collect()) } /// Import a pg_dump file into a target database using psql. /// /// Left to its defaults psql reports a failed statement, carries on and still exits 0, /// so a dump that breaks partway through imports partially and reads as a success. /// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore /// all-or-nothing, leaving the target as it was and the import retryable. async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> { let supported_settings = server_setting_names(target_db).await?; comment_out_unsupported_settings(dump_file, &supported_settings).await?; let output = psql_command(target_db) .arg("--set") .arg("ON_ERROR_STOP=1") .arg("--single-transaction") .arg("--file") .arg(&dump_file.path) .output() .await .map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(Error::internal_err(format!( "psql import failed: {}", stderr ))); } Ok(()) } #[derive(Deserialize)] struct CreatePgDatabaseRequest { /// The datatable source to determine connection info: 'datatable://name' or '$res:path' source: String, /// Name for the new database target_dbname: String, } /// Create a new PostgreSQL database. For instance datatables, creates on the Windmill PG instance. /// For resource datatables, creates on the same server as the source. async fn create_pg_database( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> Result { windmill_common::validate_dbname(&req.target_dbname)?; refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?; // Non-superadmin: restrict dbname to wm_fork_ prefix if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { if !req.target_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest( "Non-superadmin users can only create databases with names starting with 'wm_fork_'" .to_string(), )); } } if is_instance_datatable_source(&db, &w_id, &req.source).await? { windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable") .await?; } else { let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; let (client, connection) = source_pg.connect(Some(&db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let row = client .query_one( "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", &[&req.target_dbname], ) .await .map_err(|e| { Error::internal_err(format!( "Failed to check database existence: {}", pg_error_message(&e) )) })?; let db_exists: bool = row.get(0); if db_exists { drop(client); let _ = windmill_common::shutdown_pg_connection(join_handle).await; return Err(Error::BadRequest(format!( "Database '{}' already exists on the resource server", req.target_dbname ))); } client .execute(&format!("CREATE DATABASE \"{}\"", &req.target_dbname), &[]) .await .map_err(|e| { Error::internal_err(format!( "Failed to create database '{}': {}", req.target_dbname, pg_error_message(&e) )) })?; drop(client); windmill_common::shutdown_pg_connection(join_handle).await?; } Ok(format!("Created database '{}'", req.target_dbname)) } #[derive(Deserialize)] struct ImportPgDatabaseRequest { source: String, target: String, #[serde(default)] target_dbname_override: Option, fork_behavior: DataTableForkBehavior, } /// Import (pg_dump/pg_import) from source to target async fn import_pg_database( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> Result { if req.fork_behavior == DataTableForkBehavior::KeepOriginal { return Ok("No action needed for KeepOriginal behavior".to_string()); } if req.fork_behavior == DataTableForkBehavior::SchemaAndData { require_admin(authed.is_admin, &authed.username)?; if *CLOUD_HOSTED { return Err(Error::BadRequest( "Importing schema and data is not available on cloud".to_string(), )); } } // Only the fork clone flow overrides the target database name; a plain // database-to-database import is an admin moving data between databases they // already reach, and lands nowhere that strips permissions. if req.target_dbname_override.is_some() { refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?; } let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly; let source_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.source).await?; let mut target_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.target).await?; if let Some(ref override_dbname) = req.target_dbname_override { if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { if !override_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest( "Non-superadmin users can only override target dbname with names starting with 'wm_fork_'" .to_string(), )); } } target_pg.dbname = override_dbname.clone(); } windmill_common::validate_dbname(&target_pg.dbname)?; // Ownership never replays: the restore runs as the target's own connection user, and // what it creates it owns. Grants do, except around an instance data table — Windmill // plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await? || is_instance_datatable_source(&db, &w_id, &req.source).await?; let dump_file = pg_dump_database( &source_pg, PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() }, ) .await?; pg_import_dump(&target_pg, &dump_file).await?; Ok(format!( "Imported from '{}' into '{}'", req.source, target_pg.dbname )) } #[derive(Deserialize)] struct ExportPgSchemaRequest { source: String, } async fn export_pg_schema( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> Result { let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; let dump_file = pg_dump_database( &pg, PgDumpOptions { schema_only: true, ..Default::default() }, ) .await?; tokio::fs::read_to_string(&dump_file.path) .await .map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e))) } #[derive(Deserialize)] struct GetDatatableFullSchemaRequest { source: String, } async fn get_datatable_full_schema( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> JsonResult { let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; let (client, connection) = pg.connect(Some(&db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let result = windmill_common::query_builders::pg_get_full_schema(&client) .await .map_err(Error::internal_err)?; drop(client); windmill_common::shutdown_pg_connection(join_handle).await?; Ok(Json(result)) } async fn edit_ducklake_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, email, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); // Lake names end up interpolated in `ATTACH 'ducklake://'`, // generated maintenance SQL and the reserved maintenance schedule path // (CHECK-constrained to [\w-]+ segments). for name in new_config.settings.ducklakes.keys() { if !windmill_common::workspaces::is_valid_ducklake_name(name) { return Err(Error::BadRequest(format!( "Invalid ducklake name '{name}': only letters, digits, '_' and '-' are allowed" ))); } } let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.settings); audit_log( &mut *tx, &authed, "workspaces.edit_ducklake_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("ducklake", args_for_audit.as_str())].into()), ) .await?; let old_ducklakes = sqlx::query_scalar!( r#" SELECT ws.ducklake->'ducklakes' AS ducklake_name FROM workspace_settings ws WHERE ws.workspace_id = $1 "#, &w_id ) .fetch_one(&mut *tx) .await? .unwrap_or(serde_json::Value::Null); let old_ducklakes: HashMap = serde_json::from_value(old_ducklakes).unwrap_or_default(); // Check that non-superadmins are not abusing Instance databases if !is_superadmin { for (name, dl) in new_config.settings.ducklakes.iter() { if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance { let old_dl = old_ducklakes.get(name); if old_dl.is_none() || old_dl.unwrap().catalog.resource_type != DucklakeCatalogResourceType::Instance || old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path { return Err(Error::BadRequest( "Only superadmins can create or modify ducklakes with Instance databases" .to_string(), )); } } } } let config: serde_json::Value = serde_json::to_value(&new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET ducklake = $1 WHERE workspace_id = $2", config, &w_id ) .execute(&mut *tx) .await?; // Same tx as the settings update: a failed schedule sync/push must fail // the whole save — nothing reconciles a half-applied state later. let tx = windmill_queue::ducklake_maintenance::sync_ducklake_maintenance_schedules( &db, tx, &w_id, &new_config.settings.ducklakes, &old_ducklakes, &username, &email, ) .await?; tx.commit().await?; Ok(format!("Edit ducklake config for workspace {}", &w_id)) } async fn edit_datatable_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, ) -> Result { require_admin(is_admin, &username)?; let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; // This form carries the whole config forward — permissions restored from the // old value included — so it reads and writes the settings under the same // lock as the role save and the principal cleanups, or it puts back what one // of them just took away. let old_datatables: HashMap = serde_json::from_value( windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &w_id) .await? .and_then(|d| d.get("datatables").cloned()) .unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(); // Validate every persisted data table name and rename segment before // touching anything, since they become directory segments in migration // storage/export keys (`migrations/datatable//...`). Names being // introduced take the stricter git-sync-safe charset; already-persisted ones // keep the historical rule so an old name can still be saved and renamed. for name in new_config.settings.datatables.keys() { if old_datatables.contains_key(name) { crate::datatable_migrations::validate_datatable_path_segment(name)?; } else { crate::datatable_migrations::validate_new_datatable_name(name)?; } } for r in &new_config.renames { crate::datatable_migrations::validate_datatable_path_segment(&r.from)?; crate::datatable_migrations::validate_new_datatable_name(&r.to)?; } // Map new name -> old name so a renamed data table inherits the previous // flag instead of being treated as brand new. let rename_src: HashMap<&str, &str> = new_config .renames .iter() .map(|r| (r.to.as_str(), r.from.as_str())) .collect(); // Migrations opt-in is owned by the enable/disable endpoints, not this config // form: preserve each existing data table's flag, and default brand-new data // tables to enabled. for (name, dt) in new_config.settings.datatables.iter_mut() { let lookup = rename_src .get(name.as_str()) .copied() .unwrap_or(name.as_str()); dt.migrations_enabled = match old_datatables.get(lookup) { Some(old) => old.migrations_enabled, None => Some(true), }; // Same for permissions, owned by the datatable_permissions endpoints. let old = old_datatables.get(lookup); dt.permissions = old.and_then(|old| old.permissions.clone()); // The roles live in the database this data table points at: their logins // were created there and every grant they hold is recorded there. Carried // onto another database they authenticate against a cluster that never // heard of the grants, so the switch has to go through opting out first — // which is also what drops the roles from the database they belong to. if let Some(old) = old.filter(|_| dt.permissions.as_ref().is_some_and(|p| p.enabled)) { if old.database.resource_path != dt.database.resource_path || old.database.resource_type != dt.database.resource_type { return Err(Error::BadRequest(format!( "Data table '{name}' has permissions enabled, so it cannot be pointed at \ another database: disable them first, which drops its roles from the \ database they were created in." ))); } // A generated login is named from the data table's name, so a renamed // one keeps logins named for the name it left — and a data table // created under that name next generates those same names, adopts // those logins and resets their passwords. Renaming is rare; sharing // a login between two data tables is not something to leave open. if lookup != name.as_str() { return Err(Error::BadRequest(format!( "Data table '{lookup}' has permissions enabled, so it cannot be renamed to \ '{name}': its Postgres logins are named after '{lookup}' and a data table \ created under that name would take them over. Disable its permissions \ first, which drops those logins, then rename and enable them again." ))); } } } // The settings carry each role's generated login password. let args_for_audit = windmill_common::workspaces::datatable_settings_for_audit(&new_config.settings); audit_log( &mut *tx, &authed, "workspaces.edit_datatable_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("datatable", args_for_audit.as_str())].into()), ) .await?; // Check that non-superadmins are not abusing Instance databases if !is_superadmin { for (name, dt) in new_config.settings.datatables.iter() { if dt.database.resource_type == DataTableCatalogResourceType::Instance { let old_dt = old_datatables.get(name); if old_dt.is_none() || old_dt.unwrap().database.resource_type != DataTableCatalogResourceType::Instance || old_dt.unwrap().database.resource_path != dt.database.resource_path { return Err(Error::BadRequest( "Only superadmins can create or modify data tables with Instance databases" .to_string(), )); } } } } // Planned before the config is overwritten, while the data tables about to // disappear can still be resolved to a connection, and run once it has // committed: `DROP OWNED` discards the roles' grants for good, so a save that // rolls back after this point must not have destroyed anything. // // What is disappearing is read from the two configs rather than from the // request's `deleted_datatables`: a save that drops a name and adds another // declares no rename and no deletion, and the logins of the name it dropped // would be left for whatever data table is created under it next. let mut planned_role_drops = Vec::new(); for gone in old_datatables .keys() .filter(|name| !new_config.settings.datatables.contains_key(*name)) { if let Some(planned) = crate::datatable_permissions::plan_drop_of_deleted_datatable(&db, &w_id, gone).await { planned_role_drops.push((gone.clone(), planned)); } } let config: serde_json::Value = serde_json::to_value(new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; // This form owns `datatables` and nothing else. `shared_datatables` names data // tables of other workspaces, which this workspace does not administer, so it // is carried across untouched — except where a name has just been defined // here: the workspace has taken the name back, and a pointer nothing resolves // is worse than no pointer. sqlx::query!( "UPDATE workspace_settings SET datatable = $1::jsonb || jsonb_build_object('shared_datatables', COALESCE(( SELECT jsonb_object_agg(key, value) FROM jsonb_each(CASE WHEN jsonb_typeof(datatable->'shared_datatables') = 'object' THEN datatable->'shared_datatables' ELSE '{}'::jsonb END) WHERE NOT ($1::jsonb->'datatables' ? key) ), '{}'::jsonb)) WHERE workspace_id = $2", config, &w_id ) .execute(&mut *tx) .await?; let cascaded_migration_paths = crate::datatable_migrations::cascade_datatable_migration_renames_and_deletes( &db, &mut tx, &w_id, &new_config.renames, &new_config.deleted_datatables, ) .await?; tx.commit().await?; for (deleted, planned) in planned_role_drops { crate::datatable_permissions::run_planned_drop(&db, &w_id, &deleted, planned).await; } crate::datatable_migrations::record_datatable_cascade_deployments( &authed, &db, &w_id, cascaded_migration_paths, ) .await?; Ok(format!("Edit datatable config for workspace {}", &w_id)) } #[derive(Deserialize)] pub struct EditGitSyncConfig { pub git_sync_settings: Option, } #[derive(Deserialize, Debug)] pub struct EditGitSyncRepository { pub git_repo_resource_path: String, pub repository: GitRepositorySettings, } #[derive(Deserialize, Debug)] pub struct DeleteGitSyncRepositoryRequest { pub git_repo_resource_path: String, } fn validate_git_repo_resource_path(path: &str) -> Result<()> { // Resource paths should follow the pattern: $res:f// or $res:u// if path.is_empty() { return Err(Error::BadRequest( "Resource path cannot be empty".to_string(), )); } // Must start with $res: prefix if !path.starts_with("$res:") { return Err(Error::BadRequest( "Resource path must start with '$res:'".to_string(), )); } // Extract the actual path after $res: let actual_path = &path[5..]; // Remove "$res:" prefix // Basic validation: must start with f/ or u/ and contain at least one slash if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") { return Err(Error::BadRequest( "Resource path must start with '$res:f/' or '$res:u/'".to_string(), )); } // Must have at least 3 parts (type, folder/user, name) let parts: Vec<&str> = actual_path.split('/').collect(); if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) { return Err(Error::BadRequest( "Invalid resource path format".to_string(), )); } // Resource name validation (last part) let resource_name = parts.last().unwrap(); if !resource_name .chars() .all(|c| c.is_alphanumeric() || c == '_' || c == '-') { return Err(Error::BadRequest( "Resource name can only contain alphanumeric characters, underscores, and hyphens" .to_string(), )); } Ok(()) } fn cleanup_legacy_git_sync_settings_in_memory( git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, workspace_id: &str, ) { // Check if all repositories are in new format (have settings field) let all_repos_migrated = git_sync_settings .repositories .iter() .all(|repo| repo.settings.is_some()); // If all repos are migrated and we still have legacy workspace-level settings if all_repos_migrated && (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some()) { tracing::info!( workspace_id = workspace_id, "All git sync repositories migrated to new format, cleaning up legacy workspace-level settings" ); // Remove workspace-level legacy fields git_sync_settings.include_path = None; git_sync_settings.include_type = None; } } #[cfg(not(feature = "enterprise"))] const CE_GIT_SYNC_MAX_USERS: i64 = 2; /// Auto-pull is licensed per plan, not just per build: the poller only serves /// Enterprise plans at runtime, so the save path must reject the setting too — /// otherwise an EE binary without the plan could still register a webhook and /// receive webhook-driven pulls. #[cfg(feature = "enterprise")] async fn check_git_sync_ee_license(feature: &str) -> Result<()> { if !matches!( windmill_common::ee_oss::get_license_plan().await, windmill_common::ee_oss::LicensePlan::Enterprise ) { return Err(Error::BadRequest(format!( "{feature} requires an Enterprise license" ))); } Ok(()) } #[cfg(feature = "enterprise")] async fn check_auto_pull_license() -> Result<()> { check_git_sync_ee_license("Automatic pull from git").await } /// In-app PR creation (promotion/fork deploy branches) drives GitHub API calls /// from the deploy completion hook; runtime-gate it like auto-pull. #[cfg(feature = "enterprise")] async fn check_open_prs_license<'a>( mut repos: impl Iterator, ) -> Result<()> { if repos.any(|r| r.promotion_open_prs || r.fork_open_prs) { check_git_sync_ee_license("Opening pull requests from Windmill").await?; } Ok(()) } /// Promotion mode (`use_individual_branch`: per-item `wm_deploy/**` deploy /// branches) is an EE feature; runtime-gate it like auto-pull and PR creation /// so an enterprise binary without an active plan can't enable it via either /// git-sync edit endpoint. #[cfg(feature = "enterprise")] async fn check_promotion_license<'a>( mut repos: impl Iterator, ) -> Result<()> { if repos.any(|r| r.use_individual_branch.unwrap_or(false)) { check_git_sync_ee_license("Promotion mode").await?; } Ok(()) } /// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796): /// an older pinned script bundles a CLI that force-disables per-item branches /// on every fork, so enabling promotion would silently keep deploying to the /// env-label branch. Reject with an actionable error instead (the dispatcher /// demotes inherited configs the same way). Roots run promotion on any script /// version, and auto-managed repositories (no pin) always use the latest. #[cfg(feature = "enterprise")] async fn check_dev_promotion_script_version<'a>( db: &DB, w_id: &str, repos: impl Iterator, ) -> Result<()> { let mut offending: Option = None; for r in repos { if !r.use_individual_branch.unwrap_or(false) { continue; } if !r.is_script_meets_min_version(28796)? { offending = Some(r.effective_script_path().to_string()); break; } } let Some(offending) = offending else { return Ok(()); }; let is_dev = sqlx::query!( "SELECT parent_workspace_id, is_dev_workspace FROM workspace WHERE id = $1", w_id ) .fetch_optional(db) .await? .map(|r| r.is_dev_workspace) .unwrap_or(false); if !is_dev { return Ok(()); } Err(Error::BadRequest(format!( "Promotion mode on a dev workspace requires git sync script version 28796 or newer, \ but this repository pins '{offending}'. Update the pinned sync script (or reset it to \ auto-managed) first." ))) } /// A dev workspace's promotion must target its parent ("prod") workspace's own /// git repository (same URL and branch) — that is what "promote to prod" means. /// A fork-created dev inherits prod's repo; an **attached** dev keeps its own, /// which may be unrelated. Reject enabling promotion on a repo the parent does /// not track so the UI can't present an unrelated repo as prod's target. The /// deploy path re-checks the same invariant (a resource edit could break it /// after save), via the shared `dev_promotion_target_matches_parent`. #[cfg(all(feature = "enterprise", feature = "private"))] async fn check_dev_promotion_targets_parent_repo<'a>( db: &DB, w_id: &str, repos: impl Iterator, ) -> Result<()> { for r in repos.filter(|r| r.use_individual_branch.unwrap_or(false)) { let Some(m) = windmill_common::git_sync_ee::dev_promotion_target_mismatch( db, w_id, &r.git_repo_resource_path, ) .await? else { continue; }; // A resource with no `branch` field normalizes to an empty string. let named = |b: &str| { if b.is_empty() { "".to_string() } else { b.to_string() } }; let branch = named(&m.branch); let hint = if m.parent_branches.is_empty() { format!( "the parent workspace '{}' does not track that repository. Point this repository at \ the one the parent syncs with, or add it to the parent's git sync settings.", m.parent_workspace ) } else { // With no branch of its own there is nothing for the parent to add, // so only offer the side of the advice that can be acted on. let other_way = if m.branch.is_empty() { String::new() } else { format!(", or add branch '{branch}' to the parent's git sync settings") }; format!( "the parent workspace '{}' tracks it on {} '{}'. Set this repository's branch to \ match{other_way}.", m.parent_workspace, if m.parent_branches.len() > 1 { "branches" } else { "branch" }, m.parent_branches .iter() .map(|b| named(b)) .collect::>() .join("', '") ) }; return Err(Error::BadRequest(format!( "Promotion mode on a dev workspace must reuse the parent workspace's git repository \ (same URL and branch). Repository '{}' targets '{}' on branch '{branch}', but {hint}", r.git_repo_resource_path.trim_start_matches("$res:"), m.repo, ))); } Ok(()) } #[cfg(feature = "enterprise")] async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { Ok(()) } // Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise) #[cfg(feature = "enterprise")] #[allow(dead_code)] async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) { let _ = sqlx::query_scalar!( "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", w_id ) .fetch_one(db) .await; } #[cfg(not(feature = "enterprise"))] async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { let user_count: i64 = sqlx::query_scalar!( "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", w_id ) .fetch_one(db) .await? .unwrap_or(0); if user_count > CE_GIT_SYNC_MAX_USERS { return Err(Error::BadRequest(format!( "Git sync is available for workspaces with up to {} members. \ Upgrade to Windmill Enterprise Edition for unlimited workspace members.", CE_GIT_SYNC_MAX_USERS ))); } Ok(()) } #[cfg(feature = "enterprise")] async fn get_git_sync_enabled( _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, ) -> JsonResult { Ok(Json(serde_json::json!({ "enabled": true, "reason": "enterprise", "max_repos": null, "user_count": null, "max_users": null, }))) } #[cfg(not(feature = "enterprise"))] async fn get_git_sync_enabled( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { let user_count: i64 = sqlx::query_scalar!( "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); let enabled = user_count <= CE_GIT_SYNC_MAX_USERS; Ok(Json(serde_json::json!({ "enabled": enabled, "reason": if enabled { Some("free_tier") } else { None::<&str> }, "max_repos": if enabled { Some(1) } else { None:: }, "user_count": user_count, "max_users": CE_GIT_SYNC_MAX_USERS, }))) } async fn edit_git_sync_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; check_git_sync_access(&db, &w_id).await?; let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.git_sync_settings); audit_log( &mut *tx, &authed, "workspaces.edit_git_sync_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("git_sync_settings", args_for_audit.as_str())].into()), ) .await?; // The whole-config save only writes the DB below; the managed GitHub webhooks // are reconciled after the commit is durable (like the per-repository endpoint): // `post_commit` carries the saved repos to reconcile + the hooks of repos this // save removed, to delete. #[cfg(all(feature = "enterprise", feature = "private"))] let post_commit: Option<(WorkspaceGitSyncSettings, Vec<(String, i64)>)>; if let Some(mut git_sync_settings) = new_config.git_sync_settings { // Client-supplied server-owned auto-pull state is never trusted: strip it up // front, then existing repos re-derive it from `existing` below and new repos // stay clean. for repo in git_sync_settings.repositories.iter_mut() { if let Some(ap) = repo.auto_pull.as_mut() { clear_client_supplied_auto_pull_state(ap); } repo.open_pr_error = None; } reject_parent_only_git_sync_settings_on_fork( &db, &w_id, git_sync_settings.repositories.iter(), ) .await?; // Auto-pull is EE-only (see edit_git_sync_repository). #[cfg(not(feature = "enterprise"))] if git_sync_settings .repositories .iter() .any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled)) { return Err(Error::BadRequest( "Automatic pull from git is an Enterprise Edition feature".to_string(), )); } #[cfg(feature = "enterprise")] if git_sync_settings .repositories .iter() .any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled)) { check_auto_pull_license().await?; } #[cfg(feature = "enterprise")] check_open_prs_license(git_sync_settings.repositories.iter()).await?; #[cfg(feature = "enterprise")] check_promotion_license(git_sync_settings.repositories.iter()).await?; #[cfg(feature = "enterprise")] check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter()) .await?; #[cfg(all(feature = "enterprise", feature = "private"))] check_dev_promotion_targets_parent_repo(&db, &w_id, git_sync_settings.repositories.iter()) .await?; // Promotion mode: EE only (mirrors edit_git_sync_repository). #[cfg(not(feature = "enterprise"))] if git_sync_settings .repositories .iter() .any(|r| r.use_individual_branch.unwrap_or(false)) { return Err(Error::BadRequest( "Promotion mode is an Enterprise Edition feature".to_string(), )); } // Preserve server-owned auto-pull state (webhook id/secret, synced sha, last // status) that the redacted GET response omits — otherwise a whole-config // save from the UI would drop the webhook secret (breaking delivery) or // clobber what the poller/webhook layer wrote. // // `FOR UPDATE` because this is a read-modify-write of the whole `git_sync` // blob: the webhook reconciler writes hook fields into it with a targeted // update, and without the row lock one landing between this read and the // write below would be reverted, leaving its hook live but untracked. let existing: Option = sqlx::query_scalar!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", &w_id ) .fetch_optional(&mut *tx) .await? .flatten() .and_then(|v| serde_json::from_value(v).ok()); // Repos present before but absent from this save: their webhooks won't be // reconciled below (no longer listed), so capture them for deletion. #[cfg(all(feature = "enterprise", feature = "private"))] let removed_webhooks: Vec<(String, i64)> = existing .as_ref() .map(|e| { e.repositories .iter() .filter_map(|old| { let hook = old.auto_pull.as_ref().and_then(|a| a.webhook_id)?; // The save carries the hook forward (reconciled below) only // when the repo is still present AND still has auto_pull — the // preservation loop copies webhook fields only onto a Some // auto_pull. Otherwise (repo dropped, or auto_pull cleared) the // hook would orphan, so delete it. let carried = git_sync_settings .repositories .iter() .find(|n| n.git_repo_resource_path == old.git_repo_resource_path) .map(|n| n.auto_pull.is_some()) .unwrap_or(false); (!carried).then_some((old.git_repo_resource_path.clone(), hook)) }) .collect() }) .unwrap_or_default(); if let Some(existing) = &existing { for repo in git_sync_settings.repositories.iter_mut() { let Some(old) = existing .repositories .iter() .find(|r| r.git_repo_resource_path == repo.git_repo_resource_path) else { continue; }; repo.open_pr_error = old.open_pr_error.clone(); if let (Some(new_ap), Some(old_ap)) = (repo.auto_pull.as_mut(), old.auto_pull.as_ref()) { new_ap.webhook_id = old_ap.webhook_id; new_ap.webhook_secret = old_ap.webhook_secret.clone(); new_ap.webhook_url = old_ap.webhook_url.clone(); new_ap.last_synced_sha = old_ap.last_synced_sha.clone(); new_ap.last_pull_status = old_ap.last_pull_status.clone(); } } } // Clean up legacy workspace-level settings if all repos are migrated cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); let serialized_config = serde_json::to_value(&git_sync_settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", serialized_config, &w_id ) .execute(&mut *tx) .await?; #[cfg(all(feature = "enterprise", feature = "private"))] { post_commit = Some((git_sync_settings, removed_webhooks)); } } else { // Clearing the whole config removes every repo — delete all their webhooks. // `FOR UPDATE` for the same reason as the save branch: the hook ids collected // here are used to delete after commit, so a reconcile writing a new id in // the gap would leave that hook live with nothing tracking it. #[cfg(all(feature = "enterprise", feature = "private"))] { let existing: Option = sqlx::query_scalar!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", &w_id ) .fetch_optional(&mut *tx) .await? .flatten() .and_then(|v| serde_json::from_value(v).ok()); let removed_webhooks: Vec<(String, i64)> = existing .map(|e| { e.repositories .iter() .filter_map(|old| { old.auto_pull .as_ref() .and_then(|a| a.webhook_id) .map(|h| (old.git_repo_resource_path.clone(), h)) }) .collect() }) .unwrap_or_default(); post_commit = Some((WorkspaceGitSyncSettings::default(), removed_webhooks)); } sqlx::query!( "UPDATE workspace_settings SET git_sync = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } tx.commit().await?; // Post-commit: reconcile each saved repo's managed webhook to match the config // and delete the webhooks of repos this save removed. Best-effort — a failure // leaves polling on. #[cfg(all(feature = "enterprise", feature = "private"))] if let Some((mut settings, removed_webhooks)) = post_commit { for repo in settings.repositories.iter_mut() { // `sync_repo_webhook` writes back the webhook fields it changes itself: // the remote hook and the record of it have to move together, so // persisting them out here would let one land without the other. if let Err(e) = windmill_common::git_sync_ee::sync_repo_webhook(&db, &w_id, repo).await { tracing::warn!("git auto-pull: webhook sync error: {}", e); } } for (path, hook_id) in removed_webhooks { if let Ok(url) = windmill_common::git_sync_ee::resolve_repo_url_interpolated(&db, &w_id, &path).await { let _ = windmill_common::git_sync_ee::delete_repo_webhook(&db, &w_id, &url, hook_id) .await; } } } // Trigger git sync for git sync settings changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, Some("Git sync configuration updated".to_string()), false, None, ) .await?; Ok(format!("Edit git sync config for workspace {}", &w_id)) } async fn edit_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, ) -> Result { require_admin(is_admin, &username)?; check_git_sync_access(&db, &w_id).await?; // Validate the resource path format validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; // Server-owned auto-pull state (webhook id/secret + sync status) is never // accepted from the client — the webhook layer and poller own it. Strip it so an // existing repo re-derives it from the DB (carried over below) and a new one // starts clean. if let Some(ap) = new_config.repository.auto_pull.as_mut() { clear_client_supplied_auto_pull_state(ap); } new_config.repository.open_pr_error = None; reject_parent_only_git_sync_settings_on_fork( &db, &w_id, std::iter::once(&new_config.repository), ) .await?; // Auto-pull is EE-only: CE builds compile neither the poller nor the webhook // reconciler, so accepting the setting would silently do nothing. #[cfg(not(feature = "enterprise"))] if new_config .repository .auto_pull .as_ref() .is_some_and(|a| a.enabled) { return Err(Error::BadRequest( "Automatic pull from git is an Enterprise Edition feature".to_string(), )); } #[cfg(feature = "enterprise")] if new_config .repository .auto_pull .as_ref() .is_some_and(|a| a.enabled) { check_auto_pull_license().await?; } #[cfg(feature = "enterprise")] check_open_prs_license(std::iter::once(&new_config.repository)).await?; #[cfg(feature = "enterprise")] check_promotion_license(std::iter::once(&new_config.repository)).await?; #[cfg(feature = "enterprise")] check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?; #[cfg(all(feature = "enterprise", feature = "private"))] check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository)) .await?; // Promotion mode: EE only #[cfg(not(feature = "enterprise"))] if new_config.repository.use_individual_branch.unwrap_or(false) { return Err(Error::BadRequest( "Promotion mode is an Enterprise Edition feature".to_string(), )); } let mut tx = db.begin().await?; // First, get the current git sync settings. `FOR UPDATE` because this // read-modify-writes the whole `git_sync` blob: the webhook reconciler writes // hook fields into it with a targeted update, and one landing between this read // and the write below would be reverted, leaving its hook live but untracked. let current_settings = sqlx::query!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", &w_id ) .fetch_optional(&mut *tx) .await?; let mut git_sync_settings = if let Some(row) = current_settings { if let Some(git_sync) = row.git_sync { serde_json::from_value::(git_sync) .map_err(|err| Error::internal_err(err.to_string()))? } else { WorkspaceGitSyncSettings::default() } } else { WorkspaceGitSyncSettings::default() }; // Multi-repo: EE only #[cfg(not(feature = "enterprise"))] { let is_new = !git_sync_settings .repositories .iter() .any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path); if is_new && !git_sync_settings.repositories.is_empty() { return Err(Error::BadRequest( "Multiple git sync repositories is an Enterprise Edition feature".to_string(), )); } } // Audit log before we move the repository audit_log( &mut *tx, &authed, "workspaces.edit_git_sync_repository", ActionKind::Update, &w_id, Some(&authed.email), Some( [ ( "repository_path", new_config.git_repo_resource_path.as_str(), ), ("repository_data", &format!("{:?}", new_config.repository)), ] .into(), ), ) .await?; // Check if repository exists before modifying let repo_exists = git_sync_settings .repositories .iter() .any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); // Find and update the specific repository, or add it if it doesn't exist let repo_found = git_sync_settings .repositories .iter_mut() .find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); if let Some(existing_repo) = repo_found { // Update existing repository, but preserve server-owned auto-pull state // (synced sha, last pull status, webhook id/secret) so a settings save // from the UI cannot revert what the poller/webhook layer wrote. let mut updated = new_config.repository; updated.open_pr_error = existing_repo.open_pr_error.clone(); match (updated.auto_pull.as_mut(), existing_repo.auto_pull.as_ref()) { (Some(new_ap), Some(old_ap)) => { new_ap.last_synced_sha = old_ap.last_synced_sha.clone(); new_ap.last_pull_status = old_ap.last_pull_status.clone(); new_ap.webhook_id = old_ap.webhook_id; new_ap.webhook_secret = old_ap.webhook_secret.clone(); new_ap.webhook_url = old_ap.webhook_url.clone(); } // UI omitted auto_pull (e.g. older client): keep existing config. (None, Some(_)) => { updated.auto_pull = existing_repo.auto_pull.clone(); } _ => {} } // The request-side license gate above only saw the submitted config; the // preservation can resurrect an enabled auto_pull (None arm), so re-check // the effective state before it gets written and reconciled. #[cfg(feature = "enterprise")] if updated.auto_pull.as_ref().is_some_and(|a| a.enabled) { check_auto_pull_license().await?; } *existing_repo = updated; } else { // Repository doesn't exist, add it as a new repository git_sync_settings.repositories.push(new_config.repository); } // Clean up legacy workspace-level settings if all repos are migrated cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); // Save the updated configuration first, then reconcile the GitHub webhook to // match it *after* the commit is durable (phase 2). Reconciling before the // commit could leave the DB pointing at a hook that no longer matches if the // save then failed (e.g. a delete on disable); post-commit reconciliation // cannot. The pre-edit webhook id/secret are carried over above, so the // committed row stays consistent until the reconcile persists any change. let serialized_config = serde_json::to_value(&git_sync_settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", serialized_config, &w_id ) .execute(&mut *tx) .await?; tx.commit().await?; // Post-commit: create/remove the webhook to match the saved config. The resulting // hook id/secret/url are written back by `sync_repo_webhook` itself. Best-effort — // a failure leaves polling on. #[cfg(all(feature = "enterprise", feature = "private"))] if let Some(repo) = git_sync_settings .repositories .iter_mut() .find(|r| r.git_repo_resource_path == new_config.git_repo_resource_path) { if let Err(e) = windmill_common::git_sync_ee::sync_repo_webhook(&db, &w_id, repo).await { tracing::warn!("git auto-pull: webhook sync error: {}", e); } } // Trigger git sync for individual repository update/add handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, Some(format!( "Git sync repository '{}' {}", new_config.git_repo_resource_path, if repo_exists { "updated" } else { "added" } )), false, None, ) .await?; Ok(format!( "{} git sync repository '{}' for workspace {}", if repo_exists { "Updated" } else { "Added" }, new_config.git_repo_resource_path, &w_id )) } async fn delete_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(request): Json, ) -> Result { require_admin(is_admin, &username)?; // No check_git_sync_access here — admins should always be able to delete/clean up repos if request.git_repo_resource_path.is_empty() { return Err(Error::BadRequest( "Resource path cannot be empty".to_string(), )); } let mut tx = db.begin().await?; // First, get the current git sync settings. `FOR UPDATE` because this // read-modify-writes the whole `git_sync` blob: the webhook reconciler writes // hook fields into it with a targeted update, and one landing between this read // and the write below would be reverted, leaving its hook live but untracked. let current_settings = sqlx::query!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", &w_id ) .fetch_optional(&mut *tx) .await?; let mut git_sync_settings = if let Some(row) = current_settings { if let Some(git_sync) = row.git_sync { serde_json::from_value::(git_sync) .map_err(|err| Error::internal_err(err.to_string()))? } else { WorkspaceGitSyncSettings::default() } } else { WorkspaceGitSyncSettings::default() }; // Capture the repo's managed webhook id; the hook itself is deleted only after // the DB removal commits (below), so a failed save can't leave the repo pointing // at a hook that no longer exists. Deletion bypasses the sync_repo_webhook // lifecycle, so GitHub would otherwise keep delivering to an orphaned hook. #[cfg(all(feature = "enterprise", feature = "private"))] let webhook_to_delete: Option = git_sync_settings .repositories .iter() .find(|r| r.git_repo_resource_path == request.git_repo_resource_path) .and_then(|r| r.auto_pull.as_ref()) .and_then(|a| a.webhook_id); // Check if repository exists and remove it let original_count = git_sync_settings.repositories.len(); git_sync_settings .repositories .retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path); if git_sync_settings.repositories.len() == original_count { return Err(Error::BadRequest(format!( "Repository with path '{}' not found in git sync configuration", request.git_repo_resource_path ))); } // Audit log audit_log( &mut *tx, &authed, "workspaces.delete_git_sync_repository", ActionKind::Delete, &w_id, Some(&authed.email), Some([("repository_path", request.git_repo_resource_path.as_str())].into()), ) .await?; // Clean up legacy workspace-level settings if all repos are migrated cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); // Save the updated configuration let serialized_config = serde_json::to_value::(git_sync_settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", serialized_config, &w_id ) .execute(&mut *tx) .await?; tx.commit().await?; // Removal is durable now — best-effort delete the GitHub webhook. #[cfg(all(feature = "enterprise", feature = "private"))] if let Some(hook_id) = webhook_to_delete { if let Ok(url) = windmill_common::git_sync_ee::resolve_repo_url_interpolated( &db, &w_id, &request.git_repo_resource_path, ) .await { let _ = windmill_common::git_sync_ee::delete_repo_webhook(&db, &w_id, &url, hook_id).await; } } // Trigger git sync for repository deletion handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, Some(format!( "Git sync repository '{}' deleted", request.git_repo_resource_path )), false, None, ) .await?; Ok(format!( "Deleted git sync repository '{}' from workspace {}", request.git_repo_resource_path, &w_id )) } #[cfg(feature = "enterprise")] #[derive(Debug, Deserialize)] struct EditDeployUIConfig { deploy_ui_settings: Option, } #[cfg(not(feature = "enterprise"))] async fn edit_deploy_ui_config( _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, ) -> Result { return Err(Error::BadRequest( "Deployment UI is only available on Windmill Enterprise Edition".to_string(), )); } #[cfg(feature = "enterprise")] async fn edit_deploy_ui_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.deploy_ui_settings); audit_log( &mut *tx, &authed, "workspaces.edit_deploy_ui_config", ActionKind::Update, &w_id, Some(&authed.email), Some([("deployment_ui_settings", args_for_audit.as_str())].into()), ) .await?; if let Some(deploy_ui_settings) = new_config.deploy_ui_settings { let serialized_config = serde_json::to_value::(deploy_ui_settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET deploy_ui = $1 WHERE workspace_id = $2", serialized_config, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET deploy_ui = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(format!("Edit deployment UI config for workspace {}", &w_id)) } #[derive(Deserialize)] pub struct EditDefaultApp { #[cfg(feature = "enterprise")] pub default_app_path: Option, } #[cfg(not(feature = "enterprise"))] async fn edit_default_app( _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, Json(_new_config): Json, ) -> Result { return Err(Error::BadRequest( "Setting a workspace default app is only available on Windmill Enterprise Edition" .to_string(), )); } async fn edit_default_scripts( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json>, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; audit_log( &mut *tx, &authed, "workspaces.edit_default_scripts", ActionKind::Update, &w_id, Some(&authed.email), None, ) .await?; if let Some(config) = new_config { sqlx::query!( "UPDATE workspace_settings SET default_scripts = $1 WHERE workspace_id = $2", config, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET default_scripts = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } tx.commit().await?; // Trigger git sync for default scripts changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "default_scripts".to_string() }, Some("Default scripts configuration updated".to_string()), false, None, ) .await?; Ok(format!("Edit default scripts for workspace {}", &w_id)) } async fn get_default_scripts( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let mut tx = db.begin().await?; let default_scripts = sqlx::query_scalar!( "SELECT default_scripts FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_optional(&mut *tx) .await .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; tx.commit().await?; Ok(Json(default_scripts.flatten())) } #[cfg(feature = "enterprise")] async fn edit_default_app( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { #[cfg(not(feature = "enterprise"))] { return Err(Error::BadRequest( "Setting a workspace default app is only available on Windmill Enterprise Edition" .to_string(), )); } require_admin(is_admin, &username)?; let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.default_app_path); audit_log( &mut *tx, &authed, "workspaces.edit_default_app", ActionKind::Update, &w_id, Some(&authed.email), Some([("default_app", args_for_audit.as_str())].into()), ) .await?; if let Some(default_app_path) = new_config.default_app_path { sqlx::query!( "UPDATE workspace_settings SET default_app = $1 WHERE workspace_id = $2", default_app_path, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET default_app = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) .await?; } tx.commit().await?; // Trigger git sync for default app changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "default_app".to_string() }, Some("Default app configuration updated".to_string()), false, None, ) .await?; Ok(format!("Edit default app for workspace {}", &w_id)) } #[derive(Serialize)] struct WorkspaceDefaultApp { pub default_app_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_app_raw: Option, } async fn get_default_app( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { let row = sqlx::query!( "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\" FROM workspace_settings ws LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)] WHERE ws.workspace_id = $1", &w_id ) .fetch_one(&db) .await .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; Ok(Json(WorkspaceDefaultApp { default_app_path: row.default_app_path, default_app_raw: row.default_app_raw, })) } async fn edit_error_handler( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ee): Json, ) -> Result { require_admin(is_admin, &username)?; // Normalize to new format (handles both old CLI and new CLI requests) let ee = ee.into_normalized(); let mut tx = db.begin().await?; if let Some(fallback_to_instance_alerts) = ee.fallback_to_instance_alerts { if fallback_to_instance_alerts { ensure_instance_alert_fallback_allowed(&mut tx, &w_id).await?; } sqlx::query!( "UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = $1 WHERE workspace_id = $2", fallback_to_instance_alerts, &w_id ) .execute(&mut *tx) .await?; } sqlx::query_as!( Group, "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", w_id, "error_handler", "The group the error handler acts on behalf of", serde_json::json!({username_to_permissioned_as(&authed.username): true}) ) .execute(&mut *tx) .await?; if let Some(path) = &ee.path { match ee.extra_args.as_ref() { Some(extra_args) if extra_args.is_object() => { let Ok(email_recipients) = serde_json::from_value::>>( extra_args["email_recipients"].to_owned(), ) else { return Err(Error::BadRequest( "Field `email_recipients` expected to be JSON array".to_string(), )); }; if let Some(email_recipients) = email_recipients { for email in email_recipients { if !EMAIL_REGEXP.is_match(&email) { return Err(Error::BadRequest(format!( "Invalid email format: {}", email ))); } } } } None => {} _ => { return Err(Error::BadRequest( "Field `extra_args` expected to be JSON object".to_string(), )) } } // Always persist `muted_on_cancel` and `muted_on_user_path` (including // false values) so that a YAML round-trip via `wmill sync pull && wmill // sync push` is stable instead of re-firing `editErrorHandler` on every // push (the CLI sends `false` defaults and deepEqual would otherwise // mismatch an omitted-on-write shape against an always-sent-by-CLI one). let mut error_handler = serde_json::json!({ "path": path, "muted_on_cancel": ee.muted_on_cancel, "muted_on_user_path": ee.muted_on_user_path, }); if let Some(extra_args) = &ee.extra_args { error_handler["extra_args"] = extra_args.clone(); } sqlx::query!( "UPDATE workspace_settings SET error_handler = $1 WHERE workspace_id = $2", error_handler, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET error_handler = NULL WHERE workspace_id = $1", &w_id ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "workspaces.edit_error_handler", ActionKind::Update, &w_id, Some(&authed.email), Some( [ ("error_handler", &format!("{:?}", ee.path)[..]), ( "fallback_to_instance_alerts", &format!("{:?}", ee.fallback_to_instance_alerts)[..], ), ] .into(), ), ) .await?; tx.commit().await?; // Trigger git sync for error handler changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "error_handler".to_string() }, Some("Error handler configuration updated".to_string()), false, None, ) .await?; Ok(format!("Edit error_handler for workspace {}", &w_id)) } async fn edit_success_handler( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(es): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; // Normalize to new format (handles both old CLI and new CLI requests) let es = es.into_normalized(); let mut tx = db.begin().await?; sqlx::query_as!( Group, "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", w_id, "success_handler", "The group the success handler acts on behalf of", serde_json::json!({username_to_permissioned_as(&authed.username): true}) ) .execute(&mut *tx) .await?; if let Some(path) = &es.path { let mut success_handler = serde_json::json!({ "path": path, }); if let Some(extra_args) = &es.extra_args { success_handler["extra_args"] = extra_args.clone(); } sqlx::query!( "UPDATE workspace_settings SET success_handler = $1 WHERE workspace_id = $2", success_handler, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET success_handler = NULL WHERE workspace_id = $1", &w_id ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "workspaces.edit_success_handler", ActionKind::Update, &w_id, Some(&authed.email), Some([("success_handler", &format!("{:?}", es.path)[..])].into()), ) .await?; tx.commit().await?; // Trigger git sync for success handler changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "success_handler".to_string() }, Some("Success handler configuration updated".to_string()), false, None, ) .await?; Ok(format!("Edit success_handler for workspace {}", &w_id)) } #[derive(Deserialize)] struct NewEnvironmentVariable { name: String, value: Option, } async fn set_environment_variable( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(NewEnvironmentVariable { value, name }): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; match value { Some(value) => { // The worker escapes the name when it splices it into the NativeTS/Bun // prologue, so this is a friendly guard against new non-identifier // names, not the injection defense. Skip it for names that already // exist so a value edit of a grandfathered name (the edit UI resubmits // the name) isn't rejected with no in-product way to fix it. if !windmill_common::variables::is_valid_js_identifier(&name) { let already_exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace_env WHERE workspace_id = $1 AND name = $2)", &w_id, name ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if !already_exists { return Err(Error::BadRequest(format!( "Invalid environment variable name '{name}': must start with a letter, underscore or '$' and contain only letters, digits, underscores or '$'" ))); } } sqlx::query!( "INSERT INTO workspace_env (workspace_id, name, value) VALUES ($1, $2, $3) ON CONFLICT (workspace_id, name) DO UPDATE SET value = EXCLUDED.value", &w_id, name, value ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspace.set_environment_variable", ActionKind::Create, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; Ok(format!("Set environment variable {}", name)) } None => { sqlx::query!( "DELETE FROM workspace_env WHERE workspace_id = $1 AND name = $2", &w_id, name ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspace.delete_environment_variable", ActionKind::Delete, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; Ok(format!("Deleted environment variable {}", name)) } } } #[derive(Serialize)] pub struct GetEncryptionKeyResponse { key: String, } async fn get_encryption_key( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?; audit_log( &db, &authed, "workspaces.read_encryption_key", ActionKind::Execute, &w_id, None, None, ) .await?; let encryption_key_opt = sqlx::query_scalar!( "SELECT key FROM workspace_key WHERE workspace_id = $1", w_id ) .fetch_optional(&db) .await?; let encryption_key = not_found_if_none(encryption_key_opt, "workspace_encryption_key", w_id)?; return Ok(Json(GetEncryptionKeyResponse { key: encryption_key })); } #[derive(Deserialize)] struct SetEncryptionKeyRequest { new_key: String, skip_reencrypt: Option, } async fn set_encryption_key( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(request): Json, ) -> Result<()> { require_super_admin(&db, &authed).await?; windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?; if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) { return Err(Error::BadRequest( "Encryption key should be an alphanumeric string of 64 characters".to_string(), )); } // Build the previous cipher before the transaction (reads from cache/pool) let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace_key SET key = $1 WHERE workspace_id = $2", request.new_key.clone(), w_id ) .execute(&mut *tx) .await?; let mut reencrypted_secret_paths: Vec = Vec::new(); if !request.skip_reencrypt.unwrap_or(false) { // Build the new cipher directly from the key string, since the transaction // hasn't committed yet and build_crypt() would read the old key from the pool. let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { format!("{}{}", request.new_key, salt) } else { request.new_key.clone() }; let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256); let mut truncated_new_key = request.new_key.clone(); truncated_new_key.truncate(8); tracing::warn!( "Re-encrypting all secrets for workspace {}. New key is {}***", w_id, truncated_new_key ); let all_variables = sqlx::query!( "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", w_id ) .fetch_all(&mut *tx) .await?; for variable in all_variables { if !variable.is_secret { continue; } let decrypted_value = decrypt(&previous_encryption_key, variable.value).map_err(|e| { Error::internal_err(format!( "Error decrypting variable {}: {}", variable.path, e )) })?; let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str()); sqlx::query!( "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3", new_encrypted_value, w_id, variable.path ) .execute(&mut *tx) .await?; reencrypted_secret_paths.push(variable.path); } } tx.commit().await?; // Invalidate the cache only after the transaction has committed WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); // Build the batch: one event for the encryption key itself plus one per // re-encrypted secret variable. The batch entrypoint dispatches a single // git-sync job per repo carrying all items, so repos with Secrets sync // enabled receive the new ciphertexts in one commit. let mut batch: Vec = Vec::with_capacity(reencrypted_secret_paths.len() + 1); batch.push(DeployedObject::Key { key_type: "encryption_key".to_string() }); for path in reencrypted_secret_paths { batch.push(DeployedObject::Variable { path: path.clone(), parent_path: Some(path) }); } handle_deployment_metadata_batch( &authed.email, &authed.username, &db, &w_id, batch, Some("Encryption key updated".to_string()), ) .await?; return Ok(()); } #[derive(Serialize)] struct UsedTriggers { pub websocket_used: bool, pub http_routes_used: bool, pub kafka_used: bool, pub nats_used: bool, pub postgres_used: bool, pub mqtt_used: bool, pub amqp_used: bool, pub sqs_used: bool, pub gcp_used: bool, pub azure_used: bool, pub email_used: bool, pub nextcloud_used: bool, pub google_used: bool, pub github_used: bool, } async fn get_used_triggers( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let triggers_used = sqlx::query_as!( UsedTriggers, r#" SELECT EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!", EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!", EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!", EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!", EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!", EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!", EXISTS(SELECT 1 FROM amqp_trigger WHERE workspace_id = $1) AS "amqp_used!", EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!", EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!", EXISTS(SELECT 1 FROM azure_trigger WHERE workspace_id = $1) AS "azure_used!", EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!", EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!", EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!", EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'github'::native_trigger_service) AS "github_used!" "#, w_id ) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(Json(triggers_used)) } async fn get_workspace_as_superadmin( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { require_super_admin(&db, &authed).await?; let workspace = sqlx::query_as!( Workspace, "SELECT workspace.id AS \"id!\", workspace.name AS \"name!\", workspace.owner AS \"owner!\", workspace.deleted AS \"deleted!\", workspace.premium AS \"premium!\", workspace_settings.color AS \"color\", workspace.parent_workspace_id AS \"parent_workspace_id\", workspace.is_dev_workspace AS \"is_dev_workspace!\", workspace.dev_workspace_label AS \"dev_workspace_label\" FROM workspace LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id WHERE workspace.id = $1", w_id ) .fetch_optional(&db) .await?; let workspace = not_found_if_none(workspace, "workspace", w_id)?; Ok(Json(workspace)) } async fn list_workspaces_as_super_admin( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Query(pagination): Query, ) -> JsonResult> { require_devops_role(&db, &authed).await?; let (per_page, offset) = paginate(pagination); let mut tx = user_db.begin(&authed).await?; let workspaces = sqlx::query_as!( Workspace, "SELECT workspace.id AS \"id!\", workspace.name AS \"name!\", workspace.owner AS \"owner!\", workspace.deleted AS \"deleted!\", workspace.premium AS \"premium!\", workspace_settings.color AS \"color\", workspace.parent_workspace_id AS \"parent_workspace_id\", workspace.is_dev_workspace AS \"is_dev_workspace!\", workspace.dev_workspace_label AS \"dev_workspace_label\" FROM workspace LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id LIMIT $1 OFFSET $2", per_page as i32, offset as i32 ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(workspaces)) } async fn user_workspaces( Extension(db): Extension, ApiAuthed { email, .. }: ApiAuthed, ) -> JsonResult { let mut tx = db.begin().await?; let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label, workspace.owner AS \"created_by?\", CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, usr.disabled FROM workspace JOIN usr ON usr.workspace_id = workspace.id JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id WHERE usr.email = $1 AND workspace.deleted = false", email ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(WorkspaceList { email, workspaces })) } #[derive(Deserialize)] struct SessionWorkspaceStatusRequest { workspace_ids: Vec, } /// Reconciliation support for client-side AI sessions, which the backend cannot touch /// directly. The client posts the workspace ids its sessions reference and uses the /// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row, or /// no way for this caller to reach it) drops the sessions, `archived` (soft-deleted, still /// reachable) archives them, `active` restores ones previously archived-by-workspace. /// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the /// only way the client learns about a change made while it was away or on another device. /// /// Membership alone under-reports reachability: a superadmin is authed into any existing /// workspace without a `usr` row, and `admins` has no `usr` rows at all, so answering from /// `usr` destroys sessions that still work. It over-reports in one direction — a `usr` row /// with `disabled` counts here but not in the extractor — which only leaves a session /// lingering, so it is deliberately not treated as unreachable. async fn session_workspace_status( Extension(db): Extension, authed: ApiAuthed, Json(req): Json, ) -> JsonResult> { if req.workspace_ids.len() > 1000 { return Err(Error::BadRequest( "Too many workspace ids (max 1000)".to_string(), )); } let email = &authed.email; let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?; let rows = sqlx::query!( // A missing workspace row must be caught before the membership arm: for a // superadmin the two arms below both fall through, and a hard-deleted workspace // would report `active` forever. "SELECT req.id AS \"id!\", (CASE WHEN workspace.id IS NULL THEN 'deleted' WHEN usr.email IS NULL AND NOT $3 THEN 'deleted' WHEN workspace.deleted THEN 'archived' ELSE 'active' END) AS \"status!\" FROM unnest($1::text[]) AS req(id) LEFT JOIN workspace ON workspace.id = req.id LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2", &req.workspace_ids[..], email, is_superadmin, ) .fetch_all(&db) .await?; let statuses = rows.into_iter().map(|r| (r.id, r.status)).collect(); Ok(Json(statuses)) } /// The instance critical alert channels belong to the instance operator, who on cloud is /// not the workspace owner and never opted into a tenant's job failures. Fork workspaces run /// throwaway copies of their parent's runnables, so instance-wide operational alerting must /// stay a property of the real workspace. async fn ensure_instance_alert_fallback_allowed<'c>( tx: &mut Transaction<'c, Postgres>, w_id: &str, ) -> Result<()> { if *CLOUD_HOSTED { return Err(Error::BadRequest( "Reporting to the instance critical alert channels is not available on cloud" .to_string(), )); } let is_fork = sqlx::query_scalar!( r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#, w_id ) .fetch_optional(&mut **tx) .await? .unwrap_or(false); if is_fork { return Err(Error::BadRequest( "Reporting to the instance critical alert channels cannot be enabled on a fork workspace" .to_string(), )); } Ok(()) } pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> { if w_id == "global" { return Err(windmill_common::error::Error::BadRequest( "'global' is not allowed as a workspace ID".to_string(), )); } let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", w_id) .fetch_one(&mut **tx) .await? .unwrap_or(false); if exists { return Err(windmill_common::error::Error::BadRequest(format!( "Workspace {} already exists", w_id ))); } return Ok(()); } /// Reject fork creation when the target workspace id is already taken, /// distinguishing archived workspaces: archiving is a soft delete that keeps /// the id reserved, which users frequently mistake for a permanent delete. async fn check_fork_w_id_conflict(db: &DB, w_id: &str) -> Result<()> { let deleted = sqlx::query_scalar!("SELECT deleted FROM workspace WHERE id = $1", w_id) .fetch_optional(db) .await?; match deleted { Some(true) => Err(Error::BadRequest(format!( "Workspace '{w_id}' already exists but is archived (archiving does not free up the workspace id). \ To reuse this id, permanently delete the archived workspace first — its owner or a superadmin \ can do so from the fork creation dialog, the superadmin workspaces page, or the CLI \ (`wmill workspace delete-fork`) — or choose a different fork id." ))), Some(false) => Err(Error::BadRequest(format!( "Workspace '{w_id}' already exists. Delete the existing fork first or choose a different fork id." ))), None => Ok(()), } } /// A fork id is reusable: it is freed when a fork is deleted and can be claimed /// again under the same name. `workspace_diff`, `workspace_diff_full_scan` and /// `skip_workspace_diff_tally` are keyed by workspace id with no FK cascade, so a /// freshly created fork could inherit cached diff state from a previous occupant /// of its id — a stale skip row suppresses comparison entirely, a stale full-scan /// row makes a never-scanned pair report as scanned and up to date, and stale /// workspace_diff rows produce a spurious "changes not visible" warning that hides /// the deploy button. Clear all three so a new fork always starts with clean diff /// state, regardless of how the id was freed. async fn purge_stale_fork_diff_state(db: &DB, fork_id: &str) -> Result<()> { sqlx::query!( "DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1", fork_id ) .execute(db) .await?; sqlx::query!( "DELETE FROM workspace_diff_full_scan WHERE source_workspace_id = $1 OR fork_workspace_id = $1", fork_id ) .execute(db) .await?; sqlx::query!( "DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1", fork_id ) .execute(db) .await?; Ok(()) } lazy_static::lazy_static! { pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = { match std::env::var("CREATE_WORKSPACE_REQUIRE_SUPERADMIN") { Ok(val) => val == "true", Err(_) => true, } }; pub static ref DISABLE_WORKSPACE_FORK: bool = { match std::env::var("DISABLE_WORKSPACE_FORK") { Ok(val) => val == "true", Err(_) => false, } }; // Cloud only: how many forks a premium workspace may have per paid (developer) seat. pub static ref MAX_FORKS_PER_SEAT: i64 = std::env::var("MAX_FORKS_PER_SEAT") .ok() .and_then(|v| v.parse::().ok()) .filter(|v| *v >= 0) .unwrap_or(5); // How deep a fork chain may nest (root = depth 0, a direct fork = depth 1). A general guardrail // for all builds, independent of the cloud per-seat cap: deep fork chains are a footgun and no // real use case needs them. Clamped to [1, 20] so it's always a real limit and can never exceed // the fork-walk recursion backstop (20) that billing/count resolution uses (a chain deeper than // the backstop would truncate and mis-resolve its root). pub static ref MAX_FORK_DEPTH: i64 = std::env::var("MAX_FORK_DEPTH") .ok() .and_then(|v| v.parse::().ok()) .map(|v| v.clamp(1, 20)) .unwrap_or(5); } async fn create_workspace_require_superadmin() -> String { format!("{}", *CREATE_WORKSPACE_REQUIRE_SUPERADMIN) } async fn _check_nb_of_workspaces(db: &DB) -> Result<()> { let nb_workspaces = sqlx::query_scalar!( "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = false", ) .fetch_one(db) .await?; if nb_workspaces.unwrap_or(0) >= 2 { return Err(Error::BadRequest( "You have reached the maximum number of workspaces (2 outside of default workspace 'admins') without an enterprise license. Archive/delete another workspace to create a new one" .to_string(), )); } return Ok(()); } async fn _check_nb_of_archived_workspaces(db: &DB) -> Result<()> { let nb_archived = sqlx::query_scalar!( "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true", ) .fetch_one(db) .await?; if nb_archived.unwrap_or(0) >= 1 { return Err(Error::BadRequest( "You have reached the maximum number of archived workspaces (1) without an enterprise license. Permanently delete or unarchive the existing archived workspace first" .to_string(), )); } return Ok(()); } async fn create_workspace( authed: ApiAuthed, Extension(db): Extension, Json(nw): Json, ) -> Result { if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { require_super_admin(&db, &authed).await?; } #[cfg(not(feature = "enterprise"))] _check_nb_of_workspaces(&db).await?; if *CLOUD_HOSTED { let nb_workspaces = sqlx::query_scalar!( "SELECT COUNT(*) FROM workspace WHERE owner = $1", authed.email ) .fetch_one(&db) .await?; if nb_workspaces.unwrap_or(0) >= 10 { return Err(Error::BadRequest( "You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit" .to_string(), )); } } validate_workspace_name(&nw.name)?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; check_w_id_conflict(&mut tx, &nw.id).await?; sqlx::query!( "INSERT INTO workspace (id, name, owner) VALUES ($1, $2, $3)", nw.id, nw.name, authed.email, ) .execute(&mut *tx) .await?; if nw.error_handler_fallback_to_instance_alerts { ensure_instance_alert_fallback_allowed(&mut tx, &nw.id).await?; } sqlx::query!( "INSERT INTO workspace_settings (workspace_id, color, error_handler_fallback_to_instance_alerts) VALUES ($1, $2, $3)", nw.id, nw.color, nw.error_handler_fallback_to_instance_alerts, ) .execute(&mut *tx) .await?; let key = rd_string(64); sqlx::query!( "INSERT INTO workspace_key (workspace_id, kind, key) VALUES ($1, 'cloud', $2)", nw.id, &key ) .execute(&mut *tx) .await?; // let mc = magic_crypt::new_magic_crypt!(key, 256); // sqlx::query!( // "INSERT INTO variable // (workspace_id, path, value, is_secret, description) // VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'), // ($3, 'g/all/not_secret', $4, false, 'This item is not secret')", // nw.id, // crate::variables::encrypt(&mc, "pretty secret value"), // nw.id, // "finland does not actually exist", // ) // .execute(&mut *tx) // .await?; let automate_username_creation = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", AUTOMATE_USERNAME_CREATION_SETTING, ) .fetch_optional(&mut *tx) .await? .map(|v| v.as_bool()) .flatten() .unwrap_or(true); let username = if automate_username_creation { if nw.username.is_some() && nw.username.unwrap().len() > 0 { return Err(Error::BadRequest( "username is not allowed when username creation is automated".to_string(), )); } get_instance_username_or_create_pending(&mut tx, &authed.email).await? } else { nw.username .ok_or(Error::BadRequest("username is required".to_string()))? }; sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin) VALUES ($1, $2, $3, true)", nw.id, authed.email, username, ) .execute(&mut *tx) .await?; sqlx::query!( "INSERT INTO group_ VALUES ($1, 'all', 'The group that always contains all users of this workspace')", nw.id ) .execute(&mut *tx) .await?; sqlx::query!( "INSERT INTO group_ VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')", nw.id ) .execute(&mut *tx) .await?; sqlx::query!( "INSERT INTO usr_to_group VALUES ($1, 'all', $2)", nw.id, username ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.create", ActionKind::Create, &nw.id, Some(nw.name.as_str()), None, ) .await?; tx.commit().await?; Ok(format!("Created workspace {}", &nw.id)) } // `authed` is the forker — `clone_drafts` only carries this user's per-user // drafts (and the legacy NULL-email workspace draft, if any) across, since other // users aren't added to the fork's `usr` table and their drafts would dangle as // orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, db: &DB, source_workspace_id: &str, target_workspace_id: &str, authed: &ApiAuthed, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; // Clone data table migration definitions (the settings above carry the data // table config; this carries their migration history). crate::datatable_migrations::clone_datatable_migrations( tx, source_workspace_id, target_workspace_id, ) .await?; // Clone workspace environment variables clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?; // Clone folders clone_folders(tx, source_workspace_id, target_workspace_id).await?; // Clone groups clone_groups(tx, source_workspace_id, target_workspace_id).await?; // Clone resource types clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; // Clone resources clone_resources(tx, source_workspace_id, target_workspace_id).await?; // Clone variables (including external secret backend replication) clone_variables(tx, db, source_workspace_id, target_workspace_id).await?; // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; clone_eval_datasets(tx, source_workspace_id, target_workspace_id).await?; // Clone the dbt graph sidecars. After `clone_scripts`, which keeps each // script's hash: these key on it, and a static descriptor never re-ingests, // so a fork without them shows dbt scripts with no models until someone // redeploys. clone_dbt_graph(tx, source_workspace_id, target_workspace_id).await?; // Clone CI test references clone_ci_test_references(tx, source_workspace_id, target_workspace_id).await?; clone_macro_registry(tx, source_workspace_id, target_workspace_id).await?; clone_metric_catalog(tx, source_workspace_id, target_workspace_id).await?; clone_asset_usages_and_triggers(tx, source_workspace_id, target_workspace_id).await?; // Clone flows with new versions clone_flows(tx, source_workspace_id, target_workspace_id).await?; // Clone flow nodes clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; // Clone apps with new IDs and app scripts let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; // Clone the forker's own per-user drafts (plus the legacy NULL-email // workspace draft, if any) so they keep their pending edits in the // fork. Other users' drafts are intentionally NOT cloned — they don't // own a `usr` row in the fork (see `clone_workspace_full`), so those // drafts would belong to someone the fork holds no membership for. clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?; // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; // Clone workspace dependencies clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?; Ok(()) } /// Clone every trigger and schedule from the parent workspace, forcing /// `mode='disabled'` / `enabled=false`. Always runs at fork creation — /// disabled rows have no side effects, so cloning them is safe and lets /// users re-enable selectively in the fork. Listener identifiers /// (group_id, replication_slot_name, subscription_name, …) are copied /// verbatim — the runtime suffix that prevents the fork from competing with /// the parent ships in a follow-up PR. async fn clone_triggers_and_schedules( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // Managed ducklake-maintenance schedules are excluded: the fork starts // with no ducklake config, so cloned rows could never resolve a lake and // the schedule API refuses mutations under the reserved prefix. sqlx::query!( r#"INSERT INTO schedule ( workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, extra_perms, is_flow, email, error, timezone, on_failure, on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, cron_version, description, dynamic_skip, permissioned_as, labels ) SELECT $1, path, edited_by, edited_at, schedule, FALSE, script_path, args, extra_perms, is_flow, email, error, timezone, on_failure, on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, cron_version, description, dynamic_skip, permissioned_as, labels FROM schedule WHERE workspace_id = $2 AND NOT starts_with(path, $3)"#, target_workspace_id, source_workspace_id, windmill_common::workspaces::DUCKLAKE_MAINTENANCE_PATH_PREFIX, ) .execute(&mut **tx) .await?; // Skip non-workspaced HTTP triggers: their URL has no workspace prefix, so // a clone would collide with the parent's row at runtime (matchit::Router // silently drops one of two duplicates) and `route_path_key_exists` would // also fail to spot the cross-workspace conflict cleanly. The instance // settings `CLOUD_HOSTED` and `HTTP_ROUTE_WORKSPACED_ROUTE` force every // route to be workspace-prefixed regardless of the column, so when either // is on we clone everything. let force_workspaced = *CLOUD_HOSTED || HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed); sqlx::query!( r#"INSERT INTO http_trigger ( path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, authentication_method, http_method, static_asset_config, is_static_website, workspaced_route, wrap_body, raw_string, authentication_resource_path, summary, description, error_handler_path, error_handler_args, retry, request_type, mode, permissioned_as, labels ) SELECT path, route_path, route_path_key, script_path, is_flow, $1, edited_by, edited_at, extra_perms, authentication_method, http_method, static_asset_config, is_static_website, workspaced_route, wrap_body, raw_string, authentication_resource_path, summary, description, error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM http_trigger WHERE workspace_id = $2 AND (workspaced_route IS TRUE OR $3)"#, target_workspace_id, source_workspace_id, force_workspaced, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO websocket_trigger ( path, url, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, filters, initial_messages, url_runnable_args, can_return_message, error_handler_path, error_handler_args, retry, can_return_error_result, mode, permissioned_as, filter_logic, labels, heartbeat ) SELECT path, url, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, filters, initial_messages, url_runnable_args, can_return_message, error_handler_path, error_handler_args, retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels, heartbeat FROM websocket_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO kafka_trigger ( path, kafka_resource_path, topics, group_id, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, error_handler_path, error_handler_args, retry, mode, filters, auto_offset_reset, reset_offset, auto_commit, permissioned_as, filter_logic, labels ) SELECT path, kafka_resource_path, topics, group_id, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit, permissioned_as, filter_logic, labels FROM kafka_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO nats_trigger ( path, nats_resource_path, subjects, stream_name, consumer_name, use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT path, nats_resource_path, subjects, stream_name, consumer_name, use_jetstream, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM nats_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO postgres_trigger ( path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, postgres_resource_path, error, server_id, last_server_ping, replication_slot_name, publication_name, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, postgres_resource_path, NULL, NULL, NULL, replication_slot_name, publication_name, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM postgres_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO mqtt_trigger ( mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, client_id, path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM mqtt_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO amqp_trigger ( amqp_resource_path, queue_name, exchange, options, path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT amqp_resource_path, queue_name, exchange, options, path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM amqp_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO sqs_trigger ( path, queue_url, aws_resource_path, message_attributes, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, error, server_id, last_server_ping, aws_auth_resource_type, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT path, queue_url, aws_resource_path, message_attributes, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, aws_auth_resource_type, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM sqs_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO gcp_trigger ( gcp_resource_path, project_id, topic_id, subscription_id, delivery_type, delivery_config, path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, subscription_mode, error_handler_path, error_handler_args, retry, auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels ) SELECT gcp_resource_path, project_id, topic_id, subscription_id, delivery_type, delivery_config, path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, subscription_mode, error_handler_path, error_handler_args, retry, auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM gcp_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#"INSERT INTO azure_trigger ( azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, event_type_filters, push_auth_config, path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, mode, permissioned_as, error_handler_path, error_handler_args, retry, labels ) SELECT azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, event_type_filters, push_auth_config, path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path, error_handler_args, retry, labels FROM azure_trigger WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; // Skip non-workspaced email triggers: same shape as the non-workspaced // HTTP route case — a clone would share the same `local_part@domain` // address as the parent, and incoming mail would arbitrarily land in one // or the other. CLOUD_HOSTED scopes email lookup by workspace_id natively, // so on cloud we clone everything. sqlx::query!( r#"INSERT INTO email_trigger ( path, local_part, workspaced_local_part, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels ) SELECT path, local_part, workspaced_local_part, script_path, is_flow, $1, edited_by, edited_at, extra_perms, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM email_trigger WHERE workspace_id = $2 AND (workspaced_local_part IS TRUE OR $3)"#, target_workspace_id, source_workspace_id, *CLOUD_HOSTED, ) .execute(&mut **tx) .await?; Ok(()) } async fn update_workspace_settings( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO workspace_key (workspace_id, kind, key) SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( r#" UPDATE workspace_settings SET ai_config = source_ws.ai_config, large_file_storage = source_ws.large_file_storage, ducklake = source_ws.ducklake, dbt_warehouses = source_ws.dbt_warehouses, datatable = source_ws.datatable, git_app_installations = source_ws.git_app_installations FROM workspace_settings source_ws WHERE source_ws.workspace_id = $1 AND workspace_settings.workspace_id = $2 "#, source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; let current_git_sync_settings = sqlx::query!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", source_workspace_id ) .fetch_optional(&mut **tx) .await?; let mut git_sync_settings = if let Some(row) = current_git_sync_settings { if let Some(git_sync) = row.git_sync { serde_json::from_value::(git_sync) .map_err(|err| Error::internal_err(err.to_string()))? } else { WorkspaceGitSyncSettings::default() } } else { WorkspaceGitSyncSettings::default() }; // We only keep the first git sync repo that is sync mode (use_individual_branch = false), since it is considered the main one // Context: see WIN-1559 git_sync_settings.repositories = git_sync_settings .repositories .into_iter() .filter(|r| !r.use_individual_branch.unwrap_or(false)) .take(1) .map(|mut r| { // Auto-pull and fork PRs are parent-owned and must not be inherited: // the fork would otherwise carry the parent's webhook id (turning off // auto-pull on the fork would delete the parent's webhook). A fork // still inherits the push-direction config and the installation. // Repo → fork sync is driven by the parent's webhook/poller // (`sync_forks`), which routes the fork's `wm-fork/**` branch into it. r.auto_pull = None; r.fork_open_prs = false; r.open_pr_error = None; r }) .collect(); let serialized_config = serde_json::to_value::(git_sync_settings) .map_err(|err| Error::internal_err(err.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", serialized_config, target_workspace_id ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_workspace_env( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO workspace_env (workspace_id, name, value) SELECT $2, name, value FROM workspace_env WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_folders( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels FROM folder WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_groups( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO group_ (workspace_id, name, summary, extra_perms) SELECT $2, name, summary, extra_perms FROM group_ WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO usr_to_group (workspace_id, group_, usr) SELECT $2, group_, usr FROM usr_to_group WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } /// Copy the source workspace's members (the `usr` rows, carrying each member's role) into the /// target so a fork/dev can be a shared environment. Idempotent — skips members the target already /// has. Group memberships are not handled here: the sole caller is the create-fork path, where /// `clone_groups` already copies the source's full group structure (including `all` membership). async fn copy_workspace_members( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via FROM usr WHERE workspace_id = $2 ON CONFLICT DO NOTHING", target_workspace_id, source_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_resource_types( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset) SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_resources( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by) SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by FROM resource WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_eval_datasets( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // The authored evaluation data — datasets and their cases — travels with a fork like resources // and scripts do; the runs (experiments) do not, since they name jobs the fork has no copy of. sqlx::query!( "INSERT INTO eval_dataset (workspace_id, path, summary, scorers, extra_perms, created_at, created_by, edited_at, edited_by) SELECT $2, path, summary, scorers, extra_perms, created_at, created_by, edited_at, edited_by FROM eval_dataset WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; // A new id per cloned case: `eval_case`'s primary key is the id alone, unique across the whole // table, so copying it would collide with the source's own rows. sqlx::query!( "INSERT INTO eval_case (workspace_id, dataset_path, input, expected, created_at, created_by) SELECT $2, dataset_path, input, expected, created_at, created_by FROM eval_case WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_variables( tx: &mut Transaction<'_, Postgres>, db: &DB, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at) SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at FROM variable WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; // With an external backend the secret lives in the store under (workspace_id, // path), so the row copy above leaves the fork pointing at keys that don't // exist. Replicate every secret, not just marker-valued ones: migration writes // to the store without rewriting `value` to a `$...:` marker. if is_vault_backend_configured(db).await? { let secret_variables = sqlx::query!( "SELECT path FROM variable WHERE workspace_id = $1 AND is_secret = true AND value != ''", target_workspace_id, ) .fetch_all(&mut **tx) .await?; let backend = get_secret_backend(db).await?; for variable in secret_variables { match backend .get_secret(source_workspace_id, &variable.path) .await { Ok(plain_value) => { backend .set_secret(target_workspace_id, &variable.path, &plain_value) .await .map_err(|e| { Error::internal_err(format!( "Failed to replicate secret variable {} to the external secret backend for the forked workspace: {e}", variable.path )) })?; } // The source secret is unreadable (e.g. deleted out-of-band from // the external store), so the variable is equally broken in the // source workspace — don't let it block forking. Err(e) => { tracing::warn!( workspace_id = %source_workspace_id, path = %variable.path, error = %e, "Could not read secret variable from the external secret backend while forking; the forked variable will not resolve" ); } } } } Ok(()) } /// A principal is workspace-scoped — `usr` is keyed by `(workspace_id, username)` — and a /// clone lands in a workspace with its own membership, so it is kept only when it still /// resolves there (the fork copies usernames and groups verbatim, and an instance superadmin /// resolves anywhere). Dropping one that names nobody is the only safe alternative: it could /// not authenticate, and the runnable falls back to running as its caller. async fn clone_scripts( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // Clone all scripts directly with a single query sqlx::query!( r#"INSERT INTO script ( workspace_id, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, assets, modules ) SELECT $1, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, -- Same three forms and the same prefix-first rule as permissioned_as_exists, -- superadmin fallback included: one acting outside their workspaces has no usr -- row but still authenticates. CASE WHEN on_behalf_of LIKE 'u/%' THEN (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar AND u.username = substring(on_behalf_of from 3) UNION ALL SELECT 1 FROM password p WHERE p.super_admin AND (p.username = substring(on_behalf_of from 3) OR p.email = substring(on_behalf_of from 3)))) WHEN on_behalf_of LIKE 'g/%' THEN (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar AND g.name = substring(on_behalf_of from 3))) ELSE (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar AND u.username = on_behalf_of UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) END, on_behalf_of_email, assets, modules FROM script WHERE workspace_id = $2"#, target_workspace_id, source_workspace_id ) .execute(&mut **tx) .await?; clear_orphaned_compat_address( tx, "script", "hash", source_workspace_id, target_workspace_id, ) .await?; Ok(()) } /// The parsed dbt graph a deployed script carries: its models, their SQL and /// tests, and the `ref()` lineage between them. /// /// Keyed on (workspace_id, script_path, script_hash), and the fork keeps every /// script's hash, so each row moves across as itself. /// /// The DEPLOYED graph only (`job_id` all-zero). A per-run snapshot is keyed to a /// job in the source workspace, which nothing in the fork can ask for, so /// copying them adds another workspace's run history — `raw_code` and all — to /// every fork transaction to be reclaimed later by the age sweep. async fn clone_dbt_graph( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id, resource_type, name, asset_path, materialized, materialize_strategy, unique_key, tags, description, test_kind, test_column, test_args, severity, attached_node, columns, freshness, raw_code, original_file_path, ingested_at) SELECT $2, script_path, script_hash, job_id, unique_id, resource_type, name, asset_path, materialized, materialize_strategy, unique_key, tags, description, test_kind, test_column, test_args, severity, attached_node, columns, freshness, raw_code, original_file_path, ingested_at FROM dbt_node WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'", source_workspace_id, target_workspace_id ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO dbt_edge (workspace_id, script_path, script_hash, job_id, parent_unique_id, child_unique_id, ingested_at) SELECT $2, script_path, script_hash, job_id, parent_unique_id, child_unique_id, ingested_at FROM dbt_edge WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'", source_workspace_id, target_workspace_id ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id, digest, relation_root_at_last_ingest, ingested_at) SELECT $2, script_path, script_hash, job_id, digest, relation_root_at_last_ingest, ingested_at FROM dbt_graph_snapshot WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'", source_workspace_id, target_workspace_id ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_ci_test_references( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind FROM ci_test_reference WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } // DuckDB macro registry + call-site edges are deploy-derived like // ci_test_reference: without cloning them, a forked consumer script fails at // run time until every macro library is manually redeployed in the fork. async fn clone_macro_registry( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO macro_definition (workspace_id, name, provider_path, params, body, is_table_macro) SELECT $2, name, provider_path, params, body, is_table_macro FROM macro_definition WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO macro_usage (workspace_id, consumer_path, macro_name) SELECT $2, consumer_path, macro_name FROM macro_usage WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } // Declared measures/dimensions are deploy-derived like the macro registry: // without cloning them the fork's editor and agent tools report no metrics until // every producer is manually redeployed there. async fn clone_metric_catalog( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO data_metric (workspace_id, script_path, table_path, kind, name, expr, filter) SELECT $2, script_path, table_path, kind, name, expr, filter FROM data_metric WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } // Asset usage rows and `// on` subscriber triggers are deploy-derived like // ci_test_reference / the macro registry: without cloning them the fork's // pipeline graph has no asset nodes or lineage edges, and — worse — the asset // dispatch cascade never fires in the fork (it reads `script_trigger`), so // materializing an upstream node can't trigger its consumers until every // script is manually redeployed. `job`-kind usage rows (runtime-detected, // ephemeral) are skipped like the graph does. async fn clone_asset_usages_and_triggers( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns) SELECT $2, path, kind, usage_access_type, usage_path, usage_kind, columns FROM asset WHERE workspace_id = $1 AND usage_kind IN ('script', 'flow')", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO script_trigger (workspace_id, runnable_kind, runnable_path, trigger_kind, trigger_ref, join_all, debounce_s, retry_count, retry_delay_s) SELECT $2, runnable_kind, runnable_path, trigger_kind, trigger_ref, join_all, debounce_s, retry_count, retry_delay_s FROM script_trigger WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } /// The address is only meaningful next to the principal it was derived from: where the clone /// dropped one that names nobody in the target, an old worker reading the address alone would /// still run the row as the account left behind. /// /// Only where *the clone* dropped it. A source row that already had no principal is one a server /// predating this release wrote, address alone; that address is all there is to recover it from, /// and the migration that drops the column re-derives from it. async fn clear_orphaned_compat_address( tx: &mut Transaction<'_, Postgres>, table: &str, key: &str, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // SAFETY: `table` and `key` are literals from the two call sites, never user input. sqlx::query(&format!( "UPDATE {table} t SET on_behalf_of_email = NULL WHERE t.workspace_id = $1 AND t.on_behalf_of IS NULL AND t.on_behalf_of_email IS NOT NULL AND EXISTS (SELECT 1 FROM {table} s WHERE s.workspace_id = $2 AND s.{key} = t.{key} AND s.on_behalf_of IS NOT NULL)" )) .bind(target_workspace_id) .bind(source_workspace_id) .execute(&mut **tx) .await?; Ok(()) } /// Carries over the recorded principal under the rule spelled out on [`clone_scripts`]. async fn clone_flows( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // First, clone flows without versions sqlx::query!( "INSERT INTO flow ( workspace_id, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs ) SELECT $2, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, NULL, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, ARRAY[]::bigint[], -- Same predicate as clone_scripts. CASE WHEN on_behalf_of LIKE 'u/%' THEN (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar AND u.username = substring(on_behalf_of from 3) UNION ALL SELECT 1 FROM password p WHERE p.super_admin AND (p.username = substring(on_behalf_of from 3) OR p.email = substring(on_behalf_of from 3)))) WHEN on_behalf_of LIKE 'g/%' THEN (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar AND g.name = substring(on_behalf_of from 3))) ELSE (SELECT on_behalf_of WHERE EXISTS ( SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar AND u.username = on_behalf_of UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) END, on_behalf_of_email, lock_error_logs FROM flow WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; clear_orphaned_compat_address(tx, "flow", "path", source_workspace_id, target_workspace_id) .await?; // Then clone flow versions let flow_versions = sqlx::query!( "SELECT id, workspace_id, path, value, schema, created_by, created_at FROM flow_version WHERE workspace_id = $1 ORDER BY path, created_at", source_workspace_id ) .fetch_all(&mut **tx) .await?; for version in flow_versions { let new_version_id = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", target_workspace_id, version.path, version.value, version.schema, version.created_by, version.created_at, ) .fetch_one(&mut **tx) .await?; // Update flow to include this version sqlx::query!( "UPDATE flow SET versions = array_append(versions, $1) WHERE workspace_id = $2 AND path = $3", new_version_id, target_workspace_id, version.path, ) .execute(&mut **tx) .await?; } Ok(()) } async fn clone_flow_nodes( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2) SELECT $2, (SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash, source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2 FROM flow_node source_fn WHERE source_fn.workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } /// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app` /// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what /// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives /// any revocation in the parent. fn repoint_cloned_app_identity(policy: &mut serde_json::Value, authed: &ApiAuthed) { let Some(obj) = policy.as_object_mut() else { return; }; obj.insert( "on_behalf_of".to_string(), serde_json::Value::String(username_to_permissioned_as(&authed.username)), ); obj.insert( "on_behalf_of_email".to_string(), serde_json::Value::String(authed.email.clone()), ); } async fn clone_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, authed: &ApiAuthed, ) -> Result> { // `execution_mode` is cloned as-is: protection rules are workspace-scoped and not cloned, so // forcing `publisher` is only a speed bump (the creator can publish an anonymous app in the // fork freely), and it is the one policy field a deploy back to the parent carries verbatim — // `update_app` recomputes the identity but writes the policy wholesale. let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed); // Get all apps from source workspace let apps = sqlx::query!( "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path FROM app WHERE workspace_id = $1", source_workspace_id ) .fetch_all(&mut **tx) .await?; let mut app_id_mapping: HashMap = HashMap::new(); // Only a raw app's current (last) version has a bundle worth carrying into the fork: bundles exist // only for raw apps, and older versions aren't viewable/runnable (the bundle secret is only ever // minted for `versions.last()`). Copying a bundle for every version of every app is what makes // forking a workspace with many app versions hang — a serial S3 round-trip per version, held inside // the fork transaction. Collect each app's current version here; intersect with raw versions below. let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs for mut app in apps { if let Some(¤t_version) = app.versions.last() { latest_version_ids.insert(current_version); } if !preserve_identity { repoint_cloned_app_identity(&mut app.policy, authed); } // Both halves of what `create_app` demands to set a custom path: admin, and — unless // paths are scoped per workspace — that nobody else holds it. Cloning one instance-wide // would leave the parent's live public URL, resolved with no workspace filter and no // ordering, answering from either row. let scoped = *CLOUD_HOSTED || windmill_common::apps::APP_WORKSPACED_ROUTE .load(std::sync::atomic::Ordering::Relaxed); let custom_path = if scoped && authed.is_admin { app.custom_path } else { None }; let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", target_workspace_id, app.path, app.summary, app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, custom_path, ) .fetch_one(&mut **tx) .await?; app_id_mapping.insert(app.id, new_app_id); } let mut version_id_mapping: HashMap = HashMap::new(); let mut raw_version_ids: HashSet = HashSet::new(); { // Clone app versions let app_versions = sqlx::query!( "SELECT id, app_id, value, created_by, created_at, raw_app FROM app_version WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1) ORDER BY app_id, created_at", source_workspace_id ) .fetch_all(&mut **tx) .await?; for version in app_versions { if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) { if version.raw_app { raw_version_ids.insert(version.id); } let new_version_id = sqlx::query_scalar!( "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) VALUES ($1, $2, $3, $4, $5) RETURNING id", new_app_id, version.value, version.created_by, version.created_at, version.raw_app, ) .fetch_one(&mut **tx) .await?; version_id_mapping.insert(version.id, new_version_id); } } } // The bundles worth cloning: each raw app's current version (latest ∩ raw). Everything else either // has no bundle (low-code apps) or an unreachable one (older versions), so we don't touch S3 for it. let bundle_version_ids: HashSet = latest_version_ids .intersection(&raw_version_ids) .copied() .collect(); // Clone app bundles — only the current version of each raw app (see bundle_version_ids). if !bundle_version_ids.is_empty() { let old_ids: Vec = bundle_version_ids.iter().copied().collect(); let bundles = sqlx::query!( "SELECT app_version_id, file_type, data FROM app_bundles WHERE app_version_id = ANY($1) AND w_id = $2", &old_ids, source_workspace_id ) .fetch_all(&mut **tx) .await?; let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new(); for bundle in &bundles { cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone())); } for bundle in bundles { if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) { sqlx::query!( "INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)", new_version_id, target_workspace_id, bundle.file_type, bundle.data, ) .execute(&mut **tx) .await?; } } // Clone bundles from S3 for versions not found in DB #[cfg(all(feature = "enterprise", feature = "parquet"))] { let object_store = windmill_object_store::get_object_store().await; if let Some(os) = object_store { for (&old_version_id, &new_version_id) in &version_id_mapping { if !bundle_version_ids.contains(&old_version_id) { continue; } for file_type in &["js", "css"] { if cloned_from_db.contains(&(old_version_id, file_type.to_string())) { continue; } // Prefer a server-side copy (no bytes through the backend). A missing source — // e.g. a raw app with a js bundle but no css — surfaces as NotFound and is // skipped. Not every object-store provider supports server-side copy, so fall // back to download+upload on any other error. let src_path = windmill_object_store::object_store_reexports::Path::from(format!( "/app_bundles/{}/{}.{}", source_workspace_id, old_version_id, file_type )); let dst_path = windmill_object_store::object_store_reexports::Path::from(format!( "/app_bundles/{}/{}.{}", target_workspace_id, new_version_id, file_type )); match os.copy(&src_path, &dst_path).await { Ok(()) => { tracing::info!( "Cloned app bundle in object store: {}.{} -> {}.{}", old_version_id, file_type, new_version_id, file_type ); } Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { // No bundle in the object store for this version/type, skip } Err(copy_err) => { // Provider may not support server-side copy — fall back to get+put. tracing::warn!( "object store copy failed ({copy_err:#}), falling back to get+put for app bundle {}.{}", old_version_id, file_type ); match os.get(&src_path).await { Ok(result) => { let data = result.bytes().await.map_err( windmill_object_store::object_store_error_to_error, )?; os.put(&dst_path, data.into()).await.map_err( windmill_object_store::object_store_error_to_error, )?; tracing::info!( "Cloned app bundle via get+put fallback: {}.{} -> {}.{}", old_version_id, file_type, new_version_id, file_type ); } Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { // No bundle for this version/type, skip } Err(e) => { return Err( windmill_object_store::object_store_error_to_error(e), ); } } } } } } } } } // Update app versions arrays sqlx::query!( "UPDATE app SET versions = ( SELECT array_agg(av.id ORDER BY av.created_at) FROM app_version av WHERE av.app_id = app.id ) WHERE workspace_id = $1", target_workspace_id ) .execute(&mut **tx) .await?; // Clone app scripts with recomputed hashes let app_scripts = sqlx::query!( "SELECT app, hash, lock, code, code_sha256 FROM app_script WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", source_workspace_id ) .fetch_all(&mut **tx) .await?; for app_script in app_scripts { if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { // Recompute hash using app_id, code_sha256, and lock let mut hasher = Sha256::new(); hasher.update(new_app_id.to_be_bytes()); hasher.update(hex::decode(&app_script.code_sha256)?); if let Some(lock) = &app_script.lock { hasher.update(lock.as_bytes()); } let new_hash = hex::encode(hasher.finalize()); sqlx::query!( "INSERT INTO app_script (app, hash, lock, code, code_sha256) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", new_app_id, new_hash, app_script.lock, app_script.code, app_script.code_sha256, ) .execute(&mut **tx) .await?; } } Ok(app_id_mapping) } async fn clone_raw_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { sqlx::query!( "INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms) SELECT path, version, $2, summary, edited_at, data, extra_perms FROM raw_app WHERE workspace_id = $1", source_workspace_id, target_workspace_id, ) .execute(&mut **tx) .await?; Ok(()) } /// Clone every per-user draft (and the legacy NULL-email workspace draft, /// if present) from the parent. The fork target is empty at create time so /// a plain INSERT is safe — no need to UPSERT against the partial unique /// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the /// BIGSERIAL synthetic PK and is regenerated by the default; we don't list /// it in the column set. `created_at` is preserved so the per-tab /// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's /// `draft_saved_at`) lines up with the parent's timeline — otherwise the /// fork's first POST from any open editor would race a stale `last_sync` /// and trip the conflict modal on every cloned draft. // Only `email = authed_email` and the legacy NULL row are cloned — see // `clone_workspace_data` for the rationale. async fn clone_drafts( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, authed_email: &str, ) -> Result<()> { // A script/flow draft carries the principal in its value, and deploying it in the clone // would send a pair naming somebody who is not a member there. Stripped rather than // filtered like `clone_scripts`: the address the draft still carries re-derives the // clone's own principal at deploy time, which is the more accurate answer of the two. sqlx::query!( "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) SELECT $2, path, typ, CASE WHEN typ IN ('script', 'flow') THEN to_json(to_jsonb(value) - 'on_behalf_of') ELSE value END, created_at, email FROM draft WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", source_workspace_id, target_workspace_id, authed_email, ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // Clone workspace_runnable_dependencies sqlx::query!( "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path) SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path FROM workspace_runnable_dependencies WHERE workspace_id = $2", target_workspace_id, source_workspace_id ) .execute(&mut **tx) .await?; // Recorded so the clone's own relocks have something to match; with no row they record NULL // and nothing in it ever skips. Hashed from the locks the clone holds rather than copied from // the source's rows, which are only as current as the last write to them: one left stale by a // supplied lock deployed before this was recorded names a lock the clone no longer has, and an // importer that resolved against the real one would then skip a relock it needed. record_lock_hashes_for_workspace(tx, target_workspace_id).await?; // Deliberately without `imported_lockfile_hash`: it records what an importer resolved against // when it was last locked, which nothing here can establish for the version the clone got. // Left NULL, every importer relocks once and re-anchors both sides to what the clone holds. sqlx::query!( "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) SELECT $1, importer_path, importer_kind, imported_path, importer_node_id FROM dependency_map WHERE workspace_id = $2", target_workspace_id, source_workspace_id ) .execute(&mut **tx) .await?; Ok(()) } async fn clone_workspace_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { // Clone workspace_dependencies sqlx::query!( "INSERT INTO workspace_dependencies (workspace_id, language, name, description, content, archived, created_at) SELECT $1, language, name, description, content, archived, created_at FROM workspace_dependencies WHERE workspace_id = $2", target_workspace_id, source_workspace_id ) .execute(&mut **tx) .await?; Ok(()) } async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result { return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string())); } /// Return the uuids of the git sync jobs to create the branch before creating the fork async fn create_workspace_fork_branch( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(nw): Json, ) -> JsonResult> { // Pre-check the fork guards before creating any git branch, so we don't leave orphaned branches // behind when the follow-up create_workspace_fork would be rejected anyway. enforce_fork_depth(&db, &w_id, 0).await?; #[cfg(feature = "cloud")] if *CLOUD_HOSTED { enforce_cloud_fork_cap(&db, &w_id).await?; } if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed).await?; } if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &w_id, &ProtectionRuleKind::DisableWorkspaceForking, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } // Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and // validates up front; it does NOT create the workspace row. The caller follows up with // `create_workspace_fork`, which inserts the row and applies the dev designation + prod lock + // member copy. So the dev/lock/copy_members fields here are validated only — they are acted on by // that second call. Validating early lets a bad request fail before any branch is created. if nw.is_dev_workspace { validate_dev_workspace_id(&nw.id)?; // Reject a bad label before any git branch is created (acted on in create_workspace_fork). let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&w_id]).await?; ensure_dev_parent_can_host_dev(&db, &w_id).await?; reject_dev_label_taken_in_chain( &mut *db.acquire().await?, &w_id, &nw.id, false, label.as_deref(), ) .await?; // Reject before creating any git branch if the parent already has a dev workspace, // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. ensure_no_existing_dev_workspace(&db, &w_id).await?; // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking the // parent mutates its protection rules), so require admin of the parent regardless of the lock // flags — mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker // could claim the dev slot. Enforced in this first phase too so the request fails before any // git branch is created rather than leaving dangling branches. require_admin(authed.is_admin, &authed.username)?; } else { validate_fork_workspace_id(&nw.id)?; } validate_workspace_name(&nw.name)?; // Fail before creating any git branch so a name conflict doesn't leave a // dangling branch on the synced repos. check_fork_w_id_conflict(&db, &nw.id).await?; purge_stale_fork_diff_state(&db, &nw.id).await?; Ok(Json( handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, )) } /// Update a forked workspace's datatable config to point to the new database. /// For instance datatables: updates resource_path in the datatable config. /// For resource datatables: updates the resource's dbname and marks it as ws_specific. /// Snapshot the schema from the source datatable by connecting to its database. async fn snapshot_datatable_schema( db: &DB, parent_w_id: &str, dt_name: &str, ) -> Result { let pg = get_datatable_resource_from_db_unchecked(db, parent_w_id, dt_name).await?; let pg: PgDatabase = serde_json::from_value(pg) .map_err(|e| Error::internal_err(format!("Failed to parse db credentials: {}", e)))?; let (client, connection) = pg.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let schema = windmill_common::query_builders::pg_get_full_schema(&client) .await .map_err(Error::internal_err)?; drop(client); windmill_common::shutdown_pg_connection(join_handle).await?; serde_json::to_value(schema) .map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e))) } async fn apply_forked_datatable( db: &DB, tx: &mut Transaction<'_, Postgres>, parent_w_id: &str, forked_w_id: &str, fdt: &ForkedDatatableInfo, ) -> Result<()> { windmill_common::validate_dbname(&fdt.new_dbname)?; // The clone endpoints refuse this too; this is the one a caller cannot go // around, since it is what wires the fork's config to the copied database. refuse_clone_of_permissioned_datatable(db, parent_w_id, &format!("datatable://{}", fdt.name)) .await?; if !fdt.new_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest(format!( "Forked datatable database name '{}' must start with 'wm_fork_'", fdt.new_dbname ))); } // Snapshot the schema from the source (parent) datatable let schema = snapshot_datatable_schema(db, parent_w_id, &fdt.name).await?; let forked_from = serde_json::json!({ "schema": schema }); // Read the datatable config from the forked workspace let config_val = sqlx::query_scalar!( "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1", forked_w_id, &fdt.name ) .fetch_optional(&mut **tx) .await? .flatten() .ok_or_else(|| { Error::NotFound(format!( "Datatable '{}' not found in workspace '{}'", fdt.name, forked_w_id )) })?; let dt: DataTable = serde_json::from_value(config_val) .map_err(|e| Error::internal_err(format!("Failed to parse datatable config: {}", e)))?; if dt.database.resource_type == DataTableCatalogResourceType::Instance { // Instance: update resource_path to the new dbname sqlx::query!( r#"UPDATE workspace_settings SET datatable = jsonb_set( jsonb_set(datatable, ARRAY['datatables', $2, 'database', 'resource_path'], to_jsonb($3::text)), ARRAY['datatables', $2, 'forked_from'], $4::jsonb ) WHERE workspace_id = $1"#, forked_w_id, &fdt.name, &fdt.new_dbname, forked_from, ) .execute(&mut **tx) .await?; } else { // Resource: update the resource's dbname and mark as ws_specific let resource_path = &dt.database.resource_path; sqlx::query!( r#"UPDATE resource SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text)) WHERE workspace_id = $1 AND path = $2"#, forked_w_id, resource_path, &fdt.new_dbname, ) .execute(&mut **tx) .await?; sqlx::query!( "INSERT INTO ws_specific (workspace_id, item_kind, path) VALUES ($1, 'resource', $2) ON CONFLICT DO NOTHING", forked_w_id, resource_path, ) .execute(&mut **tx) .await?; // Set forked_from on the datatable config sqlx::query!( r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb) WHERE workspace_id = $1"#, forked_w_id, &fdt.name, forked_from, ) .execute(&mut **tx) .await?; } Ok(()) } /// Cloud: require the fork/dev's root (billing) workspace be premium; returns the resolved root id. #[cfg(feature = "cloud")] async fn require_cloud_fork_premium(db: &DB, parent_workspace_id: &str) -> Result { let root = windmill_common::workspaces::get_billing_workspace_id(db, parent_workspace_id).await?; if !windmill_common::workspaces::get_team_plan_status(db, &root) .await? .premium { return Err(Error::BadRequest( "Creating a fork or dev workspace on the cloud requires a paid team plan. Upgrade the workspace first.".to_string(), )); } Ok(root) } /// Cloud: reject if adding `incoming` fork/dev workspaces would push `root`'s family over its per-seat /// allotment. `incoming` is the number of workspaces the operation adds to the family — 1 for a plain /// create, but `1 + candidate_subtree` for an attach whose candidate already has child forks. #[cfg(feature = "cloud")] async fn enforce_cloud_fork_count(db: &DB, root: &str, incoming: i64) -> Result<()> { let seats = windmill_common::workspaces::count_paid_seats(db, root).await?; let per_seat = *MAX_FORKS_PER_SEAT; // Any premium workspace has at least one paid seat, so floor the seat count at 1. let allowed = seats.max(1) * per_seat; let existing = windmill_common::workspaces::count_workspace_forks(db, root).await?; let projected = existing + incoming; if projected > allowed { return Err(Error::BadRequest(format!( "Fork limit reached: this would bring the workspace family to {projected} fork(s), over the cap of {allowed} ({seats} paid seat(s) × {per_seat} per seat). Delete a fork or add seats." ))); } Ok(()) } /// Cloud-only guard for creating a fork/dev workspace. Forks piggyback on the parent's plan (a fork /// inherits the root's premium and meters its usage into the root's bill), so forking is limited to /// premium workspaces and capped at `MAX_FORKS_PER_SEAT` per paid (developer) seat of the root. #[cfg(feature = "cloud")] async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()> { let root = require_cloud_fork_premium(db, parent_workspace_id).await?; enforce_cloud_fork_count(db, &root, 1).await } /// Cloud: refuse to attach a workspace that already has a paid plan of its own. /// /// Once attached it draws the root's plan and meters its usage there, so a subscription of its own /// bills a second time for one plan. Only an attach can reach this state: a fork is created as a /// fresh workspace and never had a plan to keep. /// /// Asked only of a candidate joining this family, never of one already under the same root: that /// one is already in the double-billed state, where the settings page surfaces the leftover /// subscription and the portal that cancels it, and refusing there would block re-designating a /// renamed dev workspace over a billing problem the attach did not cause. #[cfg(feature = "cloud")] async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> { let plan = sqlx::query_scalar!( "SELECT plan FROM workspace_settings WHERE workspace_id = $1", dev_w_id ) .fetch_optional(db) .await? .flatten(); // Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan // value it does not write yet would otherwise walk straight past this. An enterprise // arrangement is deliberately not covered — it sets `premium` without a plan and has no // self-serve portal, so refusing there would be a dead end rather than something to act on. if plan.is_some() { return Err(Error::BadRequest(format!( "Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it." ))); } Ok(()) } #[cfg(all(test, feature = "cloud"))] mod attach_billing_guard_tests { use super::reject_attach_of_subscribed_workspace; use sqlx::{Pool, Postgres}; async fn workspace_on_plan(db: &Pool, id: &str, plan: Option<&str>) { sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')") .bind(id) .execute(db) .await .expect("insert workspace"); sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)") .bind(id) .bind(plan) .execute(db) .await .expect("insert workspace_settings"); } #[sqlx::test(migrations = "../migrations")] async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool) { workspace_on_plan(&db, "subscribed", Some("team")).await; workspace_on_plan(&db, "cancelled", None).await; let err = reject_attach_of_subscribed_workspace(&db, "subscribed") .await .expect_err("a workspace on a paid plan of its own must not be attachable"); assert!(err.to_string().contains("paid plan of its own"), "{err}"); // Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides. reject_attach_of_subscribed_workspace(&db, "cancelled") .await .expect("a workspace with no plan is attachable"); reject_attach_of_subscribed_workspace(&db, "no-settings-row") .await .expect("a workspace with no settings row is attachable"); } } /// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper /// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new /// node — 0 for a plain fork, or the candidate's own subtree height for an attach. async fn enforce_fork_depth( db: &DB, parent_workspace_id: &str, added_subtree_height: i64, ) -> Result<()> { let parent_depth = windmill_common::workspaces::fork_chain_depth(db, parent_workspace_id).await?; // The new node sits one level below the parent; its deepest descendant adds the grafted height. let resulting_depth = parent_depth + 1 + added_subtree_height; if resulting_depth > *MAX_FORK_DEPTH { return Err(Error::BadRequest(format!( "Fork depth limit reached: forks can be nested at most {} level(s) deep, but this would create a fork at depth {}. Fork from a workspace closer to the root instead.", *MAX_FORK_DEPTH, resulting_depth ))); } Ok(()) } /// True if `raw` (the text form of a `json` value) contains a genuine `\u0000` /// NUL escape: a `u0000` preceded by an ODD run of backslashes. Mirrors the /// parity rule in `windmill_common::utils::strip_json_nul` — an even run /// (`\\u0000`) is an escaped backslash then the literal text "u0000" (common in /// minified JS) and is jsonb-safe. A genuine NUL is exactly what the /// `json`→`jsonb` re-encode in `clone_apps` / `clone_flows` rejects with /// SQLSTATE 22P05. fn json_text_has_nul_escape(raw: &str) -> bool { let bytes = raw.as_bytes(); let mut search_from = 0; while let Some(rel) = raw[search_from..].find("u0000") { let at = search_from + rel; let mut backslashes = 0; let mut j = at; while j > 0 && bytes[j - 1] == b'\\' { backslashes += 1; j -= 1; } if backslashes % 2 == 1 { return true; } search_from = at + 5; } false } /// SQLSTATE 22P05 (`untranslatable_character`) is what Postgres raises for /// "unsupported Unicode escape sequence" when a `json` value carrying a genuine /// `\u0000` is re-encoded to `jsonb` — the exact failure the per-row `json` /// clones (`clone_apps`, `clone_flows`) hit when a source item holds a NUL. fn is_unsupported_unicode_escape(e: &Error) -> bool { matches!( e, Error::SqlErr { error, .. } if error.as_database_error().and_then(|d| d.code()).as_deref() == Some("22P05") ) } /// After a fork clone aborts on a NUL escape, locate the offending source items /// so the error can name them. Reads the committed source workspace on the pool /// (the clone transaction is already poisoned and unusable). Only the `json` /// columns re-encoded to `jsonb` during the clone can trigger the failure: /// `app_version.value` (clone_apps) and `flow_version.schema` (clone_flows). /// Best-effort — returns an empty list rather than erroring if a probe query /// fails, so the caller can still surface a generic message. async fn find_nul_escape_locations(db: &DB, workspace_id: &str) -> Vec { let mut apps: std::collections::BTreeSet = Default::default(); if let Ok(rows) = sqlx::query( "SELECT a.path AS path, av.value::text AS value FROM app_version av JOIN app a ON a.id = av.app_id WHERE a.workspace_id = $1 AND av.value IS NOT NULL", ) .bind(workspace_id) .fetch_all(db) .await { for row in rows { let value: String = row.get("value"); if json_text_has_nul_escape(&value) { apps.insert(row.get::("path")); } } } let mut flows: std::collections::BTreeSet = Default::default(); if let Ok(rows) = sqlx::query( "SELECT fv.path AS path, fv.schema::text AS schema FROM flow_version fv WHERE fv.workspace_id = $1 AND fv.schema IS NOT NULL", ) .bind(workspace_id) .fetch_all(db) .await { for row in rows { let schema: String = row.get("schema"); if json_text_has_nul_escape(&schema) { flows.insert(row.get::("path")); } } } apps.into_iter() .map(|p| format!("app: {p}")) .chain(flows.into_iter().map(|p| format!("flow: {p}"))) .collect() } async fn create_workspace_fork( authed: ApiAuthed, Extension(db): Extension, Path(parent_workspace_id): Path, Json(nw): Json, ) -> Result { enforce_fork_depth(&db, &parent_workspace_id, 0).await?; #[cfg(feature = "cloud")] if *CLOUD_HOSTED { enforce_cloud_fork_cap(&db, &parent_workspace_id).await?; } if nw.is_dev_workspace { validate_dev_workspace_id(&nw.id)?; } else { validate_fork_workspace_id(&nw.id)?; } validate_workspace_name(&nw.name)?; // The environment label only applies to dev workspaces; a non-dev fork stores NULL. let dev_workspace_label = if nw.is_dev_workspace { let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&parent_workspace_id]) .await?; reject_dev_label_taken_in_chain( &mut *db.acquire().await?, &parent_workspace_id, &nw.id, false, label.as_deref(), ) .await?; label } else { None }; // Check the id conflict before the CE workspace-count limit so that // re-using a taken (possibly archived) fork id reports the actual // conflict instead of a misleading "maximum number of workspaces" error. check_fork_w_id_conflict(&db, &nw.id).await?; purge_stale_fork_diff_state(&db, &nw.id).await?; // A previously deleted fork with this id may have left ducklake namespaces behind if its // physical cleanup failed after the delete committed (registry rows are the durable // ledger — no FK, they outlive the workspace). Retry that cleanup now, and refuse to // proceed while any metadata schema still can't be dropped: creating the fork anyway // would silently reattach the deterministic namespace and its stale tables. Data-file // leftovers alone don't block — once the schema is gone they are inert (a deleted fork's // `$res:` storage resource is gone forever, so they may never resolve again), and the // surviving registry row has the next successful same-prefix cleanup sweep them. // `$res:` fallback = the workspace being forked: the deleted fork's resource clones came // from a parent, so the new parent is the natural donor for the retry's credentials. let leftover_issues = crate::workspaces_extra::drop_forked_ducklake_namespaces_impl( &db, &nw.id, Some(&parent_workspace_id), ) .await?; let blocking: Vec<&str> = leftover_issues .iter() .filter(|i| i.blocking) .map(|i| i.msg.as_str()) .collect(); if !blocking.is_empty() { return Err(Error::BadRequest(format!( "a previously deleted workspace with id '{}' left ducklake namespaces that could \ not be cleaned up: {}; retry once the catalog is reachable again", nw.id, blocking.join("; ") ))); } for i in &leftover_issues { tracing::warn!( "creating fork {}: leftover ducklake cleanup: {}", nw.id, i.msg ); } #[cfg(not(feature = "enterprise"))] _check_nb_of_workspaces(&db).await?; if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed).await?; } if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &parent_workspace_id, &ProtectionRuleKind::DisableWorkspaceForking, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } if nw.is_dev_workspace { ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?; // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod // mutates its protection rules), so require admin of the parent regardless of the lock flags — // mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could // claim the dev slot (and, without member copy, prod admins might not even see it to detach). require_admin(authed.is_admin, &authed.username)?; ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?; } let mut tx: Transaction<'_, Postgres> = db.begin().await?; if nw.is_dev_workspace { // The checks above ran outside a transaction, so the parent's eligibility and the chain's // labels could have changed under us: re-decide both here, under the pairing lock. lock_dev_pairing(&mut tx, &[&parent_workspace_id]).await?; ensure_dev_parent_can_host_dev(&mut *tx, &parent_workspace_id).await?; ensure_no_existing_dev_workspace(&mut *tx, &parent_workspace_id).await?; reject_dev_label_taken_in_chain( &mut *tx, &parent_workspace_id, &nw.id, false, dev_workspace_label.as_deref(), ) .await?; } let forked_id = nw.id; sqlx::query!( "INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) VALUES ($1, $2, $3, $4, $5, $6)", forked_id, nw.name, authed.email, parent_workspace_id, nw.is_dev_workspace, dev_workspace_label, ) .execute(&mut *tx) .await?; sqlx::query!( "INSERT INTO workspace_settings (workspace_id, color) VALUES ($1, $2)", forked_id, nw.color, ) .execute(&mut *tx) .await?; // Optionally bring the parent's members into the fork (a shared dev env). Dev-only: it's part of the // dev-workspace feature (and the frontend only offers it there), so the backend enforces it rather // than trusting the client — copying the parent's whole team into an ordinary throwaway fork isn't // intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before // the explicit creator insert below so the creator (a parent member) is copied with full metadata // (operator/role/is_service_account/added_via), not the bare row the insert alone would leave. if nw.copy_members && nw.is_dev_workspace { copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?; } // Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent // member). No-op when copy_members already brought their full row. sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin) SELECT $1, email, username, is_admin FROM usr WHERE workspace_id = $3 AND email = $2 ON CONFLICT DO NOTHING ", forked_id, authed.email, parent_workspace_id, ) .execute(&mut *tx) .await?; // Clone all data from the parent workspace using Rust implementation if let Err(e) = clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: // Postgres raises 22P05 with only "unsupported Unicode escape sequence" // and no hint at which item. Pinpoint the offenders so the user can fix // them — re-saving strips the NUL, and the usual source is a binary file // (e.g. `.DS_Store`) accidentally bundled into a raw app. if is_unsupported_unicode_escape(&e) { drop(tx); // release the poisoned connection before probing on the pool let locations = find_nul_escape_locations(&db, &parent_workspace_id).await; let where_clause = if locations.is_empty() { "The offending item could not be pinpointed — check recently edited apps and flows." .to_string() } else { format!("Offending item(s):\n - {}", locations.join("\n - ")) }; return Err(Error::BadRequest(format!( "Cannot fork workspace '{parent_workspace_id}': an item contains a NUL character \ (\\u0000) that Postgres cannot store as jsonb. Re-save the item to remove it \ (the editor strips NUL automatically), or delete the offending binary/character \ from its source (often a file like .DS_Store bundled into a raw app). {where_clause}" ))); } return Err(e); } // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener // attaches, no cron fires) so this is safe by construction. The user // re-enables in the fork, with parent-conflict warnings on enable. clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; } // A forked data table now points at a fresh database where the parent's roles hold // nothing, so its cloned `permissions` block is meaningless and is dropped — the fork // opts in on its own. // // A data table that was NOT forked still points at the parent's database, where those // roles do hold grants, so the fork must not carry a copy of the config: dropping the // `permissions` block would let a fork (which any member may create) reach the parent's // data as root, and keeping it would freeze who may run as what at the moment of the // fork. It becomes a `shared_datatables` entry instead — a pointer, resolved in the // parent, against the caller's identity there. // // What the parent itself only points at is carried through naming the same owner, not // the parent: a chain that grows with every fork is a chain that outlives its middle. let forked_datatable_names: Vec = nw .forked_datatables .iter() .map(|f| f.name.clone()) .collect(); sqlx::query!( r#"UPDATE workspace_settings SET datatable = jsonb_set( jsonb_set(datatable, '{datatables}', ( SELECT COALESCE(jsonb_object_agg( key, CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value END ), '{}'::jsonb) FROM jsonb_each(datatable->'datatables') WHERE key = ANY($2) OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false )), '{shared_datatables}', ( SELECT COALESCE(jsonb_object_agg(key, entry), '{}'::jsonb) FROM ( SELECT key, jsonb_build_object('from', $3::text) AS entry FROM jsonb_each(datatable->'datatables') WHERE NOT (key = ANY($2)) AND COALESCE((value->'permissions'->>'enabled')::boolean, false) = true UNION ALL SELECT key, value AS entry FROM jsonb_each(CASE WHEN jsonb_typeof(datatable->'shared_datatables') = 'object' THEN datatable->'shared_datatables' ELSE '{}'::jsonb END) WHERE NOT (key = ANY($2)) ) shared )) WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#, &forked_id, &forked_datatable_names[..], &parent_workspace_id, ) .execute(&mut *tx) .await?; // The settings clone copies the source's ducklake config verbatim — including a parent // fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never // inherited: reset any cloned stamps first, then apply this fork's requested list. sqlx::query!( r#"UPDATE workspace_settings SET ducklake = jsonb_set(ducklake, '{ducklakes}', ( SELECT COALESCE(jsonb_object_agg(key, value - 'fork_behavior'), '{}'::jsonb) FROM jsonb_each(ducklake->'ducklakes') )) WHERE workspace_id = $1 AND jsonb_typeof(ducklake->'ducklakes') = 'object'"#, &forked_id, ) .execute(&mut *tx) .await?; // Stamp the per-lake ducklake fork choice into the fork's own settings. Only the `shared` // opt-out needs stamping — absent `fork_behavior` already means isolated (the default), // so unlisted lakes and API callers that omit the field stay safe. for lake in &nw.shared_ducklakes { let stamped = sqlx::query_scalar!( r#"UPDATE workspace_settings SET ducklake = jsonb_set(ducklake, ARRAY['ducklakes', $2, 'fork_behavior'], '"shared"') WHERE workspace_id = $1 AND ducklake->'ducklakes' ? $2 RETURNING 1 AS "one!""#, &forked_id, lake, ) .fetch_optional(&mut *tx) .await?; if stamped.is_none() { return Err(Error::BadRequest(format!( "cannot mark ducklake `{lake}` as shared: no such lake in the workspace settings" ))); } } // Lock the parent ("prod") so edits are funneled through this dev workspace. let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking); if locked_prod { lock_prod_workspace( &mut tx, &parent_workspace_id, nw.lock_prod_deploy, nw.lock_prod_forking, ) .await?; } audit_log( &mut *tx, &authed, "workspaces.create_fork", ActionKind::Create, &forked_id, Some(nw.name.as_str()), None, ) .await?; tx.commit().await?; // A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which // would bypass ducklake fork isolation for the TTL. The same lookup could have cached the id // as its own root workspace, which would make the fork's first jobs report themselves as their // own environment instead of the parent. windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&forked_id); windmill_queue::tags::invalidate_fork_parent_cache(&forked_id); if locked_prod { windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id); } Ok(format!("Created forked workspace {}", &forked_id)) } #[derive(Deserialize)] struct AttachDevWorkspace { dev_workspace_id: String, #[serde(default)] lock_prod_deploy: bool, #[serde(default)] lock_prod_forking: bool, /// Environment label for the attached dev workspace, e.g. 'dev' or 'staging'. None defaults to 'dev'. #[serde(default)] dev_workspace_label: Option, } #[derive(Deserialize)] struct DetachDevWorkspace { dev_workspace_id: String, } /// Pair an existing standalone workspace to this workspace ("prod") as its dev workspace, without /// cloning any data (both already exist). Sets the dev's parent to prod and, optionally, /// locks prod against direct deployment. async fn attach_dev_workspace( authed: ApiAuthed, Extension(db): Extension, Path(prod_w_id): Path, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; // Attaching grafts the candidate (and its own fork subtree) under prod, so enforce the general // depth limit on the deepest resulting node. let candidate_height = windmill_common::workspaces::fork_subtree_height(&db, &req.dev_workspace_id).await?; enforce_fork_depth(&db, &prod_w_id, candidate_height).await?; // Attaching reparents a workspace under prod (one dev per prod, admin-gated) and it then draws // prod's plan, so hold it to the same premium requirement as creating a fork. Only enforce the // per-seat count when the attach actually adds a new workspace to the family: re-designating a // workspace already under this root as its dev doesn't increase the descendant count. #[cfg(feature = "cloud")] if *CLOUD_HOSTED { let root = require_cloud_fork_premium(&db, &prod_w_id).await?; // Only count against the cap when this attach adds workspaces to the family (candidate not // already under this root). The candidate may itself have child forks, so reserve slots for its // whole incoming subtree (the candidate + its descendants), not just one. if windmill_common::workspaces::get_billing_workspace_id(&db, &req.dev_workspace_id).await? != root { let incoming = 1 + windmill_common::workspaces::count_workspace_forks(&db, &req.dev_workspace_id) .await?; enforce_cloud_fork_count(&db, &root, incoming).await?; } } let dev_w_id = req.dev_workspace_id; if dev_w_id == prod_w_id { return Err(Error::BadRequest( "A workspace cannot be its own dev workspace".to_string(), )); } // The id is interpolated into a `wm-fork//` branch name like any fork. validate_dev_workspace_id(&dev_w_id)?; let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?; let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, &dev_w_id ) .fetch_optional(&db) .await? .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?; if dev.deleted { return Err(Error::BadRequest(format!( "Workspace {} is archived", dev_w_id ))); } // A candidate that already belongs to a DIFFERENT parent can't be attached. A candidate already // parented to this prod is allowed: it's the recovery path after renaming a dev workspace (the // rename keeps the parent but drops the dev flag), and re-designating an existing fork of this // prod as its dev. if dev .parent_workspace_id .as_deref() .is_some_and(|p| p != prod_w_id) { return Err(Error::BadRequest(format!( "Workspace {} is already a fork or dev workspace of another workspace", dev_w_id ))); } let prod_exists = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS "exists!""#, &prod_w_id ) .fetch_one(&db) .await?; if !prod_exists { return Err(Error::NotFound(format!( "Workspace {} not found", prod_w_id ))); } // Prod may be a root workspace or another dev workspace (a dev of a dev); a throwaway fork // can't host one. ensure_dev_parent_can_host_dev(&db, &prod_w_id).await?; reject_attach_cycle(&db, &prod_w_id, &dev_w_id).await?; // The attached workspace keeps its own sync repos and prod keeps its config; the label branch // must not collide with either side's tracked branch. reject_dev_label_matching_tracked_branch( &db, dev_workspace_label.as_deref(), &[&prod_w_id, &dev_w_id], ) .await?; // The candidate keeps its own subtree, so its dev descendants keep their labels and join the // chain alongside it. reject_dev_label_taken_in_chain( &mut *db.acquire().await?, &prod_w_id, &dev_w_id, true, dev_workspace_label.as_deref(), ) .await?; // The caller must be admin of the dev workspace too (or a superadmin). let is_admin_of_dev = sqlx::query_scalar!( "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", &dev_w_id, &authed.email ) .fetch_optional(&db) .await? .unwrap_or(false); if !is_admin_of_dev && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied(format!( "Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)" ))); } // Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the // refusal names the candidate's plan, so running it earlier would tell any admin of any // premium workspace whether an arbitrary workspace id is on a team plan. #[cfg(feature = "cloud")] if *CLOUD_HOSTED { let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?; if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root { reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?; } } let mut tx = db.begin().await?; // Everything above ran outside a transaction, so prod's eligibility and the chain's labels could // have changed under us: re-decide both here, under the pairing lock. lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; // The candidate was read before the lock, and archiving it is one of the operations the lock // serializes: re-read it, or the pairing lands on a workspace that is gone or has since been // taken by another prod. let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, &dev_w_id ) .fetch_optional(&mut *tx) .await? .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?; if dev.deleted { return Err(Error::BadRequest(format!( "Workspace {} is archived", dev_w_id ))); } if dev .parent_workspace_id .as_deref() .is_some_and(|p| p != prod_w_id) { return Err(Error::BadRequest(format!( "Workspace {} is already a fork or dev workspace of another workspace", dev_w_id ))); } ensure_dev_parent_can_host_dev(&mut *tx, &prod_w_id).await?; reject_attach_cycle(&mut *tx, &prod_w_id, &dev_w_id).await?; ensure_no_existing_dev_workspace(&mut *tx, &prod_w_id).await?; reject_dev_label_taken_in_chain( &mut *tx, &prod_w_id, &dev_w_id, true, dev_workspace_label.as_deref(), ) .await?; sqlx::query!( "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", &prod_w_id, &dev_w_id, dev_workspace_label, ) .execute(&mut *tx) .await?; // The pair may have been compared as an arbitrary target before it was linked. Those rows were // seeded one-way (everything `ahead`, direction guessed), which the tally must not inherit as // its own history — drop them and the scan marker so the lineage diff starts from the tally. // Under the pair lock, so a scan already in flight either loses its rows to the delete below // or sees the new lineage link on its re-check and refuses. lock_workspace_pair(&mut tx, &prod_w_id, &dev_w_id).await?; sqlx::query!( "DELETE FROM workspace_diff_full_scan WHERE (source_workspace_id = $1 AND fork_workspace_id = $2) OR (source_workspace_id = $2 AND fork_workspace_id = $1)", &prod_w_id, &dev_w_id, ) .execute(&mut *tx) .await?; sqlx::query!( "DELETE FROM workspace_diff WHERE (source_workspace_id = $1 AND fork_workspace_id = $2) OR (source_workspace_id = $2 AND fork_workspace_id = $1)", &prod_w_id, &dev_w_id, ) .execute(&mut *tx) .await?; // Clearing the instance-alert opt-in here keeps the stored setting truthful for a workspace // that becomes parent-managed: dispatch enforces the fork boundary on its own, but a lingering // `true` would survive a later detach and would make the settings page submit a value the API // rejects on a fork. sqlx::query!( "UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $1", &dev_w_id ) .execute(&mut *tx) .await?; // The attached workspace is now parent-managed like any fork: its own // auto-pull (and webhook), fork PRs, and promotion repos must not stay // live — they'd keep pulling/pushing against its pre-attach tracked // branch. Mirror the fork-creation copy: keep sync repos only, strip the // parent-only fields, and delete any managed webhook after commit. // `FOR UPDATE` like the other `git_sync` read-modify-writes: the hook ids // collected here are deleted after commit, so a reconcile writing a new id in the // gap would leave that hook live with nothing tracking it. #[allow(unused_mut)] let mut stripped_webhooks: Vec<(String, i64)> = Vec::new(); if let Some(git_sync) = sqlx::query_scalar!( "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", &dev_w_id ) .fetch_optional(&mut *tx) .await? .flatten() { if let Ok(mut settings) = serde_json::from_value::(git_sync) { settings .repositories .retain(|r| !r.use_individual_branch.unwrap_or(false)); for r in settings.repositories.iter_mut() { if let Some(hook) = r.auto_pull.as_ref().and_then(|a| a.webhook_id) { stripped_webhooks.push((r.git_repo_resource_path.clone(), hook)); } r.auto_pull = None; r.fork_open_prs = false; r.open_pr_error = None; } let serialized = serde_json::to_value(&settings).map_err(|e| Error::internal_err(e.to_string()))?; sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", serialized, &dev_w_id ) .execute(&mut *tx) .await?; } } if req.lock_prod_deploy || req.lock_prod_forking { lock_prod_workspace( &mut tx, &prod_w_id, req.lock_prod_deploy, req.lock_prod_forking, ) .await?; } audit_log( &mut *tx, &authed, "workspaces.attach_dev_workspace", ActionKind::Update, &prod_w_id, Some(&dev_w_id), None, ) .await?; tx.commit().await?; // The dev workspace's lineage just changed (none -> prod); drop its cached tag workspace so // per-workspace job tags route to the prod family immediately rather than after the TTL. Tag // resolution walks ancestors, so its own forks resolve through it and must be dropped too. windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id) .await .unwrap_or_default() { windmill_queue::tags::invalidate_fork_parent_cache(&id); } if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await { tracing::warn!("failed to broadcast fork lineage change: {e:#}"); } // Best-effort: the hooks captured before the strip above are unreachable now // (their auto_pull is gone), so remove them from GitHub. #[cfg(all(feature = "enterprise", feature = "private"))] for (path, hook_id) in stripped_webhooks { if let Ok(url) = windmill_common::git_sync_ee::resolve_repo_url_interpolated(&db, &dev_w_id, &path).await { let _ = windmill_common::git_sync_ee::delete_repo_webhook(&db, &dev_w_id, &url, hook_id) .await; } } // Drop the cached ancestor chains too — the workspace existed BEFORE the attach, so a // cached empty chain reads as "not a fork" and its ducklake jobs would write the shared // lake until the TTL. Descendants' chains also gained the new root. windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&dev_w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? { windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id); } // Same reparent invalidates the billing-workspace mapping so its usage meters to prod at once. The // candidate can bring its own fork subtree, whose descendants had resolved their (now-stale) root // to the candidate's old family; invalidate them too so they meter to prod without waiting out the // 60s TTL. The tag cache is swept above: resolution walks the whole chain, not the immediate link. #[cfg(feature = "cloud")] { windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? { windmill_common::workspaces::invalidate_billing_workspace_cache(&id); } } if req.lock_prod_deploy || req.lock_prod_forking { windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id); } Ok(format!( "Attached {} as dev workspace of {}", dev_w_id, prod_w_id )) } /// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the /// prod lock. Whether `parent_workspace_id` is kept depends on the workspace's origin (see the /// UPDATE below): a genuine fork stays a fork, a standalone workspace returns to standalone. async fn detach_dev_workspace( authed: ApiAuthed, Extension(db): Extension, Path(prod_w_id): Path, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let dev_w_id = req.dev_workspace_id; let mut tx = db.begin().await?; // Under the pairing lock, so a dev workspace cannot appear beneath this one between the check // below and the update. lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; let is_dev_of_prod = sqlx::query_scalar!( r#"SELECT EXISTS( SELECT 1 FROM workspace WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace )"#, &dev_w_id, &prod_w_id ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if !is_dev_of_prod { return Err(Error::BadRequest(format!( "{} is not the dev workspace of {}", dev_w_id, prod_w_id ))); } reject_stranding_nested_dev(&mut *tx, &dev_w_id, DevTeardown::Detach).await?; // A wm-fork- workspace re-designated as dev returns to being a plain fork // (keeps its parent); a standalone workspace that was attached returns to // being standalone — with the parent kept it would still classify as a // fork and deploy to wm-fork/** branches forever. sqlx::query!( "UPDATE workspace SET is_dev_workspace = false, parent_workspace_id = CASE WHEN id LIKE 'wm-fork-%' THEN parent_workspace_id ELSE NULL END WHERE id = $1", &dev_w_id ) .execute(&mut *tx) .await?; // Only one dev per prod, so detaching it means prod no longer has a dev: drop the lock rule. sqlx::query!( "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", &prod_w_id, DEV_WORKSPACE_LOCK_RULE_NAME ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.detach_dev_workspace", ActionKind::Update, &prod_w_id, Some(&dev_w_id), None, ) .await?; tx.commit().await?; windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id); // The parent link may just have been cleared (standalone workspace that was // attached): drop the caches that resolved it, mirroring attach. windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id); windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&dev_w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? { windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id); // Tag resolution walks ancestors, so a descendant's cached tag workspace resolved through // the workspace whose dev flag just changed. windmill_queue::tags::invalidate_fork_parent_cache(&id); } if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await { tracing::warn!("failed to broadcast fork lineage change: {e:#}"); } #[cfg(feature = "cloud")] { windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? { windmill_common::workspaces::invalidate_billing_workspace_cache(&id); } } Ok(format!( "Detached dev workspace {} from {}", dev_w_id, prod_w_id )) } async fn edit_workspace( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ew): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace SET name = $1, owner = $2 WHERE id = $3", ew.name, ew.owner, &w_id ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.update", ActionKind::Update, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; Ok(format!("Updated workspace {}", &w_id)) } /// Archive a workspace: disable schedules, cancel jobs, and mark as deleted. /// Returns (schedules_disabled_count, jobs_canceled_count). pub(crate) async fn archive_workspace_impl( db: &DB, w_id: &str, username: &str, // When archiving a dev workspace, its parent prod. The pairing teardown (clear is_dev + drop the // prod's lock) is folded into the same transaction as `deleted = true` so it's atomic with the // archive — a later failure can't strand a half-archived dev that's still flagged/locked. dev_lock_parent: Option<&str>, ) -> Result<(usize, usize, usize)> { // Step 1: Disable all schedules and clear their queued jobs let mut tx = db.begin().await?; // Unconditionally, before reading any pairing state: whether this workspace is a dev, and whether // it has one, is exactly what a concurrent attach changes, so gating the lock on the caller's // `dev_lock_parent` would skip it on the strength of the value the race invalidates. lock_dev_pairing(&mut tx, &[w_id]).await?; let dev_parent = sqlx::query_scalar!( "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", w_id ) .fetch_optional(&mut *tx) .await? .flatten(); // The caller resolved this before the lock and authorized against it — its prod admin check, and // the pairing teardown below, are both answers to that value. Refuse rather than act on a pairing // nobody checked. if dev_parent.as_deref() != dev_lock_parent { return Err(Error::BadRequest(format!( "The dev pairing of {w_id} changed while it was being archived. Retry." ))); } if dev_parent.is_some() { // Archiving a dev workspace clears its dev flag, so it must not strand a dev workspace of // its own. reject_stranding_nested_dev(&mut *tx, w_id, DevTeardown::Archive).await?; } let disabled_schedules = sqlx::query_scalar!( "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path", w_id ) .fetch_all(&mut *tx) .await?; let schedules_count = disabled_schedules.len(); tracing::info!( "Disabled {} schedules in workspace {}", schedules_count, w_id ); // Clear all schedule-related jobs using the existing clear_schedule function for schedule_path in &disabled_schedules { windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?; } // Delete non-session tokens scoped to this workspace let deleted_tokens = sqlx::query_scalar!( "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", w_id ) .fetch_all(&mut *tx) .await?; tracing::info!( "Deleted {} non-session tokens in workspace {}", deleted_tokens.len(), w_id ); // Mark workspace as archived sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id) .execute(&mut *tx) .await?; if let Some(prod) = dev_lock_parent { // Dissolve the dev pairing atomically with the archive: clear the canonical-dev flag (so the // archived row no longer occupies the parent's one-dev slot), and drop the prod's lock unless a // replacement dev already holds it (NOT EXISTS sees the just-cleared flag within this tx, so the // row being archived doesn't count). sqlx::query!( "UPDATE workspace SET is_dev_workspace = false WHERE id = $1", w_id ) .execute(&mut *tx) .await?; sqlx::query!( "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2 AND NOT EXISTS ( SELECT 1 FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false )", prod, DEV_WORKSPACE_LOCK_RULE_NAME ) .execute(&mut *tx) .await?; } tx.commit().await?; // Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs) let jobs_to_cancel = sqlx::query_scalar!("SELECT id FROM v2_job_queue WHERE workspace_id = $1", w_id) .fetch_all(db) .await?; let jobs_count = jobs_to_cancel.len(); tracing::info!( "Found {} remaining jobs to cancel in workspace {}", jobs_count, w_id ); // Step 3: Cancel all remaining jobs using the existing cancel_jobs function let canceled_count = if !jobs_to_cancel.is_empty() { let axum::Json(canceled_jobs) = windmill_api_jobs::cancel_jobs( jobs_to_cancel, db, username, w_id, false, // force_cancel ) .await?; let count = canceled_jobs.len(); tracing::info!("Canceled {} jobs in workspace {}", count, w_id); count } else { 0 }; Ok((schedules_count, canceled_count, deleted_tokens.len())) } async fn archive_workspace( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, ) -> Result { require_admin(authed.is_admin, &authed.username)?; // CE caps the number of archived (soft-deleted) workspaces so archiving can't be used to // stockpile hidden workspaces. Enforced here so a second archive is refused up front. #[cfg(not(feature = "enterprise"))] _check_nb_of_archived_workspaces(&db).await?; // If this is an attached dev workspace, archiving it leaves the prod with no active dev (the // unique index and user_workspaces both ignore deleted=true), so clear the prod's // dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors // detach/delete) — a dev-admin who isn't a prod-admin must not be able to unlock prod this way. let dev_lock_parent: Option = sqlx::query_scalar!( "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", &w_id ) .fetch_optional(&db) .await? .flatten(); if let Some(ref prod) = dev_lock_parent { let is_prod_admin = sqlx::query_scalar!( "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", prod, &authed.email ) .fetch_optional(&db) .await? .unwrap_or(false); if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied(format!( "Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)" ))); } } // The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's // transaction, atomically with `deleted = true` — including the guard that it strands no nested // dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a // root archived out from under its dev is the pre-existing shape and not this pairing's to police. let (schedules_count, canceled_count, deleted_tokens_count) = archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?; // Audit log let mut tx = db.begin().await?; let mut audit_params = HashMap::new(); audit_params.insert("disabled_schedules", schedules_count.to_string()); audit_params.insert("canceled_jobs", canceled_count.to_string()); audit_params.insert("deleted_tokens", deleted_tokens_count.to_string()); let audit_params_refs: HashMap<&str, &str> = audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect(); audit_log( &mut *tx, &authed, "workspaces.archive", ActionKind::Update, &w_id, Some(&authed.email), Some(audit_params_refs.clone()), ) .await?; // Also record under the instance-level "admins" workspace so superadmins can // discover who archived a workspace after it becomes hidden from the UI. audit_log( &mut *tx, &authed, "workspaces.archive", ActionKind::Update, "admins", Some(&w_id), Some(audit_params_refs), ) .await?; tx.commit().await?; if let Some(prod) = dev_lock_parent { windmill_common::workspaces::invalidate_protection_rules_cache(&prod); // The teardown above cleared `is_dev_workspace`, which is what let this workspace keep its // own id for tag routing; it and every fork resolving through it now land on an ancestor. // Only a dev workspace reaches here, so archiving anything else needs no sweep. windmill_queue::tags::invalidate_fork_parent_cache(&w_id); for id in windmill_common::workspaces::list_fork_descendants(&db, &w_id) .await .unwrap_or_default() { windmill_queue::tags::invalidate_fork_parent_cache(&id); } if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await { tracing::warn!("failed to broadcast fork lineage change: {e:#}"); } } Ok(format!( "Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens", &w_id, schedules_count, canceled_count, deleted_tokens_count )) } async fn leave_workspace( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, ) -> Result { windmill_api_auth::forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; // Leaving frees the username here, and a role that still names it would be // inherited by the next member to take it. Read from the row rather than // from `authed`, whose username is cached and survives a rename: the name // that is about to be free is the one the row holds. let left = sqlx::query_scalar!( "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2", &w_id, &authed.email ) .fetch_all(&mut *tx) .await?; for username in &left { windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked( &w_id, &format!("u/{username}"), &mut tx, ) .await?; } sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", &w_id, &authed.email ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.leave", ActionKind::Delete, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; Ok(format!("Left workspace {}", &w_id)) } async fn unarchive_workspace( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, ) -> Result { // Global route (unarchives any workspace by id) gated on the caller's own // is_admin claim, so it must reject a job token — see require_instance_admin. require_instance_admin(&authed)?; // Unarchiving re-activates a soft-deleted workspace, so it must respect the // same CE workspace-count cap as creating one. The archived workspace is // deleted = true and thus excluded from the count until it is restored. #[cfg(not(feature = "enterprise"))] _check_nb_of_workspaces(&db).await?; let mut tx = db.begin().await?; sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.unarchive", ActionKind::Update, &w_id, Some(&authed.email), None, ) .await?; // Also record under the instance-level "admins" workspace so superadmins keep // a durable trail of who unarchived a workspace. audit_log( &mut *tx, &authed, "workspaces.unarchive", ActionKind::Update, "admins", Some(&w_id), None, ) .await?; tx.commit().await?; Ok(format!("Unarchived workspace {}", &w_id)) } /// Whether the instance is configured to suppress the email notifications sent /// when a user is invited or added to a workspace. Defaults to false (emails on). async fn workspace_invite_emails_disabled(db: &DB) -> Result { Ok( windmill_common::global_settings::load_value_from_global_settings( db, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING, ) .await? .and_then(|v| v.as_bool()) .unwrap_or(false), ) } async fn invite_user( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, Extension(webhook): Extension, Path(w_id): Path, Json(mut nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; #[cfg(not(feature = "enterprise"))] if w_id == "admins" { return Err(Error::BadRequest( "The admins workspace is reserved for superadmins. Members cannot be added to it without an enterprise license.".to_string(), )); } nu.email = nu.email.to_lowercase(); #[cfg(feature = "enterprise")] if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? { return Err(Error::BadRequest(msg)); } let mut tx = db.begin().await?; let already_in_workspace = sqlx::query_scalar!( "SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", &w_id, nu.email ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if already_in_workspace { return Err(Error::BadRequest(format!( "user with email {} already exists in workspace {}", nu.email, w_id ))); } sqlx::query!( "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email) DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator", &w_id, nu.email, nu.is_admin, nu.operator ) .execute(&mut *tx) .await?; tx.commit().await?; if !workspace_invite_emails_disabled(&db).await? { send_email_if_possible( &format!("Invited to Windmill's workspace: {w_id}"), &format!( "You have been granted access to Windmill's workspace {w_id} If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", (**BASE_URL.load()).clone() ), &nu.email, ); } webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace { email: nu.email.clone(), workspace: w_id, }); Ok(( StatusCode::CREATED, format!("user with email {} invited", nu.email), )) } /// Non-admin path for `add_user`: the creator of a fork may bring collaborators into the fork they /// created, so a team can work on it without an admin of the fork having to step in. The grant is /// deliberately narrow, because a fork holds a full clone of its parent (secrets included) and the /// creator may be an ordinary developer: /// - only on a fork they created, never on a root workspace; /// - only as a developer, so it can never mint an admin (nor an operator, which would need the /// workspace's operator settings to be meaningful); /// - only for someone who is already a developer or admin of the parent, so pulling them into the /// fork cannot widen who can read the parent's data. /// /// Anything outside those bounds stays an admin's call. Returns the username the new member must be /// given in the fork. async fn authorize_fork_owner_add_user( db: &DB, w_id: &str, authed: &ApiAuthed, nu: &NewWorkspaceUser, ) -> Result { let parent = windmill_common::workspaces::fork_owned_by(db, w_id, &authed.email) .await? .ok_or_else(|| Error::RequireAdmin(authed.username.clone()))?; if nu.is_admin || nu.operator { return Err(Error::PermissionDenied(format!( "as the creator of fork {w_id} you can only add members as developers; ask an admin of \ {w_id} for any other role" ))); } let parent_username = sqlx::query_scalar!( "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT operator AND NOT disabled", parent, nu.email, ) .fetch_optional(db) .await?; let Some(parent_username) = parent_username else { return Err(Error::PermissionDenied(format!( "as the creator of fork {w_id} you can only add developers or admins of its parent \ workspace {parent}; {} is not one, so only an admin of {w_id} can add them", nu.email ))); }; // Ownership of a `u//` path is decided by the username alone, and the fork holds a // clone of every such path from the parent. Seating the new member on a username other than // their own would therefore hand them that parent user's cloned scripts, variables and secrets // — so their parent username is the only one they may be given here, whatever the caller asked // for (`add_user` otherwise lets the caller choose it when AUTOMATE_USERNAME_CREATION is off). if nu .username .as_deref() .is_some_and(|u| !u.is_empty() && u != parent_username) { return Err(Error::PermissionDenied(format!( "as the creator of fork {w_id} you cannot choose the username of a member you add; {} \ joins as '{parent_username}', the username they already have in {parent}", nu.email ))); } Ok(parent_username) } async fn add_user( authed: ApiAuthed, Extension(db): Extension, Extension(webhook): Extension, Path(w_id): Path, Json(mut nu): Json, ) -> Result<(StatusCode, String)> { #[cfg(not(feature = "enterprise"))] if w_id == "admins" { return Err(Error::BadRequest( "The admins workspace is reserved for superadmins. Members cannot be added to it without an enterprise license.".to_string(), )); } nu.email = nu.email.to_lowercase(); let fork_owner_username = if !authed.is_admin { Some(authorize_fork_owner_add_user(&db, &w_id, &authed, &nu).await?) } else { None }; #[cfg(feature = "enterprise")] if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? { return Err(Error::BadRequest(msg)); } let mut tx = db.begin().await?; let already_exists_email = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", &w_id, nu.email, ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if already_exists_email { return Err(Error::BadRequest(format!( "user with email {} already exists in workspace {}", nu.email, w_id ))); } let automate_username_creation = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", AUTOMATE_USERNAME_CREATION_SETTING, ) .fetch_optional(&mut *tx) .await? .map(|v| v.as_bool()) .flatten() .unwrap_or(true); let username = if let Some(username) = fork_owner_username { username } else if automate_username_creation { if nu.username.is_some() && nu.username.unwrap().len() > 0 { return Err(Error::BadRequest( "username is not allowed when username creation is automated".to_string(), )); } get_instance_username_or_create_pending(&mut tx, &nu.email).await? } else { let username = nu .username .ok_or(Error::BadRequest("username is required".to_string()))?; if !VALID_USERNAME.is_match(&username) { return Err(windmill_common::error::Error::BadRequest(format!( "Usermame can only contain alphanumeric characters and underscores and must start with a letter" ))); } username }; sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin, operator) VALUES ($1, $2, $3, $4, $5)", &w_id, nu.email, username, nu.is_admin, nu.operator ) .execute(&mut *tx) .await?; sqlx::query!( "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2", &w_id, nu.email ) .execute(&mut *tx) .await?; sqlx::query_as!( Group, "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", &w_id, username, "all", ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "users.add_to_workspace", ActionKind::Create, &w_id, Some(&nu.email), None, ) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::User { email: nu.email.clone() }, Some(format!("Added user '{}' to workspace", &nu.email)), true, None, ) .await?; if !workspace_invite_emails_disabled(&db).await? { send_email_if_possible( &format!("Added to Windmill's workspace: {w_id}"), &format!( "You have been granted access to Windmill's workspace {w_id} by {} If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", authed.email, (**BASE_URL.load()).clone() ), &nu.email, ); } webhook.send_instance_event(InstanceEvent::UserAddedWorkspace { workspace: w_id.clone(), email: nu.email.clone(), }); Ok(( StatusCode::CREATED, format!("user with email {} added", nu.email), )) } #[derive(Deserialize)] pub struct NewServiceAccount { pub username: String, #[serde(default)] pub is_admin: bool, #[serde(default = "default_true")] pub operator: bool, #[serde(default)] pub add_to_deployers: bool, } fn default_true() -> bool { true } async fn create_service_account( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(nu): Json, ) -> Result<(StatusCode, String)> { crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await } async fn delete_invite( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4", &w_id, nu.email, nu.is_admin, nu.operator ) .execute(&mut *tx) .await?; tx.commit().await?; Ok(( StatusCode::CREATED, format!("invite to email {} deleted", nu.email), )) } async fn exists_username( Extension(db): Extension, Json(vu): Json, ) -> Result { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", vu.username, vu.id ) .fetch_one(&db) .await? .unwrap_or(true); if exists { return Err(Error::BadRequest("username already taken".to_string())); } Ok("valid username".to_string()) } async fn get_workspace_name( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> Result { let mut tx = user_db.begin(&authed).await?; let workspace = sqlx::query_scalar!("SELECT name FROM workspace WHERE id = $1", &w_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(workspace) } async fn get_dependency_map( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult> { require_admin(authed.is_admin, &authed.username)?; let mut tx = user_db.begin(&authed).await?; let dmap = sqlx::query_as!( DependencyMap, " SELECT workspace_id, importer_path, importer_kind::text, imported_path, importer_node_id FROM dependency_map WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(dmap)) } #[axum::debug_handler] async fn rebuild_dependency_map( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, ) -> Result { require_admin(authed.is_admin, &authed.username)?; if *CLOUD_HOSTED { return Err(Error::BadRequest("Disabled on Cloud".into())); } ScopedDependencyMap::rebuild_map(&w_id, &db).await } #[axum::debug_handler] async fn get_dependents( Extension(db): Extension, Path((w_id, imported_path)): Path<(String, String)>, _authed: ApiAuthed, ) -> JsonResult> { tracing::debug!( workspace_id = %w_id, imported_path = %imported_path, "API: Getting dependents for imported path" ); let dependents = ScopedDependencyMap::get_dependents(&imported_path, &w_id, &db).await?; tracing::debug!( workspace_id = %w_id, imported_path = %imported_path, dependents_count = dependents.len(), "API: Found dependents: {:?}", dependents ); Ok(Json(dependents)) } async fn get_imports( Extension(db): Extension, Path((w_id, importer_path)): Path<(String, String)>, _authed: ApiAuthed, ) -> JsonResult> { tracing::debug!( workspace_id = %w_id, importer_path = %importer_path, "API: Getting imports for importer path" ); let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?; tracing::debug!( workspace_id = %w_id, importer_path = %importer_path, imports_count = imports.len(), "API: Found imports: {:?}", imports ); Ok(Json(imports)) } #[derive(Serialize, Debug)] struct DependentsAmount { imported_path: String, count: i64, } #[axum::debug_handler] async fn get_dependents_amounts( Extension(db): Extension, Path(w_id): Path, Json(imported_paths): Json>, ) -> JsonResult> { tracing::debug!( workspace_id = %w_id, imported_paths = ?imported_paths, "API: Getting dependents amounts for imported paths" ); let results = sqlx::query_as!( DependentsAmount, r#" SELECT imported_path, COUNT(DISTINCT importer_path) as "count!" FROM dependency_map WHERE workspace_id = $1 AND imported_path = ANY($2) GROUP BY imported_path "#, w_id, &imported_paths ) .fetch_all(&db) .await?; tracing::debug!( workspace_id = %w_id, results_count = results.len(), "API: Found dependents amounts: {:?}", results ); Ok(Json(results)) } #[derive(Deserialize)] struct ChangeWorkspaceName { new_name: String, } #[derive(Deserialize)] struct ChangeWorkspaceColor { color: Option, } async fn change_workspace_name( authed: ApiAuthed, Path(w_id): Path, Extension(db): Extension, Json(rw): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace SET name = $1 WHERE id = $2", &rw.new_name, &w_id ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspace.change_workspace_name", ActionKind::Update, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; // Trigger git sync for workspace name changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "workspace_name".to_string() }, Some(format!("Workspace name updated to {}", &rw.new_name)), false, None, ) .await?; Ok(format!("updated workspace name to {}", &rw.new_name)) } async fn change_workspace_color( authed: ApiAuthed, Path(w_id): Path, Extension(db): Extension, Json(rw): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2", rw.color, &w_id ) .execute(&mut *tx) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: "workspace_color".to_string() }, None, false, None, ) .await?; Ok(format!( "updated workspace color to {}", rw.color.as_deref().unwrap_or("no color") )) } async fn get_usage(Extension(db): Extension, Path(w_id): Path) -> Result { // On cloud, a fork's executions meter against its billing root, so report the root's usage here too; // otherwise the free-execs indicator would show the fork's own (often 0) count while enforcement // applies the root's shared quota. Gated on `*CLOUD_HOSTED` (not just the `cloud` feature, which is // compiled into all EE builds): self-hosted doesn't meter usage this way. Off-fork it resolves to // `w_id` itself anyway. #[cfg(feature = "cloud")] let w_id = if *CLOUD_HOSTED { windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await? } else { w_id }; let usage = sqlx::query_scalar!( " SELECT usage.usage FROM usage WHERE is_workspace = true AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND id = $1", w_id ) .fetch_optional(&db) .await? .unwrap_or(0); Ok(usage.to_string()) } #[derive(Deserialize)] pub struct EditPublicAppRateLimitRequest { pub public_app_execution_limit_per_minute: Option, } async fn edit_public_app_rate_limit( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; sqlx::query!( "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2", req.public_app_execution_limit_per_minute, &w_id ) .execute(&db) .await?; // Cache is invalidated via DB trigger -> notify_event -> polling in main.rs handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() }, None, false, None, ) .await?; Ok(format!( "Updated public app rate limit for workspace: {}", &w_id )) } // 5 minutes fallback TTL (in addition to event-based invalidation) const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300; pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result> { use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE; let now = Utc::now().timestamp(); if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) { if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS { return Ok(rate_limit); } } let result: Option> = sqlx::query_scalar( "SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1", ) .bind(w_id) .fetch_optional(db) .await?; let rate_limit = result.flatten(); PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now)); Ok(rate_limit) } #[derive(Deserialize, Serialize)] struct ChangeOperatorSettings { #[serde(default)] runs: bool, #[serde(default)] schedules: bool, #[serde(default)] resources: bool, #[serde(default)] variables: bool, #[serde(default)] assets: bool, #[serde(default)] triggers: bool, #[serde(default)] audit_logs: bool, #[serde(default)] groups: bool, #[serde(default)] folders: bool, #[serde(default)] workers: bool, } async fn update_operator_settings( authed: ApiAuthed, Path(w_id): Path, Extension(db): Extension, Json(settings): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; let settings_json = serde_json::json!(settings); sqlx::query!( "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", settings_json, &w_id ) .execute(&mut *tx) .await?; tx.commit().await?; // Trigger git sync for operator settings changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, windmill_git_sync::DeployedObject::Settings { setting_type: "operator_settings".to_string(), }, Some("Operator settings updated".to_string()), false, None, ) .await?; Ok("Operator settings updated successfully".to_string()) } // Protection Rules API endpoints #[derive(Deserialize)] struct CreateProtectionRuleRequest { name: String, rules: Vec, bypass_groups: Vec, bypass_users: Vec, } #[derive(Deserialize)] struct UpdateProtectionRuleRequest { /// Renames the rule when it differs from the path name. Absent means "leave the name alone", /// which is what a client that never sends the field gets. name: Option, rules: Vec, bypass_groups: Vec, bypass_users: Vec, } #[derive(Serialize)] struct ProtectionRulesetResponse { pub workspace_id: String, pub name: String, pub rules: Vec, pub bypass_groups: Vec, pub bypass_users: Vec, } impl From for ProtectionRulesetResponse { fn from(value: ProtectionRuleset) -> Self { let mut rules = vec![]; for rule in ProtectionRuleKind::iter() { if value.rules.contains(rule.flag()) { rules.push(rule) } } ProtectionRulesetResponse { rules, workspace_id: value.workspace_id, name: value.name, bypass_groups: value.bypass_groups, bypass_users: value.bypass_users, } } } /// List all protection rules for a workspace async fn list_protection_rules( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let rules = (*windmill_common::workspaces::get_protection_rules(&w_id, &db).await?).clone(); Ok(Json( rules .into_iter() .map(ProtectionRulesetResponse::from) .collect(), )) } /// Insert or replace a protection ruleset within an existing transaction. Unlike the /// `create_protection_rule` handler (which rejects an existing name), this upserts, so it is safe to /// call programmatically when designating a dev/prod pair. Callers MUST invalidate the /// protection-rules cache (`invalidate_protection_rules_cache`) after the transaction commits. async fn upsert_protection_rule( tx: &mut Transaction<'_, Postgres>, w_id: &str, name: &str, rules: ProtectionRules, bypass_groups: &[String], bypass_users: &[String], ) -> Result<()> { sqlx::query!( r#" INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, name) DO UPDATE SET rules = EXCLUDED.rules, bypass_groups = EXCLUDED.bypass_groups, bypass_users = EXCLUDED.bypass_users "#, w_id, name, rules.bits(), bypass_groups, bypass_users, ) .execute(&mut **tx) .await?; Ok(()) } /// Lock a prod workspace by applying the reserved dev-workspace lock rule with the selected /// restrictions (block direct deployment and/or ad-hoc forking). Non-admins are then funneled /// through the one dev workspace; admins bypass the rules (their existing escape hatch). async fn lock_prod_workspace( tx: &mut Transaction<'_, Postgres>, prod_w_id: &str, block_deploy: bool, block_forking: bool, ) -> Result<()> { let mut rules = Vec::new(); if block_deploy { rules.push(ProtectionRuleKind::DisableDirectDeployment); } if block_forking { rules.push(ProtectionRuleKind::DisableWorkspaceForking); } if rules.is_empty() { return Ok(()); } upsert_protection_rule( tx, prod_w_id, DEV_WORKSPACE_LOCK_RULE_NAME, ProtectionRules::from(&rules), &[], &[], ) .await } /// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the /// partial unique index `workspace_canonical_dev_idx` with a friendly message. async fn ensure_no_existing_dev_workspace<'e, E: sqlx::Executor<'e, Database = Postgres>>( db: E, parent_w_id: &str, ) -> Result<()> { let existing = sqlx::query_scalar!( "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", parent_w_id ) .fetch_optional(db) .await?; if let Some(existing) = existing { return Err(Error::BadRequest(format!( "Workspace '{}' already has a dev workspace ('{}'). Detach it before creating another.", parent_w_id, existing ))); } Ok(()) } /// A dev workspace pairs with a root workspace or — supported, though not the recommended shape — /// with another dev workspace, giving a promotion chain (dev of dev -> dev -> prod). A throwaway /// fork is never a valid prod: its deploys go to its own `wm-fork/**` branch and it is discarded /// with its subtree, so a dev pinned under it has nowhere to promote to. Nor is an archived one, /// which hosts nothing at all. async fn ensure_dev_parent_can_host_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( db: E, parent_w_id: &str, ) -> Result<()> { let parent = sqlx::query!( r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!", is_dev_workspace, deleted FROM workspace WHERE id = $1"#, parent_w_id ) .fetch_optional(db) .await?; let Some(parent) = parent else { return Ok(()); }; if parent.deleted { return Err(Error::BadRequest(format!( "Cannot create a dev workspace of '{}' because it is archived.", parent_w_id ))); } if parent.is_fork && !parent.is_dev_workspace { return Err(Error::BadRequest(format!( "Cannot create a dev workspace of '{}' because it is a throwaway fork.", parent_w_id ))); } Ok(()) } /// Prod may be a dev workspace, so the candidate can sit ABOVE it in the tree — reparenting it below /// prod would close a parent<->child cycle and hang every hierarchy walk. Prod itself is at depth 0 /// of the chain, so callers must have rejected `dev_w_id == prod_w_id` first. /// /// `reject_dev_label_taken_in_chain` would also reject a cyclic pairing, since a cycle puts one /// workspace in the chain twice and so always repeats a label. It reports it as a workspace clashing /// with itself, which describes nothing the caller can act on — hence this, first. async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>( db: E, prod_w_id: &str, dev_w_id: &str, ) -> Result<()> { let would_cycle = sqlx::query_scalar!( r#"WITH RECURSIVE chain AS ( SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1 UNION ALL SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w JOIN chain ON w.id = chain.parent_workspace_id WHERE chain.depth < 20 ) SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS "cycle!""#, prod_w_id, dev_w_id, ) .fetch_one(db) .await?; if would_cycle { return Err(Error::BadRequest(format!( "Workspace {} is an ancestor of {} and cannot become its dev workspace", dev_w_id, prod_w_id ))); } Ok(()) } /// Serialize everything that makes or breaks a dev pairing: giving a workspace a dev workspace /// (create, attach) and clearing one's dev flag (detach, archive). Each decides on state the others /// mutate — whether a workspace already has a dev, still has one, or leaves a label free — so /// unserialized they all pass their checks and commit a shape those checks exist to reject. Take it /// before re-running them inside the mutating transaction; it releases on commit or rollback. /// /// Locks every workspace the operation's own checks read: each seed, its ancestors, and the dev /// workspaces beneath it. Locking just the endpoints is not enough — the label rule spans a whole /// chain, so two operations a couple of hops apart would hold disjoint keys and both commit. Reading /// the same set that is checked is what closes that: an attach splices two chains together and so /// holds nodes from both, and any operation that could collide with it necessarily touches the /// joined chain, hence shares a node. Acquired in id order, the only ordering rule that keeps two /// overlapping sets from deadlocking. /// /// Recomputed inside the transaction, but from a set that may already be stale — harmless, because /// whoever made it stale is the operation holding the node this one is missing. pub(crate) async fn lock_dev_pairing( tx: &mut Transaction<'_, Postgres>, seeds: &[&str], ) -> Result<()> { let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. let nodes = sqlx::query_scalar!( r#"WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id), up AS ( SELECT w.id, w.parent_workspace_id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id UNION ALL SELECT w.id, w.parent_workspace_id, up.depth + 1 FROM workspace w JOIN up ON w.id = up.parent_workspace_id WHERE up.depth < 20 ), down AS ( SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id UNION ALL SELECT w.id, down.depth + 1 FROM workspace w JOIN down ON w.parent_workspace_id = down.id WHERE down.depth < 20 AND w.is_dev_workspace ) SELECT id AS "id!" FROM ( SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down ) n ORDER BY id"#, &seeds[..] ) .fetch_all(&mut **tx) .await?; // One statement per node rather than a set-returning call: only a client-side loop actually // guarantees the acquisition order the deadlock argument above rests on. for node in nodes { sqlx::query!( "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))", node ) .execute(&mut **tx) .await?; } Ok(()) } /// What is about to clear `is_dev_workspace` on a workspace, which decides whether the workspace can /// go on hosting a dev workspace of its own afterwards. enum DevTeardown { /// Keeps `parent_workspace_id` only for a `wm-fork-` workspace, which then reads as a throwaway /// fork. A prefix-less workspace returns to standalone and hosts its dev exactly as before. Detach, /// Soft-deletes the workspace whatever its id looks like, so it hosts nothing afterwards. Archive, } /// A nested dev workspace outlives whatever clears its parent's dev flag, and the parent is then a /// shape that hosts no pairing: its settings tab offers no detach control, and `delete_workspace` /// refuses a workspace that still has a dev child, so the pairing could never be undone. Reject the /// teardown so it is done bottom-up instead. async fn reject_stranding_nested_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( db: E, w_id: &str, teardown: DevTeardown, ) -> Result<()> { let action = match teardown { DevTeardown::Detach => { if !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) { return Ok(()); } "Detaching" } DevTeardown::Archive => "Archiving", }; let nested = sqlx::query_scalar!( "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted", w_id ) .fetch_optional(db) .await?; if let Some(nested) = nested { return Err(Error::BadRequest(format!( "{action} {w_id} would leave it unable to host a pairing, but it is the prod workspace \ of '{nested}'. Detach '{nested}' first." ))); } Ok(()) } /// A dev workspace deploys to the branch named by its environment label, and every dev workspace in /// a chain inherits the same git-sync repositories, so two of them sharing a label push to one /// branch: each deploy clobbers the other environment, and the root's auto-pull routes that branch /// to whichever dev it matches first. Require every dev workspace in the resulting chain to carry a /// distinct label — the dev ancestors `new_dev_id` lands under, `new_dev_id` with `label`, and (when /// it already exists and so keeps its own subtree) the dev workspaces it brings with it. Dev /// workspaces only ever hang off a root or another dev (`ensure_dev_parent_can_host_dev`), so that /// chain is linear and this is the whole of it. async fn reject_dev_label_taken_in_chain( db: &mut sqlx::PgConnection, parent_w_id: &str, new_dev_id: &str, keeps_own_subtree: bool, label: Option<&str>, ) -> Result<()> { // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. let mut chain = sqlx::query!( r#"WITH RECURSIVE ancestors AS ( SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth FROM workspace WHERE id = $1 UNION ALL SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label, w.deleted, ancestors.depth + 1 FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id WHERE ancestors.depth < 20 ) SELECT id AS "id!", dev_workspace_label FROM ancestors WHERE is_dev_workspace AND NOT deleted"#, parent_w_id ) .fetch_all(&mut *db) .await? .into_iter() .map(|r| { ( r.id, windmill_common::workspaces::dev_workspace_branch(r.dev_workspace_label.as_deref()), ) }) .collect::>(); chain.push(( new_dev_id.to_string(), windmill_common::workspaces::dev_workspace_branch(label), )); if keeps_own_subtree { // `depth > 0`: the candidate itself is already in the list above, carrying its new label. chain.extend( sqlx::query!( r#"WITH RECURSIVE tree AS ( SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth FROM workspace WHERE id = $1 UNION ALL SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted, tree.depth + 1 FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id WHERE tree.depth < 20 ) SELECT id AS "id!", dev_workspace_label FROM tree WHERE depth > 0 AND is_dev_workspace AND NOT deleted"#, new_dev_id ) .fetch_all(&mut *db) .await? .into_iter() .map(|r| { ( r.id, windmill_common::workspaces::dev_workspace_branch( r.dev_workspace_label.as_deref(), ), ) }), ); } let mut by_branch: HashMap = HashMap::new(); for (id, branch) in chain { if let Some(other) = by_branch.insert(branch.clone(), id.clone()) { return Err(Error::BadRequest(format!( "'{other}' and '{id}' would both be '{branch}' workspaces in the same chain: dev \ workspaces in a chain share their git-sync repositories, so both would deploy to \ the '{branch}' branch. Use a different environment label." ))); } } Ok(()) } /// `dev_workspace_lock` is owned by the dev-workspace feature, which creates and removes it by name /// (attach/detach/archive/delete). Reserve the name against creation and deletion so a user-managed /// rule can't collide with the feature's name-based cleanup, and so detach stays the way a pairing's /// lock is lifted. Updating it is deliberately allowed: relaxing the lock is an admin's call, and the /// name is immutable on update so no collision can arise. fn reject_reserved_rule_name(name: &str) -> Result<()> { if name == DEV_WORKSPACE_LOCK_RULE_NAME { return Err(Error::BadRequest(format!( "'{}' is a reserved protection-rule name managed by the dev workspace feature", DEV_WORKSPACE_LOCK_RULE_NAME ))); } Ok(()) } /// Create a new protection rule async fn create_protection_rule( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; reject_reserved_rule_name(&req.name)?; let mut tx = db.begin().await?; // Check if rule with this name already exists let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", &w_id, &req.name ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if exists { return Err(Error::BadRequest(format!( "Protection rule with name '{}' already exists", req.name ))); } // Insert the new rule sqlx::query!( r#" INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users) VALUES ($1, $2, $3, $4, $5) "#, &w_id, &req.name, ProtectionRules::from(&req.rules).bits(), &req.bypass_groups, &req.bypass_users, ) .execute(&mut *tx) .await?; audit_log( &mut *tx, &authed, "workspaces.create_protection_rule", ActionKind::Create, &w_id, Some(&req.name), Some([("name", &req.name[..])].into()), ) .await?; tx.commit().await?; // Invalidate cache windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: format!("protection_rule_{}", req.name) }, None, false, None, ) .await?; Ok(format!("Created protection rule '{}'", req.name)) } /// Update an existing protection rule async fn update_protection_rule( authed: ApiAuthed, Extension(db): Extension, Path((w_id, rule_name)): Path<(String, String)>, Json(req): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; // A rename moves the row's primary key, so it needs the same name checks a create does. The // reserved rule can be neither end of one: the dev-workspace feature finds it by name, so // renaming it away would strand the lock and renaming onto it would collide with the feature. // Names are stored verbatim, as creation stores them, so this comparison is raw: a rule called // " prod-lock " survives an edit that submits its current name back untouched, and a name that // differs only in surrounding whitespace is a real rename rather than a silent no-op. let new_name = req.name.as_deref().filter(|n| *n != rule_name); if let Some(new_name) = new_name { if new_name.trim().is_empty() { return Err(Error::BadRequest( "Protection rule name cannot be empty".to_string(), )); } if rule_name == DEV_WORKSPACE_LOCK_RULE_NAME { return Err(Error::BadRequest(format!( "'{}' cannot be renamed: the dev workspace feature locates it by name", DEV_WORKSPACE_LOCK_RULE_NAME ))); } reject_reserved_rule_name(new_name)?; } let mut tx = db.begin().await?; // Check if rule exists let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", &w_id, &rule_name ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if !exists { return Err(Error::NotFound(format!( "Protection rule '{}' not found", rule_name ))); } if let Some(new_name) = new_name { let taken = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", &w_id, new_name ) .fetch_one(&mut *tx) .await? .unwrap_or(false); if taken { return Err(Error::BadRequest(format!( "Protection rule with name '{}' already exists", new_name ))); } } let final_name = new_name.unwrap_or(&rule_name); // Update the rule sqlx::query!( r#" UPDATE workspace_protection_rule SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4 WHERE workspace_id = $5 AND name = $6 "#, final_name, ProtectionRules::from(&req.rules).bits(), &req.bypass_groups, &req.bypass_users, &w_id, &rule_name ) .execute(&mut *tx) .await?; let mut audit_args = std::collections::HashMap::from([("name", final_name)]); if new_name.is_some() { audit_args.insert("previous_name", &rule_name[..]); } audit_log( &mut *tx, &authed, "workspaces.update_protection_rule", ActionKind::Update, &w_id, Some(final_name), Some(audit_args), ) .await?; tx.commit().await?; // Invalidate cache windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: format!("protection_rule_{}", final_name) }, None, false, None, ) .await?; Ok(format!("Updated protection rule '{}'", final_name)) } /// Delete a protection rule async fn delete_protection_rule( authed: ApiAuthed, Extension(db): Extension, Path((w_id, rule_name)): Path<(String, String)>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; reject_reserved_rule_name(&rule_name)?; let mut tx = db.begin().await?; // Delete the rule let result = sqlx::query!( "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", &w_id, &rule_name ) .execute(&mut *tx) .await?; if result.rows_affected() == 0 { return Err(Error::NotFound(format!( "Protection rule '{}' not found", rule_name ))); } audit_log( &mut *tx, &authed, "workspaces.delete_protection_rule", ActionKind::Delete, &w_id, Some(&rule_name), Some([("name", &rule_name[..])].into()), ) .await?; tx.commit().await?; // Invalidate cache windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) }, None, false, None, ) .await?; Ok(format!("Deleted protection rule '{}'", rule_name)) } #[derive(Serialize)] pub struct WorkspaceComparison { pub all_ahead_items_visible: bool, pub all_behind_items_visible: bool, pub skipped_comparison: bool, pub diffs: Vec, pub summary: CompareSummary, /// Items that exist in the diff but were dropped from `diffs` because they /// are not visible to the caller (excluded from the partial deploy). Split /// by direction: `hidden_ahead` lives in the fork, `hidden_behind` in the /// parent. `by_kind`/`total` are always populated (aggregate, no names); /// `items` (kind+path) is only filled for a caller who is admin of that side /// — never leak the paths of items the ACL is hiding from a regular user. pub hidden_ahead: HiddenItemsSummary, pub hidden_behind: HiddenItemsSummary, /// When the pair is outside the fork lineage, when its candidate set was last /// seeded by an explicit full scan. Absent means the pair has never been /// scanned, so an empty `diffs` says nothing about whether the two workspaces /// agree. Always absent for a lineage pair, which the tally keeps current. /// Omitted rather than null, as the schema declares. #[serde(skip_serializing_if = "Option::is_none")] pub full_scan_at: Option>, } #[derive(Serialize, Default)] pub struct HiddenItemsSummary { pub total: usize, pub by_kind: std::collections::BTreeMap, pub items: Vec, } #[derive(Serialize)] pub struct HiddenItem { pub kind: String, pub path: String, } #[derive(Serialize, Default)] pub struct CompareSummary { pub total_diffs: usize, pub total_ahead: usize, pub total_behind: usize, pub scripts_changed: usize, pub flows_changed: usize, pub apps_changed: usize, pub resources_changed: usize, pub variables_changed: usize, pub resource_types_changed: usize, pub folders_changed: usize, pub schedules_changed: usize, pub triggers_changed: usize, pub datatable_migrations_changed: usize, pub conflicts: usize, // Items that are both ahead and behind } async fn reset_workspace_diffs( authed: ApiAuthed, Path((w_id, target_workspace_id)): Path<(String, String)>, Extension(db): Extension, Extension(user_db): Extension, ) -> JsonResult<()> { // Needed to compute the has_changes: Option. Otherwise it will be None, and the query will not hit the items let _ = compare_workspaces( authed, Path((w_id.clone(), target_workspace_id.clone())), Extension(db.clone()), Extension(user_db), ) .await?; sqlx::query!( "DELETE FROM workspace_diff WHERE has_changes = false AND ( (source_workspace_id = $1 AND fork_workspace_id = $2) OR (source_workspace_id = $2 AND fork_workspace_id =$1) )", target_workspace_id, w_id, ) .execute(&db) .await?; Ok(Json(())) } #[derive(Serialize, Debug, Clone, Default)] pub struct WorkspaceDiffRow { kind: String, path: String, ahead: i32, behind: i32, has_changes: Option, exists_in_source: Option, exists_in_fork: Option, /// The last deploy event claimed on each side, per /// `windmill_common::deploy_origin`. Omitted rather than null, as the schema /// declares; absent means no evidence, which never justifies propagating a /// removal. /// /// Only the fork half is consumed today, by the merge direction. The update /// direction has the mirror shape (a parent-side removal it cannot attribute) /// but writes to the fork rather than to prod, and gating it on evidence would /// strand a row a legacy tally left without any — so the source half is /// recorded and surfaced, unread, rather than left as a gap to backfill. #[serde(skip_serializing_if = "Option::is_none")] fork_last_event_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] fork_last_event_origin: Option, #[serde(skip_serializing_if = "Option::is_none")] source_last_event_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] source_last_event_origin: Option, } async fn compare_workspaces( authed: ApiAuthed, Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>, Extension(db): Extension, Extension(user_db): Extension, ) -> JsonResult { // require_admin(authed.is_admin, &authed.username)?; // Only the lineage pair (source is the fork's parent) is kept current by the // deploy tally. Any other pair is an explicit arbitrary-target comparison, // which behaves differently on three counts, all handled below: it needs an // explicit full scan to have a candidate set at all, it re-evaluates every // candidate instead of trusting the cache, and it demands admin on both sides. let is_lineage_pair = sqlx::query_scalar!( "SELECT parent_workspace_id = $2 FROM workspace WHERE id = $1", fork_workspace_id, source_workspace_id, ) .fetch_optional(&db) .await? .flatten() .unwrap_or(false); // Loaded eagerly only for the arbitrary pair, whose authorization needs it; the // lineage path keeps it out of its early returns and loads it below. let mut fork_authed: Option = None; let full_scan_at = if is_lineage_pair { None } else { // An arbitrary pair exposes the two workspaces to each other, so require the // caller to be an admin of both (superadmin folds into `is_admin` on either // side). Without this, being an admin of one workspace would be enough to // learn how many items of each kind differ in a workspace one merely belongs to. let fa = load_workspace_authed(&db, &authed, &fork_workspace_id).await?; if !(authed.is_admin && fa.is_admin) { return Err(Error::BadRequest(format!( "Comparing {fork_workspace_id} with {source_workspace_id}, which is not its parent workspace, requires being an admin of both workspaces" ))); } fork_authed = Some(fa); sqlx::query_scalar!( "SELECT scanned_at FROM workspace_diff_full_scan WHERE source_workspace_id = $1 AND fork_workspace_id = $2", source_workspace_id, fork_workspace_id, ) .fetch_optional(&db) .await? }; // `skip_workspace_diff_tally` marks workspaces that predate the tally, whose // lineage diff can therefore never be reconstructed from it. It says nothing // about a pair whose candidate set was just enumerated in full. let skipped_comparison: bool = if full_scan_at.is_some() { false } else { sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM skip_workspace_diff_tally WHERE workspace_id = $1 )", ) .bind(&fork_workspace_id) .fetch_one(&db) .await? }; if skipped_comparison || (!is_lineage_pair && full_scan_at.is_none()) { return Ok(Json(WorkspaceComparison { all_ahead_items_visible: true, all_behind_items_visible: true, skipped_comparison, diffs: vec![], summary: Default::default(), hidden_ahead: Default::default(), hidden_behind: Default::default(), full_scan_at, })); } // Honor ws_specific at read time: a workspace-specific resource/variable keeps its own value per // environment, so it must never appear in the normal diff (the per-item compare suppresses it, // but a cached `has_changes=true` row is trusted without re-running that compare, so filter those // here too). Seeding the initial copy onto a side that lacks it is a separate explicit action // (the "Create in " button on the Workspace-specific list), not part of the diff. The row // is left intact, so unpinning resurfaces it without a re-tally. let diff_items = sqlx::query_as!( WorkspaceDiffRow, "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork, fork_last_event_kind, fork_last_event_origin, source_last_event_kind, source_last_event_origin FROM workspace_diff WHERE source_workspace_id = $1 AND fork_workspace_id = $2 AND NOT EXISTS ( SELECT 1 FROM ws_specific ws WHERE ws.path = workspace_diff.path AND ws.item_kind = workspace_diff.kind AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id) )", source_workspace_id, fork_workspace_id, ) .fetch_all(&db) .await?; // A cached `has_changes = true` row is trusted without re-running the // per-kind comparison, but that verdict can go stale: an item archived or // deleted after it was cached still carries `exists_in_*=true` here. The // common offender is the old path after a rename — for lock-gen languages // (Python/TS/…) the `has_changes=NULL` reset is deferred to the dependency // job, so until that runs (or if it fails) the archived old path looks like // a live ahead change. Treat archived as non-existent: re-validate such rows // against the live tables and, if the item no longer exists on a side the // cache claims, re-evaluate it below so it gets corrected or removed. // // Only scripts/flows can hit this (they have `archived`; other kinds reset // synchronously on delete). Probe both sides in one batched query per kind // (mirroring `query_visible_items`) rather than per row, to keep the hot // compare path off an O(number of cached diffs) sequence of round trips. // // An arbitrary pair skips the probe entirely: nothing resets its cache, so it // re-evaluates every candidate below and the staleness question never arises. let trust_cache = is_lineage_pair; let (live_source, live_fork) = { let mut cached_source: HashMap<&str, Vec<&str>> = HashMap::new(); let mut cached_fork: HashMap<&str, Vec<&str>> = HashMap::new(); for item in &diff_items { if trust_cache && item.has_changes == Some(true) && (item.kind == "script" || item.kind == "flow") { if item.exists_in_source.unwrap_or(false) { cached_source .entry(item.kind.as_str()) .or_default() .push(item.path.as_str()); } if item.exists_in_fork.unwrap_or(false) { cached_fork .entry(item.kind.as_str()) .or_default() .push(item.path.as_str()); } } } ( existing_runnables(&db, &source_workspace_id, &cached_source).await?, existing_runnables(&db, &fork_workspace_id, &cached_fork).await?, ) }; let mut confirmed_diffs = vec![]; for item in diff_items { if let Some(has_changes) = item.has_changes.filter(|_| trust_cache) { if !has_changes { // Defensive: rows that compared equal are normally deleted, so // this is rarely hit. Not a diff — skip. continue; } // Stale only applies to script/flow (others aren't in the probed // sets); a row whose claimed-existing side has no live version is // stale and falls through to re-evaluation. let key = (item.kind.clone(), item.path.clone()); let fork_stale = item.exists_in_fork.unwrap_or(false) && !live_fork.contains(&key); let source_stale = item.exists_in_source.unwrap_or(false) && !live_source.contains(&key); let probed = item.kind == "script" || item.kind == "flow"; if !(probed && (fork_stale || source_stale)) { // Cache is still valid (or not a probed kind) — trust it. confirmed_diffs.push(item); continue; } // Stale cache: fall through to re-evaluate (and correct/delete) below. } let item_comparison = match item.kind.as_str() { "script" => Some( compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path) .await?, ), "flow" => Some( compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path) .await?, ), "app" | "raw_app" => Some( compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?, ), "resource" => Some( compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path) .await?, ), "variable" => Some( compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path) .await?, ), "resource_type" => Some( compare_two_resource_types( &db, &source_workspace_id, &fork_workspace_id, &item.path, ) .await?, ), "folder" => Some( compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path) .await?, ), "datatable_migration" => Some( crate::datatable_migrations::compare_two_datatable_migration( &db, &source_workspace_id, &fork_workspace_id, &item.path, ) .await?, ), // Triggers and schedules are diffed against a hardcoded ignore list // (mode/enabled/server_id/last_server_ping/edited_at/by/error/extra_perms/permissioned_as/email) // so that fork-clones — which differ from the parent only in the runtime // mode/enabled flag — don't show as diffs. k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => Some( compare_two_trigger_or_schedule( &db, item.kind.as_str(), &source_workspace_id, &fork_workspace_id, &item.path, ) .await?, ), k => { tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path); None // Some(ItemComparison { // has_changes: true, // exists_in_source: true, // exists_in_fork: true, // }) } }; if let Some(item_comparison) = item_comparison { if item_comparison.has_changes { sqlx::query!( "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6 WHERE path = $3 AND kind = $4 AND ( (source_workspace_id = $1 AND fork_workspace_id = $2) OR (source_workspace_id = $2 AND fork_workspace_id =$1) )", source_workspace_id, fork_workspace_id, item.path, item.kind, item_comparison.exists_in_source, item_comparison.exists_in_fork, ) .execute(&db) .await?; confirmed_diffs.push(WorkspaceDiffRow { has_changes: Some(item_comparison.has_changes), exists_in_source: Some(item_comparison.exists_in_source), exists_in_fork: Some(item_comparison.exists_in_fork), ..item }); } else { sqlx::query!( "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND ( (source_workspace_id = $1 AND fork_workspace_id = $2) OR (source_workspace_id = $2 AND fork_workspace_id =$1) )", source_workspace_id, fork_workspace_id, item.path, item.kind, ) .execute(&db) .await?; } } } // The authed in `authed` is loaded for the source workspace (the one in the // URL path). Its `folders`/`groups`/`is_admin` reflect membership in the // source workspace only. Using it as the RLS context when querying the // fork's tables would hide items the user can only see via fork-specific // permissions (e.g. a folder the user owns in the fork but that does not // exist in the source), causing the spurious // "this fork has changes not visible to your user" warning. Build a // matching authed for the fork so each side's visibility check uses the // right RLS context. let fork_authed = match fork_authed { Some(fa) => fa, None => load_workspace_authed(&db, &authed, &fork_workspace_id).await?, }; let visible_diffs = filter_visible_diffs( &confirmed_diffs, &source_workspace_id, &fork_workspace_id, &authed, &fork_authed, &user_db, ) .await?; let summary = CompareSummary { total_diffs: visible_diffs.len(), total_ahead: visible_diffs .iter() .map(|s| s.ahead) .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), total_behind: visible_diffs .iter() .map(|s| s.behind) .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(), flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(), apps_changed: visible_diffs .iter() .filter(|s| s.kind == "app" || s.kind == "raw_app") .count(), resources_changed: visible_diffs .iter() .filter(|s| s.kind == "resource") .count(), variables_changed: visible_diffs .iter() .filter(|s| s.kind == "variable") .count(), resource_types_changed: visible_diffs .iter() .filter(|s| s.kind == "resource_type") .count(), folders_changed: visible_diffs.iter().filter(|s| s.kind == "folder").count(), schedules_changed: visible_diffs .iter() .filter(|s| s.kind == "schedule") .count(), triggers_changed: visible_diffs .iter() .filter(|s| s.kind.ends_with("_trigger")) .count(), datatable_migrations_changed: visible_diffs .iter() .filter(|s| s.kind == "datatable_migration") .count(), conflicts: visible_diffs .iter() .filter(|s| s.ahead > 0 && s.behind > 0) .count(), }; // Each direction accounts for what it carries (see the frontend's // `diffActionableInDirection`): a lineage merge leaves out a row the fork lacks // unless the fork's own last event says it deleted or renamed it away, while the // update takes it whatever the counters say — and since it can carry // `behind = 0`, that side counts rows rather than sums. let source_only = |d: &WorkspaceDiffRow| { d.exists_in_source.unwrap_or(false) && !d.exists_in_fork.unwrap_or(false) }; // Through the enums rather than their wire values: renaming one otherwise // compiles clean on both sides and silently makes this always false. let fork_removed_it = |d: &WorkspaceDiffRow| { use windmill_common::deploy_origin::{DeployEventKind, DeployOrigin}; d.fork_last_event_origin.as_deref() == Some(DeployOrigin::Authored.as_str()) && d.fork_last_event_kind.as_deref().is_some_and(|k| { k == DeployEventKind::Delete.as_str() || k == DeployEventKind::RenameFrom.as_str() }) }; let merge_carries = |d: &WorkspaceDiffRow| !is_lineage_pair || !source_only(d) || fork_removed_it(d); let ahead_sum = |rows: &[WorkspaceDiffRow]| { rows.iter() .filter(|d| merge_carries(d)) .map(|s| s.ahead) .fold(0i64, |acc, s| acc + i64::from(s)) }; let all_ahead_items_visible = ahead_sum(&visible_diffs) == ahead_sum(&confirmed_diffs); let all_behind_items_visible = summary.total_behind == confirmed_diffs .iter() .map(|s| s.behind) .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)) && visible_diffs.iter().filter(|d| source_only(d)).count() == confirmed_diffs.iter().filter(|d| source_only(d)).count(); // Blast-radius guard for the "changes not visible to your user" warning // (which hides the deploy button). The flag is a pure visibility guarantee — // the deploy re-authorizes each item against the target workspace's // create/update endpoints — so it is safe to force true for a caller who sees // every item on BOTH sides, for whom any diff the filter dropped is provably a // stale/phantom row, never a permission gap. // // It must be BOTH sides, not per-side: `filter_visible_diffs` keeps a modified // or conflict row (one that exists in the source AND the fork) only when the // caller can see it on both sides, so an ahead/conflict diff can be dropped for // a source-side visibility gap even when the caller is a fork admin. Gating the // ahead flag on fork-admin alone would then wrongly report "all ahead visible" // and let the UI deploy from an incomplete comparison. So require admin of the // source AND the fork (superadmin satisfies both), which guarantees full // visibility of every item on every side. `fork_authed.is_admin` already folds // in superadmin; `authed.is_admin` (source side) does not, so OR it in. let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?; let sees_all_items = is_super_admin || (authed.is_admin && fork_authed.is_admin); let all_ahead_items_visible = all_ahead_items_visible || sees_all_items; let all_behind_items_visible = all_behind_items_visible || sees_all_items; // Items dropped by the visibility filter (in confirmed_diffs but not in the // returned visible_diffs). Surface what the partial deploy excludes: aggregate // counts by kind for everyone, but kind+path only to a caller who `sees_all_items` // (superadmin, or admin of the source AND the fork). For them a dropped item is // provably a phantom/stale row, not an ACL-hidden secret, so no path leaks — // fork-admin alone is not enough (a fork-deleted ahead item lives only in the // parent, whose path a non-parent-admin must not see). let visible_keys: HashSet<(&str, &str)> = visible_diffs .iter() .map(|d| (d.kind.as_str(), d.path.as_str())) .collect(); let mut hidden_ahead = HiddenItemsSummary::default(); let mut hidden_behind = HiddenItemsSummary::default(); for d in &confirmed_diffs { if visible_keys.contains(&(d.kind.as_str(), d.path.as_str())) { continue; } // Both sides mirror the flags above: a row is only withheld from a direction // that would have carried it. if d.ahead > 0 && merge_carries(d) { hidden_ahead.total += 1; *hidden_ahead.by_kind.entry(d.kind.clone()).or_default() += 1; if sees_all_items { hidden_ahead .items .push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() }); } } if d.behind > 0 || source_only(d) { hidden_behind.total += 1; *hidden_behind.by_kind.entry(d.kind.clone()).or_default() += 1; if sees_all_items { hidden_behind .items .push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() }); } } } return Ok(Json(WorkspaceComparison { all_ahead_items_visible, all_behind_items_visible, skipped_comparison: false, diffs: visible_diffs, summary, hidden_ahead, hidden_behind, full_scan_at, })); } /// Every `(kind, path)` deployable item currently in a workspace, keyed the same /// way `workspace_diff` rows are. Mirrors the kind → table mapping of /// `query_visible_items`, minus its path filter: this enumerates the candidate set /// for a pair no tally maintains. /// /// Runs on `&db` (no RLS). Enumerating everything is what makes the diff complete; /// per-item authorization stays in `filter_visible_diffs`, and reaching this at all /// requires admin of both workspaces. async fn list_all_item_keys(db: &DB, workspace_id: &str) -> Result> { let mut keys: Vec<(String, String)> = vec![]; fn push(keys: &mut Vec<(String, String)>, kind: &str, paths: Vec) { keys.extend(paths.into_iter().map(|p| (kind.to_string(), p))); } push( &mut keys, "script", sqlx::query_scalar!( "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false", workspace_id ) .fetch_all(db) .await?, ); push( &mut keys, "flow", sqlx::query_scalar!( "SELECT path FROM flow WHERE workspace_id = $1 AND archived = false", workspace_id ) .fetch_all(db) .await?, ); // Raw apps live in the `app` table too — what sets them apart is `raw_app` on // their latest version. The kind decides which editor the merge UI links to and // which deploy path an item takes, so read it from the version, as the delete // handler does when it picks the DeployedObject variant. for row in sqlx::query!( "SELECT app.path, app_version.raw_app FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] WHERE app.workspace_id = $1", workspace_id ) .fetch_all(db) .await? { keys.push(( if row.raw_app { "raw_app" } else { "app" }.to_string(), row.path, )); } push( &mut keys, "resource", sqlx::query_scalar!( "SELECT path FROM resource WHERE workspace_id = $1", workspace_id ) .fetch_all(db) .await?, ); push( &mut keys, "variable", sqlx::query_scalar!( "SELECT path FROM variable WHERE workspace_id = $1", workspace_id ) .fetch_all(db) .await?, ); push( &mut keys, "resource_type", sqlx::query_scalar!( "SELECT name FROM resource_type WHERE workspace_id = $1", workspace_id ) .fetch_all(db) .await?, ); push( &mut keys, "folder", sqlx::query_scalar!( "SELECT 'f/' || name FROM folder WHERE workspace_id = $1", workspace_id ) .fetch_all(db) .await? .into_iter() .flatten() .collect(), ); push( &mut keys, "datatable_migration", sqlx::query_scalar!( "SELECT datatable || '/' || timestamp || '_' || name FROM datatable_migrations WHERE workspace_id = $1", workspace_id ) .fetch_all(db) .await? .into_iter() .flatten() .collect(), ); for table in TRIGGER_OR_SCHEDULE_TABLES { // SAFETY: `table` comes from the hardcoded TRIGGER_OR_SCHEDULE_TABLES // allowlist, not user input. let sql = format!("SELECT path FROM {table} WHERE workspace_id = $1"); push( &mut keys, table, sqlx::query_scalar(&sql) .bind(workspace_id) .fetch_all(db) .await?, ); } Ok(keys) } /// Whether the two workspaces are directly linked by the fork lineage, in either /// direction — the only relationship the deploy tally maintains. async fn is_lineage_pair<'e>( executor: impl sqlx::PgExecutor<'e>, a: &str, b: &str, ) -> Result { Ok(sqlx::query_scalar!( "SELECT EXISTS( SELECT 1 FROM workspace WHERE (id = $1 AND parent_workspace_id = $2) OR (id = $2 AND parent_workspace_id = $1) )", a, b, ) .fetch_one(executor) .await? .unwrap_or(false)) } /// Serialize the operations that rewrite a workspace pair's `workspace_diff` rows: /// a full scan, and the attach that turns the pair into a lineage pair and clears /// those rows. Without a common lock a scan can seed one-way candidates *after* an /// attach has cleaned them up, leaving the tally reading rows that guessed a /// direction. Order-independent, so both directions of a pair take the same lock. async fn lock_workspace_pair(tx: &mut Transaction<'_, Postgres>, a: &str, b: &str) -> Result<()> { let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; sqlx::query!( "SELECT pg_advisory_xact_lock(hashtext('workspace_diff_pair:' || $1 || '/' || $2))", lo, hi, ) .execute(&mut **tx) .await?; Ok(()) } #[derive(Serialize)] pub struct FullDiffScan { /// Candidate items the scan will compare — the union of both workspaces' /// items. The comparison itself prunes the ones that turn out to be equal. pub candidates: usize, pub scanned_at: chrono::DateTime, } /// Seed the `workspace_diff` candidate set for a pair the fork tally does not /// maintain, so the merge UI can target a workspace outside the lineage. /// /// The tally only ever records a fork against its parent, so an arbitrary pair /// starts with no rows and would compare as "identical". This enumerates every /// item on both sides as an undecided candidate (`has_changes = NULL`) and records /// the scan; the following `compare_workspaces` is what actually compares them, /// keeps the differing rows and deletes the rest. That first comparison is the /// expensive step — it costs a per-kind query pair per candidate — which is why /// scanning is an explicit action and its result is left in `workspace_diff` for /// subsequent loads to reuse. /// /// Direction is one-way: candidates are seeded `ahead = 1`, i.e. changes to push /// from `w_id` into `target_workspace_id`. A cold scan has no deploy history to /// tell which side moved, so there is nothing to say in the other direction. async fn seed_full_diff_scan( authed: ApiAuthed, Path((w_id, target_workspace_id)): Path<(String, String)>, Extension(db): Extension, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; if w_id == target_workspace_id { return Err(Error::BadRequest( "Cannot compare a workspace with itself".to_string(), )); } let target_exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1 AND deleted = false)", target_workspace_id ) .fetch_one(&db) .await? .unwrap_or(false); if !target_exists { return Err(Error::NotFound(format!( "Workspace {target_workspace_id} does not exist" ))); } let target_authed = load_workspace_authed(&db, &authed, &target_workspace_id).await?; if !target_authed.is_admin { return Err(Error::BadRequest(format!( "Computing a diff against {target_workspace_id} requires being an admin of it" ))); } // The lineage pair has its own tally, whose `ahead`/`behind` counters say which // side a change came from. Seeding one-way candidates over it would overwrite // that direction with a guess, so leave the lineage comparison to the tally. // Checked again under the pair lock below, since the answer can change under us. if is_lineage_pair(&db, &w_id, &target_workspace_id).await? { return Err(Error::BadRequest(format!( "{w_id} and {target_workspace_id} are already linked by the fork lineage — their diff is tracked continuously and needs no full scan" ))); } let mut candidates = list_all_item_keys(&db, &w_id).await?; let target_items = list_all_item_keys(&db, &target_workspace_id).await?; // Two kinds identify an item by less than their `(kind, path)` key, so the same // logical item can key differently on each side. Their comparison matches on the // weaker identity, so seeding both keys would list — and deploy — one item twice. // Both collapse onto this workspace's key, the one a deploy writes. // // A data table migration keeps its identity across a rename, so the same // `(datatable, timestamp)` can carry a different name, hence a different path. let own_migrations: HashSet<(String, i64)> = candidates .iter() .filter(|(kind, _)| kind == "datatable_migration") .filter_map(|(_, path)| { crate::datatable_migrations::parse_datatable_migration_diff_path(path) }) .collect(); // An app and a raw app share the `app` table and one path per workspace — // converting one into the other keeps the path and changes only the kind. let own_app_paths: HashSet = candidates .iter() .filter(|(kind, _)| kind == "app" || kind == "raw_app") .map(|(_, path)| path.clone()) .collect(); candidates.extend(target_items.into_iter().filter(|(kind, path)| { match kind.as_str() { "datatable_migration" => { !crate::datatable_migrations::parse_datatable_migration_diff_path(path) .is_some_and(|key| own_migrations.contains(&key)) } "app" | "raw_app" => !own_app_paths.contains(path), _ => true, } })); candidates.sort(); candidates.dedup(); let kinds: Vec = candidates.iter().map(|(k, _)| k.clone()).collect(); let paths: Vec = candidates.iter().map(|(_, p)| p.clone()).collect(); let mut tx = db.begin().await?; lock_workspace_pair(&mut tx, &w_id, &target_workspace_id).await?; // Re-checked under the lock: the pair may have been linked since the check above, // and `attach_dev_workspace` clears the arbitrary rows as it links. Seeding after // that cleanup would leave one-way `ahead` rows on a pair the compare now treats // as lineage, listing the target's own items as default-selected deletions. if is_lineage_pair(&mut *tx, &w_id, &target_workspace_id).await? { return Err(Error::BadRequest(format!( "{w_id} and {target_workspace_id} were linked by the fork lineage while the scan ran — their diff is tracked continuously and needs no full scan" ))); } // The scan replaces this pair's candidate set rather than adding to it. Merging // would keep a key the new scan no longer produces, and the comparison // re-evaluates every row of a non-lineage pair, so a renamed migration or an // app converted to a raw app would come back beside its new key — the very // duplicate the collapses above remove, one Recompute later. Nothing of value is // dropped: the verdicts are recomputed anyway, and rows left by a lineage link // since dissolved carry a `behind` that would hide the item from the one-way list. sqlx::query!( "DELETE FROM workspace_diff WHERE source_workspace_id = $1 AND fork_workspace_id = $2", target_workspace_id, w_id, ) .execute(&mut *tx) .await?; sqlx::query!( "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) SELECT $1, $2, path, kind, 1, 0, NULL FROM unnest($3::varchar[], $4::varchar[]) AS t(kind, path)", target_workspace_id, w_id, &kinds, &paths, ) .execute(&mut *tx) .await?; let scanned_at = sqlx::query_scalar!( "INSERT INTO workspace_diff_full_scan (source_workspace_id, fork_workspace_id) VALUES ($1, $2) ON CONFLICT (source_workspace_id, fork_workspace_id) DO UPDATE SET scanned_at = now() RETURNING scanned_at", target_workspace_id, w_id, ) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(Json(FullDiffScan { candidates: candidates.len(), scanned_at, })) } /// Build an `ApiAuthed` for the same user but scoped to a different workspace. /// /// Reloads `is_admin`, `groups`, and `folders` from the target workspace's /// `usr` / `group_` / `folder` tables (keyed by the caller's email) so the /// returned authed can be used as the RLS context for queries against that /// workspace. `is_admin` is OR'd with the user's superadmin status so cross- /// workspace superadmins keep their RLS bypass. /// /// If the user is not a member of `workspace_id`, returns an authed with no /// folders/groups/operator/admin (except for superadmins, who stay admin) — /// i.e. they will only see what RLS explicitly allows for unknown users. async fn load_workspace_authed( db: &DB, base_authed: &ApiAuthed, workspace_id: &str, ) -> Result { let mut conn = db .acquire() .await .map_err(|e| Error::internal_err(e.to_string()))?; // Job-aware: this grants an admin claim in a workspace the caller may have no // relationship with, and `job_id` is carried into the result — so a `WM_TOKEN` // whose on-behalf identity is a superadmin would hold admin everywhere // (GHSA-hfh4-cx4h-3fcr). It then falls through to its real membership below. let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?; let user_row = sqlx::query!( "SELECT username, is_admin, operator FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false", workspace_id, &base_authed.email ) .fetch_optional(&mut *conn) .await?; let Some(user_row) = user_row else { return Ok(ApiAuthed { email: base_authed.email.clone(), username: base_authed.username.clone(), is_admin: is_super_admin, is_operator: false, groups: vec![], folders: vec![], scopes: base_authed.scopes.clone(), username_override: base_authed.username_override.clone(), username_override_is_token_label: base_authed.username_override_is_token_label, is_session_token: base_authed.is_session_token, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, job_id: base_authed.job_id, }); }; let groups = windmill_common::auth::get_groups_for_user( workspace_id, &user_row.username, &base_authed.email, &mut *conn, ) .await?; let folders = windmill_common::auth::get_folders_for_user( workspace_id, &user_row.username, &groups, &mut *conn, ) .await?; Ok(ApiAuthed { email: base_authed.email.clone(), username: user_row.username, is_admin: is_super_admin || user_row.is_admin, is_operator: user_row.operator, groups, folders, scopes: base_authed.scopes.clone(), username_override: base_authed.username_override.clone(), username_override_is_token_label: base_authed.username_override_is_token_label, is_session_token: base_authed.is_session_token, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, job_id: base_authed.job_id, }) } async fn filter_visible_diffs( confirmed_diffs: &[WorkspaceDiffRow], source_workspace_id: &str, fork_workspace_id: &str, source_authed: &ApiAuthed, fork_authed: &ApiAuthed, user_db: &UserDB, ) -> Result> { // Step 1: Group paths by (workspace, kind) let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new(); let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new(); for diff in confirmed_diffs { if diff.exists_in_source.unwrap_or(false) { source_items.entry(&diff.kind).or_default().push(&diff.path); } if diff.exists_in_fork.unwrap_or(false) { fork_items.entry(&diff.kind).or_default().push(&diff.path); } } // Step 2: Batch query for each (workspace, kind) combination, each in its // own transaction so RLS uses the right authed for each side. The fork's // authed picks up fork-only folders/groups; without this split the fork // queries would run with the source workspace's permissions and miss any // item the user can only reach through fork-specific permissions. let source_visible = { let mut tx = user_db.clone().begin(source_authed).await?; let visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; tx.commit().await?; visible }; let fork_visible = { let mut tx = user_db.clone().begin(fork_authed).await?; let visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; tx.commit().await?; visible }; // Step 3: Filter diffs based on visibility let visible_diffs: Vec = confirmed_diffs .iter() .filter(|diff| { let v = (diff.kind.to_string(), diff.path.to_string()); let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v); let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v); source_ok && fork_ok }) .cloned() .collect(); Ok(visible_diffs) } async fn query_visible_items<'c>( tx: &mut Transaction<'c, Postgres>, workspace_id: &str, items_by_kind: &HashMap<&str, Vec<&str>>, ) -> Result> { let mut visible = HashSet::new(); for (kind, paths) in items_by_kind { let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); let results = match *kind { "script" => { sqlx::query_scalar!( "SELECT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "flow" => { sqlx::query_scalar!( "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "app" | "raw_app" => { sqlx::query_scalar!( "SELECT path FROM app WHERE workspace_id = $1 AND path = ANY($2)", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "resource" => { sqlx::query_scalar!( "SELECT path FROM resource WHERE workspace_id = $1 AND path = ANY($2)", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "variable" => { sqlx::query_scalar!( "SELECT path FROM variable WHERE workspace_id = $1 AND path = ANY($2)", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "folder" => { let a: Vec = paths_vec .iter() .map(|p| p.strip_prefix("f/").unwrap_or(p.as_str()).to_string()) .collect(); sqlx::query_scalar!( "SELECT name FROM folder WHERE workspace_id = $1 AND name = ANY($2)", workspace_id, &a, ) .fetch_all(&mut **tx) .await? .into_iter() .map(|p| format!("f/{p}")) .collect() } "resource_type" => { sqlx::query_scalar!( "SELECT name FROM resource_type WHERE workspace_id = $1 AND name = ANY($2)", workspace_id, &paths_vec ) .fetch_all(&mut **tx) .await? } "datatable_migration" => { // Match by (datatable, timestamp), not the full path: a migration // keeps its identity across a rename, so the candidate path's // `name` segment can differ from the stored one. Parse each // `/_` candidate, probe existence by // (datatable, timestamp), and return the *original* candidate path // so the visibility set stays keyed by the diff's path. let parsed: Vec<(String, i64, String)> = paths_vec .iter() .filter_map(|p| { let (dt, rest) = p.split_once('/')?; let ts = rest.split_once('_')?.0.parse::().ok()?; Some((dt.to_string(), ts, p.clone())) }) .collect(); if parsed.is_empty() { vec![] } else { let dts: Vec = parsed.iter().map(|(d, _, _)| d.clone()).collect(); let tss: Vec = parsed.iter().map(|(_, t, _)| *t).collect(); let existing: HashSet<(String, i64)> = sqlx::query!( "SELECT datatable, timestamp FROM datatable_migrations \ WHERE workspace_id = $1 AND datatable = ANY($2) AND timestamp = ANY($3)", workspace_id, &dts, &tss, ) .fetch_all(&mut **tx) .await? .into_iter() .map(|r| (r.datatable, r.timestamp)) .collect(); parsed .into_iter() .filter(|(d, t, _)| existing.contains(&(d.clone(), *t))) .map(|(_, _, p)| p) .collect() } } k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => { // SAFETY: `kind` comes from a hardcoded allowlist // TRIGGER_OR_SCHEDULE_TABLES, not user input. let sql = format!("SELECT path FROM {kind} WHERE workspace_id = $1 AND path = ANY($2)"); sqlx::query_scalar(&sql) .bind(workspace_id) .bind(&paths_vec) .fetch_all(&mut **tx) .await? } _ => vec![], // Unknown kind }; for path in results { visible.insert((kind.to_string(), path)); } } Ok(visible) } /// Batched existence probe used to detect stale `workspace_diff` cache rows. /// /// Given candidate paths grouped by kind, returns the set of `(kind, path)` /// that currently have a *deployable* (non-archived) version in the workspace, /// mirroring the existence semantics of `compare_two_scripts` / /// `compare_two_flows`. Only scripts and flows are probed — they're the only /// kinds with `archived`, and the only ones whose diff-row reset can lag behind /// the actual change (deferred dependency job for lock-gen languages); other /// kinds reset synchronously on delete, so their cache is trusted (and they're /// never passed in). One query per kind keeps the compare path off a per-row /// sequence of round trips. Runs on `&db` (no RLS) — this is a pure existence /// check; authorization stays in `filter_visible_diffs` / `query_visible_items`. async fn existing_runnables( db: &DB, workspace_id: &str, items_by_kind: &HashMap<&str, Vec<&str>>, ) -> Result> { let mut existing = HashSet::new(); for (kind, paths) in items_by_kind { let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); let found: Vec = match *kind { "script" => sqlx::query_scalar!( "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", workspace_id, &paths_vec ) .fetch_all(db) .await?, "flow" => sqlx::query_scalar!( "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", workspace_id, &paths_vec ) .fetch_all(db) .await?, _ => vec![], }; for path in found { existing.insert((kind.to_string(), path)); } } Ok(existing) } #[derive(Debug)] pub(crate) struct ItemComparison { pub(crate) has_changes: bool, pub(crate) exists_in_source: bool, pub(crate) exists_in_fork: bool, } async fn compare_two_scripts( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // Get latest script from each workspace let source_script = sqlx::query!( "SELECT hash, created_at, content, summary, description, lock, schema FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", source_workspace_id, path ) .fetch_optional(db) .await?; let target_script = sqlx::query!( "SELECT hash, created_at, content, summary, description, lock, schema FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", fork_workspace_id, path ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_script, &target_script) { if source.content != target.content || source.summary != target.summary || source.description != target.description || source.lock != target.lock || source.schema != target.schema { has_changes = true; } } else if source_script.is_some() || target_script.is_some() { // The script exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_script.is_some(), exists_in_fork: target_script.is_some(), }); } async fn compare_two_flows( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // Get latest flow from each workspace let source_flow = sqlx::query!( "SELECT value, summary, description, schema FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false", source_workspace_id, path ) .fetch_optional(db) .await?; let target_flow = sqlx::query!( "SELECT value, summary, description, schema FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false", fork_workspace_id, path ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_flow, &target_flow) { if source.value != target.value || source.summary != target.summary || source.description != target.description || source.schema != target.schema { has_changes = true; } } else if source_flow.is_some() || target_flow.is_some() { // The flow exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_flow.is_some(), exists_in_fork: target_flow.is_some(), }); } /// The policy minus the identity pair. Deploying cannot converge a difference there — the /// target recomputes the identity from the deployer's own choice, which offers its current /// value, the deployer, or a typed-in one, never the source's — so listing an app for it /// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either. fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value { let mut policy = policy.clone(); if let Some(obj) = policy.as_object_mut() { obj.remove("on_behalf_of"); obj.remove("on_behalf_of_email"); } policy } async fn compare_two_apps( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // `raw_app` is compared alongside the content: converting an app into a raw app // (or back) keeps the path and can keep every other field, and the merge UI links // to a different editor and deploys down a different path for each, so a // conversion is a change even when nothing else moved. // Get app with its latest version data from source workspace let source_app = sqlx::query!( "SELECT app.summary, app.policy, app_version.value, app_version.raw_app FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] WHERE app.workspace_id = $1 AND app.path = $2", source_workspace_id, path ) .fetch_optional(db) .await?; let target_app = sqlx::query!( "SELECT app.summary, app.policy, app_version.value, app_version.raw_app FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] WHERE app.workspace_id = $1 AND app.path = $2", fork_workspace_id, path ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata and content differences if let (Some(source), Some(target)) = (&source_app, &target_app) { if source.summary != target.summary || policy_without_identity(&source.policy) != policy_without_identity(&target.policy) || source.value != target.value || source.raw_app != target.raw_app { has_changes = true; } } else if source_app.is_some() || target_app.is_some() { // The app exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_app.is_some(), exists_in_fork: target_app.is_some(), }); } async fn compare_two_resources( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // Get resource from each workspace let source_resource = sqlx::query!( "SELECT value, description, resource_type FROM resource WHERE workspace_id = $1 AND path = $2", source_workspace_id, path ) .fetch_optional(db) .await?; let target_resource = sqlx::query!( "SELECT value, description, resource_type FROM resource WHERE workspace_id = $1 AND path = $2", fork_workspace_id, path ) .fetch_optional(db) .await?; let source_ws_specific = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)", source_workspace_id, path ) .fetch_one(db) .await? .unwrap_or(false); let target_ws_specific = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)", fork_workspace_id, path ) .fetch_one(db) .await? .unwrap_or(false); // A workspace-specific resource keeps its own value per environment, so it never appears in the // diff (in either direction). Seeding the initial copy onto a side that lacks it is a separate // explicit action ("Create in "), not a diff entry. if source_ws_specific || target_ws_specific { return Ok(ItemComparison { has_changes: false, exists_in_source: source_resource.is_some(), exists_in_fork: target_resource.is_some(), }); } let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_resource, &target_resource) { if source.value != target.value || source.description != target.description || source.resource_type != target.resource_type { has_changes = true; } } else if source_resource.is_some() || target_resource.is_some() { // The resource exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_resource.is_some(), exists_in_fork: target_resource.is_some(), }); } async fn compare_two_variables( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // Combine the four EXISTS checks (ws_specific × {source, fork}, variable // × {source, fork}) into a single round-trip; this runs per variable // during a workspace diff so the savings add up. let presence = sqlx::query!( r#"SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $3) AS "src_ws!", EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3) AS "tgt_ws!", EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $3) AS "src_var!", EXISTS(SELECT 1 FROM variable WHERE workspace_id = $2 AND path = $3) AS "tgt_var!""#, source_workspace_id, fork_workspace_id, path, ) .fetch_one(db) .await?; // A workspace-specific variable keeps its own value per environment, so it never appears in the // diff. Seeding the initial copy onto a side that lacks it is a separate explicit action. if presence.src_ws || presence.tgt_ws { return Ok(ItemComparison { has_changes: false, exists_in_source: presence.src_var, exists_in_fork: presence.tgt_var, }); } // Get variable from each workspace let source_variable = sqlx::query!( "SELECT value, is_secret, description FROM variable WHERE workspace_id = $1 AND path = $2", source_workspace_id, path ) .fetch_optional(db) .await?; let target_variable = sqlx::query!( "SELECT value, is_secret, description FROM variable WHERE workspace_id = $1 AND path = $2", fork_workspace_id, path ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_variable, &target_variable) { if source.is_secret != target.is_secret || source.value != target.value || source.description != target.description { has_changes = true; } } else if source_variable.is_some() || target_variable.is_some() { // The variable exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_variable.is_some(), exists_in_fork: target_variable.is_some(), }); } async fn compare_two_resource_types( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, name: &str, ) -> Result { // Get resource type from each workspace let source_resource_type = sqlx::query!( "SELECT schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1 AND name = $2", source_workspace_id, name ) .fetch_optional(db) .await?; let target_resource_type = sqlx::query!( "SELECT schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1 AND name = $2", fork_workspace_id, name ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_resource_type, &target_resource_type) { if source.schema != target.schema || source.description != target.description || source.format_extension != target.format_extension || source.is_fileset != target.is_fileset { has_changes = true; } } else if source_resource_type.is_some() || target_resource_type.is_some() { // The resource type exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_resource_type.is_some(), exists_in_fork: target_resource_type.is_some(), }); } async fn compare_two_folders( db: &DB, source_workspace_id: &str, fork_workspace_id: &str, name: &str, ) -> Result { // Get folder from each workspace let source_folder = sqlx::query!( "SELECT display_name, owners, extra_perms, summary FROM folder WHERE workspace_id = $1 AND name = $2", source_workspace_id, name.strip_prefix("f/"), ) .fetch_optional(db) .await?; let target_folder = sqlx::query!( "SELECT display_name, owners, extra_perms, summary FROM folder WHERE workspace_id = $1 AND name = $2", fork_workspace_id, name.strip_prefix("f/"), ) .fetch_optional(db) .await?; let mut has_changes = false; // Check metadata differences if let (Some(source), Some(target)) = (&source_folder, &target_folder) { if source.display_name != target.display_name || source.owners != target.owners || source.extra_perms != target.extra_perms || source.summary != target.summary { has_changes = true; } } else if source_folder.is_some() || target_folder.is_some() { // The folder exists in one of source or target, but not the other, this is considered as a change has_changes = true } return Ok(ItemComparison { has_changes, exists_in_source: source_folder.is_some(), exists_in_fork: target_folder.is_some(), }); } /// Fields stripped before comparing two trigger or schedule rows. /// /// `mode` and `enabled` are forced to disabled/false on fork clone so they always /// differ between fork and parent — comparing them would mark every cloned row as /// "changed". The rest are runtime state (`server_id`, `last_server_ping`, `error`) /// or per-row metadata that diverges naturally (`edited_at/by`, `email`, `extra_perms`, /// `permissioned_as`). Comparing without these answers "is this trigger/schedule /// configured the same way?" rather than "are the rows byte-identical?". const TRIGGER_COMPARE_IGNORE: &[&str] = &[ "workspace_id", "edited_by", "edited_at", "email", "error", "enabled", "mode", "server_id", "last_server_ping", "extra_perms", "permissioned_as", // Server-managed fields that the merge feature treats as workspace-local // (regenerated by the deploy handler): GCP `subscription_id` is rewritten // to `windmill__` in `CreateUpdate` mode, and Azure's // `push_auth_config` carries only the regenerated `secret_hash`. Without // stripping, GCP/Azure push triggers stay flagged as "changed" forever. "subscription_id", "push_auth_config", ]; async fn compare_two_trigger_or_schedule( db: &DB, table: &str, source_workspace_id: &str, fork_workspace_id: &str, path: &str, ) -> Result { // Whitelist guard: callers in `compare_workspaces` and `query_visible_items` // already match `table` against a closed set, but a stray future caller // could open an injection hole. Bail loudly in debug, fail safe in release. debug_assert!( TRIGGER_OR_SCHEDULE_TABLES.contains(&table), "compare_two_trigger_or_schedule called with unrecognized table: {table}" ); if !TRIGGER_OR_SCHEDULE_TABLES.contains(&table) { return Ok(ItemComparison { has_changes: false, exists_in_source: false, exists_in_fork: false, }); } let mut select_expr = String::from("to_jsonb(t)"); for f in TRIGGER_COMPARE_IGNORE { // The `-` operator on jsonb returns the object without the named key, // or the unchanged object if the key is absent — so one ignore list // works across tables with different column sets. select_expr.push_str(&format!(" - '{f}'")); } // SAFETY: `table` comes from a hardcoded allowlist TRIGGER_OR_SCHEDULE_TABLES // (guarded by the debug_assert + runtime check above), not user input. // `select_expr` is built from `TRIGGER_COMPARE_IGNORE`, also a static const. let sql = format!("SELECT {select_expr} FROM {table} t WHERE workspace_id = $1 AND path = $2"); let source_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql) .bind(source_workspace_id) .bind(path) .fetch_optional(db); let target_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql) .bind(fork_workspace_id) .bind(path) .fetch_optional(db); let (source, target) = tokio::try_join!(source_fut, target_fut)?; let has_changes = match (source.as_ref(), target.as_ref()) { (Some(s), Some(t)) => s != t, (None, None) => false, _ => true, }; Ok(ItemComparison { has_changes, exists_in_source: source.is_some(), exists_in_fork: target.is_some(), }) } const TRIGGER_OR_SCHEDULE_TABLES: &[&str] = &[ "schedule", "http_trigger", "websocket_trigger", "kafka_trigger", "nats_trigger", "postgres_trigger", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "azure_trigger", "email_trigger", ]; const MAX_FEATURE_USAGE_EVENTS: usize = 50; #[derive(Deserialize)] struct FeatureUsageEvent { feature: String, kind: String, #[serde(default)] key: String, #[serde(default)] entity_id: String, value: Option, } #[derive(Deserialize)] struct LogFeatureUsagePayload { events: Vec, } async fn log_feature_usage( Extension(db): Extension, Json(payload): Json, ) -> Result { // Pre-sum duplicate keys: two rows hitting the same conflict target in a // single INSERT error out ("cannot affect row a second time"). let mut agg: HashMap<(String, String, String, String), i64> = HashMap::new(); for e in payload.events.into_iter().take(MAX_FEATURE_USAGE_EVENTS) { // Which actions may be recorded lives in // `windmill_common::feature_usage`, shared with the in-process writer so // both admit exactly the same events. if !windmill_common::feature_usage::is_recordable_event( &e.feature, &e.kind, &e.key, &e.entity_id, ) { continue; } let value = e.value.unwrap_or(1).clamp(1, 1_000_000); *agg.entry((e.feature, e.kind, e.key, e.entity_id)) .or_insert(0) += value; } if agg.is_empty() { return Ok(StatusCode::NO_CONTENT); } // Sorted for the same reason as `flush_feature_usage`: this endpoint and the // backend flusher upsert the same rows, and two batches touching them in // opposite orders deadlock. let mut rows: Vec<((String, String, String, String), i64)> = agg.into_iter().collect(); rows.sort_unstable_by(|a, b| a.0.cmp(&b.0)); let mut features = Vec::with_capacity(rows.len()); let mut kinds = Vec::with_capacity(rows.len()); let mut keys = Vec::with_capacity(rows.len()); let mut entity_ids = Vec::with_capacity(rows.len()); let mut values = Vec::with_capacity(rows.len()); for ((feature, kind, key, entity_id), value) in rows { features.push(feature); kinds.push(kind); keys.push(key); entity_ids.push(entity_id); values.push(value); } sqlx::query!( "INSERT INTO feature_usage (feature, kind, key, entity_id, value) SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::bigint[]) ON CONFLICT (feature, kind, key, entity_id, day) DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()", &features, &kinds, &keys, &entity_ids, &values ) .execute(&db) .await?; Ok(StatusCode::NO_CONTENT) } #[derive(Serialize)] struct QuotaInfo { used: i64, limit: i64, prunable: i64, } #[derive(Serialize)] struct CloudQuotas { scripts: QuotaInfo, flows: QuotaInfo, apps: QuotaInfo, variables: QuotaInfo, resources: QuotaInfo, /// Fork/dev workspaces under this workspace's billing root vs the per-seat cap. `limit` is 0 for a /// non-premium root (forking is premium-only). Family-wide: resolves to the billing root, so it /// reads the same whether viewed from the root or one of its forks. forks: QuotaInfo, } async fn get_cloud_quotas( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; if !*CLOUD_HOSTED { return Err(Error::BadRequest( "Cloud quotas are only available on cloud-hosted instances".to_string(), )); } let scripts_used = sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id) .fetch_one(&db) .await? .unwrap_or(0); let scripts_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); let flows_used = sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) .fetch_one(&db) .await? .unwrap_or(0); let flows_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM flow_version fv JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path WHERE fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); let apps_used = sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id) .fetch_one(&db) .await? .unwrap_or(0); let apps_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM app_version av JOIN app a ON a.id = av.app_id WHERE a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); // Every path keeps exactly one current version, so the prunable count is the total minus // the number of distinct paths — one scan rather than a probe per row. let resources_prunable = sqlx::query_scalar!( "SELECT COUNT(*) - COUNT(DISTINCT path) FROM resource_version WHERE workspace_id = $1", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); let variables_used = sqlx::query_scalar!( "SELECT COUNT(*) FROM variable WHERE workspace_id = $1", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); let resources_used = sqlx::query_scalar!( "SELECT COUNT(*) FROM resource WHERE workspace_id = $1", &w_id ) .fetch_one(&db) .await? .unwrap_or(0); // Fork/dev workspaces vs the per-seat cap, resolved to the billing root. Non-premium roots can't // fork, so their allowance is 0. #[cfg(feature = "cloud")] let forks = { use windmill_common::workspaces::{ count_paid_seats, count_workspace_forks, get_billing_workspace_id, get_team_plan_status, }; let root = get_billing_workspace_id(&db, &w_id).await?; let used = count_workspace_forks(&db, &root).await?; let limit = if get_team_plan_status(&db, &root).await?.premium { count_paid_seats(&db, &root).await?.max(1) * *MAX_FORKS_PER_SEAT } else { 0 }; QuotaInfo { used, limit, prunable: 0 } }; #[cfg(not(feature = "cloud"))] let forks = QuotaInfo { used: 0, limit: 0, prunable: 0 }; Ok(Json(CloudQuotas { scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable }, flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable }, apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable }, variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 }, resources: QuotaInfo { used: resources_used, limit: 10000, prunable: resources_prunable }, forks, })) } #[derive(Deserialize)] struct PruneVersionsRequest { resource_type: String, } #[derive(Serialize)] struct PruneVersionsResponse { pruned: u64, } async fn prune_versions( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Json(req): Json, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; if !*CLOUD_HOSTED { return Err(Error::BadRequest( "Version pruning is only available on cloud-hosted instances".to_string(), )); } let pruned = match req.resource_type.as_str() { "scripts" => { let result = sqlx::query( "DELETE FROM script WHERE workspace_id = $1 AND hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", ) .bind(&w_id) .execute(&db) .await?; result.rows_affected() } "flows" => { let deleted = sqlx::query( "DELETE FROM flow_version fv USING flow f WHERE fv.workspace_id = f.workspace_id AND fv.path = f.path AND fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]", ) .bind(&w_id) .execute(&db) .await?; sqlx::query( "UPDATE flow SET versions = ARRAY[versions[array_upper(versions, 1)]] WHERE workspace_id = $1 AND array_length(versions, 1) > 1", ) .bind(&w_id) .execute(&db) .await?; deleted.rows_affected() } "apps" => { let deleted = sqlx::query( "DELETE FROM app_version av USING app a WHERE av.app_id = a.id AND a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]", ) .bind(&w_id) .execute(&db) .await?; sqlx::query( "UPDATE app SET versions = ARRAY[versions[array_upper(versions, 1)]] WHERE workspace_id = $1 AND array_length(versions, 1) > 1", ) .bind(&w_id) .execute(&db) .await?; deleted.rows_affected() } "resources" => { // No `versions` array to rewrite afterwards, unlike flows and apps: the latest // version is whichever row has the highest id for the path. let deleted = sqlx::query( "DELETE FROM resource_version WHERE workspace_id = $1 AND id NOT IN ( SELECT max(id) FROM resource_version WHERE workspace_id = $1 GROUP BY path )", ) .bind(&w_id) .execute(&db) .await?; deleted.rows_affected() } _ => { return Err(Error::BadRequest(format!( "Invalid resource type '{}'. Must be 'scripts', 'flows', 'apps', or 'resources'", req.resource_type ))); } }; Ok(Json(PruneVersionsResponse { pruned })) } #[derive(Serialize)] struct WsSpecificItem { item_kind: String, path: String, } async fn list_ws_specific( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, ) -> JsonResult> { // ws_specific itself has no per-item RLS — only the workspace_id column. // Joining against resource/variable under user_db forces the same // path-based RLS policies that govern those tables (see_own / see_member / // see_extra_perms_* / see_folder_extra_perms_user) to also gate visibility // here. Without these joins, any workspace member could enumerate paths // in folders they lack read access to (e.g. f/finance/prod_db_creds). let mut tx = user_db.begin(&authed).await?; let items = sqlx::query_as!( WsSpecificItem, r#" SELECT s.item_kind, s.path FROM ws_specific s WHERE s.workspace_id = $1 AND ( (s.item_kind = 'resource' AND EXISTS ( SELECT 1 FROM resource r WHERE r.workspace_id = s.workspace_id AND r.path = s.path )) OR (s.item_kind = 'variable' AND EXISTS ( SELECT 1 FROM variable v WHERE v.workspace_id = s.workspace_id AND v.path = s.path )) ) ORDER BY s.item_kind, s.path "#, &w_id ) .fetch_all(&mut *tx) .await?; tx.commit().await?; // RLS gates membership/folder access, but a scoped API token must also be held to its read // scopes — mirror the resource/variable list endpoints, which filter with these predicates so a // token lacking `resources:read:*` / `variables:read:*` can't enumerate pinned paths it can't read. let resource_allowed = build_scope_path_predicate(&authed, "resources", "read"); let variable_allowed = build_scope_path_predicate(&authed, "variables", "read"); let items = items .into_iter() .filter(|it| match it.item_kind.as_str() { "resource" => resource_allowed(&it.path), "variable" => variable_allowed(&it.path), _ => false, }) .collect::>(); Ok(Json(items)) } #[derive(Deserialize)] struct ListWsSpecificVersionsQuery { kind: String, path: String, } async fn list_ws_specific_versions( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(q): Query, ) -> JsonResult> { if q.kind != "resource" && q.kind != "variable" { return Err(Error::BadRequest(format!( "Invalid kind '{}'. Must be 'resource' or 'variable'", q.kind ))); } // A scoped API token must hold the read scope for this path, like the resource/variable read // endpoints. Without the scope, report no versions rather than leaking the path's history. let domain = if q.kind == "resource" { "resources" } else { "variables" }; if !build_scope_path_predicate(&authed, domain, "read")(&q.path) { return Ok(Json(vec![])); } let versions: Vec = sqlx::query_scalar!( r#"SELECT ws AS "ws!" FROM list_ws_specific_versions($1, $2, $3, $4)"#, &w_id, &authed.email, &q.kind, &q.path, ) .fetch_all(&db) .await?; Ok(Json(versions)) } #[derive(Deserialize)] struct SetWsSpecificBody { item_kind: String, path: String, value: bool, } /// Mark (or unmark) a single resource/variable as workspace-specific. Pinning /// excludes it from the deploy diff so each environment keeps its own value /// (see `compare_two_resources`/`compare_two_variables`). Set per-workspace, so /// the compare page calls this once per side to flag both environments. async fn set_ws_specific( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(body): Json, ) -> Result { if body.item_kind != "resource" && body.item_kind != "variable" { return Err(Error::BadRequest(format!( "Invalid kind '{}'. Must be 'resource' or 'variable'", body.item_kind ))); } // Reject a malformed path with a 400 before the auth check, which indexes the leading segments and // would otherwise panic (500) on a path like `u` with no segment. Accept all three shared path // shapes Windmill uses — `u/`, `f/`, `g/` (e.g. seeded `g/all/...` resources). let segs: Vec<&str> = body.path.split('/').collect(); if segs.len() < 2 || !matches!(segs[0], "u" | "f" | "g") || segs[1].is_empty() { return Err(Error::BadRequest(format!( "Invalid {} path: {}", body.item_kind, body.path ))); } // Authorize like the resource/variable editors' own ws_specific toggle: // actual write access to the item + token scope + the workspace deploy rules. // `require_owner_of_path` is the real write gate (the resource editor uses it); // `check_scopes` only constrains scoped tokens (it is a no-op for session/cookie // logins). Together: a non-admin who can edit the item may pin it, while a // read-only member is rejected and a locked workspace still blocks non-deployers. // `require_is_writer` matches the resource/variable editors' write semantics (owner, folder // writer, or item writer via extra_perms) — not owner-only. let writer_query = if body.item_kind == "resource" { "SELECT extra_perms FROM resource WHERE path = $1 AND workspace_id = $2" } else { "SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2" }; require_is_writer( &authed, &body.path, &w_id, db.clone(), writer_query, &body.item_kind, ) .await?; check_scopes(&authed, || { format!("{}s:write:{}", body.item_kind, body.path) })?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, &authed.username, &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } let mut tx = user_db.begin(&authed).await?; if body.value { // Existence guard keeps a dangling marker from being created for a // path absent in this workspace. if body.item_kind == "resource" { sqlx::query!( "INSERT INTO ws_specific (workspace_id, item_kind, path) SELECT $1::varchar, 'resource', $2::varchar WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar) ON CONFLICT DO NOTHING", w_id, body.path, ) .execute(&mut *tx) .await?; // A resource owns its `$var:` secrets, so pin those too. windmill_store::resources::mark_linked_variables_ws_specific( &mut tx, &authed, &w_id, &body.path, ) .await?; } else { sqlx::query!( "INSERT INTO ws_specific (workspace_id, item_kind, path) SELECT $1::varchar, 'variable', $2::varchar WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar) ON CONFLICT DO NOTHING", w_id, body.path, ) .execute(&mut *tx) .await?; } } else { // Unmark only this item; linked variables stay flagged (they may be // referenced by other resources) — mirrors the resource-form toggle. sqlx::query!( "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3", w_id, body.item_kind, body.path, ) .execute(&mut *tx) .await?; // While pinned, the item's cached workspace_diff verdict was never recomputed (the compare // read filter excludes it), so it may now be stale in either direction. Mark it NULL so the // next compare re-evaluates from scratch — and the now-shared item reappears (or is dropped) // correctly instead of being stuck on its pre-pin verdict. sqlx::query!( "UPDATE workspace_diff SET has_changes = NULL WHERE path = $2 AND kind = $3 AND ($1 IN (source_workspace_id, fork_workspace_id))", w_id, body.path, body.item_kind, ) .execute(&mut *tx) .await?; } let value_str = body.value.to_string(); audit_log( &mut *tx, &authed, &format!("{}s.set_ws_specific", body.item_kind), ActionKind::Update, &w_id, Some(&body.path), Some([("value", value_str.as_str())].into()), ) .await?; tx.commit().await?; Ok(format!( "Set workspace-specific={} for {} {}", body.value, body.item_kind, body.path )) }