Files
windmill/backend/windmill-native-triggers/src/workspace_integrations.rs
hugocasa 4f998cc231 feat: add GitHub as a native trigger service (#8856)
* feat: add GitHub as a native trigger service

Add GitHub webhooks as a native trigger, allowing users to trigger
scripts/flows from repository events (push, PR, issues, etc.) via
OAuth-based webhook management.

Backend:
- DB migration adding 'github' to native_trigger_service, TRIGGER_KIND,
  and job_trigger_kind enums
- Full External trait implementation: create/update/delete/get webhooks,
  per-trigger sync verification, webhook payload preparation
- Paginated repos endpoint (up to 1000 repos)
- OAuth flow with admin:repo_hook and read:user scopes

Frontend:
- GitHub trigger form with repo picker and MultiSelect event selector
- Workspace integration settings with setup instructions
- Trigger badge, editor, and wrapper integration
- GithubIcon updated to support size/class props (matching other icons)
- Hub template reference for starter scripts

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

* fix: show GitHub in sidebar when triggers exist

Add github_used to the getUsedTriggers endpoint so the sidebar picks up
GitHub as an active trigger kind. Also document this step in the native-
trigger skill so future services don't miss it.

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

* feat: on-demand GitHub repo search instead of bulk fetch

Replace the upfront pagination through all repos with a debounced search
flow: load 30 most-recently-updated repos by default, then query GitHub's
/search/repositories API (scoped to the authenticated user via user:@me
and restricted to name matches via in:name) as the user types.

Frontend uses runed's Debounced + resource to wire the Select's filterText
to the backend query with 300ms debouncing.

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

* fix: request `repo` OAuth scope to list private GitHub repos

`admin:repo_hook` grants webhook management but not repo listing — so
/user/repos and /search/repositories returned only public repos. Switch
to `repo` (full repo scope, which is a superset and also covers webhook
management).

Users who already connected GitHub need to disconnect and reconnect to
pick up the broader scope.

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

* revert: fetch all GitHub repos upfront instead of searching on demand

Revert the debounced search flow — paginate through /user/repos (up to
1000) on form open. Simpler UX: repos are all there from the start, the
Select's built-in client-side filter handles finding one.

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

* fix: typed 404 detection + add GitHub flow template reference

Replace fragile e.to_string().contains("404") matching with a proper
http_error_status helper that downcasts through anyhow to the typed
HttpRequestError and reads the StatusCode.

Also wire the hub flow template (id 80) into NATIVE_TRIGGER_SERVICES.

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

* fix: update GitHub script template hub ID to 28202

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

* fix: align GitHub trigger with Nextcloud/Google patterns

Addresses review feedback from Claude and cubic.

Backend:
- `delete()` now only swallows NotFound (DB missing row) and 404 (API
  webhook already deleted); non-404/DB errors propagate so callers know
  cleanup failed. Matches Nextcloud's delete pattern exactly.
- `get_owner_repo_from_db` returns `Result<Option<(String, String)>>`
  instead of an error on missing row (matches Google's delete flow).

Frontend:
- `loading: boolean` (required) + `$bindable()` with no default — matches
  Nextcloud, satisfies CLAUDE.md banned-pattern rule.
- Wrap `loadRepos()` in `$effect` reacting to `$workspaceStore` so repos
  load once the store is available and refresh on workspace switch.
- Replace raw `fetch('/api/.../native_triggers/github/repos')` with the
  generated `NativeTriggerService.listGithubRepos(...)` typed client.
  Adds `/repos` route + `GithubRepoEntry` schema to openapi.yaml.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:01:55 +00:00

1046 lines
32 KiB
Rust

use axum::Router;
#[cfg(feature = "native_trigger")]
use axum::{
extract::Path,
routing::{delete, get, post},
Extension, Json,
};
#[cfg(feature = "native_trigger")]
use serde_json::to_value;
#[cfg(feature = "native_trigger")]
use serde::{de::DeserializeOwned, Deserialize, Serialize};
#[cfg(feature = "native_trigger")]
use sqlx::prelude::FromRow;
#[cfg(feature = "native_trigger")]
use windmill_audit::{audit_oss::audit_log, ActionKind};
#[cfg(feature = "native_trigger")]
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
global_settings::{load_value_from_global_settings, OAUTH_SETTING},
utils::{require_admin, HTTP_CLIENT},
variables::{build_crypt, encrypt},
DB,
};
#[cfg(feature = "native_trigger")]
use windmill_api_auth::ApiAuthed;
#[cfg(feature = "native_trigger")]
use crate::{
decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse,
resolve_endpoint, store_workspace_integration, ServiceName,
};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
#[cfg(feature = "native_trigger")]
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
#[cfg(feature = "native_trigger")]
use hmac::{Hmac, Mac};
#[cfg(feature = "native_trigger")]
use serde_json::json;
#[cfg(feature = "native_trigger")]
use sha2::Sha256;
#[cfg(feature = "native_trigger")]
type HmacSha256 = Hmac<Sha256>;
#[cfg(feature = "native_trigger")]
const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes
/// Generate a signed OAuth state that is cluster-safe.
/// The state contains: workspace_id, service_name, timestamp, and nonce.
/// It's signed with HMAC-SHA256 using the workspace key.
#[cfg(feature = "native_trigger")]
async fn generate_signed_state(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
use windmill_common::variables::get_workspace_key;
let nonce = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp();
let payload = format!(
"{}:{}:{}:{}",
workspace_id,
service_name.as_str(),
timestamp,
nonce
);
// Get workspace key for signing
let key = get_workspace_key(workspace_id, db).await?;
let mut mac = HmacSha256::new_from_slice(key.as_bytes())
.map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
// Encode as: base64(payload):base64(signature)
let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes());
let encoded_signature = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}:{}", encoded_payload, encoded_signature))
}
/// Validate a signed OAuth state.
/// Returns true if the state is valid (correct signature and not expired).
#[cfg(feature = "native_trigger")]
async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result<bool> {
use windmill_common::variables::get_workspace_key;
let parts: Vec<&str> = state.split(':').collect();
if parts.len() != 2 {
return Ok(false);
}
let encoded_payload = parts[0];
let encoded_signature = parts[1];
// Decode payload
let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
let payload = match String::from_utf8(payload_bytes) {
Ok(s) => s,
Err(_) => return Ok(false),
};
// Parse payload: workspace_id:service_name:timestamp:nonce
let payload_parts: Vec<&str> = payload.split(':').collect();
if payload_parts.len() != 4 {
return Ok(false);
}
let state_workspace_id = payload_parts[0];
let timestamp: i64 = match payload_parts[2].parse() {
Ok(ts) => ts,
Err(_) => return Ok(false),
};
// Verify workspace_id matches
if state_workspace_id != workspace_id {
return Ok(false);
}
// Check expiration
let now = chrono::Utc::now().timestamp();
if now - timestamp > STATE_EXPIRATION_SECONDS {
return Ok(false);
}
// Verify signature
let key = get_workspace_key(workspace_id, db).await?;
let mut mac = HmacSha256::new_from_slice(key.as_bytes())
.map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
Ok(mac.verify_slice(&received_signature).is_ok())
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Serialize)]
pub struct IntegrationStatusResponse {
pub connected: bool,
pub service_name: ServiceName,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_by: Option<String>,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Serialize)]
pub struct ListIntegrationsResponse {
pub integrations: Vec<IntegrationStatusResponse>,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Serialize)]
pub struct ConnectIntegrationResponse {
pub auth_url: String,
}
#[cfg(feature = "native_trigger")]
#[derive(FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceOAuthConfig {
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub client_secret: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub instance_shared: bool,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Serialize)]
pub struct OAuthConfigResponse {
pub configured: bool,
pub base_url: Option<String>,
pub redirect_uri: Option<String>,
}
#[cfg(feature = "native_trigger")]
async fn generate_connect_url(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let oauth_config =
get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?;
// Generate a signed state that is cluster-safe
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
let auth_url = build_authorization_url(&oauth_config, service_name, &state, &redirect_uri);
Ok(Json(auth_url))
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct BasicOAuthData {
base_url: String,
access_token: String,
}
#[cfg(feature = "native_trigger")]
async fn try_delete_nextcloud_webhook(base_url: &str, access_token: &str, external_id: &str) {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
base_url, external_id
);
let _ = HTTP_CLIENT
.delete(&url)
.bearer_auth(access_token)
.header("OCS-APIRequest", "true")
.send()
.await;
}
#[cfg(feature = "native_trigger")]
async fn try_delete_github_webhook(
db: &DB,
workspace_id: &str,
access_token: &str,
external_id: &str,
) {
// Fetch owner/repo from service_config
let config = sqlx::query_scalar!(
"SELECT service_config FROM native_trigger WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3",
external_id,
ServiceName::Github as ServiceName,
workspace_id
)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten();
if let Some(config) = config {
let owner = config.get("owner").and_then(|v| v.as_str()).unwrap_or("");
let repo = config.get("repo").and_then(|v| v.as_str()).unwrap_or("");
if !owner.is_empty() && !repo.is_empty() {
let url = format!(
"https://api.github.com/repos/{}/{}/hooks/{}",
owner, repo, external_id
);
let _ = HTTP_CLIENT
.delete(&url)
.bearer_auth(access_token)
.header("Accept", "application/json")
.header("User-Agent", "Windmill")
.send()
.await;
}
}
}
#[cfg(feature = "native_trigger")]
async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow::Result<String> {
let url = format!("{}/ocs/v2.php/cloud/user", base_url);
let resp = HTTP_CLIENT
.get(&url)
.bearer_auth(access_token)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.send()
.await?
.error_for_status()?;
let ocs: OcsResponse<NextcloudUserData> = resp.json().await?;
Ok(ocs.ocs.data.id)
}
/// Delete all native triggers for a workspace+service, including remote webhook cleanup.
/// This is best-effort: errors during remote cleanup or token deletion are logged but ignored.
#[cfg(feature = "native_trigger")]
async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) {
let triggers = sqlx::query!(
"SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
workspace_id,
service_name as ServiceName
)
.fetch_all(db)
.await;
let triggers = match triggers {
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to fetch native triggers for service {service_name:?} in workspace {workspace_id}: {e}");
return;
}
};
if triggers.is_empty() {
return;
}
// For Nextcloud: try to delete webhooks on the remote instance (best-effort)
if service_name == ServiceName::Nextcloud {
if let Ok(oauth_data) =
decrypt_oauth_data::<BasicOAuthData>(db, workspace_id, service_name).await
{
for trigger in &triggers {
try_delete_nextcloud_webhook(
&oauth_data.base_url,
&oauth_data.access_token,
&trigger.external_id,
)
.await;
}
}
}
// For GitHub: try to delete webhooks on GitHub (best-effort)
if service_name == ServiceName::Github {
if let Ok(oauth_data) =
decrypt_oauth_data::<BasicOAuthData>(db, workspace_id, service_name).await
{
for trigger in &triggers {
try_delete_github_webhook(
db,
workspace_id,
&oauth_data.access_token,
&trigger.external_id,
)
.await;
}
}
}
// For Google: skip remote cleanup (watch channels expire naturally)
// Bulk delete all triggers
if let Err(e) = sqlx::query!(
"DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
workspace_id,
service_name as ServiceName
)
.execute(db)
.await
{
tracing::error!("Failed to delete native triggers for service {service_name:?} in workspace {workspace_id}: {e}");
}
// Delete all associated webhook tokens
for trigger in &triggers {
if let Err(e) = delete_token_by_hash(db, &trigger.webhook_token_hash).await {
tracing::error!(
"Failed to delete webhook token with hash {}: {e}",
trigger.webhook_token_hash
);
}
}
}
#[cfg(feature = "native_trigger")]
async fn delete_integration(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
// Delete triggers first (needs OAuth data that cleanup_oauth_resource will remove)
delete_triggers_for_service(&db, &workspace_id, service_name).await;
let mut tx = user_db.begin(&authed).await?;
// Clean up account+variable+resource
cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await;
let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
if !deleted {
return Err(Error::NotFound(format!(
"{} integration not found for workspace",
service_name
)));
}
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.disconnect", service_name),
ActionKind::Delete,
&workspace_id,
Some(&format!("Disconnected {} integration", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration disconnected successfully",
service_name
)))
}
#[cfg(feature = "native_trigger")]
#[derive(FromRow, Debug, Deserialize, Serialize)]
struct WorkspaceIntegrations {
service_name: ServiceName,
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
resource_path: Option<String>,
}
#[cfg(feature = "native_trigger")]
async fn list_integrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<WorkspaceIntegrations>> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = db.begin().await?;
let integrations = sqlx::query_as!(
WorkspaceIntegrations,
r#"
SELECT
oauth_data as "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName",
resource_path
FROM
workspace_integrations
WHERE
workspace_id = $1
"#,
workspace_id
)
.fetch_all(&mut *tx)
.await?;
let key_value = integrations
.into_iter()
.map(|integration| {
(
integration.service_name,
(integration.oauth_data, integration.resource_path),
)
})
.collect::<std::collections::HashMap<_, _>>();
use strum::IntoEnumIterator;
let integrations = ServiceName::iter()
.map(|service_name| {
let (oauth_data, resource_path) = key_value
.get(&service_name)
.cloned()
.map(|(od, rp)| (od, rp))
.unwrap_or((None, None));
WorkspaceIntegrations { service_name, oauth_data, resource_path }
})
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(integrations))
}
#[cfg(feature = "native_trigger")]
async fn integration_exist(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1
FROM workspace_integrations wi
WHERE wi.workspace_id = $1
AND wi.service_name = $2
AND wi.oauth_data IS NOT NULL
)
"#,
workspace_id,
service_name as ServiceName
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct RedirectUri {
redirect_uri: String,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct OAuthCallbackBody {
redirect_uri: String,
code: String,
state: String,
resource_path: Option<String>,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct NextcloudUserData {
id: String,
}
#[cfg(feature = "native_trigger")]
async fn oauth_callback(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(body): Json<OAuthCallbackBody>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).await?;
if !state_was_valid {
return Err(Error::BadRequest(
"Invalid or expired state parameter".to_string(),
));
}
// Check if this integration uses instance-shared credentials
let existing_oauth_data = sqlx::query_scalar!(
r#"SELECT oauth_data FROM workspace_integrations
WHERE workspace_id = $1 AND service_name = $2"#,
workspace_id,
service_name as ServiceName
)
.fetch_optional(&db)
.await?
.flatten();
let is_instance_shared = existing_oauth_data
.as_ref()
.and_then(|v| v.get("instance_shared"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let oauth_config = if is_instance_shared {
get_instance_oauth_config(&db, service_name).await?
} else {
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name).await?
};
let token_response =
exchange_code_for_token(&oauth_config, service_name, &body.code, &body.redirect_uri)
.await?;
let resource_path = body
.resource_path
.filter(|p| !p.is_empty())
.unwrap_or_else(|| {
format!(
"u/{}/native_{}",
authed.username,
service_name.resource_type()
)
});
let expires_in = token_response.expires_in.unwrap_or(3600);
let mut tx = user_db.begin(&authed).await?;
// Clean up any previous account+variable+resource for this integration
cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await;
// 1. Create account record for token refresh
let account_id = sqlx::query_scalar!(
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, true)
RETURNING id",
workspace_id,
service_name.as_str(),
expires_in.to_string(),
token_response.refresh_token.as_deref().unwrap_or(""),
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::InternalErr(format!("Failed to create account: {}", e)))?;
// 2. Create variable with encrypted access token
let mc = build_crypt(&db, &workspace_id).await?;
let encrypted_access_token = encrypt(&mc, &token_response.access_token);
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
VALUES ($1, $2, $3, true, $4, $5, true)
ON CONFLICT (workspace_id, path) DO UPDATE
SET value = EXCLUDED.value, account = EXCLUDED.account",
workspace_id,
resource_path,
encrypted_access_token,
format!("OAuth token for {} workspace integration", service_name),
account_id,
)
.execute(&mut *tx)
.await
.map_err(|e| Error::InternalErr(format!("Failed to create variable: {}", e)))?;
// 3. Create resource pointing to the variable
let resource_value = if service_name == ServiceName::Nextcloud {
let token_value = format!("$var:{}", resource_path);
let base_url = &oauth_config.base_url;
let user_id = fetch_nextcloud_user_id(base_url, &token_response.access_token).await;
match user_id {
Ok(user_id) => json!({
"token": token_value,
"baseUrl": base_url,
"userId": user_id,
}),
Err(e) => {
tracing::warn!("Failed to fetch Nextcloud user info: {e}");
json!({
"token": token_value,
"baseUrl": base_url,
})
}
}
} else {
json!({ "token": format!("$var:{}", resource_path) })
};
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, path) DO UPDATE
SET value = EXCLUDED.value, resource_type = EXCLUDED.resource_type",
workspace_id,
resource_path,
resource_value,
service_name.resource_type(),
format!("{} workspace integration", service_name),
authed.username,
)
.execute(&mut *tx)
.await
.map_err(|e| Error::InternalErr(format!("Failed to create resource: {}", e)))?;
// 4. Store config + resource_path in workspace_integrations (no tokens).
// For instance-shared integrations, store the flag instead of credentials.
let stored_data = if is_instance_shared {
json!({
"instance_shared": true,
"base_url": "",
})
} else {
to_value(&oauth_config).unwrap()
};
store_workspace_integration(
&mut *tx,
&authed,
&workspace_id,
service_name,
stored_data,
Some(&resource_path),
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.connect", service_name),
ActionKind::Create,
&workspace_id,
Some(&format!("Connected {} integration via OAuth", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration connected successfully via OAuth",
service_name
)))
}
/// Token response from OAuth token exchange
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
/// Build an OAuth client for native trigger services using windmill-oauth.
#[cfg(feature = "native_trigger")]
fn build_native_oauth_client(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
redirect_uri: &str,
) -> Result<OClient> {
let auth_url = Url::parse(&resolve_endpoint(
&config.base_url,
service_name.auth_endpoint(),
))
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
let token_url = Url::parse(&resolve_endpoint(
&config.base_url,
service_name.token_endpoint(),
))
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
let redirect = Url::parse(redirect_uri).map_err(|e| {
Error::BadRequest(format!(
"Invalid redirect URI '{}': {}. The redirect URI must be an absolute URL (e.g., https://example.com/callback)",
redirect_uri, e
))
})?;
let mut client = OClient::new(config.client_id.clone(), auth_url, token_url);
client.set_client_secret(config.client_secret.clone());
client.set_redirect_url(redirect);
Ok(client)
}
/// Exchange authorization code for tokens using windmill-oauth.
#[cfg(feature = "native_trigger")]
async fn exchange_code_for_token(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
code: &str,
redirect_uri: &str,
) -> Result<TokenResponse> {
let client = build_native_oauth_client(config, service_name, redirect_uri)?;
let token_response: TokenResponse = client
.exchange_code(code.to_string())
.with_client(&*OAUTH_HTTP_CLIENT)
.execute()
.await
.map_err(|e| Error::InternalErr(format!("Failed to exchange code for token: {:?}", e)))?;
Ok(token_response)
}
#[cfg(feature = "native_trigger")]
async fn get_workspace_oauth_config<T: DeserializeOwned>(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let oauth_data = sqlx::query_scalar!(
r#"
SELECT
oauth_data
FROM
workspace_integrations
WHERE
workspace_id = $1 AND
service_name = $2
"#,
workspace_id,
service_name as ServiceName
)
.fetch_optional(db)
.await?
.flatten()
.ok_or(Error::NotFound(format!(
"Integration for service {} not found",
service_name.as_str()
)))?;
serde_json::from_value::<T>(oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))
}
#[cfg(feature = "native_trigger")]
pub async fn create_workspace_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(oauth_data): Json<WorkspaceOAuthConfig>,
) -> Result<()> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
crate::store_workspace_integration(
&mut tx,
&authed,
&workspace_id,
service_name,
to_value(oauth_data).unwrap(),
None,
)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(feature = "native_trigger")]
#[inline]
async fn get_workspace_oauth_config_as_oauth_config(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<WorkspaceOAuthConfig> {
get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name).await
}
#[cfg(feature = "native_trigger")]
fn build_authorization_url(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
state: &str,
redirect_uri: &str,
) -> String {
let base_auth_url = resolve_endpoint(&config.base_url, service_name.auth_endpoint());
let mut params = vec![
("response_type", "code"),
("client_id", config.client_id.as_str()),
("redirect_uri", redirect_uri),
("state", state),
("scope", service_name.oauth_scopes()),
];
for &(key, value) in service_name.extra_auth_params() {
params.push((key, value));
}
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}?{}", base_auth_url, query_string)
}
#[cfg(feature = "native_trigger")]
pub async fn cleanup_oauth_resource(
tx: &mut sqlx::PgConnection,
workspace_id: &str,
service_name: ServiceName,
) {
// Look up the stored resource_path from workspace_integrations
let stored_resource_path: Option<String> = sqlx::query_scalar!(
r#"SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2"#,
workspace_id,
service_name as ServiceName,
)
.fetch_optional(&mut *tx)
.await
.ok()
.flatten()
.flatten();
// Find and delete any existing account+variable+resource for this integration
let account_ids: Vec<i32> = sqlx::query_scalar!(
"DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id",
workspace_id,
service_name.as_str(),
)
.fetch_all(&mut *tx)
.await
.unwrap_or_default();
if !account_ids.is_empty() {
// Delete variables linked to these accounts
let _ = sqlx::query!(
"DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)",
workspace_id,
&account_ids,
)
.execute(&mut *tx)
.await;
}
// Delete resource by exact stored path, or fall back to legacy pattern
let resource_type = service_name.resource_type();
if let Some(ref path) = stored_resource_path {
let _ = sqlx::query!(
"DELETE FROM resource WHERE workspace_id = $1 AND path = $2",
workspace_id,
path,
)
.execute(&mut *tx)
.await;
} else {
// Legacy fallback for integrations created before user-chosen paths
let _ = sqlx::query!(
"DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'",
workspace_id,
resource_type,
)
.execute(&mut *tx)
.await;
}
}
/// Check if the instance admin has enabled sharing of OAuth credentials for a given service.
/// Currently only supported for Google (gworkspace).
#[cfg(feature = "native_trigger")]
async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Result<bool> {
// Only Google supports instance sharing for now
if service_name != ServiceName::Google {
return Ok(false);
}
let oauths_value = match load_value_from_global_settings(db, OAUTH_SETTING).await? {
Some(v) => v,
None => return Ok(false),
};
let key = service_name.resource_type(); // "gworkspace"
let entry = match oauths_value.get(key) {
Some(v) => v,
None => return Ok(false),
};
let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
let secret = entry.get("secret").and_then(|v| v.as_str()).unwrap_or("");
let share = entry
.get("share_with_workspaces")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(!id.is_empty() && !secret.is_empty() && share)
}
/// Read instance-level OAuth credentials for a service (when sharing is enabled).
#[cfg(feature = "native_trigger")]
async fn get_instance_oauth_config(
db: &DB,
service_name: ServiceName,
) -> Result<WorkspaceOAuthConfig> {
if !is_instance_sharing_enabled(db, service_name).await? {
return Err(Error::BadRequest(
"Instance credential sharing is not enabled for this service".to_string(),
));
}
let (client_id, client_secret) =
windmill_common::global_settings::get_instance_oauth_credentials(
db,
service_name.resource_type(),
)
.await?;
Ok(WorkspaceOAuthConfig {
client_id,
client_secret,
base_url: String::new(), // Google uses absolute URLs
instance_shared: false,
})
}
#[cfg(feature = "native_trigger")]
async fn check_instance_sharing_available(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<bool> {
require_admin(authed.is_admin, &workspace_id)?;
let available = is_instance_sharing_enabled(&db, service_name).await?;
Ok(Json(available))
}
#[cfg(feature = "native_trigger")]
async fn generate_instance_connect_url(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let instance_config = get_instance_oauth_config(&db, service_name).await?;
// Store a marker in workspace_integrations — NOT the actual credentials.
// The callback and token refresh will read credentials from global settings
// when they see instance_shared=true.
let mut tx = user_db.begin(&authed).await?;
crate::store_workspace_integration(
&mut tx,
&authed,
&workspace_id,
service_name,
json!({ "instance_shared": true, "base_url": "" }),
None,
)
.await?;
tx.commit().await?;
// Generate signed state and build authorization URL
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
let auth_url = build_authorization_url(&instance_config, service_name, &state, &redirect_uri);
Ok(Json(auth_url))
}
#[cfg(feature = "native_trigger")]
pub fn workspaced_service() -> Router {
let router = Router::new()
.route("/list", get(list_integrations))
.route("/{service_name}/exists", get(integration_exist))
.route("/{service_name}/create", post(create_workspace_integration))
.route(
"/{service_name}/generate_connect_url",
post(generate_connect_url),
)
.route(
"/{service_name}/instance_sharing_available",
get(check_instance_sharing_available),
)
.route(
"/{service_name}/generate_instance_connect_url",
post(generate_instance_connect_url),
)
.route("/{service_name}/delete", delete(delete_integration))
.route("/{service_name}/callback", post(oauth_callback));
Router::new().nest("/integrations", router)
}
#[cfg(not(feature = "native_trigger"))]
pub fn workspaced_service() -> Router {
Router::new()
}