/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2023 * 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 std::{ collections::{BTreeSet, HashMap}, time::Duration, }; #[cfg(feature = "parquet")] mod audit_logs_s3; #[cfg(feature = "parquet")] mod audit_logs_s3_backfill; #[cfg(feature = "parquet")] mod background_task; #[cfg(feature = "private")] mod ee; pub mod ee_oss; #[cfg(feature = "parquet")] mod log_cleanup; #[cfg(feature = "parquet")] mod storage_usage; use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed}; use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; use windmill_common::DB; use ee_oss::validate_license_key; use windmill_common::usernames::generate_instance_username_for_all_users; use axum::{ body::Body, extract::{Extension, Path, Query}, response::Response, routing::{get, post}, Json, Router, }; use serde_json::json; use serde::{Deserialize, Serialize}; use windmill_ai::ai_cache::bump_instance_ai_config_revision; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ auth::is_super_admin_email, ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, }; use windmill_common::{ error::to_anyhow, worker::{reload_custom_tags_setting, CLOUD_HOSTED}, PgDatabase, }; /// Unauthenticated settings routes. /// /// Used by the extra container (LSP service) to fetch non-sensitive instance /// configuration like the shared ruff.toml content without needing to carry /// a credential. pub fn unauthed_service() -> Router { Router::new().route("/ruff_config", get(get_ruff_config_unauthed)) } /// Public endpoint that returns the instance-level ruff config as plain text /// TOML. Returns an empty body when unset. /// /// This is intentionally unauthenticated: ruff config is lint/format policy, /// not a credential, and the extra container needs to pull it from any /// deployment topology (docker-compose, k8s, local dev) without the extra /// burden of shared secrets. async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result { let value = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", RUFF_CONFIG_SETTING ) .fetch_optional(&db) .await?; let body = value .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); Ok(Response::builder() .status(200) .header("content-type", "text/plain; charset=utf-8") .header("cache-control", "no-store") .body(Body::from(body)) .unwrap()) } pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() .route("/envs", get(get_local_settings)) .route( "/global/{key}", post(set_global_setting).get(get_global_setting), ) .route("/list_global", get(list_global_settings)) .route("/github_app_stale_webhooks", get(github_app_stale_webhooks)) .route( "/instance_config", get(get_instance_config).put(set_instance_config), ) .route("/instance_config/yaml", get(get_instance_config_yaml)) .route("/test_smtp", post(test_email)) .route("/test_license_key", post(test_license_key)) .route("/send_stats", post(send_stats)) .route("/get_stats", get(get_stats)) .route( "/latest_key_renewal_attempt", get(get_latest_key_renewal_attempt), ) .route("/renew_license_key", post(renew_license_key)) .route("/offline_license_status", get(get_offline_license_status)) .route("/instance_hash", get(get_instance_hash)) .route("/customer_portal", post(create_customer_portal_session)) .route("/test_critical_channels", post(test_critical_channels)) .route("/critical_alerts", get(get_critical_alerts)) .route( "/critical_alerts/{id}/acknowledge", post(acknowledge_critical_alert), ) .route( "/list_custom_instance_pg_databases", post(list_custom_instance_pg_databases), ) .route( "/refresh_custom_instance_user_pwd", post(refresh_custom_instance_user_pwd), ) .route( "/setup_custom_instance_pg_database/{name}", post(setup_custom_instance_pg_database), ) .route( "/drop_custom_instance_pg_database/{name}", post(drop_custom_instance_pg_database), ) .route( "/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts), ) .route( "/sync_cached_resource_types", post(sync_cached_resource_types), ) .route( "/restart_worker_group/{worker_group}", post(restart_worker_group), ); // Vault/Azure KV integration routes (EE only - requires both private and enterprise features) #[cfg(all(feature = "private", feature = "enterprise"))] let r = r .route("/test_secret_backend", post(test_secret_backend)) .route("/migrate_secrets_to_vault", post(migrate_secrets_to_vault)) .route( "/migrate_secrets_to_database", post(migrate_secrets_to_database), ) .route("/test_azure_kv_backend", post(test_azure_kv_backend)) .route( "/migrate_secrets_to_azure_kv", post(migrate_secrets_to_azure_kv), ) .route( "/migrate_secrets_from_azure_kv", post(migrate_secrets_from_azure_kv), ) .route("/test_aws_sm_backend", post(test_aws_sm_backend)) .route( "/migrate_secrets_to_aws_sm", post(migrate_secrets_to_aws_sm), ) .route( "/migrate_secrets_from_aws_sm", post(migrate_secrets_from_aws_sm), ); #[cfg(feature = "parquet")] { return r .route("/test_object_storage_config", post(test_s3_bucket)) .route( "/object_storage_usage", get(get_object_storage_usage).post(compute_object_storage_usage), ) .route("/run_log_cleanup", post(run_log_cleanup)) .route("/log_cleanup_status", get(log_cleanup_status)) .route("/audit_logs_s3_status", get(audit_logs_s3_status)) .route("/audit_logs_s3_backfill", post(run_audit_logs_s3_backfill)) .route( "/audit_logs_s3_backfill_status", get(audit_logs_s3_backfill_status), ); } #[cfg(not(feature = "parquet"))] { return r; } } #[derive(Deserialize)] pub struct TestEmail { pub to: String, pub smtp: Smtp, } pub async fn test_email( Extension(db): Extension, authed: ApiAuthed, Json(test_email): Json, ) -> error::Result { require_super_admin(&db, &authed.email).await?; let smtp = test_email.smtp; let to = test_email.to; let client_timeout = Duration::from_secs(3); send_email_plain_text( "Test email from Windmill", "Test email content", vec![to], smtp, Some(client_timeout), ) .await?; Ok("Sent test email".to_string()) } #[cfg(feature = "parquet")] use windmill_object_store::ObjectSettings; #[cfg(feature = "parquet")] use windmill_object_store::build_object_store_from_settings; #[cfg(feature = "parquet")] pub async fn test_s3_bucket( authed: ApiAuthed, Extension(db): Extension, Json(test_s3_bucket): Json, ) -> error::Result { use bytes::Bytes; use futures::StreamExt; // The probe executes on the API server itself. On multi-tenant Cloud that is a shared control // plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration / // local-filesystem surface (see validate_object_storage_test). On self-hosted instances the // object store usually lives on the local/private network and all authenticated users are // trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too. let is_super_admin = is_super_admin_email(&db, &authed.email).await?; let restrict = !is_super_admin && *CLOUD_HOSTED; if restrict { validate_object_storage_test(&test_s3_bucket).await?; } let client = build_object_store_from_settings(test_s3_bucket, Some(&db)) .await? .store; let run = async { let mut list = client.list(Some( &windmill_object_store::object_store_reexports::Path::from("".to_string()), )); match list.next().await { Some(Err(e)) => { tracing::error!("error listing bucket: {e:#}"); return Err(error::Error::internal_err(format!( "Failed to list files in blob storage: {e:#}" ))); } Some(Ok(first_file)) => tracing::info!("Listed files: {:?}", first_file), None => tracing::info!("No files in blob storage"), } let path = windmill_object_store::object_store_reexports::Path::from(format!( "/test-s3-bucket-{uuid}", uuid = uuid::Uuid::new_v4() )); tracing::info!("Testing blob storage at path: {path}"); client .put( &path, windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"), ) .await .map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?; let content = client .get(&path) .await .map_err(to_anyhow)? .bytes() .await .map_err(to_anyhow)?; if content != Bytes::from_static(b"hello") { return Err(error::Error::internal_err( "Failed to read back from blob storage".to_string(), )); } client.delete(&path).await.map_err(to_anyhow)?; Ok::("Tested blob storage successfully".to_string()) }; if restrict { // The object-store client is built with timeouts disabled, so a malicious endpoint could // otherwise hold the API server connection open indefinitely. tokio::time::timeout(Duration::from_secs(15), run) .await .map_err(|_| { error::Error::internal_err("Object storage connectivity test timed out".to_string()) })? } else { run.await } } // Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on // Cloud. The probe runs on the shared API server, so without these constraints an authenticated // user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing // requests with the instance role (credential exfiltration), or reading/writing the server's local // disk (filesystem object store). #[cfg(feature = "parquet")] async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Result<()> { fn non_empty(opt: &Option) -> bool { opt.as_ref().is_some_and(|s| !s.is_empty()) } // Reject backends that rely on the server's identity or local filesystem, require explicit // credentials for the rest (so the server never falls back to its own ambient credentials), and // resolve the host the client will actually connect to. We derive the *effective* endpoint here // — mirroring build_*_from_settings: the region/account-derived default and the virtual-hosted // bucket prefix — rather than only validating a caller-supplied `endpoint`, so caller-controlled // `region`/`account_name`/`bucket` cannot smuggle an internal host past the check (e.g. an empty // endpoint with region = "@169.254.169.254/" otherwise resolves to the cloud metadata service). let effective_endpoint: Option = match settings { ObjectSettings::Filesystem(_) => { return Err(error::Error::NotAuthorized( "Testing a local filesystem object store requires a super admin".to_string(), )); } ObjectSettings::AwsOidc(_) => { return Err(error::Error::NotAuthorized( "Testing OIDC-based object storage requires a super admin".to_string(), )); } ObjectSettings::S3(s3) => { if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) { return Err(error::Error::NotAuthorized( "Testing S3 storage without explicit credentials requires a super admin" .to_string(), )); } let region = s3 .region .clone() .filter(|r| !r.is_empty()) .or_else(|| std::env::var("AWS_REGION").ok().filter(|r| !r.is_empty())) .unwrap_or_else(|| "us-east-1".to_string()); let raw_endpoint = s3 .endpoint .clone() .filter(|e| !e.is_empty()) .or_else(|| std::env::var("S3_ENDPOINT").ok().filter(|e| !e.is_empty())) .unwrap_or_else(|| format!("s3.{region}.amazonaws.com")); Some(windmill_object_store::render_endpoint( raw_endpoint, !s3.allow_http.unwrap_or(true), s3.port, s3.path_style, s3.bucket.clone().unwrap_or_default(), )) } ObjectSettings::Azure(azure) => { if !non_empty(&azure.access_key) { return Err(error::Error::NotAuthorized( "Testing Azure storage without an explicit access key requires a super admin" .to_string(), )); } Some( azure .endpoint .clone() .filter(|e| !e.is_empty()) .unwrap_or_else(|| format!("{}.blob.core.windows.net", azure.account_name)), ) } ObjectSettings::Gcs(gcs) => { // Mirror `build_gcs_client`'s blank-key check (shared predicate): a blank/`{}` key falls // back to the instance's ambient credentials there, so it must be rejected here too — // otherwise an untrusted caller could probe with the server's identity (the very // SSRF/credential-exfil this function guards against). if windmill_object_store::gcs_service_account_key_is_blank(&gcs.service_account_key) { return Err(error::Error::NotAuthorized( "Testing GCS storage without a service account key requires a super admin" .to_string(), )); } // The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the // OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at. // Validate every http(s) URL embedded in the key. When none override it, the host stays // the public storage.googleapis.com, so no further check is needed. if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::(&gcs.service_account_key) { for value in map.values() { if let Some(url) = value.as_str() { // Match how the URL parser reads the value: leading whitespace/control is // ignored and the scheme is case-insensitive. let url = url.trim_start_matches(|c: char| c.is_whitespace() || c.is_control()); if strip_http_scheme(url).is_some() { validate_public_endpoint(url).await?; } } } } None } }; // Block non-public network targets (internal services, cloud metadata, loopback, ...). if let Some(endpoint) = effective_endpoint { validate_public_endpoint(&endpoint).await?; } Ok(()) } #[cfg(feature = "parquet")] async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> { let host = extract_host(endpoint).ok_or_else(|| { error::Error::BadRequest(format!("Invalid object storage endpoint: {endpoint}")) })?; let addrs: Vec = tokio::net::lookup_host((host.as_str(), 443u16)) .await .map_err(|e| { error::Error::BadRequest(format!( "Could not resolve object storage endpoint '{host}': {e}" )) })? .collect(); if addrs.is_empty() { return Err(error::Error::BadRequest(format!( "Could not resolve object storage endpoint '{host}'" ))); } // Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding // attempts (a name resolving to both a public and a private address). for addr in addrs { if is_forbidden_ip(addr.ip()) { return Err(error::Error::NotAuthorized( "Testing object storage at a private, loopback, or link-local endpoint requires a super admin" .to_string(), )); } } Ok(()) } // Strip a leading `http://`/`https://` scheme case-insensitively (URL schemes are // case-insensitive), returning the remainder when one was present. #[cfg(feature = "parquet")] fn strip_http_scheme(s: &str) -> Option<&str> { for scheme in ["https://", "http://"] { let b = scheme.as_bytes(); if s.len() >= b.len() && s.as_bytes()[..b.len()].eq_ignore_ascii_case(b) { return Some(&s[b.len()..]); } } None } #[cfg(feature = "parquet")] fn extract_host(endpoint: &str) -> Option { let mut s = endpoint.trim(); if let Some(rest) = strip_http_scheme(s) { s = rest; } s = s.split(['/', '?', '#', '\\']).next().unwrap_or(s); if let Some((_, rest)) = s.rsplit_once('@') { s = rest; } let host = if let Some(rest) = s.strip_prefix('[') { // IPv6 literal, e.g. [::1]:9000 rest.split(']').next().unwrap_or(rest) } else { // host or host:port s.split(':').next().unwrap_or(s) } .trim(); if host.is_empty() { None } else { Some(host.to_string()) } } #[cfg(feature = "parquet")] fn is_forbidden_ip(ip: std::net::IpAddr) -> bool { use std::net::{IpAddr, Ipv4Addr}; match ip { IpAddr::V4(v4) => { v4.is_loopback() || v4.is_private() || v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint || v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() || v4.is_multicast() || v4.octets()[0] == 0 // 0.0.0.0/8 || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT } IpAddr::V6(v6) => { // Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible // ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g. // 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network. let seg = v6.segments(); let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0]; let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0]; if let Some(v4) = v6.to_ipv4_mapped() { return is_forbidden_ip(IpAddr::V4(v4)); } if is_v4_compatible || is_nat64 { let embedded = Ipv4Addr::new( (seg[6] >> 8) as u8, (seg[6] & 0xff) as u8, (seg[7] >> 8) as u8, (seg[7] & 0xff) as u8, ); if is_forbidden_ip(IpAddr::V4(embedded)) { return true; } } v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local } } } #[cfg(feature = "parquet")] async fn get_object_storage_usage( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { require_super_admin(&db, &authed.email).await?; Ok(Json(storage_usage::get_status(&db).await?)) } #[cfg(feature = "parquet")] async fn compute_object_storage_usage( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { require_super_admin(&db, &authed.email).await?; storage_usage::try_start(&db).await?; storage_usage::spawn_compute(db.clone()); Ok(axum::http::StatusCode::ACCEPTED) } #[cfg(feature = "parquet")] async fn run_log_cleanup( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { require_super_admin(&db, &authed.email).await?; log_cleanup::try_start(&db).await?; log_cleanup::spawn_cleanup(db.clone()); Ok(axum::http::StatusCode::ACCEPTED) } #[cfg(feature = "parquet")] async fn log_cleanup_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { require_super_admin(&db, &authed.email).await?; Ok(Json(log_cleanup::get_status(&db).await?)) } #[cfg(feature = "parquet")] async fn audit_logs_s3_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { require_super_admin(&db, &authed.email).await?; Ok(Json(audit_logs_s3::get_status(&db).await?)) } #[cfg(feature = "parquet")] async fn run_audit_logs_s3_backfill( Extension(db): Extension, authed: ApiAuthed, Json(req): Json, ) -> error::Result { require_super_admin(&db, &authed.email).await?; if !matches!(get_license_plan().await, LicensePlan::Enterprise) { return Err(error::Error::BadRequest( "Audit log export to object storage is an Enterprise feature".to_string(), )); } audit_logs_s3_backfill::try_start(&db, req.from, req.to).await?; audit_logs_s3_backfill::spawn_backfill(db.clone(), req.from, req.to); Ok(axum::http::StatusCode::ACCEPTED) } #[cfg(feature = "parquet")] async fn audit_logs_s3_backfill_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { require_super_admin(&db, &authed.email).await?; Ok(Json(audit_logs_s3_backfill::get_status(&db).await?)) } #[derive(Deserialize)] pub struct TestKey { pub license_key: String, } pub async fn test_license_key( Extension(db): Extension, authed: ApiAuthed, Json(TestKey { license_key }): Json, ) -> error::Result { require_super_admin(&db, &authed.email).await?; let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?; if expired { Err(error::Error::BadRequest("Expired license key".to_string())) } else { Ok("Valid license key".to_string()) } } #[derive(serde::Serialize)] pub struct InstanceHash { pub instance_hash: Option, } /// Returns the live cap status for an offline license, or `null` when no /// offline license is loaded. Used by the superadmin settings panel. pub async fn get_offline_license_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { require_super_admin(&db, &authed.email).await?; let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone(); let is_offline = matches!(&offline, Some(m) if m.is_offline()); if !is_offline { return Ok(Json(None)); } #[cfg(feature = "enterprise")] let cap = windmill_common::ee_oss::enforce_offline_caps(&db) .await .map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?; #[cfg(not(feature = "enterprise"))] let cap: Option = None; Ok(Json(cap)) } /// Returns the per-instance binding hash that goes into offline license keys. /// Admin invokes via `curl` with their personal token when requesting a key /// from support. pub async fn get_instance_hash( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { require_super_admin(&db, &authed.email).await?; #[cfg(feature = "enterprise")] let hash = windmill_common::ee_oss::compute_instance_hash(&db) .await .map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?; #[cfg(not(feature = "enterprise"))] let hash: Option = None; Ok(Json(InstanceHash { instance_hash: hash })) } pub async fn get_local_settings( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { require_super_admin(&db, &authed.email).await?; let mut settings = serde_json::Map::new(); for key in ENV_SETTINGS.iter() { if let Some(value) = std::env::var(key).ok() { settings.insert(key.to_string(), serde_json::Value::String(value)); } } Ok(Json(serde_json::Value::Object(settings))) } #[derive(serde::Deserialize)] pub struct Value { pub value: Option, } pub async fn delete_global_setting(db: &DB, key: &str) -> error::Result<()> { // ducklake_user_pg_pwd and ducklake_settings were old names stored as standalone global settings. // Leave them for backward compatibility (CLI will try to delete them if not present in the yaml) if key == "ducklake_user_pg_pwd" || key == "ducklake_settings" || key == "custom_instance_pg_databases" { tracing::error!("Tried to unset global setting {}, ignored", key); return Ok(()); } sqlx::query!("DELETE FROM global_settings WHERE name = $1", key,) .execute(db) .await?; tracing::info!("Unset global setting {}", key); Ok(()) } /// Returns true when `key` is one of the workspace-fairness settings whose /// writes must be gated to cloud only. fn is_workspace_fairness_setting(key: &str) -> bool { matches!( key, WORKSPACE_FAIRNESS_ENABLED_SETTING | WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING | WORKSPACE_FAIRNESS_DURATION_SECS_SETTING | WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING ) } /// Enterprise gate for the workspace-fairness settings. Workspace fairness is /// only useful on multi-tenant clusters where one workspace can starve other /// workspaces sharing the same worker pool, and the feature is licensed as /// part of Enterprise. Non-EE installs are rejected at write time; the runtime /// dispatch additionally honours the `WORKSPACE_FAIRNESS_ENABLED` toggle. async fn workspace_fairness_settings_allowed() -> bool { matches!(get_license_plan().await, LicensePlan::Enterprise) } pub async fn set_global_setting( Extension(db): Extension, authed: ApiAuthed, Path(key): Path, Json(value): Json, ) -> error::Result<()> { require_super_admin(&db, &authed.email).await?; set_global_setting_internal(&db, key, value.value.unwrap_or(serde_json::Value::Null)).await } pub async fn set_global_setting_internal( db: &DB, key: String, value: serde_json::Value, ) -> error::Result<()> { let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING; let value = if key == "retention_period_secs" { instance_config::clamp_retention_period(value) } else { value }; // EE gate for workspace-fairness settings. Workspace fairness only matters // on multi-tenant clusters; it is licensed as an Enterprise feature so the // setter rejects writes from non-EE builds. Disabling/clearing writes are // *always* allowed regardless of license, so an admin who downgrades from // EE (or imports a row from a cloned EE DB) can always turn the cap off: // - `Null` / empty-string → row delete // - `Bool(false)` on `workspace_fairness_enabled` → explicit disable // Without the `Bool(false)` carve-out, a stale `enabled=true` row from a // downgrade would be impossible to flip off through the normal API/UI // and the runtime path (which only checks the toggle) would keep // throttling. let is_clearing_value = matches!(&value, serde_json::Value::Null) || matches!(&value, serde_json::Value::String(s) if s.trim().is_empty()) || (key == WORKSPACE_FAIRNESS_ENABLED_SETTING && matches!(&value, serde_json::Value::Bool(false))); if is_workspace_fairness_setting(&key) && !is_clearing_value && !workspace_fairness_settings_allowed().await { return Err(error::Error::BadRequest(format!( "{} requires an Enterprise license", key ))); } run_setting_pre_write_hook(db, &key, &value).await?; match value { serde_json::Value::Null => { if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) { return Err(error::Error::BadRequest(format!( "{key} is a protected setting and cannot be deleted" ))); } delete_global_setting(db, &key).await?; } serde_json::Value::String(ref x) if x.trim().is_empty() => { if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) { return Err(error::Error::BadRequest(format!( "{key} is a protected setting and cannot be set to empty" ))); } delete_global_setting(db, &key).await?; } v => { sqlx::query!( "INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()", key, v ) .execute(db) .await?; tracing::info!( "Set global setting {} to {}", key, instance_config::format_setting_value(&key, &v) ); } }; if should_bump_instance_ai_revision { bump_instance_ai_config_revision(); } // Tag reads are served from an in-memory cache that this process otherwise only // refreshes on the next global-settings poll, so without this a refetch right after // the write still returns the pre-write list. The setting is already persisted at // this point, so a failed refresh must not be reported as a failed write — the // poller retries it. if key == CUSTOM_TAGS_SETTING { if let Err(e) = reload_custom_tags_setting(db).await { tracing::error!(error = %e, "Could not reload custom tags setting after write"); } } Ok(()) } /// Run side-effect hooks for specific settings before writing to DB. /// Extracted from `set_global_setting_internal` for reuse by the bulk endpoint. async fn run_setting_pre_write_hook( db: &DB, key: &str, value: &serde_json::Value, ) -> error::Result<()> { match key { AUTOMATE_USERNAME_CREATION_SETTING => { if value.as_bool().unwrap_or(false) { generate_instance_username_for_all_users(db) .await .map_err(|err| { error::Error::internal_err(format!( "Failed to generate instance wide usernames: {}", err )) })?; } else { // Disabling is only allowed before any instance-wide username has been // assigned. Once usernames exist they are globally unique and are baked // into stored `u/` identities (schedules, triggers, drafts, // and non-member superadmin ownership). Disabling would drop back to // workspace-local username uniqueness, letting a member reuse an // existing instance username and silently take over those identities — // so the setting is effectively one-way once derivation has taken // effect. Re-saving `false` on an already-disabled instance is a no-op // and stays allowed (guarded by the current-value check). let currently_enabled = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", AUTOMATE_USERNAME_CREATION_SETTING ) .fetch_optional(db) .await? .and_then(|v| v.as_bool()) .unwrap_or(true); if currently_enabled { let usernames_exist = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM password WHERE username IS NOT NULL)" ) .fetch_one(db) .await? .unwrap_or(false); if usernames_exist { return Err(error::Error::BadRequest( "automate_username_creation cannot be disabled once instance-wide usernames have been assigned: existing u/ identities (schedules, triggers, drafts, superadmin ownership) rely on those usernames staying stable and globally unique.".to_string(), )); } } } } CRITICAL_ALERT_MUTE_UI_SETTING => { if value.as_bool().unwrap_or(false) { sqlx::query!("UPDATE alerts SET acknowledged = true") .execute(db) .await?; } } APP_WORKSPACED_ROUTE_SETTING => { let serde_json::Value::Bool(workspaced_route) = value else { return Err(error::Error::BadRequest(format!( "{} setting Expected to be boolean", APP_WORKSPACED_ROUTE_SETTING ))); }; // Cloud always scopes app custom paths by workspace_id (see // `custom_path_exists` in apps.rs), so duplicates across workspaces // are expected and this setting has no runtime effect on cloud. if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateApp { custom_path: Option, path: String, } let duplicate_app = sqlx::query_as!( DuplicateApp, r#" SELECT path, custom_path FROM app WHERE custom_path IN ( SELECT custom_path FROM app GROUP BY custom_path HAVING COUNT(*) > 1 ) ORDER BY custom_path "# ) .fetch_all(db) .await?; if !duplicate_app.is_empty() { tracing::error!( "Cannot disable {} setting as duplicate app with custom path were found: {:?}", APP_WORKSPACED_ROUTE_SETTING, &duplicate_app ); #[derive(Serialize)] struct ErrorResponse { error: String, details: Vec, } let error_response = ErrorResponse { error: "Duplicate custom paths detected".to_string(), details: duplicate_app, }; return Err(error::Error::JsonErr( serde_json::to_value(error_response).unwrap(), )); } } } HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { let serde_json::Value::Bool(workspaced_route) = value else { return Err(error::Error::BadRequest(format!( "{} setting expected to be boolean", HTTP_ROUTE_WORKSPACED_ROUTE_SETTING ))); }; // Cloud always scopes routes by workspace_id (see // `route_path_key_exists` in windmill-trigger-http), so duplicates // across workspaces are expected and this setting has no runtime // effect on cloud. if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateRoute { route_path: String, workspace_id: String, http_method: String, } let duplicate_routes = sqlx::query_as!( DuplicateRoute, r#" SELECT route_path, workspace_id, http_method::TEXT AS "http_method!" FROM http_trigger WHERE workspaced_route IS FALSE AND route_path_key IN ( SELECT route_path_key FROM http_trigger WHERE workspaced_route IS FALSE GROUP BY route_path_key, http_method HAVING COUNT(*) > 1 ) ORDER BY route_path_key "# ) .fetch_all(db) .await?; if !duplicate_routes.is_empty() { tracing::error!( "Cannot disable {} setting as duplicate http routes were found: {:?}", HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, &duplicate_routes ); #[derive(Serialize)] struct ErrorResponse { error: String, details: Vec, } let error_response = ErrorResponse { error: "Duplicate HTTP route paths detected".to_string(), details: duplicate_routes, }; return Err(error::Error::JsonErr( serde_json::to_value(error_response).unwrap(), )); } } } RETENTION_PERIOD_SECS_OVERRIDES_SETTING => { // Reject a malformed map at write time so it can never be persisted. A persisted bad // value (negative or non-integer) would fail to parse on the next server start and, // because the loader fails closed (skips cleanup until a known-good value is read), // silently disable ALL job-retention cleanup indefinitely. This shape check must stay in // sync with `parse_retention_overrides` in backend/src/monitor.rs. match value { // Clearing (delete row) is handled by the caller; allow it through. serde_json::Value::Null => {} serde_json::Value::String(s) if s.trim().is_empty() => {} serde_json::Value::Object(map) => { if map.len() > MAX_RETENTION_OVERRIDE_WORKSPACES { return Err(error::Error::BadRequest(format!( "retention_period_secs_overrides: at most {MAX_RETENTION_OVERRIDE_WORKSPACES} per-workspace overrides are allowed, got {}", map.len() ))); } for (ws, v) in map { if !v.as_i64().is_some_and(|secs| secs >= 0) { return Err(error::Error::BadRequest(format!( "retention_period_secs_overrides: override for '{ws}' must be a non-negative integer number of seconds, got {v}" ))); } } } _ => { return Err(error::Error::BadRequest( "retention_period_secs_overrides must be a JSON object of {workspace_id: seconds}".to_string(), )); } } } GITHUB_APP_WEBHOOK_BASE_URL_SETTING => { // A bad value here yields a webhook GitHub can never deliver to, and the // failure only shows up much later as "falling back to polling" on a // repository — so reject it at the boundary instead. match value { // Clearing (delete row) is handled by the caller; allow it through. serde_json::Value::Null => {} serde_json::Value::String(s) if s.trim().is_empty() => {} serde_json::Value::String(s) => { windmill_common::global_settings::validate_webhook_base_url(s).map_err( |e| { error::Error::BadRequest(format!( "{GITHUB_APP_WEBHOOK_BASE_URL_SETTING}: {e}" )) }, )?; } _ => { return Err(error::Error::BadRequest(format!( "{GITHUB_APP_WEBHOOK_BASE_URL_SETTING} must be a URL string" ))); } } } _ => {} } Ok(()) } // --------------------------------------------------------------------------- // Bulk instance config endpoints // --------------------------------------------------------------------------- async fn get_instance_config( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let config = InstanceConfig::from_db(&db) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; Ok(Json(config)) } async fn get_instance_config_yaml( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { require_super_admin(&db, &authed.email).await?; let config = InstanceConfig::from_db(&db) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; let yaml = config .to_sorted_yaml() .map_err(|e| error::Error::internal_err(e))?; Response::builder() .header("content-type", "application/yaml") .body(Body::from(yaml)) .map_err(|e| error::Error::internal_err(e.to_string())) } async fn set_instance_config( Extension(db): Extension, authed: ApiAuthed, Json(desired): Json, ) -> error::Result<()> { require_super_admin(&db, &authed.email).await?; let current = InstanceConfig::from_db(&db) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; let desired_map = desired.global_settings.to_settings_map(); if !desired_map.is_empty() { let current_map = current.global_settings.to_settings_map(); let settings_diff = instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge); let ai_config_changed = settings_diff .upserts .iter() .any(|(key, _)| key == AI_CONFIG_SETTING); // Mirror the per-key EE gate in `set_global_setting_internal`. Without // this, the bulk endpoint would let a non-EE superadmin persist // `workspace_fairness_*` rows even though the per-key API rejects them. // Only block *non-disabling* upserts; deletes are allowed everywhere // (already filtered into `settings_diff.removals`) and a // `workspace_fairness_enabled=false` upsert is treated as a disable, // so a downgraded instance can always turn the cap off via the bulk // YAML endpoint too. let upserts_touch_fairness_non_disable = settings_diff.upserts.iter().any(|(k, v)| { if !is_workspace_fairness_setting(k) { return false; } !(k == WORKSPACE_FAIRNESS_ENABLED_SETTING && matches!(v, serde_json::Value::Bool(false))) }); if upserts_touch_fairness_non_disable && !workspace_fairness_settings_allowed().await { return Err(error::Error::BadRequest( "Workspace fairness settings require an Enterprise license".to_string(), )); } for (key, value) in &settings_diff.upserts { run_setting_pre_write_hook(&db, key, value).await?; } instance_config::apply_settings_diff(&db, &settings_diff) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; if ai_config_changed { bump_instance_ai_config_revision(); } } if !desired.worker_configs.is_empty() { let current_wc: std::collections::BTreeMap = current .worker_configs .iter() .map(|(k, v)| { ( k.clone(), serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"), ) }) .collect(); let desired_wc: std::collections::BTreeMap = desired .worker_configs .iter() .map(|(k, v)| { ( k.clone(), serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"), ) }) .collect(); let configs_diff = instance_config::diff_worker_configs(¤t_wc, &desired_wc, ApplyMode::Merge); instance_config::apply_configs_diff(&db, &configs_diff) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; } Ok(()) } pub async fn get_global_setting( Extension(db): Extension, authed: ApiAuthed, Path(key): Path, ) -> JsonResult { if !key.starts_with("default_error_handler_") && !key.starts_with("default_recovery_handler_") && !key.starts_with("default_success_handler_") && key != AUTOMATE_USERNAME_CREATION_SETTING && key != DEFAULT_TAGS_WORKSPACES_SETTING && key != HUB_BASE_URL_SETTING && key != HUB_ACCESSIBLE_URL_SETTING && key != DISABLE_HUB_SETTING && key != EMAIL_DOMAIN_SETTING && key != APP_WORKSPACED_ROUTE_SETTING && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING { require_super_admin(&db, &authed.email).await?; } let value = sqlx::query!("SELECT value FROM global_settings WHERE name = $1", key) .fetch_optional(&db) .await? .map(|x| x.value); Ok(Json(value.unwrap_or_else(|| serde_json::Value::Null))) } #[cfg(feature = "enterprise")] #[derive(Deserialize, serde::Serialize)] struct GlobalSetting { name: String, value: serde_json::Value, } /// Repositories whose registered webhook still points at a receiver the instance no /// longer uses — what an admin has to re-save after changing the webhook base URL. /// Read-only; changing the setting never moves a live hook on its own. async fn github_app_stale_webhooks( Extension(_db): Extension, authed: ApiAuthed, ) -> JsonResult { require_super_admin(&_db, &authed.email).await?; #[cfg(all(feature = "enterprise", feature = "private"))] { let stale = windmill_common::git_sync_ee::stale_webhook_repos(&_db).await?; return Ok(Json(serde_json::to_value(stale).map_err(|e| { error::Error::internal_err(format!("Failed to serialize stale webhooks: {e}")) })?)); } #[cfg(not(all(feature = "enterprise", feature = "private")))] Ok(Json(serde_json::json!([]))) } #[cfg(feature = "enterprise")] async fn list_global_settings( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult> { require_super_admin(&db, &authed.email).await?; let settings = sqlx::query_as!(GlobalSetting, "SELECT name, value FROM global_settings") .fetch_all(&db) .await?; Ok(Json(settings)) } #[cfg(not(feature = "enterprise"))] async fn list_global_settings() -> JsonResult { return Err(error::Error::BadRequest( "Listing global settings not available on community edition".to_string(), )); } pub async fn send_stats(Extension(db): Extension, authed: ApiAuthed) -> Result { require_super_admin(&db, &authed.email).await?; windmill_common::stats_oss::send_stats( &HTTP_CLIENT, &db, windmill_common::stats_oss::SendStatsReason::Manual, false, ) .await?; Ok("Sent stats".to_string()) } async fn restart_worker_group( Extension(db): Extension, authed: ApiAuthed, Path(worker_group): Path, ) -> error::Result { require_devops_role(&db, &authed.email).await?; sqlx::query!( "INSERT INTO notify_event (channel, payload) VALUES ('restart_worker_group', $1)", worker_group ) .execute(&db) .await?; Ok(format!( "Restart signal sent to worker group '{worker_group}'" )) } #[derive(serde::Serialize)] pub struct StatsDownload { pub signature: String, pub data: String, } #[cfg(feature = "enterprise")] pub async fn get_stats( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { require_super_admin(&db, &authed.email).await?; let stats = windmill_common::stats_oss::get_stats_payload( &db, &windmill_common::stats_oss::SendStatsReason::Manual, false, ) .await?; let json = serde_json::to_string(&stats).map_err(|e| error::Error::InternalErr(e.to_string()))?; let signature = windmill_common::stats_oss::sign_stats(&json); Ok(axum::Json(StatsDownload { signature, data: json })) } #[cfg(not(feature = "enterprise"))] pub async fn get_stats() -> error::JsonResult { Err(error::Error::BadRequest( "Downloading telemetry is only available on enterprise edition".to_string(), )) } #[derive(serde::Serialize)] pub struct KeyRenewalAttempt { result: String, attempted_at: chrono::DateTime, } pub async fn get_latest_key_renewal_attempt( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult> { require_super_admin(&db, &authed.email).await?; let last_attempt = sqlx::query!( "SELECT value, created_at FROM metrics WHERE id = $1 ORDER BY created_at DESC LIMIT 1", "license_key_renewal" ) .fetch_optional(&db) .await?; match last_attempt { Some(last_attempt) => { let last_attempt_result = serde_json::from_value::(last_attempt.value) .map_err(|e| { error::Error::internal_err(format!("Failed to parse last attempt: {}", e)) })?; Ok(Json(Some(KeyRenewalAttempt { result: last_attempt_result, attempted_at: last_attempt.created_at, }))) } None => Ok(Json(None)), } } #[cfg(feature = "enterprise")] #[derive(Deserialize)] pub struct LicenseQuery { license_key: Option, } #[cfg(not(feature = "enterprise"))] pub async fn renew_license_key() -> Result { return Err(error::Error::BadRequest( "License key renewal not available on community edition".to_string(), )); } #[cfg(feature = "enterprise")] pub async fn renew_license_key( Extension(db): Extension, Query(LicenseQuery { license_key }): Query, authed: ApiAuthed, ) -> Result { require_super_admin(&db, &authed.email).await?; let result = windmill_common::ee_oss::renew_license_key( &HTTP_CLIENT, &db, license_key, windmill_common::ee_oss::RenewReason::Manual, ) .await; if result != "success" { return Err(error::Error::BadRequest(format!( "Failed to renew license key: {}", if result == "Unauthorized" { "Invalid key".to_string() } else { result } ))); } else { return Ok("Renewed license key".to_string()); } } #[cfg(not(feature = "enterprise"))] pub async fn create_customer_portal_session() -> Result { return Err(error::Error::BadRequest( "Customer portal is not available on community edition".to_string(), )); } #[cfg(feature = "enterprise")] pub async fn create_customer_portal_session( Query(LicenseQuery { license_key }): Query, ) -> Result { let url = windmill_common::ee_oss::create_customer_portal_session(&HTTP_CLIENT, license_key).await?; return Ok(url); } #[cfg(feature = "enterprise")] pub async fn test_critical_channels( Extension(db): Extension, authed: ApiAuthed, Json(test_critical_channels): Json>, ) -> Result { require_super_admin(&db, &authed.email).await?; #[cfg(feature = "enterprise")] send_critical_alert( "Test critical error".to_string(), &db, CriticalAlertKind::CriticalError, Some(test_critical_channels), ) .await; Ok("Sent test critical error".to_string()) } #[cfg(not(feature = "enterprise"))] pub async fn test_critical_channels() -> Result { Ok("Critical channels require EE".to_string()) } #[cfg(feature = "enterprise")] pub async fn get_critical_alerts( Extension(db): Extension, authed: ApiAuthed, Query(params): Query, ) -> JsonResult { require_devops_role(&db, &authed.email).await?; windmill_alerting::get_critical_alerts(db, params, None).await } #[cfg(not(feature = "enterprise"))] pub async fn get_critical_alerts() -> error::Error { error::Error::NotFound("Critical Alerts require EE".to_string()) } #[cfg(feature = "enterprise")] pub async fn acknowledge_critical_alert( Extension(db): Extension, authed: ApiAuthed, Path(id): Path, ) -> error::Result { require_devops_role(&db, &authed.email).await?; windmill_alerting::acknowledge_critical_alert(db, None, id).await } #[cfg(not(feature = "enterprise"))] pub async fn acknowledge_critical_alert() -> error::Error { error::Error::NotFound("Critical Alerts require EE".to_string()) } #[cfg(feature = "enterprise")] pub async fn acknowledge_all_critical_alerts( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { require_super_admin(&db, &authed.email).await?; windmill_alerting::acknowledge_all_critical_alerts(db, None).await } #[cfg(not(feature = "enterprise"))] pub async fn acknowledge_all_critical_alerts() -> error::Error { error::Error::NotFound("Critical Alerts require EE".to_string()) } #[derive(Deserialize, Debug, Serialize)] struct CustomInstanceDb { logs: CustomInstanceDbLogs, // (Step, Message)[] success: bool, error: Option, tag: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] used_by_workspaces: Vec, } #[derive(Deserialize, Debug, Serialize, Default)] #[serde(default)] struct CustomInstanceDbLogs { super_admin: String, #[serde(skip_serializing_if = "String::is_empty")] database_credentials: String, #[serde(skip_serializing_if = "String::is_empty")] valid_dbname: String, #[serde(skip_serializing_if = "String::is_empty")] created_database: String, #[serde(skip_serializing_if = "String::is_empty")] db_connect: String, #[serde(skip_serializing_if = "String::is_empty")] grant_permissions: String, #[serde(skip_serializing_if = "String::is_empty")] replication_user: String, #[serde(skip_serializing_if = "Option::is_none")] replication_user_error: Option, } async fn list_custom_instance_pg_databases( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { let result = sqlx::query_scalar!( r#"SELECT value->'databases' FROM global_settings WHERE name = 'custom_instance_pg_databases'"#, ) .fetch_one(&db) .await? .ok_or_else(|| error::Error::ExecutionErr("Couldn't find custom_instance_pg_databases".to_string()))?; let mut result: HashMap = serde_json::from_value(result).map_err(|e| { error::Error::ExecutionErr(format!( "couldn't parse custom_instance_pg_databases.databases : {}", e.to_string() )) })?; if is_super_admin_email(&db, &authed.email).await? { // Enrich each database with the list of workspaces referencing it through // either a ducklake catalog or a datatable database whose resource_type is // 'instance'. Not stored in DB to avoid drift. let usages = sqlx::query!( r#" SELECT ws.workspace_id AS "workspace_id!", entry->'catalog'->>'resource_path' AS dbname FROM workspace_settings ws CROSS JOIN LATERAL jsonb_each( CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END ) AS dl(k, entry) WHERE entry->'catalog'->>'resource_type' = 'instance' AND entry->'catalog'->>'resource_path' IS NOT NULL UNION ALL SELECT ws.workspace_id AS "workspace_id!", entry->'database'->>'resource_path' AS dbname FROM workspace_settings ws CROSS JOIN LATERAL jsonb_each( CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' THEN ws.datatable->'datatables' ELSE '{}'::jsonb END ) AS dt(k, entry) WHERE entry->'database'->>'resource_type' = 'instance' AND entry->'database'->>'resource_path' IS NOT NULL "#, ) .fetch_all(&db) .await?; let mut by_db: HashMap> = HashMap::new(); for row in usages { if let Some(dbname) = row.dbname { by_db.entry(dbname).or_default().insert(row.workspace_id); } } for (dbname, entry) in result.iter_mut() { if let Some(workspaces) = by_db.remove(dbname) { entry.used_by_workspaces = workspaces.into_iter().collect(); } } } return Ok(Json(result)); } async fn refresh_custom_instance_user_pwd( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult<()> { require_super_admin(&db, &authed.email).await?; windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?; windmill_common::utils::refresh_custom_instance_replication_user_pwd(&db).await?; Ok(Json(())) } #[derive(Deserialize)] struct SetupCustomInstanceDbBody { tag: Option, } async fn setup_custom_instance_pg_database( authed: ApiAuthed, Extension(db): Extension, Path(dbname): Path, Json(body): Json, ) -> JsonResult { let mut logs = CustomInstanceDbLogs::default(); let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); let status = CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; // Save that the database was setup successfully sqlx::query!( r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#, json!({ dbname: status_json }) ).execute(&db).await?; Ok(Json(status)) } async fn setup_custom_instance_pg_database_inner( authed: ApiAuthed, db: &DB, dbname: &str, logs: &mut CustomInstanceDbLogs, ) -> Result<()> { require_super_admin(db, &authed.email).await?; logs.super_admin = "OK".to_string(); let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; logs.database_credentials = "OK".to_string(); // Validate name to ensure it only contains alphanumeric characters // Prevents SQL injection on the instance database lazy_static::lazy_static! { // Must start with a letter, then alphanumeric/underscore/hyphen static ref VALID_NAME: regex::Regex = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]*$").unwrap(); } let dbname = dbname.trim(); if dbname.is_empty() { return Err(error::Error::BadRequest( "Database name cannot be empty".to_string(), )); } // PostgreSQL identifier limit is 63 bytes if dbname.len() > 63 { return Err(error::Error::BadRequest( "Database name cannot exceed 63 characters".to_string(), )); } if !VALID_NAME.is_match(dbname) { return Err(error::Error::BadRequest( "Database name must start with a letter and contain only alphanumeric characters, underscores, or hyphens".to_string(), )); } // Additional check: block PostgreSQL reserved/special names let lower = dbname.to_lowercase(); if lower == "template0" || lower == "template1" || lower == "postgres" { return Err(error::Error::BadRequest( "Cannot use reserved PostgreSQL database names".to_string(), )); } if wmill_pg_creds .dbname .trim() .eq_ignore_ascii_case(dbname.trim()) { return Err(error::Error::BadRequest( "Database name cannot be the same as the main database".to_string(), )); } logs.valid_dbname = "OK".to_string(); let db_exists = sqlx::query_scalar!( "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", dbname ) .fetch_one(db) .await? .unwrap_or(false); let pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds }; logs.created_database = "SKIP".to_string(); if !db_exists { // SAFETY: `dbname` has been validated by the VALID_NAME regex and length checks above (lines 1088–1120). sqlx::query(&format!("CREATE DATABASE \"{dbname}\"")) .execute(db) .await?; logs.created_database = "OK".to_string(); } // We have to connect to the newly created database as admin to grant permissions let (client, connection) = pg_creds.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); logs.db_connect = "OK".to_string(); // SAFETY: `dbname` has been validated by the VALID_NAME regex and length checks above. client .batch_execute(&format!( "GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user; GRANT USAGE ON SCHEMA public TO custom_instance_user; GRANT CREATE ON SCHEMA public TO custom_instance_user; GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user; ALTER ROLE custom_instance_user CREATEROLE;" )) .await .map_err(|e| { error::Error::ExecutionErr(format!( "Failed to grant permissions to custom_instance_user: {}", pg_error_message(&e), )) })?; logs.grant_permissions = "OK".to_string(); drop(client); // /!\ Drop before joining to avoid deadlock windmill_common::shutdown_pg_connection(join_handle).await?; // Roles are cluster-wide, so the dedicated role used by postgres trigger connections is // provisioned on the main pool rather than on the new database. Reported as its own step // rather than failing the setup: without the role the database still serves datatables, only // postgres triggers on them break. match windmill_common::utils::ensure_custom_instance_replication_user(db).await { Ok(()) => logs.replication_user = "OK".to_string(), Err(e) => { tracing::error!("Failed to provision custom_instance_replication_user: {e:#}"); logs.replication_user = "FAIL".to_string(); logs.replication_user_error = Some(e.to_string()); } } Ok(()) } async fn drop_custom_instance_pg_database( authed: ApiAuthed, Extension(db): Extension, Path(dbname): Path, ) -> Result { require_super_admin(&db, &authed.email).await?; windmill_common::drop_custom_instance_database(&db, &dbname).await?; Ok(format!("Database '{}' dropped successfully", dbname)) } // ============================================================================ // Secret Backend Settings (HashiCorp Vault Integration) - Enterprise Edition // ============================================================================ /// Test connection to a secret backend (HashiCorp Vault) /// /// This endpoint validates that the Vault settings are correct and that /// Windmill can successfully authenticate and communicate with Vault. /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn test_secret_backend( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> Result { require_super_admin(&db, &authed.email).await?; windmill_common::secret_backend::test_vault_connection(&settings, Some(&db)).await?; Ok("Successfully connected to HashiCorp Vault".to_string()) } /// Migrate existing secrets from database to HashiCorp Vault /// /// This endpoint reads all encrypted secrets from the database, decrypts them, /// and stores them in HashiCorp Vault. The database values are NOT deleted /// automatically to allow for rollback if needed. /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_to_vault( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_to_vault(&db, &settings).await?; Ok(Json(report)) } /// Migrate secrets from HashiCorp Vault back to database /// /// This endpoint reads all secrets from HashiCorp Vault, encrypts them using /// the workspace encryption keys, and stores them in the database. The Vault /// values are NOT deleted automatically to allow for rollback if needed. /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_to_database( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_to_database(&db, &settings).await?; Ok(Json(report)) } /// Test connection to Azure Key Vault /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn test_azure_kv_backend( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> Result { require_super_admin(&db, &authed.email).await?; windmill_common::secret_backend::test_azure_kv_connection(&settings).await?; Ok("Successfully connected to Azure Key Vault".to_string()) } /// Migrate existing secrets from database to Azure Key Vault /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_to_azure_kv( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_to_azure_kv(&db, &settings).await?; Ok(Json(report)) } /// Migrate secrets from Azure Key Vault back to database /// /// This is an Enterprise Edition feature. #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_from_azure_kv( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_from_azure_kv(&db, &settings).await?; Ok(Json(report)) } /// Test connection to AWS Secrets Manager #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn test_aws_sm_backend( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> Result { require_super_admin(&db, &authed.email).await?; windmill_common::secret_backend::test_aws_sm_connection(&settings).await?; Ok("Successfully connected to AWS Secrets Manager".to_string()) } /// Migrate existing secrets from database to AWS Secrets Manager #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_to_aws_sm( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_to_aws_sm(&db, &settings).await?; Ok(Json(report)) } /// Migrate secrets from AWS Secrets Manager back to database #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn migrate_secrets_from_aws_sm( Extension(db): Extension, authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { require_super_admin(&db, &authed.email).await?; let report = windmill_common::secret_backend::migrate_secrets_from_aws_sm(&db, &settings).await?; Ok(Json(report)) } // ============================================================================ // JWKS Endpoint for Vault JWT Authentication // ============================================================================ /// JSON Web Key Set response structure #[derive(Serialize)] pub struct JwksResponse { pub keys: Vec, } /// Fallback JWKS endpoint used when OIDC support is not compiled in. /// /// When built with `private` + `enterprise` + `openidconnect`, the route in /// `windmill-api` dispatches to `oidc_oss::jwks` (re-exported from /// `oidc_ee::jwks`) instead, which serves the actual public keys. pub async fn get_jwks() -> JsonResult { Ok(Json(JwksResponse { keys: vec![] })) } #[derive(serde::Deserialize, serde::Serialize)] struct CachedResourceType { #[allow(dead_code)] id: i64, name: String, schema: Option, #[allow(dead_code)] app: String, description: Option, } #[derive(serde::Deserialize)] struct HubResourceTypeRaw { id: i64, name: String, schema: Option, app: String, description: Option, } async fn fetch_resource_types_from_hub() -> error::Result> { let response = HTTP_CLIENT .get(format!( "{}/resource_types/list", windmill_common::DEFAULT_HUB_BASE_URL )) .header("Accept", "application/json") .send() .await .map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?; if !response.status().is_success() { return Err(error::Error::InternalErr(format!( "Hub returned status {}", response.status() ))); } let raw_types: Vec = response .json() .await .map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?; Ok(raw_types .into_iter() .filter_map(|rt| { let schema = match rt.schema { Some(s) => match serde_json::from_str(&s) { Ok(v) => Some(v), Err(_) => return None, }, None => None, }; Some(CachedResourceType { id: rt.id, name: rt.name, schema, app: rt.app, description: rt.description, }) }) .collect()) } #[derive(serde::Deserialize)] struct SyncResourceTypesQuery { name: Option, } async fn sync_cached_resource_types( Extension(db): Extension, authed: ApiAuthed, Query(SyncResourceTypesQuery { name }): Query, ) -> error::Result { require_super_admin(&db, &authed.email).await?; use windmill_common::worker::HUB_RT_CACHE_DIR; let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); // Manual sync is hub-first so it lands newly-published hub types on demand. The // on-disk cache is only a fallback for when the hub is unreachable (airgapped // installs / network error); refreshing it is left to the daily cache-rt cron and // the startup sync in main.rs, which own the offline path. let (resource_types, from_hub) = match fetch_resource_types_from_hub().await { Ok(types) => { tracing::info!("Fetched {} resource types live from the hub", types.len()); (types, true) } Err(hub_err) => { tracing::warn!( "Live hub fetch failed ({hub_err}), falling back to on-disk cache at {cache_path}" ); match tokio::fs::read_to_string(&cache_path).await { Ok(content) => { let parsed = serde_json::from_str::>(&content) .map_err(|e| { error::Error::InternalErr(format!( "Failed to parse cached resource types: {}", e )) })?; (parsed, false) } Err(_) => return Err(hub_err), } } }; let mut synced_count = 0; for rt in &resource_types { let exists: Option = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)", &rt.name, rt.schema.as_ref(), rt.description.as_deref(), ) .fetch_one(&db) .await?; if exists.unwrap_or(false) { continue; } sqlx::query!( "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at) VALUES ('admins', $1, $2, $3, now()) ON CONFLICT (workspace_id, name) DO UPDATE SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()", &rt.name, rt.schema.as_ref(), rt.description.as_deref(), ) .execute(&db) .await?; synced_count += 1; } // If a specific type was requested and is still absent after syncing, surface an // explicit not-found instead of a silent "Synced 0". Word it by source so the // cache-fallback path does not claim it checked the hub. if let Some(name) = name.as_deref() { if !resource_types.iter().any(|rt| rt.name == name) { let source = if from_hub { "on the hub" } else { "in the cached resource types (hub unreachable)" }; return Err(error::Error::NotFound(format!( "resource type '{}' not found {}", name, source ))); } } Ok(format!( "Synced {} resource types ({} unchanged)", synced_count, resource_types.len() - synced_count )) } #[cfg(test)] mod tests { use std::collections::BTreeMap; use windmill_common::instance_config::{GlobalSettings, InstanceConfig, WorkerGroupConfig}; #[test] fn instance_config_yaml_round_trip() { let config = InstanceConfig { global_settings: GlobalSettings { base_url: Some("https://windmill.example.com".to_string()), retention_period_secs: Some(86400), expose_metrics: Some(true), ..Default::default() }, worker_configs: BTreeMap::from([( "default".to_string(), WorkerGroupConfig { worker_tags: Some(vec!["deno".to_string(), "python3".to_string()]), init_bash: Some("apt-get update".to_string()), ..Default::default() }, )]), }; let yaml = config.to_sorted_yaml().unwrap(); // Verify key fields appear in the YAML output assert!(yaml.contains("base_url: https://windmill.example.com")); assert!(yaml.contains("retention_period_secs: 86400")); assert!(yaml.contains("expose_metrics: true")); assert!(yaml.contains("default:")); assert!(yaml.contains("- deno")); assert!(yaml.contains("- python3")); assert!(yaml.contains("init_bash: apt-get update")); // Round-trip back to struct let deserialized: InstanceConfig = serde_yml::from_str(&yaml).unwrap(); assert_eq!( deserialized.global_settings.base_url.as_deref(), Some("https://windmill.example.com") ); assert_eq!( deserialized.global_settings.retention_period_secs, Some(86400) ); assert_eq!(deserialized.global_settings.expose_metrics, Some(true)); let wc = &deserialized.worker_configs["default"]; assert_eq!( wc.worker_tags.as_deref(), Some(["deno".to_string(), "python3".to_string()].as_slice()) ); assert_eq!(wc.init_bash.as_deref(), Some("apt-get update")); } #[test] fn sorted_yaml_global_settings_alphabetical() { let config = InstanceConfig { global_settings: GlobalSettings { retention_period_secs: Some(3600), base_url: Some("https://test.com".to_string()), expose_metrics: Some(true), email_domain: Some("example.com".to_string()), ..Default::default() }, worker_configs: BTreeMap::new(), }; let yaml = config.to_sorted_yaml().unwrap(); // Keys must appear in alphabetical order let base_url_pos = yaml.find("base_url:").unwrap(); let email_pos = yaml.find("email_domain:").unwrap(); let expose_pos = yaml.find("expose_metrics:").unwrap(); let retention_pos = yaml.find("retention_period_secs:").unwrap(); assert!( base_url_pos < email_pos && email_pos < expose_pos && expose_pos < retention_pos, "global_settings keys should be alphabetically sorted, got yaml:\n{yaml}" ); } #[test] fn sorted_yaml_worker_configs_default_and_native_first() { let config = InstanceConfig { global_settings: GlobalSettings::default(), worker_configs: BTreeMap::from([ ( "gpu".to_string(), WorkerGroupConfig { init_bash: Some("echo gpu".to_string()), ..Default::default() }, ), ( "native".to_string(), WorkerGroupConfig { init_bash: Some("echo native".to_string()), ..Default::default() }, ), ( "default".to_string(), WorkerGroupConfig { init_bash: Some("echo default".to_string()), ..Default::default() }, ), ( "alpha".to_string(), WorkerGroupConfig { init_bash: Some("echo alpha".to_string()), ..Default::default() }, ), ]), }; let yaml = config.to_sorted_yaml().unwrap(); let default_pos = yaml.find("default:").unwrap(); let native_pos = yaml.find("native:").unwrap(); let alpha_pos = yaml.find("alpha:").unwrap(); let gpu_pos = yaml.find("gpu:").unwrap(); assert!( default_pos < native_pos && native_pos < alpha_pos && alpha_pos < gpu_pos, "worker_configs should have default, native first, then rest alphabetically, got yaml:\n{yaml}" ); } #[test] fn sorted_yaml_roundtrips() { let config = InstanceConfig { global_settings: GlobalSettings { base_url: Some("https://rt.test".to_string()), retention_period_secs: Some(7200), expose_metrics: Some(false), ..Default::default() }, worker_configs: BTreeMap::from([ ( "default".to_string(), WorkerGroupConfig { worker_tags: Some(vec!["deno".to_string()]), ..Default::default() }, ), ( "native".to_string(), WorkerGroupConfig { init_bash: Some("echo hi".to_string()), ..Default::default() }, ), ]), }; let yaml = config.to_sorted_yaml().unwrap(); let deserialized: InstanceConfig = serde_yml::from_str(&yaml).unwrap(); assert_eq!( deserialized.global_settings.base_url.as_deref(), Some("https://rt.test") ); assert_eq!( deserialized.global_settings.retention_period_secs, Some(7200) ); assert_eq!(deserialized.global_settings.expose_metrics, Some(false)); assert_eq!(deserialized.worker_configs.len(), 2); assert_eq!( deserialized.worker_configs["default"] .worker_tags .as_deref(), Some(["deno".to_string()].as_slice()) ); assert_eq!( deserialized.worker_configs["native"].init_bash.as_deref(), Some("echo hi") ); } } #[cfg(all(test, feature = "parquet"))] mod object_storage_test_hardening { use super::{extract_host, is_forbidden_ip, validate_object_storage_test}; use std::net::IpAddr; use windmill_object_store::ObjectSettings; // IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host` // parses them without any network round-trip. fn gcs_settings(gcs_base_url: &str) -> ObjectSettings { serde_json::from_value(serde_json::json!({ "type": "Gcs", "bucket": "b", "serviceAccountKey": { "gcs_base_url": gcs_base_url, "client_email": "x@y.z" } })) .unwrap() } #[tokio::test] async fn rejects_gcs_internal_base_url() { // gcs_base_url in the service-account key must not smuggle an internal host past the check, // including via a mixed-case scheme (URL schemes are case-insensitive). for url in [ "http://169.254.169.254", "HTTP://169.254.169.254", "Https://10.0.0.5", ] { assert!( validate_object_storage_test(&gcs_settings(url)) .await .is_err(), "{url} should be rejected" ); } } #[tokio::test] async fn allows_gcs_public_base_url() { assert!( validate_object_storage_test(&gcs_settings("https://8.8.8.8")) .await .is_ok() ); } #[tokio::test] async fn rejects_gcs_blank_service_account_key() { // A blank key makes build_gcs_client fall back to the instance's ambient credentials, so an // untrusted caller must not be allowed to test with it. The `serviceAccountKey` field is // serialized via serde's `as_string` (`to_string` of the JSON value), so the settings UI's // "no key" empty object arrives as `"{}"` and a null as `"null"` — both must be rejected. for key in [serde_json::json!({}), serde_json::json!(null)] { let settings: ObjectSettings = serde_json::from_value(serde_json::json!({ "type": "Gcs", "bucket": "b", "serviceAccountKey": key })) .unwrap(); assert!( validate_object_storage_test(&settings).await.is_err(), "blank key {key:?} should be rejected" ); } } fn ip(s: &str) -> IpAddr { s.parse().unwrap() } #[test] fn forbids_internal_ips() { for s in [ "127.0.0.1", // loopback "169.254.169.254", // cloud metadata (link-local) "10.0.0.5", // private "172.16.3.4", // private "192.168.1.10", // private "0.0.0.0", // unspecified "100.64.0.1", // CGNAT "::1", // IPv6 loopback "fe80::1", // IPv6 link-local "fc00::1", // IPv6 unique local "::ffff:127.0.0.1", // IPv4-mapped loopback "::ffff:169.254.169.254", // IPv4-mapped metadata "::169.254.169.254", // IPv4-compatible metadata "64:ff9b::169.254.169.254", // NAT64-embedded metadata "64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form) ] { assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden"); } } #[test] fn allows_public_ips() { for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] { assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed"); } } #[test] fn extracts_host_from_endpoint() { let cases = [ ("s3.amazonaws.com", Some("s3.amazonaws.com")), ("https://minio.internal:9000", Some("minio.internal")), ("http://10.0.0.5:9000/bucket", Some("10.0.0.5")), ("user:pass@host.example:443", Some("host.example")), ("[::1]:9000", Some("::1")), ("https://[fe80::1]/x", Some("fe80::1")), ("", None), // Injection via region/bucket interpolation into the default endpoint string: the // userinfo `@` and the path `/` must not hide the real authority from the host check. ( "https://s3.@169.254.169.254/.amazonaws.com", Some("169.254.169.254"), ), ( "https://@169.254.169.254/mybucket.s3.amazonaws.com", Some("169.254.169.254"), ), ("s3.#@169.254.169.254/x.amazonaws.com", Some("s3.")), // Scheme is case-insensitive. ("HTTP://169.254.169.254", Some("169.254.169.254")), ]; for (input, expected) in cases { assert_eq!(extract_host(input).as_deref(), expected, "input: {input}"); } } }