Merge branch 'main' into claude/issue-7902-20260211-1113

This commit is contained in:
Guilhem
2026-02-16 16:32:49 +00:00
committed by GitHub
124 changed files with 5701 additions and 783 deletions
+756
View File
@@ -0,0 +1,756 @@
# Skill: Adding Native Trigger Services
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
## Architecture Overview
The native trigger system consists of:
1. **Database Layer** - PostgreSQL tables and enum types
2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate
3. **Frontend Svelte Components** - Configuration forms and UI components
### Key Files
| Component | Path |
|-----------|------|
| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` |
| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` |
| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` |
| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` |
| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` |
| TriggerKind enum | `backend/windmill-common/src/triggers.rs` |
| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` |
| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` |
| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` |
| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` |
| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` |
| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` |
| OpenAPI spec | `backend/windmill-api/openapi.yaml` |
| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` |
| Reference: Google module | `backend/windmill-native-triggers/src/google/` |
### Crate Structure
The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim:
```rust
// backend/windmill-api/src/native_triggers/mod.rs
pub use windmill_native_triggers::*;
```
All new service modules go in `backend/windmill-native-triggers/src/`.
---
## Core Concepts
### The `External` Trait
Every native trigger service implements the `External` trait defined in `lib.rs`:
```rust
#[async_trait]
pub trait External: Send + Sync + 'static {
// Associated types:
type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync;
type TriggerData: Debug + Serialize + Send + Sync;
type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync;
type CreateResponse: DeserializeOwned + Send + Sync;
// Constants:
const SUPPORT_WEBHOOK: bool;
const SERVICE_NAME: ServiceName;
const DISPLAY_NAME: &'static str;
const TOKEN_ENDPOINT: &'static str;
const REFRESH_ENDPOINT: &'static str;
const AUTH_ENDPOINT: &'static str;
// Required methods:
async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result<Self::CreateResponse>;
async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>;
async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>;
async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>;
async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result<bool>;
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>);
// Methods with defaults:
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
fn additional_routes(&self) -> axum::Router;
async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>;
}
```
Key design points:
- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config.
- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).
- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies.
- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern.
### Create Lifecycle: Two Paths
The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`:
**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`:
1. `create()` registers on external service
2. `external_id_and_metadata_from_response()` extracts the ID
3. `service_config_from_create_response()` builds the config directly from input data + response metadata
4. Stores trigger in DB -- done, no extra round-trip
Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default):
1. `create()` registers on external service (webhook URL has no external_id yet)
2. `external_id_and_metadata_from_response()` extracts the ID
3. `update()` is called to fix the webhook URL with the now-known external_id
4. `update()` returns the resolved service_config
5. Stores trigger in DB
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
### OAuth Token Storage (Three-Table Pattern)
OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly:
| Table | What's Stored |
|-------|---------------|
| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable |
| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column |
| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` |
The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct:
```rust
pub struct OAuthConfig {
pub base_url: String,
pub access_token: String, // decrypted from variable
pub refresh_token: Option<String>, // from account table
pub client_id: String, // from oauth_data or instance settings
pub client_secret: String, // from oauth_data or instance settings
}
```
Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations.
### URL Resolution
The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs:
```rust
pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string() // Google: absolute URLs
} else {
format!("{}{}", base_url, endpoint) // Nextcloud: relative paths
}
}
```
### ServiceName Methods
`ServiceName` is the central registry enum. Each variant must implement these match arms:
| Method | Purpose |
|--------|---------|
| `as_str()` | Lowercase identifier (e.g., `"google"`) |
| `as_trigger_kind()` | Maps to `TriggerKind` enum |
| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum |
| `token_endpoint()` | OAuth token endpoint (relative or absolute) |
| `auth_endpoint()` | OAuth authorization endpoint |
| `oauth_scopes()` | Space-separated OAuth scopes |
| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) |
| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) |
| `integration_service()` | Maps to the workspace integration service (usually `*self`) |
| `TryFrom<String>` | Parse from string |
| `Display` | Delegates to `as_str()` |
---
## Step-by-Step Implementation Guide
### Step 1: Database Migration
Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql`
```sql
-- Add the service to the native_trigger_service enum
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice';
-- Add to TRIGGER_KIND enum (used for trigger tracking)
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice';
-- Add to job_trigger_kind enum (used for job tracking)
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice';
```
Also create the corresponding down migration.
### Step 2: Update windmill-common Enums
#### `backend/windmill-common/src/triggers.rs`
Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations.
#### `backend/windmill-common/src/jobs.rs`
Add variant to `JobTriggerKind` enum and update the `Display` implementation.
### Step 3: Backend Service Module
Create a new directory: `backend/windmill-native-triggers/src/newservice/`
#### `mod.rs` - Type Definitions
```rust
use serde::{Deserialize, Serialize};
pub mod external;
// pub mod routes; // Only if you need additional service-specific routes
/// OAuth data deserialized from the three-table pattern.
/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NewServiceOAuthData {
pub base_url: String, // from workspace_integrations.oauth_data
pub access_token: String, // decrypted from variable table
pub refresh_token: Option<String>, // from account table
// Note: client_id and client_secret are in OAuthConfig, not here
// unless the service needs them at runtime for API calls
}
/// Configuration provided by user when creating/updating a trigger.
/// Stored as JSON in native_trigger.service_config.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewServiceConfig {
// Service-specific configuration fields
pub folder_path: String,
pub file_filter: Option<String>,
}
/// Data retrieved from the external service about a trigger.
/// Returned by the get() method and shown in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewServiceTriggerData {
pub folder_path: String,
pub file_filter: Option<String>,
// Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)]
}
/// Response from external service when creating a trigger/webhook.
#[derive(Debug, Deserialize)]
pub struct CreateTriggerResponse {
pub id: String,
}
/// Handler struct (stateless, used for routing)
#[derive(Copy, Clone)]
pub struct NewService;
```
#### `external.rs` - External Trait Implementation
```rust
use async_trait::async_trait;
use reqwest::Method;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::{
generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName,
sync::{SyncError, TriggerSyncInfo},
};
use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse};
#[async_trait]
impl External for NewService {
type ServiceConfig = NewServiceConfig;
type TriggerData = NewServiceTriggerData;
type OAuthData = NewServiceOAuthData;
type CreateResponse = CreateTriggerResponse;
const SERVICE_NAME: ServiceName = ServiceName::NewService;
const DISPLAY_NAME: &'static str = "New Service";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "/oauth/token";
const REFRESH_ENDPOINT: &'static str = "/oauth/token";
const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
async fn create(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
let base_url = &*BASE_URL.read().await;
// external_id is None during create (we get it from the response)
let webhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
None, Self::SERVICE_NAME, webhook_token,
);
let url = format!("{}/api/webhooks/create", oauth_data.base_url);
let payload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
let response: CreateTriggerResponse = self
.http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
.await?;
Ok(response)
}
/// Update returns the resolved service_config as JSON.
/// For services using the update+get pattern, call self.get() and serialize.
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<serde_json::Value> {
let base_url = &*BASE_URL.read().await;
let webhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
Some(external_id), Self::SERVICE_NAME, webhook_token,
);
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
let payload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
let _: serde_json::Value = self
.http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
.await?;
// Fetch back the updated state to get the resolved config
let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?;
serde_json::to_value(&trigger_data)
.map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e)))
}
async fn get(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData> {
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
}
async fn delete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
let _: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
_ => Err(e),
})?;
Ok(())
}
async fn exists(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool> {
match self.get(w_id, oauth_data, external_id, db, tx).await {
Ok(_) => Ok(true),
Err(Error::NotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
/// Background maintenance. Choose the right pattern for your service:
/// - For services with queryable external state: use reconcile_with_external_state()
/// - For channel-based services with expiration: implement renewal logic
async fn maintain_triggers(
&self,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
oauth_data: &Self::OAuthData,
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
// Option A: Reconcile with external state (Nextcloud pattern)
// Fetch all triggers from external service and compare with DB
let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
Ok(triggers) => triggers,
Err(e) => {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to list triggers: {}", e),
error_type: "api_error".to_string(),
});
return;
}
};
// Convert to (external_id, config_json) pairs
let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
.into_iter()
.map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default()))
.collect();
crate::sync::reconcile_with_external_state(
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.clone(), None)
}
// service_config_from_create_response: NOT overridden (returns None).
// This means the handler uses the update+get pattern after create.
// Override and return Some(...) to skip the update+get cycle (Google pattern).
}
impl NewService {
/// Private helper to list all triggers from the external service.
async fn list_all(
&self,
w_id: &str,
oauth_data: &<Self as External>::OAuthData,
db: &DB,
) -> Result<Vec<<Self as External>::TriggerData>> {
// Implementation depends on the external service's API
todo!()
}
}
```
### Step 4: Update lib.rs Registry
In `backend/windmill-native-triggers/src/lib.rs`:
```rust
// Service modules - add new services here:
#[cfg(feature = "native_trigger")]
pub mod newservice; // <-- Add this
// ServiceName enum - add variant:
pub enum ServiceName {
Nextcloud,
Google,
NewService, // <-- Add this
}
// Then add match arms in ALL ServiceName methods:
// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(),
// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(),
// integration_service(), TryFrom<String>, Display
```
### Step 5: Update handler.rs Routes
In `backend/windmill-native-triggers/src/handler.rs`:
```rust
pub fn generate_native_trigger_routers() -> Router {
// ...
#[cfg(feature = "native_trigger")]
{
use crate::newservice::NewService;
return router
.nest("/nextcloud", service_routes(NextCloud))
.nest("/google", service_routes(Google))
.nest("/newservice", service_routes(NewService)); // <-- Add this
}
// ...
}
```
### Step 6: Update sync.rs
In `backend/windmill-native-triggers/src/sync.rs`:
```rust
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
// ...
#[cfg(feature = "native_trigger")]
{
use crate::newservice::NewService;
// ... existing service syncs ...
// New service sync
let (service_name, result) = sync_service_triggers(db, NewService).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
}
// ...
}
```
### Step 7: Frontend Service Registry
In `frontend/src/lib/components/triggers/native/utils.ts`:
Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`.
### Step 8: Frontend Trigger Form Component
Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte`
### Step 9: Frontend Icon Component
Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte`
### Step 10: Update NativeTriggerEditor
Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name.
### Step 11: Workspace Integration UI
Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`:
```typescript
const supportedServices: Record<string, ServiceConfig> = {
// ... existing services ...
newservice: {
name: 'newservice',
displayName: 'New Service',
description: 'Connect to New Service for triggers',
icon: NewServiceIcon,
docsUrl: 'https://www.windmill.dev/docs/integrations/newservice',
requiresBaseUrl: false, // false for cloud services, true for self-hosted
setupInstructions: [
'Step 1: Create an OAuth app on the service',
'Step 2: Configure the redirect URI shown below',
'Step 3: Enter the client credentials below'
]
}
}
```
### Step 12: Update `frontend/src/lib/components/triggers/utils.ts`
Update ALL of these maps/functions:
1. `triggerIconMap` - import and add icon
2. `triggerDisplayNamesMap` - add display name
3. `triggerTypeOrder` in `sortTriggers()` - add type
4. `getLightConfig()` - add case for your service
5. `getTriggerLabel()` - add case for your service
6. `jobTriggerKinds` - add to array
7. `countPropertyMap` - add count property
8. `triggerSaveFunctions` - add save function
### Step 13: Update TriggersBadge Component
In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`:
1. Import the icon
2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`)
3. Add to the `allTypes` array
### Step 14: Update OpenAPI Spec and Regenerate Types
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
```bash
cd frontend && npm run generate-backend-client
```
---
## Special Patterns
### Unified Service with `trigger_type` (Google Pattern)
When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field:
```rust
pub enum GoogleTriggerType { Drive, Calendar }
pub struct GoogleServiceConfig {
pub trigger_type: GoogleTriggerType,
// Drive-specific fields (only used when trigger_type = Drive)
pub resource_id: Option<String>,
pub resource_name: Option<String>,
// Calendar-specific fields (only used when trigger_type = Calendar)
pub calendar_id: Option<String>,
pub calendar_name: Option<String>,
// Metadata set after creation
pub google_resource_id: Option<String>,
pub expiration: Option<String>,
}
```
Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
See `backend/windmill-native-triggers/src/google/` for the reference implementation.
### Skipping update+get After Create (Google Pattern)
Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call:
```rust
fn service_config_from_create_response(
&self,
data: &NativeTriggerData<Self::ServiceConfig>,
resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
// Clone input config, add metadata from response
let mut config = data.service_config.clone();
config.google_resource_id = Some(resp.resource_id.clone());
config.expiration = Some(resp.expiration.clone());
Some(serde_json::to_value(&config).unwrap())
}
```
### Services with Absolute OAuth Endpoints (Google)
Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs:
```rust
// Nextcloud: relative paths
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
// Google: absolute URLs
ServiceName::Google => "https://oauth2.googleapis.com/token",
```
The `resolve_endpoint()` function handles both. For services with absolute endpoints:
- `base_url` can be empty
- `requiresBaseUrl: false` in the frontend workspace integration config
- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`)
### Channel-Based Push Notifications with Renewal (Google Pattern)
For services using expiring watch channels instead of persistent webhooks:
1. Store expiration in `service_config` (as part of `ServiceConfig`)
2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`:
```rust
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) {
for trigger in triggers {
if should_renew_channel(trigger) {
self.renew_channel(db, trigger, oauth_data).await;
}
}
}
```
3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)
### reconcile_with_external_state (Nextcloud Pattern)
The reusable function in `sync.rs` compares external triggers with DB state:
- Triggers missing externally: sets error "Trigger no longer exists on external service"
- Triggers present externally: clears errors, updates service_config if it differs
Usage in `maintain_triggers()`:
```rust
let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */;
crate::sync::reconcile_with_external_state(
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;
```
### Webhook Payload Processing
Override `prepare_webhook()` to parse service-specific payloads into script/flow args:
```rust
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned> {
let mut args = HashMap::new();
args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _);
args.insert("payload".to_string(), Box::new(serde_json::from_str::<serde_json::Value>(&body)?) as _);
Ok(PushArgsOwned { extra: None, args })
}
```
Then register in `prepare_native_trigger_args()` in `lib.rs`:
```rust
pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result<Option<PushArgsOwned>> {
match service_name {
ServiceName::Google => { /* ... */ Ok(Some(args)) }
ServiceName::NewService => { /* ... */ Ok(Some(args)) }
ServiceName::Nextcloud => Ok(None), // Uses default body parsing
}
}
```
### Instance-Level OAuth Credentials
When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`.
---
## Testing Checklist
- [ ] Database migration runs successfully
- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes
- [ ] `npx svelte-check --threshold error` passes (in frontend/)
- [ ] Service appears in workspace integrations list
- [ ] OAuth flow completes successfully
- [ ] Can create a new trigger
- [ ] Can view trigger details
- [ ] Can update trigger configuration
- [ ] Can delete trigger
- [ ] Webhook receives and processes payloads
- [ ] Background sync works correctly (reconciliation or channel renewal)
- [ ] Error handling works (expired tokens, service unavailable)
---
## Reference Implementations
### Nextcloud (Self-Hosted, Update+Get Pattern)
| File | Purpose |
|------|---------|
| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData |
| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync |
| `nextcloud/routes.rs` | Additional route: `GET /events` |
Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().
### Google (Cloud, Unified Service, Short Create)
| File | Purpose |
|------|---------|
| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum |
| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync |
| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` |
Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n oauth_data as \"oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
"name": "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
"type_info": "Jsonb"
},
{
@@ -22,6 +22,11 @@
}
}
}
},
{
"ordinal": 2,
"name": "resource_path",
"type_info": "Text"
}
],
"parameters": {
@@ -30,9 +35,10 @@
]
},
"nullable": [
true,
false,
false
true
]
},
"hash": "5368683c19f8d6744d5dbc53e5b2ab0f2348646d79f5306c6868e2c3a8f389ee"
"hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM resource WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "05e05a9b979941c7a11cd881da652f459e4a0444d63a96deba4a879fbe1124ff"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -122,7 +122,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -1,11 +1,10 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_integrations\n SET oauth_data = $1, updated_at = now()\n WHERE workspace_id = $2 AND service_name = $3\n ",
"query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text",
{
"Custom": {
@@ -22,5 +21,5 @@
},
"nullable": []
},
"hash": "3b3f60623126626b52ca0a4a188655ddf728cd3f21ee308db7393694ccc5c7b3"
"hash": "1af48c42255f1c973b4a9c9a58050bf5ec1ee6f93f0a90c1c7d0c0fcd816702d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, is_workspace_integration FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "mcp_server_url",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "is_workspace_integration",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
true,
true,
true,
true
true,
false
]
},
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
"hash": "1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b"
}
@@ -34,7 +34,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -67,7 +68,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -40,7 +40,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, true)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "26beff5e94b68703ad81ef9dd2d08869eb3bb7659efd9bac04cdf98ae963063d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "27065225c6affd26f1533dacffe1c38321511b5a7dd2a7e9435c04868188fd44"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "2e5dd992b0bfd7550d6f4cb5424a1c14352527b98249bce286790641bf56491e"
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -30,7 +30,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value) VALUES ('oauths', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "4be53f0b801ebc1a33a184556fd138fdec8082f31f56d7023cf8c6311964f3b0"
}
@@ -37,7 +37,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -70,7 +71,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -245,7 +245,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "607c13333627e79557d3b6f6f68eee0a5dbe7cd4643e4bf99a592eb1bb82580c"
}
@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, resource_type = EXCLUDED.resource_type",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Jsonb",
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "6868520d496afe306bbd93293076ea4bb155097d1e8d3ffe5b75dd80ced735de"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "refresh_token",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false
]
},
"hash": "6b4d48527af6f1411dc5e03f9144fb127488a79ac53a154d71253628320b1084"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7"
}
@@ -29,7 +29,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
"describe": {
"columns": [
{
@@ -30,16 +30,21 @@
},
{
"ordinal": 3,
"name": "resource_path",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
}
@@ -63,11 +68,12 @@
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false
]
},
"hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2"
"hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef"
}
@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "7a57f58e809e482a599722d3887fb7e115506ff1e5ec9cf6dd2af84ed9a78632"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121"
}
@@ -27,7 +27,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations\n WHERE workspace_id = $1\n AND service_name = $2\n AND oauth_data IS NOT NULL\n )\n ",
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations wi\n WHERE wi.workspace_id = $1\n AND wi.service_name = $2\n AND wi.oauth_data IS NOT NULL\n )\n ",
"describe": {
"columns": [
{
@@ -29,5 +29,5 @@
null
]
},
"hash": "95c57fb921a2e3725b92cbafac6e3dc360b88429f03dd1e2b1b55cfabe208cb7"
"hash": "823fc5f998fe747ec8537752d9eb7ef548b2fd9ee1f5380084f27796c2bcc8ad"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4Array"
]
},
"nullable": []
},
"hash": "826a4216830f6a930c382209a20bc7f8b460064480e080b989e31df3d6a30e31"
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,39 @@
{
"db_name": "PostgreSQL",
"query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "external_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "webhook_token_prefix",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75"
}
@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5"
}
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM native_trigger\n WHERE\n workspace_id = $1 AND\n service_name = $2 AND\n external_id = $3\n )\n ",
"query": "\n SELECT service_config\n FROM native_trigger\n WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
"name": "service_config",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -27,8 +27,8 @@
]
},
"nullable": [
null
true
]
},
"hash": "06072cfe26abe58629623a8b38382b33947c5a5c702ce586e6e6ea51430380bf"
"hash": "8a4d42e373043bc509985ab320894bf3e6afa7b2019cc4739baac9165f7ead9e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "8e5881225f4bf7243bd40397ac8b8708fb6ce6c0ba0d263bb7eeb404f5dd62ff"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8f33846f6c25a78267a5c8143b414f033280ebf57b7b9568dc2fe31bc625020d"
}
@@ -26,7 +26,7 @@
]
},
"nullable": [
false
true
]
},
"hash": "9052b7cd438ff029a37bd489190d98d365acec09f2f102b7de71dcc9d356900e"
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9242b5a866d0dd489bebe4284413d37202be70068affca92c45d112e0210538a"
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "resource_path",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
true
]
},
"hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e"
}
@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -155,7 +155,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ",
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ",
"describe": {
"columns": [],
"parameters": {
@@ -18,10 +18,11 @@
}
},
"Jsonb",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8"
"hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS \"google_used!\"\n ",
"describe": {
"columns": [
{
@@ -52,6 +52,11 @@
"ordinal": 9,
"name": "nextcloud_used!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "google_used!",
"type_info": "Bool"
}
],
"parameters": {
@@ -69,8 +74,9 @@
null,
null,
null,
null,
null
]
},
"hash": "d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3"
"hash": "a6a25545af16db9f03552ce2ac178cfda3f2ced1b8f1e60bdfc7d84c642903d2"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ($1, $2, $3, true, $4, $5, true)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, account = EXCLUDED.account",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "ab5720c0af66aba9fd7d6b842f098878c431d52bdd7b71355fc7192144b91300"
}
@@ -160,7 +160,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "bc1298e492d3008386d9b1b449a98156fe186832494d16a434921727e5d3314d"
}
@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
null
]
},
"hash": "c1757ea525295ac9a0681be83a1f9d1e70944f65562e38c078b683e09cd9fb09"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)\n VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "c2bf1109d208d3aa989b2e12c0380f54638edc40788d7417a08d08a267426b5e"
}
@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -25,7 +25,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "SELECT oauth_data FROM workspace_integrations\n WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "oauth_data",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
true
]
},
"hash": "e241023a0d7b24adf7940ae764f14136b6d19fefbd8389e5ecd3bfc9bd652632"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e4c508a9bc69ccb4b32cf50caa97f9a2ff7c5990df953296d7227c1c81bc5130"
}
@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "e80dc984cd1d2388cbf17206ad059137cf7f92d0222382af1a66de807f3138e8"
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d"
}
+2
View File
@@ -16160,8 +16160,10 @@ dependencies = [
"sqlx",
"tokio",
"uuid",
"windmill-api-auth",
"windmill-api-client",
"windmill-common",
"windmill-native-triggers",
"windmill-test-utils",
]
+1 -1
View File
@@ -1 +1 @@
c927593b0b49867284fc64d50c59daba6c70da84
8c214ec5039be5353f5fea920e27b0c6af61e1fd
@@ -0,0 +1,11 @@
-- Note: PostgreSQL does not support removing enum values directly.
-- This down migration is a placeholder. To fully reverse, you would need to:
-- 1. Create a new enum type without the values
-- 2. Update all columns to use the new type
-- 3. Drop the old type
-- 4. Rename the new type
-- For now, we just document what was added:
-- Removed from native_trigger_service: 'google'
-- Removed from TRIGGER_KIND: 'google'
-- Removed from job_trigger_kind: 'google'
@@ -0,0 +1,10 @@
-- Add Google to native_trigger_service enum
-- 'google' is a unified service that handles both Drive and Calendar triggers
-- The trigger_type field in service_config determines which Google service is used
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'google';
-- Add to TRIGGER_KIND enum (used for trigger tracking)
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'google';
-- Add to job_trigger_kind enum (used for job tracking)
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'google';
@@ -0,0 +1,3 @@
ALTER TABLE account DROP COLUMN IF EXISTS is_workspace_integration;
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data SET NOT NULL;
ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS resource_path;
@@ -0,0 +1,10 @@
-- Migrate native trigger OAuth tokens from workspace_integrations to account+variable+resource pattern
-- Add flag to distinguish workspace integration accounts from regular user OAuth accounts
ALTER TABLE account ADD COLUMN is_workspace_integration BOOLEAN NOT NULL DEFAULT false;
-- Make oauth_data nullable since it will only store client config (no tokens) going forward
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data DROP NOT NULL;
-- Add resource_path column to workspace_integrations
ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS resource_path TEXT;
@@ -19,6 +19,8 @@ mcp = []
windmill-test-utils.workspace = true
windmill-api-client.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-native-triggers = { workspace = true, features = ["native_trigger"] }
windmill-api-auth.workspace = true
sqlx.workspace = true
serde_json.workspace = true
serde.workspace = true
@@ -0,0 +1,595 @@
/*!
* Integration tests for the native trigger system (Google).
*
* Tests cover 4 business-logic areas:
* 1. Resource path change — cleanup old path, recreate at new path
* 2. Config loading — workspace-level, instance-level, token update
* 3. Channel expiration renewal — should_renew_channel pure logic
* 4. Delete workspace integration — full cascade, cleanup preserves triggers, parse_stop_channel_params
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::variables::{build_crypt, encrypt};
use windmill_native_triggers::{
decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
get_workspace_integration,
google::{parse_stop_channel_params, should_renew_channel},
store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig,
ServiceName,
};
// ============================================================================
// Helpers
// ============================================================================
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
'test-user', 'python3', 'script', '')",
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
fn test_authed() -> ApiAuthed {
ApiAuthed {
email: "test@windmill.dev".to_string(),
username: "test-user".to_string(),
is_admin: true,
is_operator: false,
groups: vec!["all".to_string()],
folders: vec![],
scopes: None,
username_override: None,
token_prefix: None,
}
}
/// Set up a complete workspace integration with account+variable+resource.
/// Returns (resource_path, account_id).
async fn setup_oauth_integration(
db: &Pool<Postgres>,
service_name: ServiceName,
resource_path: &str,
access_token: &str,
refresh_token: &str,
oauth_data_override: Option<serde_json::Value>,
) -> anyhow::Result<i32> {
// 1. Create account with is_workspace_integration=true
let account_id: i32 = sqlx::query_scalar!(
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)
RETURNING id",
service_name.as_str(),
refresh_token,
)
.fetch_one(db)
.await?;
// 2. Encrypt and create variable
let mc = build_crypt(db, "test-workspace").await?;
let encrypted = encrypt(&mc, access_token);
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
resource_path,
encrypted,
account_id,
)
.execute(db)
.await?;
// 3. Create resource
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
resource_path,
resource_value,
service_name.resource_type(),
)
.execute(db)
.await?;
// 4. Store workspace integration with resource_path
let oauth_data = oauth_data_override.unwrap_or_else(|| {
json!({
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"base_url": "https://example.com",
"resource_path": resource_path,
})
});
let authed = test_authed();
let mut tx = db.begin().await?;
store_workspace_integration(
&mut *tx,
&authed,
"test-workspace",
service_name,
oauth_data,
Some(resource_path),
)
.await?;
tx.commit().await?;
Ok(account_id)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
// ============================================================================
// 1. Resource Path Change
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_resource_path_change(db: Pool<Postgres>) -> anyhow::Result<()> {
let path_a = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
path_a,
"token-a",
"refresh-a",
None,
)
.await?;
// Verify decrypt works at path A
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-a");
// Cleanup old path
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Recreate at path B
let path_b = "u/test-user/native_gworkspace_v2";
setup_oauth_integration(
&db,
ServiceName::Google,
path_b,
"token-b",
"refresh-b",
None,
)
.await?;
// Path A resources should be gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0, "variable at old path should be deleted");
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0, "resource at old path should be deleted");
// Path B should work
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-b");
assert_eq!(config.refresh_token.as_deref(), Some("refresh-b"));
Ok(())
}
// ============================================================================
// 2. Config Loading — workspace vs instance + token update
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"ws-access-token",
"ws-refresh-token",
None,
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "ws-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
assert_eq!(config.client_id, "test-client-id");
assert_eq!(config.client_secret, "test-client-secret");
assert_eq!(config.base_url, "https://example.com");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
// Insert instance-level credentials into global_settings
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ('oauths', $1)
ON CONFLICT (name) DO UPDATE SET value = $1",
json!({
"gworkspace": {
"id": "instance-client-id",
"secret": "instance-client-secret"
}
}),
)
.execute(&db)
.await?;
let resource_path = "u/test-user/native_gworkspace";
let oauth_data = json!({
"instance_shared": true,
"base_url": "https://accounts.google.com",
"resource_path": resource_path,
});
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"inst-access-token",
"inst-refresh-token",
Some(oauth_data),
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.client_id, "instance-client-id");
assert_eq!(config.client_secret, "instance-client-secret");
assert_eq!(config.access_token, "inst-access-token");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"old-access-token",
"old-refresh-token",
None,
)
.await?;
// Verify old tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "old-access-token");
// Simulate token refresh: update variable + account
let mc = build_crypt(&db, "test-workspace").await?;
let new_encrypted = encrypt(&mc, "new-access-token");
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
new_encrypted,
resource_path,
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"new-refresh-token",
account_id,
)
.execute(&db)
.await?;
// Verify new tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "new-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
Ok(())
}
// ============================================================================
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[test]
fn test_should_renew_drive_channel_expired() {
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() - 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_within_window() {
// 30 minutes remaining — within the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 30 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_not_yet() {
// 2 hours remaining — outside the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 2 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_within_window() {
// 12 hours remaining — within the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 12 * 60 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_not_yet() {
// 2 days remaining — outside the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 2 * 24 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_zero_expiration() {
let config = json!({
"triggerType": "drive",
"expiration": "0",
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_missing_fields() {
assert!(!should_renew_channel(&json!({})));
}
// ============================================================================
// 4. Delete Workspace Integration
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Add a native trigger linked to this integration
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
)
.await?;
// Step 1: Delete triggers
let deleted =
delete_native_trigger(&db, "test-workspace", ServiceName::Google, "ext-1").await?;
assert!(deleted);
// Step 2: Cleanup OAuth resources
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Step 3: Delete workspace integration
let mut tx = db.begin().await?;
let deleted =
delete_workspace_integration(&mut *tx, "test-workspace", ServiceName::Google).await?;
tx.commit().await?;
assert!(deleted);
// Verify everything is gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
let acc_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
account_id,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(acc_count, 0);
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0);
assert!(
get_workspace_integration(&db, "test-workspace", ServiceName::Google)
.await
.is_err()
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Create a trigger
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
)
.await?;
// Cleanup OAuth only — should NOT remove the trigger
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// OAuth resources gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
// Trigger still exists
let trigger_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'"
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(trigger_count, 1, "trigger should survive OAuth cleanup");
Ok(())
}
// --- parse_stop_channel_params ---
#[test]
fn test_parse_stop_channel_params_drive() {
let config = json!({
"triggerType": "drive",
"googleResourceId": "res-123",
});
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-123");
assert!(
url.contains("googleapis.com/drive/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_calendar() {
let config = json!({
"triggerType": "calendar",
"googleResourceId": "res-456",
});
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-456");
assert!(
url.contains("googleapis.com/calendar/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_default() {
// Missing triggerType defaults to Drive
let config = json!({ "googleResourceId": "res-789" });
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-789");
assert!(url.contains("drive/v3/channels/stop"), "url={}", url);
}
#[test]
fn test_parse_stop_channel_params_missing_resource_id() {
let config = json!({ "triggerType": "drive" });
let (resource_id, _url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "");
}
@@ -2499,6 +2499,7 @@ struct UsedTriggers {
pub gcp_used: bool,
pub email_used: bool,
pub nextcloud_used: bool,
pub google_used: bool,
}
async fn get_used_triggers(
@@ -2520,7 +2521,8 @@ async fn get_used_triggers(
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!"
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!"
"#,
w_id
)
+215 -13
View File
@@ -3676,6 +3676,8 @@ paths:
type: boolean
nextcloud_used:
type: boolean
google_used:
type: boolean
required:
- http_routes_used
- websocket_used
@@ -3687,6 +3689,7 @@ paths:
- sqs_used
- email_used
- nextcloud_used
- google_used
/w/{workspace}/users/list:
get:
summary: list users
@@ -12338,6 +12341,55 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/instance_sharing_available:
get:
summary: check if instance-level credential sharing is available for a service
operationId: checkInstanceSharingAvailable
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
responses:
"200":
description: whether instance sharing is available
content:
application/json:
schema:
type: boolean
/w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url:
post:
summary: generate connect url using instance-level credentials
operationId: generateInstanceConnectUrl
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
description: redirect_uri
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
responses:
"200":
description: authorization URL using instance credentials
content:
application/json:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/delete:
delete:
summary: delete native trigger service
@@ -12359,7 +12411,7 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/callback/{code}/{state}:
/w/{workspace}/native_triggers/integrations/{service_name}/callback:
post:
summary: native trigger service oauth callback
operationId: nativeTriggerServiceCallback
@@ -12372,23 +12424,26 @@ paths:
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: code
in: path
required: true
schema:
type: string
- name: state
in: path
required: true
schema:
type: string
requestBody:
description: redirect_uri
description: OAuth callback data
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
type: object
properties:
code:
type: string
state:
type: string
redirect_uri:
type: string
resource_path:
type: string
required:
- code
- state
- redirect_uri
responses:
"200":
description: native trigger service oauth completed
@@ -12628,6 +12683,91 @@ paths:
items:
$ref: "#/components/schemas/NextCloudEventType"
/w/{workspace}/native_triggers/google/calendars:
get:
summary: list Google Calendars for the authenticated user
operationId: listGoogleCalendars
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
"200":
description: list of Google Calendars
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/GoogleCalendarEntry"
/w/{workspace}/native_triggers/google/drive/files:
get:
summary: list or search Google Drive files
operationId: listGoogleDriveFiles
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
- name: q
in: query
description: search query to filter files by name
schema:
type: string
- name: parent_id
in: query
description: folder ID to list children of
schema:
type: string
- name: page_token
in: query
description: token for next page of results
schema:
type: string
- name: shared_with_me
in: query
description: if true, list files shared with the user
schema:
type: boolean
default: false
responses:
"200":
description: list of Google Drive files
content:
application/json:
schema:
$ref: "#/components/schemas/GoogleDriveFilesResponse"
/w/{workspace}/native_triggers/google/drive/shared_drives:
get:
summary: list shared drives accessible to the user
operationId: listGoogleSharedDrives
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
"200":
description: list of shared drives
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/SharedDriveEntry"
/native_triggers/{service_name}/w/{workspace_id}/webhook/{internal_id}:
post:
summary: receive webhook from external native trigger service
@@ -20068,6 +20208,7 @@ components:
- mqtt
- sqs
- gcp
- google
TriggerMode:
description: job trigger mode
@@ -20531,6 +20672,8 @@ components:
type: number
nextcloud_count:
type: number
google_count:
type: number
WebsocketTrigger:
allOf:
@@ -23531,6 +23674,7 @@ components:
type: string
enum:
- nextcloud
- google
NativeTrigger:
type: object
@@ -23613,6 +23757,10 @@ components:
oauth_data:
nullable: true
$ref: "#/components/schemas/WorkspaceOAuthConfig"
resource_path:
type: string
nullable: true
description: Path to the resource storing the OAuth token
required:
- service_name
@@ -23734,3 +23882,57 @@ components:
- id
- name
- path
GoogleCalendarEntry:
type: object
properties:
id:
type: string
summary:
type: string
primary:
type: boolean
default: false
required:
- id
- summary
GoogleDriveFile:
type: object
properties:
id:
type: string
name:
type: string
mime_type:
type: string
is_folder:
type: boolean
default: false
required:
- id
- name
- mime_type
GoogleDriveFilesResponse:
type: object
properties:
files:
type: array
items:
$ref: "#/components/schemas/GoogleDriveFile"
next_page_token:
type: string
required:
- files
SharedDriveEntry:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
- name
+22 -8
View File
@@ -3413,29 +3413,43 @@ pub async fn get_args_and_trigger_metadata(
// Build trigger metadata if this is a native trigger request
#[cfg(feature = "native_trigger")]
let trigger_metadata = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::ServiceName;
let (trigger_metadata, native_args) = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::{prepare_native_trigger_args, ServiceName};
let service_name = ServiceName::try_from(service_name_str.to_owned())?;
Some(TriggerMetadata::new(
let metadata = Some(TriggerMetadata::new(
run_query.trigger_external_id.clone(),
service_name.as_job_trigger_kind(),
))
));
let body = match &args.body {
crate::args::RawBody::Json(s) => s.clone(),
crate::args::RawBody::Text(s) => s.clone(),
_ => String::new(),
};
let native =
prepare_native_trigger_args(service_name, db, w_id, &args.metadata.headers, body)
.await?;
(metadata, native)
} else {
None
(None, None)
};
#[cfg(not(feature = "native_trigger"))]
let trigger_metadata: Option<TriggerMetadata> = None;
#[cfg(not(feature = "native_trigger"))]
let native_args: Option<windmill_queue::PushArgsOwned> = None;
let args = args
.to_args_from_runnable(
let args = if let Some(prepared) = native_args {
prepared
} else {
args.to_args_from_runnable(
&authed,
&db,
&w_id,
runnable_id,
run_query.skip_preprocessor,
)
.await?;
.await?
};
Ok((args, trigger_metadata))
}
@@ -136,6 +136,7 @@ pub struct TriggersCount {
sqs_count: i64,
gcp_count: i64,
nextcloud_count: i64,
google_count: i64,
}
pub async fn get_triggers_count_internal(
@@ -305,6 +306,16 @@ pub async fn get_triggers_count_internal(
.await?
.unwrap_or(0);
let google_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
Ok(axum::Json(TriggersCount {
primary_schedule: primary_schedule
.map(|s| windmill_trigger::handler::TriggerPrimarySchedule { schedule: s }),
@@ -321,5 +332,6 @@ pub async fn get_triggers_count_internal(
gcp_count,
sqs_count,
nextcloud_count,
google_count,
}))
}
+9
View File
@@ -335,6 +335,15 @@ async fn update_username_in_workpsace<'c>(
).execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
@@ -145,6 +145,68 @@ pub async fn load_value_from_global_settings(
Ok(r)
}
/// Read OAuth client_id and client_secret from instance-level global settings.
/// `oauth_key` is the key under `oauths` (e.g., "gworkspace", "nextcloud").
pub async fn get_instance_oauth_credentials(
db: &Pool<Postgres>,
oauth_key: &str,
) -> error::Result<(String, String)> {
let oauths_value = load_value_from_global_settings(db, OAUTH_SETTING)
.await?
.ok_or_else(|| {
error::Error::InternalErr("Instance OAuth settings not found".to_string())
})?;
let entry = oauths_value.get(oauth_key).ok_or_else(|| {
error::Error::InternalErr(format!("No {} entry in instance OAuth settings", oauth_key))
})?;
let id = entry
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let secret = entry
.get("secret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if id.is_empty() || secret.is_empty() {
return Err(error::Error::InternalErr(format!(
"Instance OAuth credentials for {} are incomplete",
oauth_key
)));
}
Ok((id, secret))
}
/// Map service client name to the OAuth settings key in global_settings.
/// e.g. "google" -> "gworkspace", "nextcloud" -> "nextcloud"
pub fn workspace_integration_oauth_key(client_name: &str) -> &str {
match client_name {
"google" => "gworkspace",
other => other,
}
}
/// Resolve the token endpoint URL for a workspace integration service.
pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://oauth2.googleapis.com/token".to_string(),
_ => format!("{}/apps/oauth2/api/v1/token", base_url),
}
}
/// Resolve the auth endpoint URL for a workspace integration service.
pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
_ => format!("{}/apps/oauth2/authorize", base_url),
}
}
pub async fn set_value_in_global_settings(
db: &Pool<Postgres>,
setting_name: &str,
+20 -8
View File
@@ -176,10 +176,7 @@ fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schem
metadata: Some(Box::default()),
extensions: {
let mut m = schemars::Map::new();
m.insert(
"nullable".to_string(),
serde_json::Value::Bool(true),
);
m.insert("nullable".to_string(), serde_json::Value::Bool(true));
m.insert(
"x-kubernetes-preserve-unknown-fields".to_string(),
serde_json::Value::Bool(true),
@@ -292,7 +289,10 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_tracing_proxy: Option<OtelTracingProxySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "instance_config_schema", schemars(schema_with = "opaque_json_schema"))]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub object_store_cache_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_error_channels: Option<Vec<CriticalErrorChannel>>,
@@ -305,13 +305,22 @@ pub struct GlobalSettings {
// Opaque settings (EE-private structs or no clear schema)
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "instance_config_schema", schemars(schema_with = "opaque_json_schema"))]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub secret_backend: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "instance_config_schema", schemars(schema_with = "opaque_json_schema"))]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub slack: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "instance_config_schema", schemars(schema_with = "opaque_json_schema"))]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub teams: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
@@ -399,6 +408,8 @@ pub struct OAuthClient {
pub connect_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub login_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share_with_workspaces: Option<bool>,
}
/// OAuth provider endpoint configuration.
@@ -1941,6 +1952,7 @@ mod tests {
allowed_domains: None,
connect_config: None,
login_config: None,
share_with_workspaces: None,
},
);
m
@@ -0,0 +1,531 @@
use async_trait::async_trait;
use reqwest::Method;
use serde_json::value::RawValue;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
worker::to_raw_value,
BASE_URL, DB,
};
use windmill_queue::PushArgsOwned;
use crate::{
generate_webhook_service_url, get_token_by_prefix,
sync::{SyncAction, SyncError, TriggerSyncInfo},
update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger,
NativeTriggerData, ServiceName,
};
use super::{
endpoints, routes, CreateWatchResponse, Google, GoogleOAuthData, GoogleServiceConfig,
GoogleTriggerType, StopChannelRequest, WatchRequest,
};
#[async_trait]
impl External for Google {
type ServiceConfig = GoogleServiceConfig;
// Google has no "get channel" API, so TriggerData is never constructed.
// The trait default for get() returns Ok(None).
type TriggerData = ();
type OAuthData = GoogleOAuthData;
type CreateResponse = CreateWatchResponse;
const SERVICE_NAME: ServiceName = ServiceName::Google;
const DISPLAY_NAME: &'static str = "Google";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
const REFRESH_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
const AUTH_ENDPOINT: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
async fn create(
&self,
w_id: &str,
_oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
_tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
let channel_id = uuid::Uuid::new_v4().to_string();
self.create_watch_channel(w_id, &channel_id, webhook_token, data, db)
.await
}
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<serde_json::Value> {
// Google doesn't support updating watch channels — delete old, create new.
let _ = self.delete(w_id, oauth_data, external_id, db, tx).await;
// Reuse the same channel ID so external_id stays permanent
let resp = self
.create_watch_channel(w_id, external_id, webhook_token, data, db)
.await?;
self.service_config_from_create_response(data, &resp)
.ok_or_else(|| {
Error::InternalErr(
"Failed to build service_config from create response".to_string(),
)
})
}
async fn delete(
&self,
w_id: &str,
_oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
// Get the stored trigger to find the google_resource_id and trigger_type
let trigger = sqlx::query_scalar!(
r#"
SELECT service_config
FROM native_trigger
WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3
"#,
external_id,
ServiceName::Google as ServiceName,
w_id
)
.fetch_optional(&mut *tx)
.await?;
let config = trigger.flatten();
if config.is_none() {
return Ok(());
}
let config = config.unwrap();
let (google_resource_id, url) = super::parse_stop_channel_params(&config);
if !google_resource_id.is_empty() {
let stop_request =
StopChannelRequest { id: external_id.to_string(), resource_id: google_resource_id };
// Stop the channel (ignore errors - channel may have already expired)
let result: std::result::Result<serde_json::Value, _> = self
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
.await;
if let Err(e) = result {
tracing::warn!("Failed to stop Google channel {}: {}", external_id, e);
}
}
Ok(())
}
async fn maintain_triggers(
&self,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
_oauth_data: &Self::OAuthData,
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
renew_expiring_channels(self, db, workspace_id, triggers, synced, errors).await;
}
async fn prepare_webhook(
&self,
_db: &DB,
_w_id: &str,
headers: HashMap<String, String>,
_body: String,
_script_path: &str,
_is_flow: bool,
) -> Result<PushArgsOwned> {
// Google sends notification info in headers (same format for Drive and Calendar)
let payload = serde_json::json!({
"channel_id": headers.get("x-goog-channel-id").cloned().unwrap_or_default(),
"resource_id": headers.get("x-goog-resource-id").cloned().unwrap_or_default(),
"resource_state": headers.get("x-goog-resource-state").cloned().unwrap_or_default(),
"resource_uri": headers.get("x-goog-resource-uri").cloned().unwrap_or_default(),
"message_number": headers.get("x-goog-message-number").cloned().unwrap_or_default(),
"channel_expiration": headers.get("x-goog-channel-expiration").cloned().unwrap_or_default(),
"changed": headers.get("x-goog-changed").cloned().unwrap_or_default(),
"channel_token": headers.get("x-goog-channel-token").cloned().unwrap_or_default(),
});
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
args.insert("payload".to_string(), to_raw_value(&payload));
Ok(PushArgsOwned { extra: None, args })
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
let metadata = serde_json::json!({
"googleResourceId": resp.resource_id,
"expiration": resp.expiration,
});
(resp.id.clone(), Some(metadata))
}
fn service_config_from_create_response(
&self,
data: &NativeTriggerData<Self::ServiceConfig>,
resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
let mut config = data.service_config.clone();
config.google_resource_id = Some(resp.resource_id.clone());
config.expiration = Some(resp.expiration.clone());
serde_json::to_value(&config).ok()
}
fn additional_routes(&self) -> axum::Router {
routes::google_routes(self.clone())
}
}
// Helper methods for creating trigger type-specific watches
impl Google {
/// Build a webhook URL and watch request, then register the channel with Google.
/// Used by both `create()` (new UUID) and `update()` (reuse existing external_id).
async fn create_watch_channel(
&self,
w_id: &str,
channel_id: &str,
webhook_token: &str,
data: &NativeTriggerData<GoogleServiceConfig>,
db: &DB,
) -> Result<CreateWatchResponse> {
let base_url = &*BASE_URL.read().await;
let webhook_url = generate_webhook_service_url(
base_url,
w_id,
&data.script_path,
data.is_flow,
Some(channel_id),
ServiceName::Google,
webhook_token,
);
tracing::info!(
"Creating Google {} watch channel '{}' with webhook URL: {}",
data.service_config.trigger_type,
channel_id,
webhook_url
);
let expiration_ms = chrono::Utc::now().timestamp_millis()
+ (data.service_config.max_expiration_hours() as i64 * 3600 * 1000);
let mut watch_request = WatchRequest::new(channel_id.to_string(), webhook_url);
watch_request.expiration = Some(expiration_ms);
match data.service_config.trigger_type {
GoogleTriggerType::Drive => {
self.create_drive_watch(w_id, &data.service_config, &watch_request, db)
.await
}
GoogleTriggerType::Calendar => {
self.create_calendar_watch(w_id, &data.service_config, &watch_request, db)
.await
}
}
}
async fn create_drive_watch(
&self,
w_id: &str,
config: &GoogleServiceConfig,
watch_request: &WatchRequest,
db: &DB,
) -> Result<CreateWatchResponse> {
match config.resource_id.as_deref().filter(|s| !s.is_empty()) {
Some(resource_id) => {
// Specific file: use files.watch
let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id);
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
.await
}
None => {
// All changes: use changes.watch
let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE);
let token_response: serde_json::Value = self
.http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None)
.await?;
let start_page_token = token_response
.get("startPageToken")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::InternalErr("Failed to get startPageToken".to_string())
})?;
let watch_body = serde_json::to_value(watch_request)?;
let watch_url = format!(
"{}/changes/watch?pageToken={}",
endpoints::DRIVE_API_BASE,
start_page_token
);
self.http_client_request(
&watch_url,
Method::POST,
w_id,
db,
None,
Some(&watch_body),
)
.await
}
}
}
async fn create_calendar_watch(
&self,
w_id: &str,
config: &GoogleServiceConfig,
watch_request: &WatchRequest,
db: &DB,
) -> Result<CreateWatchResponse> {
let calendar_id = config.calendar_id.as_ref().ok_or_else(|| {
Error::BadRequest("calendar_id is required for Calendar triggers".into())
})?;
let url = format!(
"{}/calendars/{}/events/watch",
endpoints::CALENDAR_API_BASE,
urlencoding::encode(calendar_id)
);
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
.await
}
/// Renew an expiring Google watch channel.
/// Stops the old channel and creates a new one with the same channel ID.
/// Returns the updated service_config with new expiration.
pub async fn renew_channel(
&self,
w_id: &str,
trigger: &NativeTrigger,
db: &DB,
) -> Result<serde_json::Value> {
let config: GoogleServiceConfig = trigger
.service_config
.as_ref()
.map(|v| serde_json::from_value(v.clone()))
.transpose()?
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix)
.await?
.ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?;
let base_url = &*BASE_URL.read().await;
// Reuse the same channel ID so external_id stays permanent
let channel_id = trigger.external_id.clone();
let webhook_url = generate_webhook_service_url(
base_url,
w_id,
&trigger.script_path,
trigger.is_flow,
Some(&channel_id),
ServiceName::Google,
&webhook_token,
);
tracing::info!(
"Renewing Google {} watch channel '{}' with webhook URL: {}",
config.trigger_type,
channel_id,
webhook_url
);
let expiration_ms = chrono::Utc::now().timestamp_millis()
+ (config.max_expiration_hours() as i64 * 3600 * 1000);
let mut watch_request = WatchRequest::new(channel_id.clone(), webhook_url);
watch_request.expiration = Some(expiration_ms);
// Best-effort stop old channel before creating a new one
let old_google_resource_id = trigger
.service_config
.as_ref()
.and_then(|c| c.get("googleResourceId"))
.and_then(|r| r.as_str())
.unwrap_or_default();
if !old_google_resource_id.is_empty() {
let stop_request = StopChannelRequest {
id: channel_id.clone(),
resource_id: old_google_resource_id.to_string(),
};
let url = match config.trigger_type {
GoogleTriggerType::Calendar => {
format!("{}/channels/stop", endpoints::CALENDAR_API_BASE)
}
GoogleTriggerType::Drive => {
format!("{}/channels/stop", endpoints::DRIVE_API_BASE)
}
};
let result: std::result::Result<serde_json::Value, _> = self
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
.await;
if let Err(e) = result {
tracing::warn!(
"Failed to stop old Google channel {} during renewal: {}",
channel_id,
e
);
}
}
// Create new watch channel with the same channel ID
let resp = match config.trigger_type {
GoogleTriggerType::Drive => {
self.create_drive_watch(w_id, &config, &watch_request, db)
.await?
}
GoogleTriggerType::Calendar => {
self.create_calendar_watch(w_id, &config, &watch_request, db)
.await?
}
};
// Build the updated service_config with new expiration
let mut new_config = config;
new_config.google_resource_id = Some(resp.resource_id);
new_config.expiration = Some(resp.expiration);
serde_json::to_value(&new_config)
.map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))
}
}
/// Renewal window: renew Drive channels with <1 hour remaining, Calendar with <1 day remaining.
pub fn should_renew_channel(service_config: &serde_json::Value) -> bool {
let expiration_ms = service_config
.get("expiration")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
if expiration_ms == 0 {
return false;
}
let now_ms = chrono::Utc::now().timestamp_millis();
let remaining_ms = expiration_ms - now_ms;
let trigger_type = service_config
.get("triggerType")
.and_then(|t| t.as_str())
.unwrap_or("drive");
let renewal_window_ms: i64 = match trigger_type {
"calendar" => 24 * 60 * 60 * 1000, // 1 day for Calendar (7 day expiry)
_ => 60 * 60 * 1000, // 1 hour for Drive (24h expiry)
};
remaining_ms < renewal_window_ms
}
async fn renew_expiring_channels(
handler: &Google,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
for trigger in triggers {
let Some(config) = &trigger.service_config else {
continue;
};
if !should_renew_channel(config) {
continue;
}
tracing::info!(
"Renewing expiring Google channel {} for script_path '{}' in workspace '{}'",
trigger.external_id,
trigger.script_path,
workspace_id
);
match handler.renew_channel(workspace_id, trigger, db).await {
Ok(new_config) => {
match update_native_trigger_service_config(
db,
workspace_id,
ServiceName::Google,
&trigger.external_id,
&new_config,
)
.await
{
Ok(()) => {
tracing::info!(
"Renewed Google channel {} for '{}'",
trigger.external_id,
trigger.script_path
);
synced.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
});
}
Err(e) => {
tracing::error!(
"Failed to update DB after renewing Google channel {}: {}",
trigger.external_id,
e
);
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update DB after channel renewal for {}: {}",
trigger.external_id, e
),
error_type: "channel_renewal_error".to_string(),
});
}
}
}
Err(e) => {
tracing::error!(
"Failed to renew Google channel {} for '{}': {}",
trigger.external_id,
trigger.script_path,
e
);
let _ = update_native_trigger_error(
db,
workspace_id,
ServiceName::Google,
&trigger.external_id,
Some(&format!("Channel renewal failed: {}", e)),
)
.await;
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Channel renewal failed for {}: {}",
trigger.external_id, e
),
error_type: "channel_renewal_error".to_string(),
});
}
}
}
}
@@ -0,0 +1,241 @@
//! Google Native Trigger Module
//!
//! This module provides integration with Google services (Drive, Calendar)
//! push notification system to trigger Windmill scripts/flows when changes occur.
//!
//! ## Unified Architecture
//! A single "google" native trigger service handles both Drive and Calendar triggers.
//! The `trigger_type` field in `GoogleServiceConfig` determines which service to use.
//!
//! ## How it works:
//! 1. User configures a trigger with trigger_type (drive/calendar) and service-specific settings
//! 2. Windmill creates a "watch channel" via the appropriate Google API
//! 3. Google sends push notifications to Windmill's webhook when changes occur
//! 4. The webhook triggers the configured script/flow
//!
//! ## Important notes:
//! - Drive watch channels expire after max 24 hours
//! - Calendar watch channels expire after max 7 days
//! - Background sync job renews channels before expiration
use serde::{Deserialize, Serialize};
pub mod external;
pub mod routes;
pub use external::should_renew_channel;
/// Extracts `(google_resource_id, stop_url)` from a native trigger's service_config JSON.
/// Used by the `delete` method and tested independently.
pub fn parse_stop_channel_params(config: &serde_json::Value) -> (String, String) {
let google_resource_id = config
.get("googleResourceId")
.and_then(|r| r.as_str())
.map(String::from)
.unwrap_or_default();
let trigger_type = config
.get("triggerType")
.and_then(|t| t.as_str())
.unwrap_or("drive");
let stop_url = match trigger_type {
"calendar" => format!("{}/channels/stop", endpoints::CALENDAR_API_BASE),
_ => format!("{}/channels/stop", endpoints::DRIVE_API_BASE),
};
(google_resource_id, stop_url)
}
/// Handler struct for Google triggers (stateless, used for routing)
#[derive(Copy, Clone)]
pub struct Google;
/// Type of Google trigger
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoogleTriggerType {
Drive,
Calendar,
}
impl std::fmt::Display for GoogleTriggerType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GoogleTriggerType::Drive => write!(f, "drive"),
GoogleTriggerType::Calendar => write!(f, "calendar"),
}
}
}
/// User-provided configuration for a Google trigger.
/// The trigger_type determines which service-specific config is used.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GoogleServiceConfig {
/// The type of trigger (drive or calendar)
pub trigger_type: GoogleTriggerType,
// Drive-specific fields (only used when trigger_type = drive)
/// The file ID to watch, or None for all changes (Drive only)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
/// Human-readable name/path for display purposes (Drive only)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
// Calendar-specific fields (only used when trigger_type = calendar)
/// The calendar ID to watch (Calendar only, e.g., "primary")
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_id: Option<String>,
/// Human-readable calendar name (Calendar only)
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_name: Option<String>,
// Metadata from Google watch channel (set after creation, used for renewal/deletion)
/// The resource ID assigned by Google for the watch channel
#[serde(skip_serializing_if = "Option::is_none")]
pub google_resource_id: Option<String>,
/// Channel expiration time (Unix timestamp in milliseconds, as string)
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<String>,
}
impl GoogleServiceConfig {
/// Returns the expiration duration for this trigger type in hours
pub fn max_expiration_hours(&self) -> u64 {
match self.trigger_type {
GoogleTriggerType::Drive => 24, // Google Drive: max 24 hours
GoogleTriggerType::Calendar => 168, // Google Calendar: max 7 days
}
}
}
/// OAuth data structure shared by all Google services.
/// Stored encrypted in workspace_integrations table with service_name = 'google'.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GoogleOAuthData {
/// The OAuth access token for API requests
pub access_token: String,
/// The OAuth refresh token for obtaining new access tokens
pub refresh_token: Option<String>,
/// When the access token expires
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Google API endpoints
pub mod endpoints {
/// Google Drive API v3 base URL
pub const DRIVE_API_BASE: &str = "https://www.googleapis.com/drive/v3";
/// Google Calendar API v3 base URL
pub const CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3";
/// Google OAuth2 token endpoint
pub const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
/// Google OAuth2 authorization endpoint
pub const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
}
/// OAuth scopes for Google services
pub mod scopes {
/// Read-only access to Google Drive files
pub const DRIVE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
/// Read-only access to Google Calendar
pub const CALENDAR_READONLY: &str = "https://www.googleapis.com/auth/calendar.readonly";
/// Events access to Google Calendar
pub const CALENDAR_EVENTS: &str = "https://www.googleapis.com/auth/calendar.events";
/// Returns all scopes needed for Google triggers (both Drive and Calendar)
pub fn all_scopes() -> Vec<&'static str> {
vec![DRIVE_READONLY, CALENDAR_READONLY, CALENDAR_EVENTS]
}
}
/// Common response wrapper for Google API errors
#[derive(Debug, Deserialize)]
pub struct GoogleApiError {
pub error: GoogleErrorDetails,
}
#[derive(Debug, Deserialize)]
pub struct GoogleErrorDetails {
pub code: i32,
pub message: String,
#[serde(default)]
pub errors: Vec<GoogleErrorItem>,
}
#[derive(Debug, Deserialize)]
pub struct GoogleErrorItem {
pub domain: Option<String>,
pub reason: Option<String>,
pub message: Option<String>,
}
/// Google Watch Channel response (used by Drive and Calendar push notifications)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchChannel {
/// Unique channel ID (we generate this as UUID)
pub id: String,
/// Resource ID assigned by Google
pub resource_id: String,
/// Resource URI being watched
pub resource_uri: Option<String>,
/// Channel expiration time (Unix timestamp in milliseconds)
pub expiration: i64,
/// Token for validation (optional)
pub token: Option<String>,
}
/// Response from Google API when creating a watch channel
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateWatchResponse {
/// The channel ID we provided
pub id: String,
/// Resource ID assigned by Google
pub resource_id: String,
/// Resource URI being watched
pub resource_uri: Option<String>,
/// Channel expiration (Unix timestamp in milliseconds)
pub expiration: String,
}
/// Request body for creating a watch channel
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchRequest {
/// Unique channel ID (UUID)
pub id: String,
/// Type of delivery mechanism (always "web_hook")
#[serde(rename = "type")]
pub channel_type: String,
/// The URL to receive notifications
pub address: String,
/// Optional token for validation
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
/// Optional expiration time in milliseconds (Google may adjust this)
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<i64>,
}
impl WatchRequest {
pub fn new(channel_id: String, webhook_url: String) -> Self {
Self {
id: channel_id,
channel_type: "web_hook".to_string(),
address: webhook_url,
token: None,
expiration: None,
}
}
}
/// Request body for stopping a watch channel
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopChannelRequest {
pub id: String,
pub resource_id: String,
}
@@ -0,0 +1,220 @@
use std::sync::Arc;
use axum::{
extract::{Path, Query},
routing::get,
Extension, Json, Router,
};
use http::Method;
use serde::{Deserialize, Serialize};
use windmill_common::{error::JsonResult, DB};
use crate::{get_workspace_integration, External, ServiceName};
use super::Google;
fn escape_drive_query(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleCalendarEntry {
pub id: String,
pub summary: String,
#[serde(default)]
pub primary: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GoogleCalendarListResponse {
#[serde(default)]
items: Vec<GoogleCalendarListItem>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GoogleCalendarListItem {
id: String,
#[serde(default)]
summary: String,
#[serde(default)]
primary: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleDriveFile {
pub id: String,
pub name: String,
pub mime_type: String,
#[serde(default)]
pub is_folder: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleDriveFilesResponse {
pub files: Vec<GoogleDriveFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DriveApiResponse {
#[serde(default)]
files: Vec<DriveApiFile>,
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DriveApiFile {
id: String,
name: String,
mime_type: String,
}
#[derive(Debug, Deserialize)]
pub struct DriveFilesQuery {
pub q: Option<String>,
pub parent_id: Option<String>,
pub page_token: Option<String>,
#[serde(default)]
pub shared_with_me: bool,
}
async fn list_calendars(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GoogleCalendarEntry>> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
"{}/users/me/calendarList",
super::endpoints::CALENDAR_API_BASE
);
let response: GoogleCalendarListResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let calendars = response
.items
.into_iter()
.map(|item| GoogleCalendarEntry {
id: item.id,
summary: item.summary,
primary: item.primary,
})
.collect();
Ok(Json(calendars))
}
async fn list_drive_files(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
Query(query): Query<DriveFilesQuery>,
) -> JsonResult<GoogleDriveFilesResponse> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let drive_query = if query.shared_with_me {
"sharedWithMe = true and trashed = false".to_string()
} else if let Some(ref parent_id) = query.parent_id {
format!(
"'{}' in parents and trashed = false",
escape_drive_query(parent_id)
)
} else if let Some(ref search) = query.q {
format!(
"name contains '{}' and trashed = false",
escape_drive_query(search)
)
} else {
"'root' in parents and trashed = false".to_string()
};
let mut url = format!(
"{}/files?q={}&fields=files(id,name,mimeType),nextPageToken&pageSize=50&orderBy=folder,name&supportsAllDrives=true&includeItemsFromAllDrives=true",
super::endpoints::DRIVE_API_BASE,
urlencoding::encode(&drive_query)
);
if let Some(ref page_token) = query.page_token {
url.push_str(&format!("&pageToken={}", urlencoding::encode(page_token)));
}
let response: DriveApiResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let files = response
.files
.into_iter()
.map(|f| {
let is_folder = f.mime_type == "application/vnd.google-apps.folder";
GoogleDriveFile { id: f.id, name: f.name, mime_type: f.mime_type, is_folder }
})
.collect();
Ok(Json(GoogleDriveFilesResponse {
files,
next_page_token: response.next_page_token,
}))
}
#[derive(Debug, Serialize)]
pub struct SharedDriveEntry {
pub id: String,
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SharedDrivesApiResponse {
#[serde(default)]
drives: Vec<SharedDriveApiEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SharedDriveApiEntry {
id: String,
name: String,
}
async fn list_shared_drives(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<SharedDriveEntry>> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
"{}/drives?pageSize=100&fields=drives(id,name)",
super::endpoints::DRIVE_API_BASE
);
let response: SharedDrivesApiResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let drives = response
.drives
.into_iter()
.map(|d| SharedDriveEntry { id: d.id, name: d.name })
.collect();
Ok(Json(drives))
}
pub fn google_routes(service: Google) -> Router {
let service = Arc::new(service);
Router::new()
.route("/calendars", get(list_calendars))
.route("/drive/files", get(list_drive_files))
.route("/drive/shared_drives", get(list_shared_drives))
.layer(Extension(service))
}
+66 -124
View File
@@ -1,8 +1,7 @@
use crate::{
delete_native_trigger, delete_token_by_prefix, get_native_trigger, get_token_by_prefix,
get_workspace_integration, list_native_triggers, store_native_trigger,
update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData,
ServiceName,
decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger,
get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error,
External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName,
};
use axum::{
extract::{Path, Query},
@@ -65,7 +64,7 @@ pub struct ListQuery {
pub struct FullTriggerResponse<T: Serialize> {
#[serde(flatten)]
pub windmill_data: NativeTrigger,
pub external_data: T,
pub external_data: Option<T>,
}
#[derive(Debug, Serialize)]
@@ -132,15 +131,9 @@ async fn create_native_trigger<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
let resp = handler
.create(
@@ -155,24 +148,25 @@ async fn create_native_trigger<T: External>(
let (external_id, _) = handler.external_id_and_metadata_from_response(&resp);
// update the created external trigger with a new uri containing the external_id
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
// 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?
};
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
@@ -227,22 +221,32 @@ async fn update_native_trigger_handler<T: External>(
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).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)))?;
// Look up the full token using the stored prefix (use db, not tx, for token table)
let runnable_changed =
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
Some(token) => token,
None => {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
new_webhook_token(
Some(token) if !runnable_changed => token,
existing_token => {
if let Some(_) = existing_token {
delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?;
} else {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
}
let token = new_webhook_token(
&mut *tx,
&db,
&authed,
@@ -251,21 +255,14 @@ async fn update_native_trigger_handler<T: External>(
&workspace_id,
service_name,
)
.await?
.await?;
tx.commit().await?;
tx = user_db.begin(&authed).await?;
token
}
};
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
let service_config = handler
.update(
&workspace_id,
&oauth_data,
@@ -277,12 +274,6 @@ async fn update_native_trigger_handler<T: External>(
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
@@ -341,22 +332,16 @@ async fn get_native_trigger_handler<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
let native_trigger = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await;
let native_trigger_config = match native_trigger {
Ok(native_cfg) => {
let external_data = match native_trigger {
Ok(Some(native_cfg)) => {
// Clear error if it was set
if windmill_trigger.error.is_some() {
update_native_trigger_error(
@@ -368,8 +353,9 @@ async fn get_native_trigger_handler<T: External>(
)
.await?;
}
native_cfg
Some(native_cfg)
}
Ok(None) => None,
Err(Error::NotFound(_)) => {
let error_msg = "Trigger no longer exists on external service".to_string();
tracing::warn!(
@@ -396,10 +382,7 @@ async fn get_native_trigger_handler<T: External>(
Err(e) => return Err(e),
};
let full_resp = Json(FullTriggerResponse {
windmill_data: windmill_trigger,
external_data: native_trigger_config,
});
let full_resp = Json(FullTriggerResponse { windmill_data: windmill_trigger, external_data });
Ok(full_resp)
}
@@ -430,15 +413,9 @@ async fn delete_native_trigger_handler<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
handler
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
@@ -476,34 +453,6 @@ async fn delete_native_trigger_handler<T: External>(
Ok(format!("Native trigger deleted"))
}
async fn exists_native_trigger_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM native_trigger
WHERE
workspace_id = $1 AND
service_name = $2 AND
external_id = $3
)
"#,
workspace_id,
service_name as ServiceName,
external_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn list_native_triggers_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
@@ -543,10 +492,6 @@ pub fn service_routes<T: External + 'static>(handler: T) -> Router {
.route(
"/delete/:external_id",
delete(delete_native_trigger_handler::<T>),
)
.route(
"/exists/:external_id",
get(exists_native_trigger_handler::<T>),
);
standard_routes
@@ -562,15 +507,12 @@ pub fn generate_native_trigger_routers() -> Router {
#[cfg(feature = "native_trigger")]
{
use crate::google::Google;
use crate::nextcloud::NextCloud;
// Register all service routes here
// When adding a new service:
// 1. Import the handler: use crate::newservice::NewServiceHandler;
// 2. Add the route: .nest("/newservice", service_routes(NewServiceHandler))
return router.nest("/nextcloud", service_routes(NextCloud));
// Add new services here:
// .nest("/newservice", service_routes(NewServiceHandler))
return router
.nest("/nextcloud", service_routes(NextCloud))
.nest("/google", service_routes(Google));
}
#[cfg(not(feature = "native_trigger"))]
+337 -171
View File
@@ -36,7 +36,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use http::StatusCode;
use itertools::Itertools;
use reqwest::{Client, Method};
use reqwest::Method;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json;
use serde_json::value::RawValue;
@@ -47,6 +47,7 @@ use tokio::task;
use windmill_common::{
error::{to_anyhow, Error, Result},
triggers::TriggerKind,
utils::HTTP_CLIENT,
variables::{build_crypt, decrypt, encrypt},
DB,
};
@@ -62,9 +63,9 @@ pub mod workspace_integrations;
// Service modules - add new services here:
#[cfg(feature = "native_trigger")]
pub mod google;
#[cfg(feature = "native_trigger")]
pub mod nextcloud;
// #[cfg(feature = "native_trigger")]
// pub mod newservice;
/// Enum of all supported native trigger services.
/// When adding a new service, add a variant here (e.g., `NewService`).
@@ -73,17 +74,15 @@ pub mod nextcloud;
#[serde(rename_all = "lowercase")]
pub enum ServiceName {
Nextcloud,
// Add new services here:
// NewService,
Google,
}
impl TryFrom<String> for ServiceName {
type Error = Error;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
// Add new service match arms here:
let service = match value.as_str() {
"nextcloud" => ServiceName::Nextcloud,
// "newservice" => ServiceName::NewService,
"google" => ServiceName::Google,
_ => {
return Err(anyhow::anyhow!(
"Unknown service, currently supported services are: [{}]",
@@ -99,49 +98,73 @@ impl TryFrom<String> for ServiceName {
impl ServiceName {
/// Returns the lowercase string identifier for this service.
/// Add new service match arms here.
pub fn as_str(&self) -> &'static str {
match self {
ServiceName::Nextcloud => "nextcloud",
// ServiceName::NewService => "newservice",
ServiceName::Google => "google",
}
}
/// Returns the corresponding TriggerKind for this service.
/// Requires adding the variant to TriggerKind in windmill_common.
pub fn as_trigger_kind(&self) -> TriggerKind {
match self {
ServiceName::Nextcloud => TriggerKind::Nextcloud,
// ServiceName::NewService => TriggerKind::NewService,
ServiceName::Google => TriggerKind::Google,
}
}
/// Returns the corresponding JobTriggerKind for this service.
/// Requires adding the variant to JobTriggerKind in windmill_common.
pub fn as_job_trigger_kind(&self) -> windmill_common::jobs::JobTriggerKind {
match self {
ServiceName::Nextcloud => windmill_common::jobs::JobTriggerKind::Nextcloud,
// ServiceName::NewService => windmill_common::jobs::JobTriggerKind::NewService,
ServiceName::Google => windmill_common::jobs::JobTriggerKind::Google,
}
}
/// Returns the OAuth token endpoint path for this service.
/// Used for building OAuth clients dynamically.
pub fn token_endpoint(&self) -> &'static str {
match self {
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
// ServiceName::NewService => "/oauth/token",
ServiceName::Google => "https://oauth2.googleapis.com/token",
}
}
/// Returns the OAuth authorization endpoint path for this service.
/// Used for building OAuth authorization URLs.
pub fn auth_endpoint(&self) -> &'static str {
match self {
ServiceName::Nextcloud => "/apps/oauth2/authorize",
// ServiceName::NewService => "/oauth/authorize",
ServiceName::Google => "https://accounts.google.com/o/oauth2/v2/auth",
}
}
/// Returns the OAuth scopes for this service's authorization flow.
pub fn oauth_scopes(&self) -> &'static str {
match self {
ServiceName::Nextcloud => "read write",
ServiceName::Google => "https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events",
}
}
/// Returns the resource type used for storing OAuth tokens.
pub fn resource_type(&self) -> &'static str {
match self {
ServiceName::Nextcloud => "nextcloud",
ServiceName::Google => "gworkspace",
}
}
/// Returns extra OAuth authorization parameters required by this service.
pub fn extra_auth_params(&self) -> &[(&'static str, &'static str)] {
match self {
ServiceName::Google => &[("access_type", "offline"), ("prompt", "consent")],
ServiceName::Nextcloud => &[],
}
}
/// Returns the integration service name for workspace_integrations lookup.
pub fn integration_service(&self) -> ServiceName {
*self
}
}
impl std::fmt::Display for ServiceName {
@@ -150,6 +173,16 @@ impl std::fmt::Display for ServiceName {
}
}
/// Resolves an endpoint URL. If the endpoint is already an absolute URL (starts with http),
/// returns it as-is. Otherwise, prepends the base_url.
pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else {
format!("{}{}", base_url, endpoint)
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct NativeTrigger {
pub external_id: String,
@@ -183,6 +216,7 @@ pub struct WorkspaceIntegration {
pub workspace_id: String,
pub service_name: ServiceName,
pub oauth_data: serde_json::Value,
pub resource_path: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
@@ -200,6 +234,7 @@ pub trait External: Send + Sync + 'static {
const DISPLAY_NAME: &'static str;
const TOKEN_ENDPOINT: &'static str;
const REFRESH_ENDPOINT: &'static str;
const AUTH_ENDPOINT: &'static str;
async fn create(
&self,
@@ -211,6 +246,10 @@ pub trait External: Send + Sync + 'static {
tx: &mut PgConnection,
) -> Result<Self::CreateResponse>;
/// Update a trigger on the external service and return the resolved service_config to store.
/// Each service is responsible for resolving the final config:
/// - Services that re-create the resource (e.g. Google) build config from request data + response metadata.
/// - Services that modify in-place (e.g. Nextcloud) fetch back the updated state and extract config.
async fn update(
&self,
w_id: &str,
@@ -220,16 +259,21 @@ pub trait External: Send + Sync + 'static {
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<()>;
) -> Result<serde_json::Value>;
/// Fetch the trigger's state from the external service.
/// Returns `Ok(None)` (default) when the service has no "get" API (e.g. Google).
/// Services that can fetch state (e.g. Nextcloud) override to return `Ok(Some(data))`.
async fn get(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData>;
_w_id: &str,
_oauth_data: &Self::OAuthData,
_external_id: &str,
_db: &DB,
_tx: &mut PgConnection,
) -> Result<Option<Self::TriggerData>> {
Ok(None)
}
async fn delete(
&self,
@@ -240,23 +284,19 @@ pub trait External: Send + Sync + 'static {
tx: &mut PgConnection,
) -> Result<()>;
#[allow(unused)]
async fn exists(
/// Periodic background maintenance for triggers in a workspace.
/// Each service implements its own logic:
/// - Nextcloud: lists external triggers and reconciles with DB state
/// - Google: renews expiring watch channels
async fn maintain_triggers(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool>;
async fn list_all(
&self,
w_id: &str,
workspace_id: &str,
triggers: &[NativeTrigger],
oauth_data: &Self::OAuthData,
db: &DB,
tx: &mut PgConnection,
) -> Result<Vec<Self::TriggerData>>;
synced: &mut Vec<crate::sync::TriggerSyncInfo>,
errors: &mut Vec<crate::sync::SyncError>,
);
async fn prepare_webhook(
&self,
@@ -275,19 +315,18 @@ pub trait External: Send + Sync + 'static {
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>);
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String;
/// Extracts the service-specific config from trigger data (from external service).
/// Used for comparison during sync to detect config drift.
/// Default implementation converts the trigger data to a JSON value
/// If you need to exclude some fields, skip serializing attributes on the TriggerData struct or override this method.
fn extract_service_config_from_trigger_data(
/// Build the service_config directly from the create response and input data,
/// skipping the update+get cycle after creation.
/// Return `None` (default) to use the update+get pattern (e.g. Nextcloud needs to
/// correct the webhook URL with the external_id assigned by the remote service).
/// Return `Some(config)` to skip update+get entirely (e.g. Google already includes
/// the channel_id in the webhook URL from the start).
fn service_config_from_create_response(
&self,
data: &Self::TriggerData,
) -> Result<serde_json::Value> {
serde_json::to_value(data).map_err(|e| {
Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e))
})
_data: &NativeTriggerData<Self::ServiceConfig>,
_resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
None
}
fn additional_routes(&self) -> axum::Router {
@@ -299,13 +338,12 @@ pub trait External: Send + Sync + 'static {
url: &str,
method: Method,
workspace_id: &str,
tx: &mut PgConnection,
db: &DB,
headers: Option<HashMap<String, String>>,
body: Option<&B>,
) -> Result<T> {
let oauth_config: OAuthConfig =
decrypt_oauth_data(tx, db, workspace_id, Self::SERVICE_NAME).await?;
decrypt_oauth_data(db, workspace_id, Self::SERVICE_NAME).await?;
let result = make_http_request(
url,
@@ -327,19 +365,26 @@ pub trait External: Send + Sync + 'static {
err.status().unwrap()
);
let refreshed_oauth_config =
refresh_oauth_tokens(&oauth_config, Self::REFRESH_ENDPOINT).await?;
let refreshed_oauth_config = refresh_oauth_tokens(
&oauth_config,
Self::REFRESH_ENDPOINT,
Self::AUTH_ENDPOINT,
)
.await?;
task::spawn({
let db_clone = db.clone();
let workspace_id_clone = workspace_id.to_string();
let refreshed_json = oauth_config_to_json(&refreshed_oauth_config);
let service_name = Self::SERVICE_NAME;
let new_access_token = refreshed_oauth_config.access_token.clone();
let new_refresh_token = refreshed_oauth_config.refresh_token.clone();
async move {
update_workspace_integration_tokens_helper(
db_clone,
workspace_id_clone,
Self::SERVICE_NAME,
refreshed_json,
update_oauth_token_resource(
&db_clone,
&workspace_id_clone,
service_name,
&new_access_token,
new_refresh_token.as_deref(),
)
.await;
}
@@ -376,8 +421,8 @@ pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>(
headers: Option<HashMap<String, String>>,
body: Option<&B>,
access_token: &str,
) -> std::result::Result<T, reqwest::Error> {
let client = Client::new();
) -> std::result::Result<T, HttpRequestError> {
let client = &*HTTP_CLIENT;
let mut request = client.request(method, url);
request = request
@@ -400,91 +445,148 @@ pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>(
let response = request.send().await?.error_for_status()?;
let response_json = response.json().await?;
Ok(response_json)
// Handle empty responses (e.g. 204 No Content from Google channels/stop)
let bytes = response.bytes().await?;
if bytes.is_empty() {
serde_json::from_str("null").map_err(HttpRequestError::Json)
} else {
serde_json::from_slice(&bytes).map_err(HttpRequestError::Json)
}
}
pub async fn decrypt_oauth_data<
'c,
E: sqlx::Executor<'c, Database = Postgres>,
T: DeserializeOwned,
>(
tx: E,
#[derive(Debug)]
pub enum HttpRequestError {
Reqwest(reqwest::Error),
Json(serde_json::Error),
}
impl std::fmt::Display for HttpRequestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpRequestError::Reqwest(e) => write!(f, "{}", e),
HttpRequestError::Json(e) => write!(f, "JSON decode error: {}", e),
}
}
}
impl std::error::Error for HttpRequestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
HttpRequestError::Reqwest(e) => Some(e),
HttpRequestError::Json(e) => Some(e),
}
}
}
impl From<reqwest::Error> for HttpRequestError {
fn from(e: reqwest::Error) -> Self {
HttpRequestError::Reqwest(e)
}
}
impl HttpRequestError {
pub fn status(&self) -> Option<StatusCode> {
match self {
HttpRequestError::Reqwest(e) => e.status(),
HttpRequestError::Json(_) => None,
}
}
}
/// Read OAuth client_id and client_secret from instance-level global settings.
/// Used when a workspace integration has `instance_shared: true`.
async fn get_instance_oauth_credentials(
db: &DB,
service_name: ServiceName,
) -> Result<(String, String)> {
windmill_common::global_settings::get_instance_oauth_credentials(
db,
service_name.resource_type(),
)
.await
}
pub async fn decrypt_oauth_data<T: DeserializeOwned>(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let integration = get_workspace_integration(tx, workspace_id, service_name).await?;
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
let oauth_data = integration.oauth_data;
let resource_path = integration.resource_path.as_deref().ok_or_else(|| {
Error::InternalErr(format!(
"No resource_path in {} integration config. Please reconnect the integration.",
service_name
))
})?;
let mc = build_crypt(db, workspace_id).await?;
let mut oauth_data: serde_json::Value = integration.oauth_data;
if let Some(encrypted_access_token) = oauth_data.get("access_token").and_then(|v| v.as_str()) {
let decrypted_access_token = decrypt(&mc, encrypted_access_token.to_string())
.map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?;
oauth_data["access_token"] = serde_json::Value::String(decrypted_access_token);
}
let var_row = sqlx::query!(
"SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
workspace_id,
resource_path,
)
.fetch_optional(db)
.await?
.ok_or_else(|| {
Error::InternalErr(format!(
"Variable at {} not found for {} integration",
resource_path, service_name
))
})?;
if let Some(encrypted_refresh_token) = oauth_data.get("refresh_token").and_then(|v| v.as_str())
let access_token = decrypt(&mc, var_row.value)
.map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?;
let refresh_token = if let Some(account_id) = var_row.account {
sqlx::query_scalar!(
"SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
workspace_id,
account_id,
)
.fetch_optional(db)
.await?
} else {
None
};
let (client_id, client_secret) = if oauth_data
.get("instance_shared")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let decrypted_refresh_token = decrypt(&mc, encrypted_refresh_token.to_string())
.map_err(|e| Error::InternalErr(format!("Failed to decrypt refresh token: {}", e)))?;
oauth_data["refresh_token"] = serde_json::Value::String(decrypted_refresh_token);
}
// Read credentials from instance-level global settings instead of workspace_integrations
let (id, secret) = get_instance_oauth_credentials(db, service_name)
.await
.map_err(|e| {
Error::InternalErr(format!(
"Failed to read instance OAuth credentials for {}: {}",
service_name, e
))
})?;
(id, secret)
} else {
(
oauth_data["client_id"].as_str().unwrap_or("").to_string(),
oauth_data["client_secret"]
.as_str()
.unwrap_or("")
.to_string(),
)
};
serde_json::from_value(oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to deserialize OAuth data: {}", e)))
}
#[allow(unused)]
pub fn oauth_data_to_config(oauth_data: &serde_json::Value) -> Result<OAuthConfig> {
let base_url = oauth_data
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InternalErr("No base_url in OAuth data".to_string()))?
.to_string();
let access_token = oauth_data
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InternalErr("No access_token in OAuth data".to_string()))?
.to_string();
let refresh_token = oauth_data
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_id = oauth_data
.get("client_id")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InternalErr("No client_id in OAuth data".to_string()))?
.to_string();
let client_secret = oauth_data
.get("client_secret")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InternalErr("No client_secret in OAuth data".to_string()))?
.to_string();
Ok(OAuthConfig { base_url, access_token, refresh_token, client_id, client_secret })
}
#[inline]
pub fn oauth_config_to_json(config: &OAuthConfig) -> serde_json::Value {
let mut json = json!({
"base_url": config.base_url,
"access_token": config.access_token,
"client_id": config.client_id,
"client_secret": config.client_secret,
let assembled = json!({
"base_url": oauth_data["base_url"].as_str().unwrap_or(""),
"access_token": access_token,
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
});
if let Some(refresh_token) = &config.refresh_token {
json["refresh_token"] = serde_json::Value::String(refresh_token.clone());
}
json
serde_json::from_value(assembled)
.map_err(|e| Error::InternalErr(format!("Failed to deserialize OAuth data: {}", e)))
}
/// Token refresh response
@@ -500,6 +602,7 @@ struct RefreshTokenResponse {
pub async fn refresh_oauth_tokens(
oauth_config: &OAuthConfig,
refresh_endpoint: &str,
auth_endpoint: &str,
) -> Result<OAuthConfig> {
let refresh_token_str = oauth_config
.refresh_token
@@ -508,9 +611,9 @@ pub async fn refresh_oauth_tokens(
// Build OAuth client for token refresh
// Auth URL is not used for refresh, but required by the client constructor
let auth_url = Url::parse(&format!("{}/oauth/authorize", oauth_config.base_url))
let auth_url = Url::parse(&resolve_endpoint(&oauth_config.base_url, auth_endpoint))
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
let token_url = Url::parse(&format!("{}{}", oauth_config.base_url, refresh_endpoint))
let token_url = Url::parse(&resolve_endpoint(&oauth_config.base_url, refresh_endpoint))
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
let mut client = OClient::new(oauth_config.client_id.clone(), auth_url, token_url);
@@ -539,62 +642,80 @@ pub async fn refresh_oauth_tokens(
pub async fn refresh_oauth_tokens(
_oauth_config: &OAuthConfig,
_refresh_endpoint: &str,
_auth_endpoint: &str,
) -> Result<OAuthConfig> {
Err(Error::InternalErr(
"Native triggers feature is not enabled".to_string(),
))
}
async fn update_workspace_integration_tokens_helper(
db: DB,
workspace_id: String,
async fn update_oauth_token_resource(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
oauth_data: serde_json::Value,
new_access_token: &str,
new_refresh_token: Option<&str>,
) {
let result = async {
let mut tx = db.begin().await?;
let mc = build_crypt(&db, &workspace_id).await?;
let mut encrypted_oauth_data = oauth_data;
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
let resource_path = integration.resource_path.ok_or_else(|| {
Error::InternalErr(format!(
"No resource_path in {} integration config",
service_name
))
})?;
if let Some(access_token) = encrypted_oauth_data
.get("access_token")
.and_then(|v| v.as_str())
{
let encrypted_access_token = encrypt(&mc, access_token);
encrypted_oauth_data["access_token"] =
serde_json::Value::String(encrypted_access_token);
}
if let Some(refresh_token) = encrypted_oauth_data
.get("refresh_token")
.and_then(|v| v.as_str())
{
let encrypted_refresh_token = encrypt(&mc, refresh_token);
encrypted_oauth_data["refresh_token"] =
serde_json::Value::String(encrypted_refresh_token);
}
let mc = build_crypt(db, workspace_id).await?;
let encrypted_token = encrypt(&mc, new_access_token);
sqlx::query!(
r#"
UPDATE workspace_integrations
SET oauth_data = $1, updated_at = now()
WHERE workspace_id = $2 AND service_name = $3
"#,
encrypted_oauth_data,
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
encrypted_token,
workspace_id,
service_name as ServiceName,
resource_path,
)
.execute(&mut *tx)
.execute(db)
.await?;
tx.commit().await?;
if let Some(refresh_token) = new_refresh_token {
sqlx::query!(
"UPDATE account SET
refresh_token = $1,
expires_at = now() + interval '1 hour',
refresh_error = NULL
WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
refresh_token,
workspace_id,
service_name.as_str(),
)
.execute(db)
.await?;
} else {
// Even without a new refresh token, update expires_at to prevent
// the background refresh from re-refreshing immediately
sqlx::query!(
"UPDATE account SET
expires_at = now() + interval '1 hour',
refresh_error = NULL
WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
workspace_id,
service_name.as_str(),
)
.execute(db)
.await?;
}
Ok::<(), Error>(())
}
.await;
if let Err(e) = result {
tracing::error!("Critical error: Failed to update workspace integration tokens for {} in workspace {}: {}",
service_name, workspace_id, e);
tracing::error!(
"Failed to update OAuth tokens for {} in workspace {}: {}",
service_name,
workspace_id,
e
);
}
}
@@ -939,6 +1060,7 @@ pub async fn store_workspace_integration(
workspace_id: &str,
service_name: ServiceName,
oauth_data: serde_json::Value,
resource_path: Option<&str>,
) -> Result<()> {
sqlx::query!(
r#"
@@ -946,20 +1068,23 @@ pub async fn store_workspace_integration(
workspace_id,
service_name,
oauth_data,
resource_path,
created_by,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, now(), now()
$1, $2, $3, $4, $5, now(), now()
)
ON CONFLICT (workspace_id, service_name)
DO UPDATE SET
oauth_data = $3,
resource_path = $4,
updated_at = now()
"#,
workspace_id,
service_name as ServiceName,
oauth_data,
resource_path,
authed.username,
)
.execute(&mut *tx)
@@ -980,6 +1105,7 @@ pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Post
workspace_id,
service_name AS "service_name!: ServiceName",
oauth_data,
resource_path,
created_at,
updated_at,
created_by
@@ -1051,3 +1177,43 @@ pub fn generate_webhook_service_url(
url
}
/// Process incoming webhook request for a native trigger service.
/// Dispatches to the service-specific `prepare_webhook` to transform headers/body into args.
/// Returns `None` if the service doesn't need special processing (standard body parsing is used).
#[cfg(feature = "native_trigger")]
pub async fn prepare_native_trigger_args(
service_name: ServiceName,
db: &DB,
w_id: &str,
headers: &http::HeaderMap,
body: String,
) -> Result<Option<PushArgsOwned>> {
let headers_map: HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
.collect();
match service_name {
ServiceName::Google => {
let handler = google::Google;
let args = handler
.prepare_webhook(db, w_id, headers_map, body, "", false)
.await?;
Ok(Some(args))
}
ServiceName::Nextcloud => Ok(None),
}
}
/// Fallback when native_trigger feature is disabled
#[cfg(not(feature = "native_trigger"))]
pub async fn prepare_native_trigger_args(
_service_name: ServiceName,
_db: &DB,
_w_id: &str,
_headers: &http::HeaderMap,
_body: String,
) -> Result<Option<PushArgsOwned>> {
Ok(None)
}
@@ -102,6 +102,7 @@ impl External for NextCloud {
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
const REFRESH_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
async fn create(
&self,
@@ -110,7 +111,7 @@ impl External for NextCloud {
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
_tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
// During create, we don't have external_id yet (it comes from NextCloud's response)
let full_nextcloud_payload =
@@ -129,7 +130,6 @@ impl External for NextCloud {
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
@@ -148,7 +148,7 @@ impl External for NextCloud {
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
) -> Result<serde_json::Value> {
// During update, we have the external_id so include it in the webhook URL
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, Some(external_id), webhook_token, data).await;
@@ -166,14 +166,25 @@ impl External for NextCloud {
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(())
// Fetch back the updated state and convert to JSON config
let trigger_data = self
.get(w_id, oauth_data, external_id, db, tx)
.await?
.ok_or_else(|| {
Error::InternalErr(format!(
"Failed to fetch back trigger {} after update",
external_id
))
})?;
serde_json::to_value(&trigger_data).map_err(|e| {
Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e))
})
}
async fn get(
@@ -182,8 +193,8 @@ impl External for NextCloud {
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData> {
_tx: &mut PgConnection,
) -> Result<Option<Self::TriggerData>> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
@@ -193,10 +204,10 @@ impl External for NextCloud {
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response: OcsResponse<NextCloudTriggerData> = self
.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, Some(headers), None)
.http_client_request::<_, ()>(&url, Method::GET, w_id, db, Some(headers), None)
.await?;
Ok(ocs_response.ocs.data)
Ok(Some(ocs_response.ocs.data))
}
async fn delete(
@@ -205,7 +216,7 @@ impl External for NextCloud {
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
_tx: &mut PgConnection,
) -> Result<()> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
@@ -216,7 +227,7 @@ impl External for NextCloud {
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, Some(headers), None)
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, Some(headers), None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
@@ -226,44 +237,72 @@ impl External for NextCloud {
Ok(())
}
async fn exists(
async fn maintain_triggers(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
workspace_id: &str,
triggers: &[crate::NativeTrigger],
oauth_data: &Self::OAuthData,
synced: &mut Vec<crate::sync::TriggerSyncInfo>,
errors: &mut Vec<crate::sync::SyncError>,
) {
let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
Ok(triggers) => triggers,
Err(e) => {
tracing::error!(
"Failed to fetch external triggers for {}: {}",
workspace_id,
e
);
errors.push(crate::sync::SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to fetch external triggers: {}", e),
error_type: "external_service_error".to_string(),
});
return;
}
};
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
// Convert to (external_id, config_json) pairs for reconciliation
let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
.iter()
.filter_map(|data| {
let config = serde_json::to_value(data).ok()?;
Some((data.id.to_string(), config))
})
.collect();
let _ = self
.http_client_request::<serde_json::Value, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(true)
crate::sync::reconcile_with_external_state(
db,
workspace_id,
ServiceName::Nextcloud,
triggers,
&external_pairs,
synced,
errors,
)
.await;
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.to_string(), None)
}
fn additional_routes(&self) -> axum::Router {
routes::nextcloud_routes(self.clone())
}
}
impl NextCloud {
async fn list_all(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
oauth_data: &NextCloudOAuthData,
db: &DB,
tx: &mut PgConnection,
) -> Result<Vec<Self::TriggerData>> {
) -> Result<Vec<NextCloudTriggerData>> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
@@ -277,7 +316,6 @@ impl External for NextCloud {
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
@@ -286,19 +324,4 @@ impl External for NextCloud {
Ok(ocs_response.ocs.data)
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.to_string(), None)
}
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String {
data.id.to_string()
}
fn additional_routes(&self) -> axum::Router {
routes::nextcloud_routes(self.clone())
}
}
@@ -3,7 +3,6 @@ use std::{collections::HashMap, sync::Arc};
use axum::{extract::Path, routing::get, Extension, Json, Router};
use http::Method;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
DB,
};
@@ -11,27 +10,25 @@ use windmill_common::{
use crate::{
get_workspace_integration,
nextcloud::{NextCloudEventType, OcsResponse},
External, OAuthConfig, ServiceName,
External, ServiceName,
};
use windmill_api_auth::ApiAuthed;
async fn list_available_events<T: External>(
authed: ApiAuthed,
Extension(handler): Extension<Arc<T>>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<NextCloudEventType>> {
let mut tx = user_db.clone().begin(&authed).await?;
let integration =
get_workspace_integration(&mut *tx, &workspace_id, ServiceName::Nextcloud).await?;
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
let auth = serde_json::from_value::<OAuthConfig>(integration.oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?;
let base_url = integration
.oauth_data
.get("base_url")
.and_then(|v| v.as_str())
.unwrap_or("");
let url = format!(
"{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events",
&auth.base_url,
base_url,
);
let mut headers = HashMap::new();
@@ -42,13 +39,11 @@ async fn list_available_events<T: External>(
&url,
Method::GET,
&workspace_id,
&mut *tx,
&db,
Some(headers),
None,
)
.await?;
tx.commit().await?;
let events = serde_json::from_str(&ocs_response.ocs.data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud events data: {}", e)))?;
+71 -64
View File
@@ -9,7 +9,7 @@ use crate::ServiceName;
#[cfg(feature = "native_trigger")]
use crate::{
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
update_native_trigger_service_config, External,
update_native_trigger_service_config, External, NativeTrigger,
};
#[derive(Debug, Serialize)]
@@ -60,19 +60,20 @@ pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
// Each service only syncs workspaces that have the corresponding integration configured
#[cfg(feature = "native_trigger")]
{
use crate::google::Google;
use crate::nextcloud::NextCloud;
// Nextcloud sync
let (service_name, result) = sync_service_triggers(db, NextCloud).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
// Add new services here:
// use crate::newservice::NewService;
// let (service_name, result) = sync_service_triggers(db, NewService).await;
// total_synced += result.synced_triggers.len();
// total_errors += result.errors.len();
// service_results.insert(service_name, result);
// Google sync (handles both Drive and Calendar triggers)
let (service_name, result) = sync_service_triggers(db, Google).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
}
// Count unique workspaces processed across all services
@@ -105,6 +106,9 @@ async fn sync_service_triggers<T: External>(
let mut all_synced_triggers = Vec::new();
let mut all_errors = Vec::new();
// Use the integration service for lookup (e.g., GoogleDrive/GoogleCalendar -> Google)
let integration_service = T::SERVICE_NAME.integration_service();
// Only sync workspaces that have the corresponding integration configured
let workspaces_with_integration = match sqlx::query_scalar!(
r#"
@@ -115,7 +119,7 @@ async fn sync_service_triggers<T: External>(
AND wi.oauth_data IS NOT NULL
AND w.deleted = false
"#,
T::SERVICE_NAME as ServiceName
integration_service as ServiceName
)
.fetch_all(db)
.await
@@ -210,11 +214,14 @@ pub async fn sync_workspace_triggers<T: External>(
return Ok((Vec::new(), Vec::new()));
}
let mut all_synced_triggers = Vec::new();
let mut all_sync_errors = Vec::new();
let mut synced = Vec::new();
let mut errors = Vec::new();
// Use the integration service for OAuth lookup (e.g., GoogleDrive/GoogleCalendar -> Google)
let integration_service = T::SERVICE_NAME.integration_service();
let oauth_data = {
match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await {
match decrypt_oauth_data(db, workspace_id, integration_service).await {
Ok(oauth_data) => oauth_data,
Err(e) => {
tracing::error!(
@@ -222,46 +229,58 @@ pub async fn sync_workspace_triggers<T: External>(
workspace_id,
e
);
all_sync_errors.push(SyncError {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to get workspace integration OAuth data: {}", e),
error_type: "oauth_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
return Ok((Vec::new(), errors));
}
}
};
let mut tx = db.begin().await?;
let external_triggers = match handler
.list_all(workspace_id, &oauth_data, db, &mut tx)
.await
{
Ok(triggers) => triggers,
Err(e) => {
tracing::error!(
"Failed to fetch external triggers for {}: {}",
workspace_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to fetch external triggers: {}", e),
error_type: "external_service_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
}
};
tx.commit().await?;
handler
.maintain_triggers(
db,
workspace_id,
&windmill_triggers,
&oauth_data,
&mut synced,
&mut errors,
)
.await;
// Build a map of external trigger IDs to their data
let mut external_trigger_map: HashMap<String, &T::TriggerData> = HashMap::new();
for external_trigger in &external_triggers {
let external_id = handler.get_external_id_from_trigger_data(external_trigger);
external_trigger_map.insert(external_id, external_trigger);
tracing::info!(
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
T::SERVICE_NAME.as_str(),
workspace_id,
synced.len(),
errors.len()
);
Ok((synced, errors))
}
/// Reusable reconciliation logic for services with real external state (e.g. Nextcloud).
/// Compares external triggers with DB triggers: sets errors for missing ones,
/// clears errors and updates config for existing ones.
#[cfg(feature = "native_trigger")]
pub async fn reconcile_with_external_state(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
windmill_triggers: &[NativeTrigger],
external_triggers: &[(String, serde_json::Value)],
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
// Build a map of external trigger IDs to their config
let mut external_trigger_map: HashMap<String, &serde_json::Value> = HashMap::new();
for (external_id, config) in external_triggers {
external_trigger_map.insert(external_id.clone(), config);
}
for trigger in &windmill_triggers {
for trigger in windmill_triggers {
if !external_trigger_map.contains_key(&trigger.external_id) {
// Trigger no longer exists on external service - set error
let error_msg = "Trigger no longer exists on external service".to_string();
@@ -276,14 +295,14 @@ pub async fn sync_workspace_triggers<T: External>(
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
service_name,
&trigger.external_id,
Some(&error_msg),
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
synced.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorSet(error_msg),
@@ -295,7 +314,7 @@ pub async fn sync_workspace_triggers<T: External>(
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update error for trigger (external_id: '{}'): {}",
@@ -308,7 +327,7 @@ pub async fn sync_workspace_triggers<T: External>(
}
} else {
// Trigger exists on external service
let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap();
let external_service_config = external_trigger_map.get(&trigger.external_id).unwrap();
// Clear error if it was set
if trigger.error.is_some() {
@@ -321,14 +340,14 @@ pub async fn sync_workspace_triggers<T: External>(
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
service_name,
&trigger.external_id,
None,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
synced.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorCleared,
@@ -340,7 +359,7 @@ pub async fn sync_workspace_triggers<T: External>(
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to clear error for trigger (external_id: '{}'): {}",
@@ -353,14 +372,12 @@ pub async fn sync_workspace_triggers<T: External>(
}
// Compare service_config and update if different
let external_service_config =
handler.extract_service_config_from_trigger_data(external_trigger_data)?;
let stored_service_config = trigger
.service_config
.clone()
.unwrap_or(serde_json::Value::Null);
if external_service_config != stored_service_config {
if **external_service_config != stored_service_config {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config",
trigger.external_id,
@@ -370,14 +387,14 @@ pub async fn sync_workspace_triggers<T: External>(
match update_native_trigger_service_config(
db,
workspace_id,
T::SERVICE_NAME,
service_name,
&trigger.external_id,
&external_service_config,
external_service_config,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
synced.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
@@ -389,7 +406,7 @@ pub async fn sync_workspace_triggers<T: External>(
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update config for trigger (external_id: '{}'): {}",
@@ -408,14 +425,4 @@ pub async fn sync_workspace_triggers<T: External>(
}
}
}
tracing::info!(
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
T::SERVICE_NAME.as_str(),
workspace_id,
all_synced_triggers.len(),
all_sync_errors.len()
);
Ok((all_synced_triggers, all_sync_errors))
}
@@ -23,7 +23,8 @@ use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
global_settings::{load_value_from_global_settings, OAUTH_SETTING},
utils::{require_admin, HTTP_CLIENT},
variables::{build_crypt, encrypt},
DB,
};
@@ -32,10 +33,10 @@ use windmill_common::{
use windmill_api_auth::ApiAuthed;
#[cfg(feature = "native_trigger")]
use crate::ServiceName;
#[cfg(feature = "native_trigger")]
use crate::{delete_workspace_integration, store_workspace_integration};
use crate::{
decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, resolve_endpoint,
store_workspace_integration, ServiceName,
};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
@@ -45,6 +46,8 @@ 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")]
@@ -173,10 +176,14 @@ pub struct ConnectIntegrationResponse {
#[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,
pub access_token: Option<String>,
#[serde(default)]
pub instance_shared: bool,
}
#[cfg(feature = "native_trigger")]
@@ -201,20 +208,112 @@ async fn generate_connect_url(
// 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, &state, &redirect_uri);
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;
}
/// 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_prefix 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 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_prefix(db, &trigger.webhook_token_prefix).await {
tracing::error!(
"Failed to delete webhook token with prefix {}: {e}",
trigger.webhook_token_prefix
);
}
}
}
#[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 {
@@ -248,6 +347,7 @@ async fn delete_integration(
struct WorkspaceIntegrations {
service_name: ServiceName,
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
resource_path: Option<String>,
}
#[cfg(feature = "native_trigger")]
@@ -263,8 +363,9 @@ async fn list_integrations(
WorkspaceIntegrations,
r#"
SELECT
oauth_data as "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName"
oauth_data as "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName",
resource_path
FROM
workspace_integrations
WHERE
@@ -277,14 +378,23 @@ async fn list_integrations(
let key_value = integrations
.into_iter()
.map(|integration| (integration.service_name, integration.oauth_data))
.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| WorkspaceIntegrations {
service_name: service_name,
oauth_data: key_value.get(&service_name).cloned().flatten(),
.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<_>>();
@@ -304,10 +414,10 @@ async fn integration_exist(
r#"
SELECT EXISTS (
SELECT 1
FROM workspace_integrations
WHERE workspace_id = $1
AND service_name = $2
AND oauth_data IS NOT NULL
FROM workspace_integrations wi
WHERE wi.workspace_id = $1
AND wi.service_name = $2
AND wi.oauth_data IS NOT NULL
)
"#,
workspace_id,
@@ -326,18 +436,26 @@ 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")]
async fn oauth_callback(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name, code, state)): Path<(String, ServiceName, String, String)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(body): Json<OAuthCallbackBody>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
// Validate the signed state (cluster-safe, no DB storage needed)
let state_was_valid = validate_signed_state(&db, &state, &workspace_id).await?;
let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).await?;
if !state_was_valid {
return Err(Error::BadRequest(
@@ -345,31 +463,122 @@ async fn oauth_callback(
));
}
let oauth_config =
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name)
.await?;
// 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, &code, &redirect_uri).await?;
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 mut oauth_data = serde_json::to_value(oauth_config).unwrap();
let encrypted_access_token = encrypt(&mc, &token_response.access_token);
oauth_data["access_token"] = serde_json::Value::String(encrypted_access_token);
if let Some(refresh_token) = token_response.refresh_token {
let encrypted_refresh_token = encrypt(&mc, &refresh_token);
oauth_data["refresh_token"] = serde_json::Value::String(encrypted_refresh_token);
}
if let Some(expires_in) = token_response.expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
oauth_data["token_expires_at"] = serde_json::Value::String(expires_at.to_rfc3339());
}
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)))?;
store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?;
// 3. Create resource pointing to the variable
let resource_value = 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,
@@ -407,16 +616,14 @@ fn build_native_oauth_client(
service_name: ServiceName,
redirect_uri: &str,
) -> Result<OClient> {
let auth_url = Url::parse(&format!(
"{}{}",
config.base_url,
service_name.auth_endpoint()
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(&format!(
"{}{}",
config.base_url,
service_name.token_endpoint()
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| {
@@ -459,7 +666,7 @@ async fn get_workspace_oauth_config<T: DeserializeOwned>(
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let oauth_configs = sqlx::query_scalar!(
let oauth_data = sqlx::query_scalar!(
r#"
SELECT
oauth_data
@@ -474,15 +681,14 @@ async fn get_workspace_oauth_config<T: DeserializeOwned>(
)
.fetch_optional(db)
.await?
.flatten()
.ok_or(Error::NotFound(format!(
"Integration for service {} not found",
service_name.as_str()
)))?;
let config = serde_json::from_value::<T>(oauth_configs)
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))?;
Ok(config)
serde_json::from_value::<T>(oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))
}
#[cfg(feature = "native_trigger")]
@@ -502,6 +708,7 @@ pub async fn create_workspace_integration(
&workspace_id,
service_name,
to_value(oauth_data).unwrap(),
None,
)
.await?;
@@ -523,24 +730,193 @@ async fn get_workspace_oauth_config_as_oauth_config(
#[cfg(feature = "native_trigger")]
fn build_authorization_url(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
state: &str,
redirect_uri: &str,
) -> String {
let params = [
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),
("client_id", config.client_id.as_str()),
("redirect_uri", redirect_uri),
("state", state),
("scope", "read write"),
("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!("{}/apps/oauth2/authorize?{}", config.base_url, query_string)
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")]
@@ -553,8 +929,16 @@ pub fn workspaced_service() -> Router {
"/: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/:code/:state", post(oauth_callback));
.route("/:service_name/callback", post(oauth_callback));
Router::new().nest("/integrations", router)
}
+9
View File
@@ -1064,6 +1064,15 @@ async fn update_resource(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
}
}
+9
View File
@@ -803,6 +803,15 @@ async fn update_variable(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
}
}

Some files were not shown because too many files have changed in this diff Show More