fix: authorize oauth account links and scope native trigger disconnects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-28 09:01:28 +02:00
co-authored by Claude Opus 5.5
parent 5e48084911
commit f2a5dd02b6
4 changed files with 87 additions and 16 deletions
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "script_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google",
"github"
]
}
}
},
"Text"
]
},
"nullable": [
false
]
},
"hash": "387aebd876ef240f9c7c99c977d0b66587f50a70c24f331aa5838a754d13ada9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = EXCLUDED.oauth_data\n WHERE workspace_integrations.oauth_data IS NULL",
"query": "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, service_name) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
@@ -24,5 +24,5 @@
},
"nullable": []
},
"hash": "3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c"
"hash": "5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d"
}
@@ -1,7 +1,7 @@
use crate::{
classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_token_by_hash,
get_native_trigger, list_native_triggers, lock::TriggerLock, map_external_error,
map_external_error_with, resolve_usable_connection, rotate_webhook_token,
get_native_trigger, list_native_triggers, list_usable_connections, lock::TriggerLock,
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,
@@ -468,6 +468,28 @@ async fn get_native_trigger_handler<T: External>(
)
.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(&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,
@@ -30,7 +30,7 @@ use windmill_common::{
};
#[cfg(feature = "native_trigger")]
use windmill_api_auth::{require_is_writer, ApiAuthed};
use windmill_api_auth::{check_scopes, require_is_writer, ApiAuthed};
#[cfg(feature = "native_trigger")]
use crate::{
@@ -709,8 +709,7 @@ async fn oauth_callback(
sqlx::query!(
"INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = EXCLUDED.oauth_data
WHERE workspace_integrations.oauth_data IS NULL",
ON CONFLICT (workspace_id, service_name) DO NOTHING",
workspace_id,
service_name as ServiceName,
json!({ "instance_shared": true, "base_url": "" }),
@@ -1005,9 +1004,10 @@ async fn get_instance_oauth_config(
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(),
));
return Err(Error::BadRequest(format!(
"No {service_name} OAuth app is configured for this workspace. A workspace admin can \
configure one in the workspace settings, under native triggers."
)));
}
let (client_id, client_secret) =
@@ -1064,6 +1064,7 @@ async fn delete_connection(
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Query(ConnectionPathQuery { path }): Query<ConnectionPathQuery>,
) -> JsonResult<String> {
check_scopes(&authed, || format!("variables:write:{path}"))?;
require_is_writer(
&authed,
&path,
@@ -1074,20 +1075,33 @@ async fn delete_connection(
)
.await?;
let mut tx = user_db.begin(&authed).await?;
if !list_usable_connections(&mut tx, &workspace_id, service_name)
.await?
.iter()
.any(|c| c.path == path)
{
let mut tx = user_db.clone().begin(&authed).await?;
let usable = list_usable_connections(&mut tx, &workspace_id, service_name).await?;
tx.commit().await?;
if !usable.iter().any(|c| c.path == path) {
return Err(Error::NotFound(format!(
"No {service_name} connection at {path}"
)));
}
// Disconnecting deletes these triggers, so a path-scoped token must cover each of them.
let trigger_paths = sqlx::query_scalar!(
"SELECT script_path FROM native_trigger
WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
workspace_id,
service_name as ServiceName,
path,
)
.fetch_all(&db)
.await?;
for script_path in &trigger_paths {
check_scopes(&authed, || format!("native_triggers:write:{script_path}"))?;
}
// Before the connection goes: removing the registrations needs its token.
delete_triggers_for_service(&db, &workspace_id, service_name, Some(&path)).await;
let mut tx = user_db.begin(&authed).await?;
let account = cleanup_connection(&mut *tx, &workspace_id, &path).await?;
audit_log(