feat: add instance setting to enforce workspace prefix for HTTP routes (#8528)

* feat: add instance-level setting to enforce workspace prefix for HTTP routes

Add `http_route_workspaced_route` instance setting that forces all HTTP routes
to use workspace prefix (`/api/r/{workspace_id}/{route}`), mirroring the existing
`app_workspaced_route` setting for apps.

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

* fix: bump http trigger version on setting change to invalidate route cache

The route cache is version-based, not TTL-based. Without bumping the
version sequence when the instance setting changes, cached routes would
continue serving with the old prefix behavior until a route is
created/updated/deleted or the server restarts.

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

* fix: immediately refresh HTTP routers on setting change

The route cache polls every 60 seconds, but bumping the version sequence
only makes the next poll pick up changes. Explicitly call refresh_routers
after the setting reload so routes are rebuilt immediately.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-03-25 22:54:36 +01:00
committed by GitHub
parent 36a81004dc
commit 9b3e558d84
12 changed files with 281 additions and 28 deletions

View File

@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "http_method!",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
null
]
},
"hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30"
}

View File

@@ -44,8 +44,8 @@ use windmill_common::{
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
@@ -104,10 +104,10 @@ use crate::monitor::{
reload_base_url_setting, reload_bunfig_install_scopes_setting,
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting,
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
};
@@ -1814,6 +1814,23 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db).await {
Ok((true, _)) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
);
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}");
}
_ => {}
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();

View File

@@ -88,7 +88,13 @@ use windmill_common::{
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
use windmill_common::{
client::AuthedClient,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
},
};
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
@@ -296,6 +302,10 @@ pub async fn initial_load(
if let Err(e) = reload_app_workspaced_route_setting(db).await {
tracing::error!("Error reloading app workspaced route: {:?}", e)
}
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!("Error reloading http route workspaced route: {:?}", e)
}
}
#[cfg(feature = "parquet")]
@@ -3390,6 +3400,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
Ok(())
}
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
let http_route_workspaced_route =
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
let ws_route = match http_route_workspaced_route {
Some(serde_json::Value::Bool(ws_route)) => ws_route,
None => false,
_ => {
tracing::error!(
"Expected {} to be a boolean got: {:?}. Defaulting to false",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
http_route_workspaced_route
);
false
}
};
let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await;
if *l != ws_route {
*l = ws_route;
drop(l);
// Bump the HTTP trigger version so the route cache is rebuilt with
// the updated workspaced_route behavior on the next request.
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
.fetch_one(conn)
.await?;
} else {
*l = ws_route;
}
Ok(())
}
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
#[derive(Deserialize)]
struct DBOversize {

View File

@@ -45,8 +45,8 @@ use windmill_common::{
global_settings::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook(
}
}
}
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
)));
};
if !*workspaced_route {
#[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(),
));
}
}
}
_ => {}
}
Ok(())
@@ -541,6 +609,7 @@ pub async fn get_global_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?;

View File

@@ -58,12 +58,20 @@ pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
pub const OTEL_SETTING: &str = "otel";
pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy";
pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route";
pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route";
pub const SECRET_BACKEND_SETTING: &str = "secret_backend";
pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version";
pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook";
pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries";
use std::sync::Arc;
use tokio::sync::RwLock;
lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
}
pub const ENV_SETTINGS: &[&str] = &[
"DISABLE_NSJAIL",
"MODE",

View File

@@ -237,6 +237,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub app_workspaced_route: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_route_workspaced_route: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_default_maven: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_tags_per_workspace: Option<bool>,

View File

@@ -8,6 +8,7 @@ use sqlx::PgConnection;
use std::collections::HashSet;
use windmill_api_auth::ApiAuthed;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::{
db::UserDB,
error::{Error, Result},
@@ -61,11 +62,12 @@ pub async fn route_path_key_exists(
.await?
.unwrap_or(false)
} else {
let route_path_key = match workspaced_route {
Some(true) => {
std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/')))
}
_ => std::borrow::Cow::Borrowed(route_path_key),
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced;
let route_path_key = if effective_workspaced {
std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/')))
} else {
std::borrow::Cow::Borrowed(route_path_key)
};
sqlx::query_scalar!(
@@ -146,6 +148,10 @@ pub async fn insert_new_trigger_into_db(
) -> Result<()> {
require_admin(authed.is_admin, &authed.username)?;
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced =
trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced;
let request_type = trigger.config.request_type;
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
@@ -186,7 +192,7 @@ pub async fn insert_new_trigger_into_db(
trigger.base.path,
trigger.config.route_path,
route_path_key,
trigger.config.workspaced_route.unwrap_or(false),
effective_workspaced,
trigger.config.authentication_resource_path,
trigger.config.wrap_body.unwrap_or(false),
trigger.config.raw_string.unwrap_or(false),
@@ -445,6 +451,10 @@ impl TriggerCrud for HttpTrigger {
let route_path_key =
check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?;
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced =
trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced;
let request_type = trigger.config.request_type;
sqlx::query!(
@@ -481,7 +491,7 @@ impl TriggerCrud for HttpTrigger {
"#,
route_path,
&route_path_key,
trigger.config.workspaced_route,
Some(effective_workspaced),
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.authentication_resource_path,

View File

@@ -7,6 +7,7 @@ use tokio::sync::{RwLock, RwLockReadGuard};
use windmill_common::{
error::{Error, Result},
flows::Retry,
global_settings::HTTP_ROUTE_WORKSPACED_ROUTE,
utils::ExpiringCacheEntry,
worker::CLOUD_HOSTED,
DB,
@@ -273,13 +274,15 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
.await?;
let mut router = matchit::Router::new();
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
for trigger in triggers {
let full_path = if trigger.workspaced_route || *CLOUD_HOSTED {
format!("/{}/{}", trigger.workspace_id, trigger.route_path)
} else {
format!("/{}", trigger.route_path)
};
let full_path =
if trigger.workspaced_route || *CLOUD_HOSTED || http_route_workspaced {
format!("/{}/{}", trigger.workspace_id, trigger.route_path)
} else {
format!("/{}", trigger.route_path)
};
if trigger.is_static_website {
router

View File

@@ -196,6 +196,16 @@ export const settings: Record<string, Setting[]> = {
ee_only: '',
hideInQuickSetup: true
},
{
label: 'HTTP route workspace prefix',
description:
'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict',
key: 'http_route_workspaced_route',
fieldType: 'boolean',
storage: 'setting',
ee_only: '',
hideInQuickSetup: true
},
{
label: 'Audit log retention (days)',
key: 'audit_log_retention_days',

View File

@@ -5,7 +5,7 @@
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { HttpTriggerService } from '$lib/gen'
import { HttpTriggerService, SettingService } from '$lib/gen'
// import { page } from '$app/state'
import { getHttpRoute } from './utils'
import { isCloudHosted } from '$lib/cloud'
@@ -106,6 +106,26 @@
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved
let globalHttpWorkspacedRoute = $state(false)
async function loadGlobalHttpWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
globalHttpWorkspacedRoute = (setting as boolean) ?? false
} catch (error) {
globalHttpWorkspacedRoute = false
}
}
loadGlobalHttpWorkspacedRouteSetting()
$effect.pre(() => {
if (globalHttpWorkspacedRoute && !workspaced_route) {
workspaced_route = true
dirtyRoutePath = true
}
})
</script>
<div>
@@ -172,13 +192,15 @@
<Toggle
size="sm"
checked={workspaced_route}
disabled={!can_write || !userCanEditConfig}
disabled={!can_write || !userCanEditConfig || globalHttpWorkspacedRoute}
on:change={() => {
workspaced_route = !workspaced_route
dirtyRoutePath = true
}}
options={{
right: 'Prefix with workspace',
right: globalHttpWorkspacedRoute
? 'Prefix with workspace (enforced by instance setting)'
: 'Prefix with workspace',
rightTooltip:
'Prefixes the route with the workspace ID (e.g., {base_url}/api/r/{workspace_id}/{route}). Note: deploying the HTTP trigger to another workspace updates the route workspace prefix accordingly.',
rightDocumentationLink:

View File

@@ -1,5 +1,11 @@
<script lang="ts">
import { HttpTriggerService, type EditHttpTrigger, type HttpTrigger, type NewHttpTrigger } from '$lib/gen'
import {
HttpTriggerService,
SettingService,
type EditHttpTrigger,
type HttpTrigger,
type NewHttpTrigger
} from '$lib/gen'
import { Pen, Save } from 'lucide-svelte'
import Button from '../../common/button/Button.svelte'
import ToggleButton from '../../common/toggleButton-v2/ToggleButton.svelte'
@@ -24,6 +30,19 @@
let { closeFn }: Props = $props()
let globalHttpWorkspacedRoute = $state(false)
async function loadGlobalHttpWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
globalHttpWorkspacedRoute = (setting as boolean) ?? false
} catch {
globalHttpWorkspacedRoute = false
}
}
loadGlobalHttpWorkspacedRouteSetting()
let routeEditor: RouteEditor
let routesGenerator: Drawer
@@ -288,7 +307,7 @@
<div>
<div class="text-primary">
{httpTrigger.http_method.toUpperCase()}
{isCloudHosted() || httpTrigger.workspaced_route
{isCloudHosted() || httpTrigger.workspaced_route || globalHttpWorkspacedRoute
? $workspaceStore! + '/' + httpTrigger.route_path
: httpTrigger.route_path}
</div>

View File

@@ -3,6 +3,7 @@
import {
HttpTriggerService,
SettingService,
WorkspaceService,
type HttpTrigger,
type TriggerMode,
@@ -71,6 +72,18 @@
let routesGenerator: RoutesGenerator | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let globalHttpWorkspacedRoute = $state(false)
async function loadGlobalHttpWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
globalHttpWorkspacedRoute = (setting as boolean) ?? false
} catch {
globalHttpWorkspacedRoute = false
}
}
loadGlobalHttpWorkspacedRouteSetting()
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
@@ -355,7 +368,7 @@
{summary}
{:else}
{http_method.toUpperCase()}
/{isCloudHosted() || workspaced_route
/{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute
? workspace_id + '/' + route_path
: route_path}
{/if}
@@ -399,7 +412,12 @@
<Button
on:click={() =>
copyToClipboard(
getHttpRoute('r', route_path, workspaced_route ?? false, workspace_id)
getHttpRoute(
'r',
route_path,
(workspaced_route ?? false) || globalHttpWorkspacedRoute,
workspace_id
)
)}
variant="subtle"
unifiedSize="md"