mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
4f998cc231
* 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>
1046 lines
32 KiB
Rust
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()
|
|
}
|