[ee] feat(scim): OAuth2 client-credentials grant for SCIM provisioning

Support OAuth 2.0 client-credentials authentication for SCIM provisioning
(e.g. Microsoft Entra ID) alongside the existing static bearer token, so
identity providers can use short-lived, rotatable access tokens.

Backend (EE companion PR modifies scim_ee.rs):
- Unauthenticated token endpoint POST /api/scim_token/token issues a
  short-lived scope:scim JWT via the client-credentials grant.
- has_scim_token validates SCIM JWTs (when OAuth is configured) in addition
  to the static token — fully backward compatible.
- Super-admin config endpoints (generate/rotate/disable) store the client
  secret hashed (SHA-256). New scim_oauth global setting is agent-worker
  blocked and live-reloaded.

Frontend:
- OAuth 2.0 client-credentials section in the SCIM/SAML instance settings:
  enable toggle, generate-secret (shown once), copyable token endpoint and
  client ID, regenerate/disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-07-14 10:36:08 +00:00
co-authored by Claude Opus 4.8
parent 710a13a59d
commit a89fcf72fa
10 changed files with 294 additions and 13 deletions
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
2ba6a2a75b6fc97858b306b2c98ada481e363c10
57199e6e8890a115882d5b683f5a65a13733ca9d
+9 -8
View File
@@ -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,
+15 -4
View File
@@ -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,
+68
View File
@@ -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)
+8
View File
@@ -203,6 +203,10 @@ lazy_static::lazy_static! {
pub static ref REQUEST_SIZE_LIMIT: Arc<RwLock<usize>> = Arc::new(RwLock::new(DEFAULT_BODY_LIMIT));
pub static ref SCIM_TOKEN: Arc<RwLock<Option<String>>> = 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<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
pub static ref SAML_METADATA: Arc<RwLock<Option<String>>> = 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())
+5
View File
@@ -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();
@@ -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,