Files
windmill/backend/windmill-api-settings/src/lib.rs
T
Ruben Fiszel 552ad9c859 fix: reflect custom tag add/remove in the manage-tags drawer immediately (#10526)
* fix: reflect custom tag add/remove in the manage-tags drawer immediately

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: do not fail the custom_tags write when the cache refresh errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 23:57:35 +02:00

2450 lines
89 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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<DB>) -> error::Result<Response> {
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<DB>,
authed: ApiAuthed,
Json(test_email): Json<TestEmail>,
) -> error::Result<String> {
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<DB>,
Json(test_s3_bucket): Json<ObjectSettings>,
) -> error::Result<String> {
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::<String, error::Error>("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<String>) -> 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<String> = 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::<serde_json::Value>(&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<std::net::SocketAddr> = 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<String> {
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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<storage_usage::StorageUsageProgress>> {
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<DB>,
authed: ApiAuthed,
) -> error::Result<axum::http::StatusCode> {
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<DB>,
authed: ApiAuthed,
) -> error::Result<axum::http::StatusCode> {
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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<log_cleanup::LogCleanupProgress>> {
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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<audit_logs_s3::AuditLogsS3ExportStatus>> {
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<DB>,
authed: ApiAuthed,
Json(req): Json<audit_logs_s3_backfill::BackfillRequest>,
) -> error::Result<axum::http::StatusCode> {
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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<audit_logs_s3_backfill::AuditBackfillProgress>> {
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<DB>,
authed: ApiAuthed,
Json(TestKey { license_key }): Json<TestKey>,
) -> error::Result<String> {
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<String>,
}
/// 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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<windmill_common::ee_oss::OfflineCapStatus>> {
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<windmill_common::ee_oss::OfflineCapStatus> = 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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<InstanceHash> {
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<String> = None;
Ok(Json(InstanceHash { instance_hash: hash }))
}
pub async fn get_local_settings(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<serde_json::Value> {
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<serde_json::Value>,
}
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<DB>,
authed: ApiAuthed,
Path(key): Path<String>,
Json(value): Json<Value>,
) -> 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/<username>` 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/<username> 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<String>,
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<DuplicateApp>,
}
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<DuplicateRoute>,
}
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<DB>,
authed: ApiAuthed,
) -> JsonResult<InstanceConfig> {
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<DB>,
authed: ApiAuthed,
) -> error::Result<Response> {
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<DB>,
authed: ApiAuthed,
Json(desired): Json<InstanceConfig>,
) -> 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(&current_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<String, serde_json::Value> = 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<String, serde_json::Value> = 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(&current_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<DB>,
authed: ApiAuthed,
Path(key): Path<String>,
) -> JsonResult<serde_json::Value> {
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<DB>,
authed: ApiAuthed,
) -> JsonResult<serde_json::Value> {
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<DB>,
authed: ApiAuthed,
) -> JsonResult<Vec<GlobalSetting>> {
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<String> {
return Err(error::Error::BadRequest(
"Listing global settings not available on community edition".to_string(),
));
}
pub async fn send_stats(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Path(worker_group): Path<String>,
) -> error::Result<String> {
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<DB>,
authed: ApiAuthed,
) -> error::JsonResult<StatsDownload> {
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<StatsDownload> {
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<chrono::Utc>,
}
pub async fn get_latest_key_renewal_attempt(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<Option<KeyRenewalAttempt>> {
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::<String>(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<String>,
}
#[cfg(not(feature = "enterprise"))]
pub async fn renew_license_key() -> Result<String> {
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<DB>,
Query(LicenseQuery { license_key }): Query<LicenseQuery>,
authed: ApiAuthed,
) -> Result<String> {
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<String> {
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<LicenseQuery>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(test_critical_channels): Json<Vec<CriticalErrorChannel>>,
) -> Result<String> {
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<String> {
Ok("Critical channels require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn get_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(params): Query<windmill_alerting::AlertQueryParams>,
) -> JsonResult<serde_json::Value> {
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<DB>,
authed: ApiAuthed,
Path(id): Path<i32>,
) -> error::Result<String> {
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<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
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<String>,
tag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
used_by_workspaces: Vec<String>,
}
#[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<String>,
}
async fn list_custom_instance_pg_databases(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<HashMap<String, CustomInstanceDb>> {
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<String, CustomInstanceDb> =
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<String, BTreeSet<String>> = 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<DB>,
) -> 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<String>,
}
async fn setup_custom_instance_pg_database(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(dbname): Path<String>,
Json(body): Json<SetupCustomInstanceDbBody>,
) -> JsonResult<CustomInstanceDb> {
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 10881120).
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<DB>,
Path(dbname): Path<String>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
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<serde_json::Value>,
}
/// 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<JwksResponse> {
Ok(Json(JwksResponse { keys: vec![] }))
}
#[derive(serde::Deserialize, serde::Serialize)]
struct CachedResourceType {
#[allow(dead_code)]
id: i64,
name: String,
schema: Option<serde_json::Value>,
#[allow(dead_code)]
app: String,
description: Option<String>,
}
#[derive(serde::Deserialize)]
struct HubResourceTypeRaw {
id: i64,
name: String,
schema: Option<String>,
app: String,
description: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
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<HubResourceTypeRaw> = 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<String>,
}
async fn sync_cached_resource_types(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(SyncResourceTypesQuery { name }): Query<SyncResourceTypesQuery>,
) -> error::Result<String> {
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::<Vec<CachedResourceType>>(&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<bool> = 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}");
}
}
}