mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
* fix: only let an oauth account's creator or readers link it to a variable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop an oauth account link the caller may not hold instead of failing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover relinking an oauth account through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover a permitted relink through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an oauth account's creator in step with the user's username Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: follow a non-member superadmin's instance username on the OAuth accounts they created Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: link a variable to its OAuth account only in the connect flow The variable endpoints no longer take an account link from the caller. The connect flows create the token variable first and pass its path to create_account, which links it server-side. This replaces the creator check and the created_by bookkeeping, along with both migrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: keep the MCP secret variable test's formatting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link the account to the token variable AppConnect created, even if the path is edited mid-save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop exporting the OAuth account a variable links, and regenerate the chat tool schema Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: pin the EE refresh and linking checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete AppConnect's token variable when linking its account fails, so Save can be retried Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the resource AppConnect fills when linking its account fails Deleting a variable also deletes the resource at its path, so the rollback is skipped when filling an existing resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: create an OAuth token variable and its account in one request createAccount takes the variable and the store creates both in one transaction, so a connect is either fully saved or not at all. The connect flows make that single call instead of creating the variable and linking it afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop listing the ignored account input in the chat's write_variable instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: drop the account visibility helper the oauth refresh route no longer uses Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let native triggers act through per-user or shared connections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bind native trigger oauth state to its user and follow connection renames Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: authorize oauth account links and scope native trigger disconnects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: order the native trigger connection migration after account ownership Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete only the scope-checked triggers when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: skip triggers moved since their scope check when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope-check the connection path when connecting, keep a connection still in use Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key token write-back by account and serialize trigger creation with disconnect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let an admin share one native trigger connection with the whole workspace Members can use the shared connection without reading its token. It reuses workspace_integrations.resource_path, so existing workspace connections stay usable by members after upgrading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: label the disconnect action in the connection picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop the own-account note from the workspace connection field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let admins share any connection, serialize service removal with trigger creation An admin can already read every connection, so restricting sharing to their own added nothing. Removing a service now takes each connection's lock and keeps everything if a trigger was created meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show each native trigger service's OAuth app status and keep removal apart Remove now only appears with a workspace OAuth app, since a workspace using the instance app has nothing of its own to remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: configure a native trigger service's OAuth app in a dialog The card states once which OAuth app is in use, with a single Configure action, and the form follows the design system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a stray brace from the OAuth app dialog binding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: lead the OAuth app row with which app is in use, with a link to change it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: compact the native trigger service card Edit and Remove sit on the workspace OAuth app row, and the workspace connection reads as part of the same section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show the OAuth app status in each native trigger card's header App actions move into a menu, the card body holds only the shared account, and service descriptions name what fires the trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say who configured each native trigger service, and name removal for what it removes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: say what members can do with each native trigger service, keep the shared account optional Nextcloud, whose webhooks need an admin account, offers connecting and sharing one directly instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pick the shared account with an inline select, and keep Nextcloud's own-account path in its wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: one status line per native trigger service, a Nextcloud admin hint, and clearer refusals The shared account is a regular form field, Nextcloud shows an alert while no admin account is shared, a non-admin Nextcloud account gets a message saying why it cannot create triggers, and the OAuth dialog only accepts a full server URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: point native trigger error hints at the account picker and the shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say members can browse what a shared native trigger account can access Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: make the OAuth app dialog's open prop required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep pre-connection triggers working, and guard reconnects and service removal - The migration recovers a service's connection when the workspace row lost its path, which saving the OAuth app or an abandoned instance connect used to do. - A trigger created without a connection uses the shared account, as older clients and trigger specs expect. - Only whoever connected a path, or an admin, may reconnect over it. - Removing a service or sharing an account checks a path-scoped token against what it touches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: connect native trigger accounts at a chosen path through the resource drawer, and reconnect from the picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say a disconnect deletes the triggers other members created on the account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer Reconnect next to an existing trigger's account and point the refresh hints at it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to da49c12e3006fc77de789277f1462a4d8d7d92ce This commit updates the EE repository reference after PR #829 was merged in windmill-ee-private. Previous ee-repo-ref: 58681a2e7eafb5689ed739cd2560fa0f5d82c3dc New ee-repo-ref: da49c12e3006fc77de789277f1462a4d8d7d92ce Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
846 lines
26 KiB
Rust
846 lines
26 KiB
Rust
use crate::{
|
|
classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_token_by_hash,
|
|
get_native_trigger, list_native_triggers, list_usable_connections, lock::TriggerLock,
|
|
lock_connection, map_external_error, map_external_error_with, resolve_usable_connection,
|
|
rotate_webhook_token, set_native_trigger_enabled, store_native_trigger,
|
|
sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error,
|
|
update_native_trigger_if_runnable_unchanged, webhook_token_label, webhook_token_scopes,
|
|
External, ExternalReadFailure, NativeTrigger, NativeTriggerConfig, NativeTriggerData,
|
|
ServiceName,
|
|
};
|
|
use axum::{
|
|
extract::{Path, Query},
|
|
routing::{delete, get, post},
|
|
Extension, Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgConnection;
|
|
use std::sync::Arc;
|
|
use windmill_api_auth::{
|
|
build_scope_path_filter, check_scopes, create_token_internal, require_is_writer, ApiAuthed,
|
|
NewToken,
|
|
};
|
|
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, JsonResult, Result},
|
|
DB,
|
|
};
|
|
|
|
async fn require_is_writer_on_runnable(
|
|
authed: &ApiAuthed,
|
|
path: &str,
|
|
is_flow: bool,
|
|
w_id: &str,
|
|
db: DB,
|
|
) -> Result<()> {
|
|
if is_flow {
|
|
require_is_writer(
|
|
authed,
|
|
path,
|
|
w_id,
|
|
db,
|
|
"SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2",
|
|
"flow",
|
|
)
|
|
.await
|
|
} else {
|
|
require_is_writer(
|
|
authed,
|
|
path,
|
|
w_id,
|
|
db,
|
|
"SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
|
|
"script",
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// A trigger may only point at a live runnable.
|
|
///
|
|
/// The webhook URL is built from this path, so a trigger pointed at a path the runnable has left
|
|
/// keeps delivering there: for a flow the row is gone and the trigger vanishes from listings,
|
|
/// for a script the abandoned version is still resolvable and fires stale code indefinitely. A
|
|
/// client that loaded before a rename submits the old path in good faith, so this needs no
|
|
/// concurrency to happen. The writer checks above do not cover it: they return early for admins
|
|
/// and path owners without ever looking at the runnable.
|
|
async fn require_runnable_exists(db: &DB, w_id: &str, path: &str, is_flow: bool) -> Result<()> {
|
|
let exists = if is_flow {
|
|
sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)",
|
|
path,
|
|
w_id
|
|
)
|
|
.fetch_one(db)
|
|
.await?
|
|
} else {
|
|
// Renaming a script archives the version at the old path instead of removing it, so a
|
|
// plain existence check would still accept a path the script has moved off. Every deploy
|
|
// archives its parent, leaving exactly one non-archived version at a live path and none
|
|
// at an abandoned one. Soft-delete sets `archived` too, and `deleted` is checked because
|
|
// it, not `archived`, is what stops a version from being resolved for execution.
|
|
sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 \
|
|
AND archived = false AND deleted = false)",
|
|
path,
|
|
w_id
|
|
)
|
|
.fetch_one(db)
|
|
.await?
|
|
};
|
|
|
|
if exists.unwrap_or(false) {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"There is no {kind} at {path} to trigger. If the {kind} was renamed since this page \
|
|
was loaded, reload and try again; otherwise point this trigger at an existing {kind} \
|
|
or delete it.",
|
|
kind = if is_flow { "flow" } else { "script" }
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListQuery {
|
|
pub page: Option<usize>,
|
|
pub per_page: Option<usize>,
|
|
pub path: Option<String>,
|
|
pub is_flow: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FullTriggerResponse<T: Serialize> {
|
|
#[serde(flatten)]
|
|
pub windmill_data: NativeTrigger,
|
|
pub external_data: Option<T>,
|
|
/// Why `external_data` is missing, when the service could not be read. The stored
|
|
/// configuration is still returned so the trigger stays viewable and editable.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub external_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CreateTriggerResponse {
|
|
pub external_id: String,
|
|
}
|
|
|
|
pub(crate) async fn new_webhook_token(
|
|
tx: &mut PgConnection,
|
|
db: &DB,
|
|
authed: &ApiAuthed,
|
|
script_path: &str,
|
|
is_flow: bool,
|
|
workspace_id: &str,
|
|
service_name: ServiceName,
|
|
) -> Result<String> {
|
|
let scopes = webhook_token_scopes(script_path, is_flow);
|
|
let label = webhook_token_label(service_name);
|
|
let expiration = service_name
|
|
.webhook_token_expiration()
|
|
.map(|d| chrono::Utc::now() + d);
|
|
let token_config = NewToken::new(
|
|
Some(label),
|
|
expiration,
|
|
None,
|
|
Some(scopes),
|
|
Some(workspace_id.to_owned()),
|
|
None,
|
|
);
|
|
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
async fn create_native_trigger<T: External>(
|
|
Extension(handler): Extension<Arc<T>>,
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(workspace_id): Path<String>,
|
|
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
|
|
) -> JsonResult<CreateTriggerResponse> {
|
|
check_scopes(&authed, || {
|
|
format!("native_triggers:write:{}", &data.script_path)
|
|
})?;
|
|
require_is_writer_on_runnable(
|
|
&authed,
|
|
&data.script_path,
|
|
data.is_flow,
|
|
&workspace_id,
|
|
db.clone(),
|
|
)
|
|
.await?;
|
|
require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?;
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let webhook_token = new_webhook_token(
|
|
&mut *tx,
|
|
&db,
|
|
&authed,
|
|
&data.script_path,
|
|
data.is_flow,
|
|
&workspace_id,
|
|
service_name,
|
|
)
|
|
.await?;
|
|
|
|
let connection_path = resolve_usable_connection(
|
|
&db,
|
|
&mut tx,
|
|
&workspace_id,
|
|
service_name.integration_service(),
|
|
data.connection_path.as_deref(),
|
|
)
|
|
.await?;
|
|
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
|
&db,
|
|
&workspace_id,
|
|
service_name.integration_service(),
|
|
Some(&connection_path),
|
|
)
|
|
.await?;
|
|
|
|
let resp = handler
|
|
.create(
|
|
&workspace_id,
|
|
&oauth_data,
|
|
&webhook_token,
|
|
&data,
|
|
&db,
|
|
&mut tx,
|
|
)
|
|
.await
|
|
.map_err(map_external_error)?;
|
|
|
|
let (external_id, _) = handler.external_id_and_metadata_from_response(&resp);
|
|
|
|
// Some services (e.g. Google) can build service_config directly from the create response,
|
|
// while others (e.g. Nextcloud) need an update+get cycle to correct the webhook URL
|
|
// with the external_id assigned by the remote service.
|
|
let service_config =
|
|
if let Some(config) = handler.service_config_from_create_response(&data, &resp) {
|
|
config
|
|
} else {
|
|
handler
|
|
.update(
|
|
&workspace_id,
|
|
&oauth_data,
|
|
&external_id,
|
|
&webhook_token,
|
|
&data,
|
|
&db,
|
|
&mut tx,
|
|
)
|
|
.await
|
|
.map_err(map_external_error)?
|
|
};
|
|
|
|
let config = NativeTriggerConfig {
|
|
script_path: data.script_path.clone(),
|
|
is_flow: data.is_flow,
|
|
webhook_token,
|
|
};
|
|
|
|
lock_connection(&mut tx, &workspace_id, &connection_path).await?;
|
|
// Through `db`: a member using the workspace connection cannot read its variable.
|
|
let still_connected = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
|
workspace_id,
|
|
connection_path,
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if !still_connected {
|
|
return Err(Error::BadRequest(format!(
|
|
"{connection_path} was disconnected while this trigger was being created. Its \
|
|
registration on {service_name} may remain; connect an account and create it again."
|
|
)));
|
|
}
|
|
|
|
store_native_trigger(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
&external_id,
|
|
&config,
|
|
service_config,
|
|
data.summary.as_deref(),
|
|
data.enabled,
|
|
&connection_path,
|
|
)
|
|
.await?;
|
|
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
&format!("native_triggers.{}.create", service_name),
|
|
ActionKind::Create,
|
|
&workspace_id,
|
|
Some(&external_id),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok(Json(CreateTriggerResponse { external_id }))
|
|
}
|
|
|
|
async fn update_native_trigger_handler<T: External>(
|
|
Extension(handler): Extension<Arc<T>>,
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((workspace_id, external_id)): Path<(String, String)>,
|
|
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
|
|
) -> Result<String> {
|
|
check_scopes(&authed, || {
|
|
format!("native_triggers:write:{}", &data.script_path)
|
|
})?;
|
|
require_is_writer_on_runnable(
|
|
&authed,
|
|
&data.script_path,
|
|
data.is_flow,
|
|
&workspace_id,
|
|
db.clone(),
|
|
)
|
|
.await?;
|
|
require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?;
|
|
|
|
let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?;
|
|
|
|
let mut tx = user_db.clone().begin(&authed).await?;
|
|
|
|
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
|
|
|
|
// Saving re-registers the webhook as the connection's account, so the editor must be allowed
|
|
// to act as it, not just to write the runnable.
|
|
let integration_service = service_name.integration_service();
|
|
if let Some(path) = existing.connection_path.as_deref() {
|
|
resolve_usable_connection(&db, &mut tx, &workspace_id, integration_service, Some(path))
|
|
.await?;
|
|
}
|
|
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
|
&db,
|
|
&workspace_id,
|
|
integration_service,
|
|
existing.connection_path.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
let runnable_changed =
|
|
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
|
|
|
|
// Track old token hash so we can clean it up after everything succeeds
|
|
let mut old_token_hash_to_delete: Option<String> = None;
|
|
|
|
let webhook_token = if runnable_changed {
|
|
// Scopes change when the runnable changes — delete old, create fresh token
|
|
old_token_hash_to_delete = Some(existing.webhook_token_hash.clone());
|
|
let token = new_webhook_token(
|
|
&mut *tx,
|
|
&db,
|
|
&authed,
|
|
&data.script_path,
|
|
data.is_flow,
|
|
&workspace_id,
|
|
service_name,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
tx = user_db.begin(&authed).await?;
|
|
token
|
|
} else {
|
|
// Same runnable — rotate the token (mints a fresh label + expiration)
|
|
match rotate_webhook_token(
|
|
&db,
|
|
&existing.webhook_token_hash,
|
|
service_name,
|
|
webhook_token_scopes(&data.script_path, data.is_flow),
|
|
)
|
|
.await?
|
|
{
|
|
Some(rotated) => {
|
|
old_token_hash_to_delete = Some(rotated.old_token_hash);
|
|
rotated.new_token
|
|
}
|
|
None => {
|
|
// Old token gone — create a fresh one
|
|
let token = new_webhook_token(
|
|
&mut *tx,
|
|
&db,
|
|
&authed,
|
|
&data.script_path,
|
|
data.is_flow,
|
|
&workspace_id,
|
|
service_name,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
tx = user_db.begin(&authed).await?;
|
|
token
|
|
}
|
|
}
|
|
};
|
|
|
|
let service_config = handler
|
|
.update(
|
|
&workspace_id,
|
|
&oauth_data,
|
|
&external_id,
|
|
&webhook_token,
|
|
&data,
|
|
&db,
|
|
&mut tx,
|
|
)
|
|
.await
|
|
.map_err(map_external_error)?;
|
|
|
|
let config = NativeTriggerConfig {
|
|
script_path: data.script_path.clone(),
|
|
is_flow: data.is_flow,
|
|
webhook_token,
|
|
};
|
|
|
|
// `existing` was read before the network call, and a rename writes `script_path` from the
|
|
// deploy transaction, which this lock does not cover. Writing a stale path back would undo the
|
|
// rename and drop the trigger out of every listing, so refuse the edit instead. The rename's
|
|
// own re-registration is queued behind this lock and puts the service back in step.
|
|
let applied = update_native_trigger_if_runnable_unchanged(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
&external_id,
|
|
&config,
|
|
service_config,
|
|
data.summary.as_deref(),
|
|
&existing.script_path,
|
|
existing.is_flow,
|
|
)
|
|
.await?;
|
|
|
|
if !applied {
|
|
return Err(Error::BadRequest(format!(
|
|
"The runnable of {external_id} was renamed while this trigger was being saved, so the \
|
|
edit was not applied. Reload and save again."
|
|
)));
|
|
}
|
|
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
&format!("native_triggers.{}.update", service_name),
|
|
ActionKind::Update,
|
|
&workspace_id,
|
|
Some(&external_id),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
lock.release().await?;
|
|
|
|
// Everything succeeded — clean up old token (best-effort)
|
|
if let Some(old_hash) = old_token_hash_to_delete {
|
|
if let Err(e) = delete_token_by_hash(&db, &old_hash).await {
|
|
tracing::warn!(
|
|
"Failed to delete old webhook token after trigger update: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(format!("Native trigger updated"))
|
|
}
|
|
|
|
async fn get_native_trigger_handler<T: External>(
|
|
Extension(handler): Extension<Arc<T>>,
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((workspace_id, external_id)): Path<(String, String)>,
|
|
) -> JsonResult<FullTriggerResponse<T::TriggerData>> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let windmill_trigger = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
|
|
|
|
check_scopes(&authed, || {
|
|
format!("native_triggers:read:{}", &windmill_trigger.script_path)
|
|
})?;
|
|
require_is_writer_on_runnable(
|
|
&authed,
|
|
&windmill_trigger.script_path,
|
|
windmill_trigger.is_flow,
|
|
&workspace_id,
|
|
db.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Reading the registration acts as the connection's account, like saving does.
|
|
let usable = match windmill_trigger.connection_path.as_deref() {
|
|
Some(path) => list_usable_connections(
|
|
&db,
|
|
&mut tx,
|
|
&workspace_id,
|
|
service_name.integration_service(),
|
|
)
|
|
.await?
|
|
.iter()
|
|
.any(|c| c.path == path),
|
|
None => false,
|
|
};
|
|
if !usable {
|
|
tx.commit().await?;
|
|
return Ok(Json(FullTriggerResponse {
|
|
windmill_data: windmill_trigger,
|
|
external_data: None,
|
|
external_error: Some(format!(
|
|
"You cannot use this trigger's {service_name} connection, so its registration was \
|
|
not read."
|
|
)),
|
|
}));
|
|
}
|
|
|
|
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
|
&db,
|
|
&workspace_id,
|
|
service_name.integration_service(),
|
|
windmill_trigger.connection_path.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
let native_trigger = handler
|
|
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
|
.await;
|
|
|
|
let mut external_error = None;
|
|
|
|
let external_data = match native_trigger {
|
|
Ok(Some(native_cfg)) => {
|
|
// Only the "no longer exists" error is disproven by the trigger being there; other
|
|
// paths record failures (e.g. a webhook still aimed at a pre-rename path) that a
|
|
// successful fetch says nothing about.
|
|
if windmill_trigger.error.as_deref() == Some(EXTERNAL_TRIGGER_MISSING_ERROR) {
|
|
update_native_trigger_error(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
&external_id,
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
Some(native_cfg)
|
|
}
|
|
Ok(None) => None,
|
|
Err(e) => match classify_read_failure(e) {
|
|
ExternalReadFailure::Missing => {
|
|
let error_msg = EXTERNAL_TRIGGER_MISSING_ERROR.to_string();
|
|
tracing::warn!(
|
|
"Native trigger no longer exists on external service {}, setting error",
|
|
service_name
|
|
);
|
|
|
|
update_native_trigger_error(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
&external_id,
|
|
Some(&error_msg),
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
return Err(Error::NotFound(format!(
|
|
"Trigger '{}' no longer exists on external service {}",
|
|
external_id, service_name
|
|
)));
|
|
}
|
|
// The service being unreadable says nothing about the trigger Windmill stores, and
|
|
// failing here would leave the editor with no configuration to show at all. Report
|
|
// what the service said alongside the stored configuration instead.
|
|
ExternalReadFailure::Unreadable(message) => {
|
|
tracing::warn!(
|
|
"Could not read trigger '{}' from {}, returning the stored configuration: {}",
|
|
external_id,
|
|
service_name,
|
|
message
|
|
);
|
|
external_error = Some(message);
|
|
None
|
|
}
|
|
ExternalReadFailure::Internal(e) => return Err(e),
|
|
},
|
|
};
|
|
|
|
tx.commit().await?;
|
|
|
|
let full_resp = Json(FullTriggerResponse {
|
|
windmill_data: windmill_trigger,
|
|
external_data,
|
|
external_error,
|
|
});
|
|
|
|
Ok(full_resp)
|
|
}
|
|
|
|
async fn delete_native_trigger_handler<T: External>(
|
|
Extension(handler): Extension<Arc<T>>,
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((workspace_id, external_id)): Path<(String, String)>,
|
|
) -> Result<String> {
|
|
let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?;
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
|
|
|
|
check_scopes(&authed, || {
|
|
format!("native_triggers:write:{}", &existing.script_path)
|
|
})?;
|
|
require_is_writer_on_runnable(
|
|
&authed,
|
|
&existing.script_path,
|
|
existing.is_flow,
|
|
&workspace_id,
|
|
db.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// A connection can be deleted from under its triggers. Without it the registration cannot be
|
|
// removed, but deleting the row still revokes the webhook token, so the service's deliveries
|
|
// stop starting jobs; refusing would leave a trigger nobody can remove.
|
|
match decrypt_oauth_data::<T::OAuthData>(
|
|
&db,
|
|
&workspace_id,
|
|
service_name.integration_service(),
|
|
existing.connection_path.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(oauth_data) => handler
|
|
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
|
.await
|
|
.map_err(|e| {
|
|
map_external_error_with(e, |m| {
|
|
let end = if m.ends_with(['.', '!', '?']) {
|
|
""
|
|
} else {
|
|
"."
|
|
};
|
|
format!("{m}{end} The trigger was kept in Windmill, so it can still fire.")
|
|
})
|
|
})?,
|
|
Err(e @ (Error::NotFound(_) | Error::BadRequest(_))) => tracing::warn!(
|
|
"Deleting {service_name} trigger {external_id} without removing its registration: {e}"
|
|
),
|
|
Err(e) => return Err(e),
|
|
}
|
|
|
|
let deleted =
|
|
delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?;
|
|
|
|
if !deleted {
|
|
return Err(Error::NotFound(format!("Native trigger not found")));
|
|
}
|
|
|
|
// Delete the webhook token using its hash
|
|
if !delete_token_by_hash(&db, &existing.webhook_token_hash).await? {
|
|
tracing::warn!(
|
|
"Webhook token not found when deleting trigger {} (hash: {})",
|
|
external_id,
|
|
existing.webhook_token_hash
|
|
);
|
|
}
|
|
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
&format!("native_triggers.{}.delete", service_name),
|
|
ActionKind::Delete,
|
|
&workspace_id,
|
|
Some(&external_id),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
lock.release().await?;
|
|
|
|
Ok(format!("Native trigger deleted"))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SetEnabledPayload {
|
|
pub enabled: bool,
|
|
}
|
|
|
|
/// Pause or resume a trigger, without touching its registration on the external service.
|
|
///
|
|
/// Leaving the webhook registered is what makes this reversible: services drop or deactivate a
|
|
/// subscription that keeps failing, so a paused trigger keeps answering deliveries normally and
|
|
/// simply starts no job.
|
|
async fn set_native_trigger_enabled_handler<T: External>(
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((workspace_id, external_id)): Path<(String, String)>,
|
|
Json(payload): Json<SetEnabledPayload>,
|
|
) -> Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
|
|
|
|
check_scopes(&authed, || {
|
|
format!("native_triggers:write:{}", &existing.script_path)
|
|
})?;
|
|
require_is_writer_on_runnable(
|
|
&authed,
|
|
&existing.script_path,
|
|
existing.is_flow,
|
|
&workspace_id,
|
|
db.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let updated = set_native_trigger_enabled(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
&external_id,
|
|
payload.enabled,
|
|
)
|
|
.await?;
|
|
|
|
// The read above takes no row lock, so a concurrent delete can land in between; reporting
|
|
// success then would tell the caller a trigger that is gone had been paused.
|
|
if !updated {
|
|
return Err(Error::NotFound(format!(
|
|
"Native trigger not found: {}",
|
|
external_id
|
|
)));
|
|
}
|
|
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
&format!(
|
|
"native_triggers.{}.{}",
|
|
service_name,
|
|
if payload.enabled { "enable" } else { "disable" }
|
|
),
|
|
ActionKind::Update,
|
|
&workspace_id,
|
|
Some(&external_id),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok(format!(
|
|
"Native trigger {}",
|
|
if payload.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
}
|
|
))
|
|
}
|
|
|
|
async fn list_native_triggers_handler<T: External>(
|
|
Extension(service_name): Extension<ServiceName>,
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(workspace_id): Path<String>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> JsonResult<Vec<NativeTrigger>> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
let triggers = list_native_triggers(
|
|
&mut *tx,
|
|
&workspace_id,
|
|
service_name,
|
|
query.page,
|
|
query.per_page,
|
|
query.path.as_deref(),
|
|
query.is_flow,
|
|
build_scope_path_filter(&authed, "native_triggers", "read"),
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(Json(triggers))
|
|
}
|
|
|
|
pub fn service_routes<T: External + 'static>(handler: T) -> Router {
|
|
let additional_routes = handler.additional_routes();
|
|
let service_name = T::SERVICE_NAME;
|
|
|
|
let handler_arc = Arc::new(handler);
|
|
|
|
let standard_routes = Router::new()
|
|
.route("/create", post(create_native_trigger::<T>))
|
|
.route("/list", get(list_native_triggers_handler::<T>))
|
|
.route("/get/{external_id}", get(get_native_trigger_handler::<T>))
|
|
.route(
|
|
"/update/{external_id}",
|
|
post(update_native_trigger_handler::<T>),
|
|
)
|
|
.route(
|
|
"/delete/{external_id}",
|
|
delete(delete_native_trigger_handler::<T>),
|
|
)
|
|
.route(
|
|
"/setenabled/{external_id}",
|
|
post(set_native_trigger_enabled_handler::<T>),
|
|
);
|
|
|
|
standard_routes
|
|
.merge(additional_routes)
|
|
.layer(Extension(handler_arc))
|
|
.layer(Extension(service_name))
|
|
}
|
|
|
|
/// Generates routes for all registered native trigger services.
|
|
/// When adding a new service, add a new `.nest()` call here.
|
|
pub fn generate_native_trigger_routers() -> Router {
|
|
let router = Router::new();
|
|
|
|
#[cfg(feature = "native_trigger")]
|
|
{
|
|
use crate::github::GitHub;
|
|
use crate::google::Google;
|
|
use crate::nextcloud::NextCloud;
|
|
|
|
return router
|
|
.nest("/nextcloud", service_routes(NextCloud))
|
|
.nest("/google", service_routes(Google))
|
|
.nest("/github", service_routes(GitHub));
|
|
}
|
|
|
|
#[cfg(not(feature = "native_trigger"))]
|
|
{
|
|
router
|
|
}
|
|
}
|