mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
[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:
+15
@@ -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 @@
|
||||
2ba6a2a75b6fc97858b306b2c98ada481e363c10
|
||||
57199e6e8890a115882d5b683f5a65a13733ca9d
|
||||
|
||||
+9
-8
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}
|
||||
<ScimOauthSettings />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { SettingService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { Copy, KeyRound, RefreshCw } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
|
||||
let loading = $state(true)
|
||||
let enabled = $state(false)
|
||||
let clientId = $state<string | undefined>(undefined)
|
||||
let tokenEndpoint = $state('')
|
||||
let generating = $state(false)
|
||||
// Plaintext secret returned once by the generate call — never re-fetchable.
|
||||
let newSecret = $state<string | undefined>(undefined)
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
try {
|
||||
const res = await SettingService.getScimOauthConfig()
|
||||
enabled = res.enabled
|
||||
clientId = res.client_id
|
||||
tokenEndpoint = res.token_endpoint
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to load SCIM OAuth config: ${e}`, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
generating = true
|
||||
try {
|
||||
const res = await SettingService.generateScimOauthSecret({
|
||||
requestBody: clientId ? { client_id: clientId } : {}
|
||||
})
|
||||
clientId = res.client_id
|
||||
tokenEndpoint = res.token_endpoint
|
||||
newSecret = res.client_secret
|
||||
enabled = true
|
||||
sendUserToast('Client secret generated — copy it now, it will not be shown again')
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to generate client secret: ${e}`, true)
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(target: boolean) {
|
||||
if (target) {
|
||||
await generate()
|
||||
} else {
|
||||
try {
|
||||
await SettingService.disableScimOauthConfig()
|
||||
enabled = false
|
||||
clientId = undefined
|
||||
newSecret = undefined
|
||||
sendUserToast('OAuth client credentials disabled')
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to disable OAuth client credentials: ${e}`, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-primary">OAuth 2.0 client credentials</span>
|
||||
<span class="text-xs text-secondary max-w-lg">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
disabled={loading || generating}
|
||||
checked={enabled}
|
||||
on:change={(e) => toggle(e.detail)}
|
||||
options={{ right: 'Enabled' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if enabled}
|
||||
<div class="flex flex-col gap-3 border rounded-md p-3 bg-surface-secondary">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xs font-semibold text-secondary uppercase">Token endpoint</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs break-all grow">{tokenEndpoint}</code>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
startIcon={{ icon: Copy }}
|
||||
iconOnly
|
||||
on:click={() => copyToClipboard(tokenEndpoint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xs font-semibold text-secondary uppercase">Client ID</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs break-all grow">{clientId ?? ''}</code>
|
||||
{#if clientId}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
startIcon={{ icon: Copy }}
|
||||
iconOnly
|
||||
on:click={() => copyToClipboard(clientId)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if newSecret}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xs font-semibold text-secondary uppercase">Client secret</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs break-all grow text-primary">{newSecret}</code>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="dark"
|
||||
startIcon={{ icon: Copy }}
|
||||
iconOnly
|
||||
on:click={() => copyToClipboard(newSecret)}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-2xs text-orange-600 dark:text-orange-400">
|
||||
Copy this secret now — it will not be shown again. Regenerate to get a new one.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
disabled={generating}
|
||||
on:click={generate}
|
||||
>
|
||||
Regenerate secret
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !loading}
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="xs"
|
||||
color="dark"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
disabled={generating}
|
||||
on:click={generate}
|
||||
>
|
||||
Generate client credentials
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user