diff --git a/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json b/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json new file mode 100644 index 0000000000..d87d3aeba5 --- /dev/null +++ b/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 589a683dba..7328560bbe 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2ba6a2a75b6fc97858b306b2c98ada481e363c10 +57199e6e8890a115882d5b683f5a65a13733ca9d diff --git a/backend/src/main.rs b/backend/src/main.rs index 7080ae0c13..0214b38fc4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -59,12 +59,12 @@ use windmill_common::{ RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, - SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, - SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, - UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, - WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, - WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, - WORKSPACE_REGISTRIES_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_OAUTH_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, + TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, + WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -140,8 +140,8 @@ use crate::monitor::{ reload_pip_index_url_setting, reload_retention_period_setting, reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting, reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting, - reload_sandbox_registry_auth_setting, reload_scim_token_setting, reload_smtp_config, - reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_sandbox_registry_auth_setting, reload_scim_oauth_setting, reload_scim_token_setting, + reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1917,6 +1917,7 @@ async fn process_notify_event( } } SCIM_TOKEN_SETTING => reload_scim_token_setting(conn).await, + SCIM_OAUTH_SETTING => reload_scim_oauth_setting(conn).await, EXTRA_PIP_INDEX_URL_SETTING => reload_extra_pip_index_url_setting(conn).await, PIP_INDEX_URL_SETTING => reload_pip_index_url_setting(conn).await, UV_INDEX_STRATEGY_SETTING => reload_uv_index_strategy_setting(conn).await, diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 6632e3b3a8..ac1a4d4fc5 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -26,7 +26,7 @@ use uuid::Uuid; use windmill_api::embeddings::update_embeddings_db; use windmill_api::{ jobs::TIMEOUT_WAIT_RESULT, DEFAULT_BODY_LIMIT, IS_SECURE, REQUEST_SIZE_LIMIT, SAML_METADATA, - SCIM_TOKEN, + SCIM_OAUTH_CONFIG, SCIM_TOKEN, }; #[cfg(feature = "native_trigger")] @@ -68,9 +68,9 @@ use windmill_common::{ REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, - SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, - STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, - UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_OAUTH_SETTING, + SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, }, @@ -415,6 +415,7 @@ pub async fn initial_load( reload_request_size(&conn).await; reload_saml_metadata_setting(&conn).await; reload_scim_token_setting(&conn).await; + reload_scim_oauth_setting(&conn).await; // Ensure audit partitions exist before any requests arrive if let Some(db) = conn.as_sql() { @@ -2081,6 +2082,16 @@ pub async fn reload_scim_token_setting(conn: &Connection) { .await; } +pub async fn reload_scim_oauth_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SCIM_OAUTH_SETTING, + "SCIM_OAUTH", + SCIM_OAUTH_CONFIG.clone(), + ) + .await; +} + pub async fn reload_timeout_wait_result_setting(conn: &Connection) { reload_option_setting_with_tracing( conn, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c4e68526fa..aee34ab1f9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1475,6 +1475,74 @@ paths: schema: type: string + /scim_token/config: + get: + summary: get SCIM OAuth client-credentials config status + operationId: getScimOauthConfig + tags: + - setting + responses: + "200": + description: SCIM OAuth config status (never returns the secret) + content: + application/json: + schema: + type: object + required: + - enabled + - token_endpoint + properties: + enabled: + type: boolean + client_id: + type: string + token_endpoint: + type: string + delete: + summary: disable SCIM OAuth client-credentials + operationId: disableScimOauthConfig + tags: + - setting + responses: + "204": + description: disabled + + /scim_token/config/generate: + post: + summary: generate or rotate the SCIM OAuth client secret + operationId: generateScimOauthSecret + tags: + - setting + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + client_id: + type: string + token_ttl_secs: + type: integer + responses: + "200": + description: generated credentials, client_secret returned once + content: + application/json: + schema: + type: object + required: + - client_id + - client_secret + - token_endpoint + properties: + client_id: + type: string + client_secret: + type: string + token_endpoint: + type: string + /settings_u/ruff_config: get: summary: get instance ruff config (unauthenticated) diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index b4f3482ac1..95745ee9a5 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -203,6 +203,10 @@ lazy_static::lazy_static! { pub static ref REQUEST_SIZE_LIMIT: Arc> = Arc::new(RwLock::new(DEFAULT_BODY_LIMIT)); pub static ref SCIM_TOKEN: Arc>> = Arc::new(RwLock::new(None)); + // Raw JSON of the SCIM OAuth2 client-credentials config (client_id + + // hashed client_secret + optional token TTL). Parsed lazily at the token + // endpoint; `None` disables the OAuth grant entirely (see has_scim_token). + pub static ref SCIM_OAUTH_CONFIG: Arc>> = Arc::new(RwLock::new(None)); pub static ref SAML_METADATA: Arc>> = Arc::new(RwLock::new(None)); @@ -748,6 +752,10 @@ pub async fn run_server( scim_oss::global_service() .route_layer(axum::middleware::from_fn(has_scim_token)), ) + // Unauthenticated: the OAuth2 client-credentials token endpoint + // authenticates itself via client_id/client_secret, so it must + // NOT sit behind has_scim_token. + .nest("/scim_token", scim_oss::token_service()) .nest("/tokens", token::global_service()) .nest("/concurrency_groups", concurrency_groups::global_service()) .nest("/scripts_u", scripts::global_unauthed_service()) diff --git a/backend/windmill-api/src/scim_oss.rs b/backend/windmill-api/src/scim_oss.rs index 845c854960..d623699e67 100644 --- a/backend/windmill-api/src/scim_oss.rs +++ b/backend/windmill-api/src/scim_oss.rs @@ -18,6 +18,11 @@ pub fn global_service() -> Router { Router::new().route("/ee", get(ee)) } +#[cfg(not(feature = "private"))] +pub fn token_service() -> Router { + Router::new().route("/ee", get(ee)) +} + #[cfg(not(feature = "private"))] pub async fn ee() -> String { return "Enterprise Edition".to_string(); diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 0ee326ce6b..dde10327f0 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -52,6 +52,7 @@ pub const BUN_INSTALL_MIN_RELEASE_AGE_SETTING: &str = "bun_install_min_release_a pub const INSTANCE_PYTHON_VERSION_SETTING: &str = "instance_python_version"; pub const RUFF_CONFIG_SETTING: &str = "ruff_config"; pub const SCIM_TOKEN_SETTING: &str = "scim_token"; +pub const SCIM_OAUTH_SETTING: &str = "scim_oauth"; pub const SAML_METADATA_SETTING: &str = "saml_metadata"; pub const SMTP_SETTING: &str = "smtp_settings"; pub const TEAMS_SETTING: &str = "teams"; @@ -142,6 +143,7 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[ OAUTH_SETTING, SMTP_SETTING, SCIM_TOKEN_SETTING, + SCIM_OAUTH_SETTING, SAML_METADATA_SETTING, SECRET_BACKEND_SETTING, GITHUB_ENTERPRISE_APP_SETTING, @@ -360,6 +362,7 @@ mod tests { OAUTH_SETTING, SMTP_SETTING, SCIM_TOKEN_SETTING, + SCIM_OAUTH_SETTING, SAML_METADATA_SETTING, SECRET_BACKEND_SETTING, GITHUB_ENTERPRISE_APP_SETTING, diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index b3d0ee1446..aeb7624c27 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -13,6 +13,7 @@ import { createEventDispatcher } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' import AuthSettings from './AuthSettings.svelte' + import ScimOauthSettings from './ScimOauthSettings.svelte' import oauthConnectRegistry from '$oauth_connect_registry' import InstanceSetting from './InstanceSetting.svelte' import { writable, type Writable } from 'svelte/store' @@ -1171,6 +1172,9 @@ {version} {oauths} /> + {#if setting.key === 'scim_token'} + + {/if} {/each} {/snippet} diff --git a/frontend/src/lib/components/ScimOauthSettings.svelte b/frontend/src/lib/components/ScimOauthSettings.svelte new file mode 100644 index 0000000000..0a48367744 --- /dev/null +++ b/frontend/src/lib/components/ScimOauthSettings.svelte @@ -0,0 +1,166 @@ + + +
+
+
+ OAuth 2.0 client credentials + + Let an identity provider (e.g. Microsoft Entra ID) obtain short-lived access tokens via the + OAuth 2.0 client-credentials grant instead of a static bearer token. Both methods stay + valid. + +
+ toggle(e.detail)} + options={{ right: 'Enabled' }} + /> +
+ + {#if enabled} +
+
+ Token endpoint +
+ {tokenEndpoint} +
+
+ +
+ Client ID +
+ {clientId ?? ''} + {#if clientId} +
+
+ + {#if newSecret} +
+ Client secret +
+ {newSecret} +
+ + Copy this secret now — it will not be shown again. Regenerate to get a new one. + +
+ {/if} + +
+ +
+
+ {:else if !loading} +
+ +
+ {/if} +