refactor: extract windmill-api-auth and windmill-triggers crates

Split the 90K-line windmill-api crate into three crates to improve
incremental compilation times. Changes to trigger protocol code no
longer recompile API endpoint code and vice versa.

New crate dependency DAG:
  windmill-common → windmill-audit → windmill-api-auth → windmill-triggers → windmill-api

windmill-api-auth (~2K lines): API authentication infrastructure
- ApiAuthed struct and trait impls
- AuthCache and token extraction
- FromRequestParts extractors for axum
- Scope checking (ScopeDefinition, check_scopes)
- fetch_api_authed for listener contexts

windmill-triggers (~14.6K lines): All trigger protocol code
- HTTP, WebSocket, Kafka, NATS, MQTT, PostgreSQL, SQS, GCP, email triggers
- Native triggers (workspace integrations)
- JobOps trait with OnceLock-based dependency injection for functions
  that must stay in windmill-api (push_*_job, run_wait_result, etc.)
- Extension modules (args_ext, capture_ext, jobs_ext, etc.)

windmill-api changes:
- Depends on both new crates
- Implements JobOps trait in job_ops_impl.rs
- Re-exports jobs_ext and resource_ext for EE file compatibility
- Feature flags forwarded to windmill-triggers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-06 12:33:20 +00:00
co-authored by Claude Opus 4.6
parent 4a1e61f2f9
commit b838e6caff
71 changed files with 18800 additions and 1943 deletions
+115
View File
@@ -15831,6 +15831,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"windmill-api-auth",
"windmill-audit",
"windmill-autoscaling",
"windmill-common",
@@ -15844,9 +15845,32 @@ dependencies = [
"windmill-parser-sql",
"windmill-parser-ts",
"windmill-queue",
"windmill-triggers",
"windmill-worker",
]
[[package]]
name = "windmill-api-auth"
version = "1.628.2"
dependencies = [
"axum 0.7.9",
"chrono",
"http 1.4.0",
"itertools 0.14.0",
"lazy_static",
"quick_cache",
"serde",
"serde_json",
"sqlx",
"time",
"tokio",
"tower-cookies",
"tracing",
"uuid",
"windmill-audit",
"windmill-common",
]
[[package]]
name = "windmill-api-client"
version = "1.628.2"
@@ -16337,6 +16361,97 @@ dependencies = [
"windmill-parser-sql",
]
[[package]]
name = "windmill-triggers"
version = "1.628.2"
dependencies = [
"anyhow",
"async-nats",
"async-recursion",
"async-stream",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-sqs",
"aws-sdk-sso",
"aws-sdk-ssooidc",
"aws-sdk-sts",
"aws-smithy-types",
"axum 0.7.9",
"backon",
"base64 0.22.1",
"byteorder",
"bytes",
"chrono",
"constant_time_eq 0.3.1",
"dashmap 6.1.0",
"datafusion",
"futures",
"google-cloud-googleapis",
"google-cloud-pubsub",
"hex",
"hmac",
"http 1.4.0",
"hyper 1.8.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
"lazy_static",
"mail-parser",
"matchit 0.7.3",
"native-tls",
"nkeys",
"object_store",
"openssl",
"pg_escape",
"pin-project",
"postgres-native-tls 0.5.0",
"postgres-native-tls 0.5.1",
"prometheus",
"quick_cache",
"rand 0.9.0",
"rdkafka",
"rdkafka-sys",
"regex",
"reqwest 0.13.1",
"rumqttc",
"rust_decimal",
"serde",
"serde_json",
"serde_urlencoded",
"serde_yml",
"sha1",
"sha2 0.10.9",
"sql-builder",
"sqlx",
"strum 0.27.2",
"thiserror 2.0.18",
"tokio",
"tokio-native-tls",
"tokio-postgres 0.7.11",
"tokio-postgres 0.7.13",
"tokio-stream",
"tokio-tungstenite 0.24.0",
"tokio-util",
"tonic",
"tower 0.4.13",
"tower-cookies",
"tower-http",
"tracing",
"ulid",
"url",
"urlencoding",
"uuid",
"windmill-api-auth",
"windmill-audit",
"windmill-common",
"windmill-git-sync",
"windmill-oauth",
"windmill-parser",
"windmill-parser-py",
"windmill-parser-ts",
"windmill-queue",
]
[[package]]
name = "windmill-worker"
version = "1.628.2"
+4
View File
@@ -8,6 +8,8 @@ edition.workspace = true
resolver = "2"
members = [
"./windmill-api",
"./windmill-api-auth",
"./windmill-triggers",
"./windmill-queue",
"./windmill-worker",
"./windmill-common",
@@ -217,6 +219,8 @@ tempfile.workspace = true
[workspace.dependencies]
windmill-api = { path = "./windmill-api", default-features = false }
windmill-api-auth = { path = "./windmill-api-auth" }
windmill-triggers = { path = "./windmill-triggers" }
windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
windmill-common = { path = "./windmill-common", default-features = false }
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "windmill-api-auth"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_auth"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise"]
no_auth = []
private = ["windmill-common/private"]
cloud = ["windmill-common/cloud"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-audit.workspace = true
axum.workspace = true
chrono.workspace = true
http.workspace = true
itertools.workspace = true
lazy_static.workspace = true
quick_cache.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
time.workspace = true
tokio.workspace = true
tower-cookies.workspace = true
tracing.workspace = true
uuid.workspace = true
+868
View File
@@ -0,0 +1,868 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use axum::{
async_trait,
extract::{FromRequestParts, OriginalUri, Query},
Extension, Json,
};
use chrono::TimeZone;
use http::{request::Parts, StatusCode};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use tower_cookies::Cookies;
use tracing::Span;
use crate::{scopes, ApiAuthed, OptJobAuthed};
use std::{
str::FromStr,
sync::{
atomic::{AtomicI64, AtomicU64, Ordering},
Arc,
},
};
use windmill_common::{
auth::{
fetch_authed_from_permissioned_as, get_folders_for_user, get_groups_for_user,
JWTAuthClaims, TOKEN_PREFIX_LEN,
},
db::DB,
error::{Error, JsonResult},
jwt,
users::{username_to_permissioned_as, COOKIE_NAME, SUPERADMIN_SECRET_EMAIL},
};
lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
}
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {
// Remove all cache entries for this token (across all workspaces)
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token);
tracing::info!(
"Invalidated token from auth cache: {}...",
&token[..token.len().min(8)]
);
}
#[derive(Clone)]
pub struct ExpiringAuthCache {
pub authed: ApiAuthed,
pub expiry: chrono::DateTime<chrono::Utc>,
pub job_id: Option<uuid::Uuid>,
}
/// Callback trait for enterprise JWT external auth.
/// windmill-api provides the implementation; windmill-api-auth calls it through this trait.
#[async_trait]
pub trait JwtExtAuthBackend: Send + Sync + 'static {
async fn jwt_ext_auth(
&self,
w_id: Option<&String>,
token: &str,
db: &DB,
) -> Option<(ApiAuthed, usize, Option<uuid::Uuid>)>;
}
/// A no-op backend used when enterprise JWT ext auth is not available.
pub struct NoopJwtExtAuth;
#[async_trait]
impl JwtExtAuthBackend for NoopJwtExtAuth {
async fn jwt_ext_auth(
&self,
_w_id: Option<&String>,
_token: &str,
_db: &DB,
) -> Option<(ApiAuthed, usize, Option<uuid::Uuid>)> {
None
}
}
pub struct AuthCache {
db: DB,
superadmin_secret: Option<String>,
jwt_ext_auth: Arc<dyn JwtExtAuthBackend>,
}
impl AuthCache {
pub fn new(
db: DB,
superadmin_secret: Option<String>,
jwt_ext_auth: Arc<dyn JwtExtAuthBackend>,
) -> Self {
AuthCache {
db,
superadmin_secret,
jwt_ext_auth,
}
}
pub async fn invalidate(&self, w_id: &str, token: String) {
AUTH_CACHE.remove(&(w_id.to_string(), token));
}
pub async fn get_authed(&self, w_id: Option<String>, token: &str) -> Option<ApiAuthed> {
Some(self.get_opt_job_authed(w_id, token).await?.authed)
}
pub async fn get_opt_job_authed(
&self,
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
);
let s = AUTH_CACHE.get(&key).map(|c| c.to_owned());
match s {
Some(ExpiringAuthCache {
authed, expiry, job_id,
}) if expiry > chrono::Utc::now() => Some(OptJobAuthed { authed, job_id }),
_ if token.starts_with("jwt_ext_") => {
let authed_and_exp = self
.jwt_ext_auth
.jwt_ext_auth(
w_id.as_ref(),
token.trim_start_matches("jwt_ext_"),
&self.db,
)
.await;
if let Some((authed, exp, job_id)) = authed_and_exp {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc.timestamp_nanos(exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
} else {
None
}
}
_ if token.starts_with("jwt_") => {
let jwt_token = token.trim_start_matches("jwt_");
let jwt_result = jwt::decode_with_internal_secret::<JWTAuthClaims>(jwt_token).await;
match jwt_result {
Ok(claims) => {
if w_id.is_some_and(|w_id| !claims.allowed_in_workspace(&w_id)) {
tracing::error!("JWT auth error: workspace_id mismatch");
return None;
}
let username_override = username_override_from_label(claims.label);
let authed = ApiAuthed {
email: claims.email,
username: claims.username,
is_admin: claims.is_admin,
is_operator: claims.is_operator,
groups: claims.groups,
folders: claims.folders,
scopes: None,
username_override,
token_prefix: claims.audit_span,
};
let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok());
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc
.timestamp_nanos(claims.exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
}
Err(err) => {
tracing::error!("JWT auth error: {:?}", err);
None
}
}
}
_ => {
let user_o = sqlx::query!(
"UPDATE token SET last_used_at = now() WHERE
token = $1
AND (expiration > NOW() OR expiration IS NULL)
AND (workspace_id IS NULL OR workspace_id = $2)
RETURNING owner, email, super_admin, scopes, label",
token,
w_id.as_ref(),
)
.map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label))
.fetch_optional(&self.db)
.await
.ok()
.flatten();
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => {
let username_override = username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
let (is_admin, is_operator) = if super_admin {
(true, false)
} else {
let r = sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
.fetch_one(&self.db)
.await
.ok();
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
(false, true)
}
};
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
let folders =
get_folders_for_user(w_id, &name, &groups, &self.db)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
} else {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!("group-{name}"),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
} else {
let groups = vec![];
let folders = vec![];
Some(ApiAuthed {
email: email,
username: owner,
is_admin: super_admin,
is_operator: true,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
}
(_, Some(email), super_admin, scopes, label) => {
let username_override = username_override_from_label(label);
if w_id.is_some() {
let row_o = sqlx::query!(
"SELECT username, is_admin, operator FROM usr WHERE
email = $1 AND workspace_id = $2 AND disabled = false",
&email,
w_id.as_ref().unwrap()
)
.map(|x| (x.username, x.is_admin, x.operator))
.fetch_optional(&self.db)
.await
.unwrap_or(Some(("error".to_string(), false, false)));
match row_o {
Some((username, is_admin, is_operator)) => {
let groups = get_groups_for_user(
&w_id.as_ref().unwrap(),
&username,
&email,
&self.db,
)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
&w_id.unwrap(),
&username,
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email,
username,
is_admin: is_admin || super_admin,
is_operator,
groups,
folders,
scopes,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
None if super_admin => Some(ApiAuthed {
email: email.clone(),
username: email,
is_admin: super_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
}),
None => None,
}
} else {
Some(ApiAuthed {
email: email.to_string(),
username: email,
is_admin: super_admin,
is_operator: true,
groups: Vec::new(),
folders: Vec::new(),
scopes,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
}
_ => None,
}
};
if let Some(authed) = authed_o.as_ref() {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc::now()
+ chrono::Duration::try_seconds(120).unwrap(),
job_id: None,
},
);
}
authed_o.map(|authed| OptJobAuthed { authed, job_id: None })
} else if self
.superadmin_secret
.as_ref()
.map(|x| x == token)
.unwrap_or(false)
{
let authed = ApiAuthed {
email: SUPERADMIN_SECRET_EMAIL.to_string(),
username: "superadmin_secret".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
};
Some(OptJobAuthed { authed, job_id: None })
} else {
None
}
}
}
}
}
pub async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let from_cookie = match auth_header {
Some(x) => Some(x.to_owned()),
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
};
#[derive(Deserialize)]
struct Token {
token: Option<String>,
}
match from_cookie {
Some(token) => Some(token),
None => Query::<Token>::from_request_parts(parts, state)
.await
.ok()
.and_then(|token| token.token.clone()),
}
}
#[derive(Clone, Debug)]
pub struct Tokened {
pub token: String,
}
#[derive(Clone, Debug)]
pub struct OptTokened {
#[allow(dead_code)]
pub token: Option<String>,
}
struct BruteForceCounter {
counter: AtomicU64,
last_reset: AtomicI64,
}
lazy_static::lazy_static! {
static ref BRUTE_FORCE_COUNTER: BruteForceCounter =
BruteForceCounter { last_reset: AtomicI64::new(0), counter: AtomicU64::new(0) };
}
impl BruteForceCounter {
async fn increment(&self) {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
if self.counter.fetch_add(1, Ordering::Relaxed) > 10000 {
tracing::error!(
"Brute force attack to find valid token detected, sleeping unauthorized response for 2 seconds"
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if now - self.last_reset.load(Ordering::Relaxed) > 60 {
self.counter.store(0, Ordering::Relaxed);
self.last_reset.store(now, Ordering::Relaxed);
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for Tokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(Tokened {
token: "".to_string(),
});
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(tokened.clone())
} else {
let token_o = extract_token(parts, state).await;
if let Some(token) = token_o {
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for OptTokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(OptTokened { token: None });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(OptTokened {
token: Some(tokened.token.clone()),
})
} else {
let token_o = extract_token(parts, state).await;
Ok(OptTokened { token: token_o })
}
}
}
pub fn transform_old_scope_to_new_scope(scopes: Option<&mut Vec<String>>) {
if let Some(scopes) = scopes {
for scope in scopes.iter_mut() {
if scope.starts_with("run:") {
let (_, part_scope) = scope.split_once(":").unwrap();
if let Some((kind, path)) = part_scope.split_once("/") {
//appending a 's' as runnable kind is singular while new scope format expect it to be plural
*scope = format!("jobs:run:{}s:{}", kind, path);
}
} else if scope.starts_with("jobs:") {
// Map old jobs scopes to new format
let new_scope = match scope.as_str() {
"jobs:listjobs" => "jobs:read",
"jobs:runscript" => "jobs:run:scripts",
"jobs:runflow" => "jobs:run:flows",
"jobs:resumeflow" => "jobs:run:flows",
"jobs:deletejob" => "jobs:write",
_ => continue,
};
*scope = new_scope.to_string();
}
}
}
}
fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
if path_vec.len() >= 5 && path_vec[0] == "" && path_vec[2] == "srch" && path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
None
}
};
workspace_id
}
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let opt_job_authed = OptJobAuthed::from_request_parts(parts, state).await?;
Ok(opt_job_authed.authed)
}
}
#[async_trait]
impl<S> FromRequestParts<S> for OptJobAuthed
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(OptJobAuthed::default());
};
#[cfg(feature = "no_auth")]
{
let authed = ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
};
return Ok(OptJobAuthed {
authed,
job_id: None,
});
}
let already_authed = parts.extensions.get::<OptJobAuthed>();
if let Some(authed) = already_authed {
return Ok(authed.clone());
}
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = maybe_get_workspace_id_from_path(&path_vec);
if let Some(mut opt_job_authed) =
cache.get_opt_job_authed(workspace_id.clone(), &token).await
{
let authed = &mut opt_job_authed.authed;
if authed.scopes.is_some() {
transform_old_scope_to_new_scope(authed.scopes.as_mut());
let path = original_uri.path();
let method = parts.method.as_str();
if let Err(err) = scopes::check_scopes_for_route(
authed.scopes.as_deref(),
path,
method,
) {
BRUTE_FORCE_COUNTER.increment().await;
return Err(err);
}
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(opt_job_authed);
}
}
}
BRUTE_FORCE_COUNTER.increment().await;
Err(Error::NotAuthorized("Unauthorized".to_string()))
}
}
fn username_override_from_label(label: Option<String>) -> Option<String> {
match label {
Some(label)
if label.starts_with("webhook-")
|| label.starts_with("http-")
|| label.starts_with("email-")
|| label.starts_with("ws-") =>
{
Some(label)
}
Some(label) if label.starts_with("ephemeral-script-end-user-") => Some(
label
.trim_start_matches("ephemeral-script-end-user-")
.to_string(),
),
Some(label) if label == "Ephemeral lsp token" => Some("lsp".to_string()),
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => {
Some(format!("label-{label}"))
}
_ => None,
}
}
#[derive(FromRow, Serialize)]
pub struct TruncatedTokenWithEmail {
pub label: Option<String>,
pub token_prefix: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub email: Option<String>,
}
pub async fn list_tokens_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let tokens = if is_flow {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
concat(substring(token for 10)) AS token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:flows:' || $2]::text[]
OR scopes @> ARRAY['run:flow/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
} else {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
concat(substring(token for 10)) AS token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]
OR scopes @> ARRAY['run:script/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
};
Ok(Json(tokens))
}
lazy_static::lazy_static! {
pub static ref API_AUTHED_CACHE: Cache<(String,String,String), ExpiringAuthCache> = Cache::new(300);
}
pub async fn fetch_api_authed(
username: String,
email: String,
w_id: &str,
db: &DB,
username_override: Option<String>,
) -> windmill_common::error::Result<ApiAuthed> {
let permissioned_as = username_to_permissioned_as(username.as_str());
fetch_api_authed_from_permissioned_as(permissioned_as, email, w_id, db, username_override).await
}
#[allow(unused)]
pub async fn fetch_api_authed_from_permissioned_as(
permissioned_as: String,
email: String,
w_id: &str,
db: &DB,
username_override: Option<String>,
) -> windmill_common::error::Result<ApiAuthed> {
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
Some(expiring_authed) if expiring_authed.expiry > chrono::Utc::now() => {
tracing::debug!("API authed cache hit for user {}", email);
expiring_authed.authed
}
_ => {
tracing::debug!("API authed cache miss for user {}", email);
let authed =
fetch_authed_from_permissioned_as(permissioned_as, email.clone(), w_id, db).await?;
let api_authed = ApiAuthed {
username: authed.username,
email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
groups: authed.groups,
folders: authed.folders,
scopes: authed.scopes,
username_override: None,
token_prefix: authed.token_prefix,
};
API_AUTHED_CACHE.insert(
key,
ExpiringAuthCache {
authed: api_authed.clone(),
expiry: chrono::Utc::now() + chrono::Duration::try_seconds(120).unwrap(),
job_id: None,
},
);
api_authed
}
};
api_authed.username_override = username_override;
Ok(api_authed)
}
+14
View File
@@ -0,0 +1,14 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
mod auth;
pub mod scopes;
mod types;
pub use auth::*;
pub use types::*;
+959
View File
@@ -0,0 +1,959 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use windmill_common::error::{Error, Result};
use crate::ApiAuthed;
/// Comprehensive scope system for JWT token authorization
///
/// Scopes follow the format: {domain}:{action}[:{resource}]
/// Examples:
/// - "jobs:read" - Read access to jobs
/// - "scripts:write:f/folder/*" - Write access to scripts in a folder
/// - "*" - Full access (superuser)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeDefinition {
pub domain: String,
pub action: String,
pub kind: Option<String>, // For jobs:run:kind (optional)
pub resource: Option<Vec<String>>,
}
impl ScopeDefinition {
pub fn new(
domain: &str,
action: &str,
kind: Option<&str>,
resource: Option<Vec<String>>,
) -> Self {
Self {
domain: domain.to_string(),
action: action.to_string(),
kind: kind.map(|s| s.to_string()),
resource: resource,
}
}
pub fn from_scope_string(scope: &str) -> Result<Self> {
let parts: Vec<&str> = scope.split(':').collect();
let into_owned_vec = |resources: &str| -> Vec<String> {
let resources = resources
.split(",")
.collect_vec()
.into_iter()
.map(ToOwned::to_owned)
.collect_vec();
resources
};
match parts.len() {
2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action
3 => {
if parts[0] == "jobs" && parts[1] == "run" {
Ok(Self::new(parts[0], parts[1], Some(parts[2]), None))
} else {
Ok(Self::new(
parts[0],
parts[1],
None,
Some(into_owned_vec(parts[2])),
))
}
}
4 => {
if parts[0] == "jobs" && parts[1] == "run" {
Ok(Self::new(
parts[0],
parts[1],
Some(parts[2]),
Some(into_owned_vec(parts[3])),
))
} else {
Err(Error::BadRequest(format!(
"Invalid 4-part scope: {}",
scope
)))
}
}
_ => Err(Error::BadRequest(format!(
"Invalid scope format: {}",
scope
))),
}
}
pub fn as_string(&self) -> String {
match (&self.kind, &self.resource) {
(Some(kind), Some(resource)) => {
format!(
"{}:{}:{}:{}",
self.domain,
self.action,
kind,
resource.join(",")
)
}
(Some(kind), None) => {
format!("{}:{}:{}", self.domain, self.action, kind)
}
(None, Some(resource)) => {
format!("{}:{}:{}", self.domain, self.action, resource.join(","))
}
(None, None) => format!("{}:{}", self.domain, self.action),
}
}
pub fn includes(&self, other: &ScopeDefinition) -> bool {
if self.domain != other.domain {
return false;
}
match (self.action.as_str(), other.action.as_str()) {
(a, b) if (a == "write" && b == "read") || (a == b) => {}
_ => return false,
}
if self.domain == "jobs" && self.action == "run" {
match (&self.kind, &other.kind) {
(Some(self_kind), Some(other_kind)) => {
if self_kind != other_kind {
return false;
}
}
(Some(_), None) => {
return false;
}
(None, _) => {
return true;
}
}
}
match (&self.resource, &other.resource) {
(Some(self_resources), Some(other_resources)) => {
resources_match(self_resources, other_resources)
}
(Some(_), None) => false,
(None, _) => true,
}
}
}
fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool {
if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) {
return true;
}
if scope_resources.len() <= 4 && accepted_resources.len() <= 4 {
return resources_match_small(scope_resources, accepted_resources);
}
resources_match_large(scope_resources, accepted_resources)
}
fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool {
for required in accepted_resources {
for scope_resource in scope_resources {
if resource_matches_pattern(scope_resource, required) {
return true;
}
}
}
false
}
fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool {
let mut exact_matches = HashSet::new();
let mut patterns = Vec::new();
for scope_resource in scope_resources {
if scope_resource.contains('*') {
patterns.push(scope_resource);
} else {
exact_matches.insert(scope_resource);
}
}
for accepted_resource in accepted_resources {
if exact_matches.contains(accepted_resource) {
return true;
}
for pattern in &patterns {
if resource_matches_pattern(pattern, accepted_resource) {
return true;
}
}
}
false
}
fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool {
if scope_resource == accepted_resource {
return true;
}
let matches_wildcard = |pattern: &str, resource: &str| -> bool {
if !pattern.ends_with("/*") {
return false;
}
let prefix = &pattern[..pattern.len() - 2];
if !resource.starts_with(prefix) {
return false;
}
// If the resource is exactly the prefix, it matches
if resource.len() == prefix.len() {
return true;
}
// If the resource is longer, the next character must be '/' for a valid match
// This prevents "u/user" from matching "u/use/*"
resource.chars().nth(prefix.len()) == Some('/')
};
// Check if either resource is a wildcard pattern and matches the other
matches_wildcard(scope_resource, accepted_resource)
|| matches_wildcard(accepted_resource, scope_resource)
}
/// Available scope domains (top-level API categories)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeDomain {
// Core resource domains
Jobs,
Scripts,
Flows,
Apps,
Variables,
Resources,
Schedules,
Folders,
Users,
Groups,
Workspaces,
// Trigger domains
HttpTriggers,
WebsocketTriggers,
KafkaTriggers,
NatsTriggers,
MqttTriggers,
SqsTriggers,
GcpTriggers,
PostgresTriggers,
EmailTriggers,
// Native trigger domains
NativeTriggers,
// System domains
Audit,
Settings,
Workers,
ServiceLogs,
Configs,
OAuth,
AI,
Indexer,
Teams, // Microsoft Teams integration
GitSync, // Git synchronization
// Special domains
Capture, // Webhook capture
Drafts, // Draft resources
Favorites, // User favorites
Inputs, // Input templates
JobHelpers, // Job helper functions
ConcurrencyGroups, // Concurrency groups
Oidc, // OpenID Connect
Openapi, // OpenAPI generation
// Additional domains
Acls, // Granular access control lists
RawApps, // Raw application data
AgentWorkers, // Agent workers management
Mcp, // MCP
}
impl ScopeDomain {
pub fn as_str(&self) -> &'static str {
match self {
Self::Jobs => "jobs",
Self::Scripts => "scripts",
Self::Flows => "flows",
Self::Apps => "apps",
Self::Variables => "variables",
Self::Resources => "resources",
Self::Schedules => "schedules",
Self::Folders => "folders",
Self::Users => "users",
Self::Groups => "groups",
Self::Workspaces => "workspaces",
Self::HttpTriggers => "http_triggers",
Self::WebsocketTriggers => "websocket_triggers",
Self::KafkaTriggers => "kafka_triggers",
Self::NatsTriggers => "nats_triggers",
Self::MqttTriggers => "mqtt_triggers",
Self::SqsTriggers => "sqs_triggers",
Self::GcpTriggers => "gcp_triggers",
Self::PostgresTriggers => "postgres_triggers",
Self::EmailTriggers => "email_triggers",
Self::NativeTriggers => "native_triggers",
Self::Audit => "audit",
Self::Settings => "settings",
Self::Workers => "workers",
Self::ServiceLogs => "service_logs",
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::Capture => "capture",
Self::Drafts => "drafts",
Self::Favorites => "favorites",
Self::Inputs => "inputs",
Self::JobHelpers => "job_helpers",
Self::ConcurrencyGroups => "concurrency_groups",
Self::Oidc => "oidc",
Self::Openapi => "openapi",
Self::Acls => "acls",
Self::RawApps => "raw_apps",
Self::AgentWorkers => "agent_workers",
Self::Indexer => "indexer",
Self::Teams => "teams",
Self::GitSync => "git_sync",
Self::Mcp => "mcp",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"jobs" | "jobs_u" => Some(Self::Jobs),
"scripts" => Some(Self::Scripts),
"flows" => Some(Self::Flows),
"apps" | "apps_u" => Some(Self::Apps),
"variables" => Some(Self::Variables),
"resources" => Some(Self::Resources),
"schedules" => Some(Self::Schedules),
"folders" => Some(Self::Folders),
"users" => Some(Self::Users),
"groups" => Some(Self::Groups),
"workspaces" => Some(Self::Workspaces),
"http_triggers" => Some(Self::HttpTriggers),
"websocket_triggers" => Some(Self::WebsocketTriggers),
"kafka_triggers" => Some(Self::KafkaTriggers),
"nats_triggers" => Some(Self::NatsTriggers),
"mqtt_triggers" => Some(Self::MqttTriggers),
"sqs_triggers" => Some(Self::SqsTriggers),
"gcp_triggers" => Some(Self::GcpTriggers),
"postgres_triggers" => Some(Self::PostgresTriggers),
"email_triggers" => Some(Self::EmailTriggers),
"audit" => Some(Self::Audit),
"settings" => Some(Self::Settings),
"workers" => Some(Self::Workers),
"service_logs" => Some(Self::ServiceLogs),
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
"native_triggers" => Some(Self::NativeTriggers),
"git_sync" | "github_app" => Some(Self::GitSync),
"capture" => Some(Self::Capture),
"drafts" => Some(Self::Drafts),
"favorites" => Some(Self::Favorites),
"inputs" => Some(Self::Inputs),
"job_helpers" => Some(Self::JobHelpers),
"concurrency_groups" => Some(Self::ConcurrencyGroups),
"oidc" => Some(Self::Oidc),
"openapi" => Some(Self::Openapi),
"acls" => Some(Self::Acls),
"raw_apps" => Some(Self::RawApps),
"agent_workers" => Some(Self::AgentWorkers),
"mcp" => Some(Self::Mcp),
_ => None,
}
}
}
/// Available scope actions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeAction {
Read, // GET operations, list, view
Write, // POST, PUT, PATCH, DELETE operations, create, update, delete
Run, // Special action for running (scripts, flows, etc.)
}
impl ScopeAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Run => "run",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"read" => Some(Self::Read),
"write" => Some(Self::Write),
"delete" => Some(Self::Write),
"run" => Some(Self::Run),
_ => None,
}
}
/// Check if this action includes another action
/// Write includes Read
pub fn includes(&self, other: &ScopeAction) -> bool {
match (self, other) {
(ScopeAction::Write, ScopeAction::Read) => true,
(ScopeAction::Run, ScopeAction::Read) => true,
(a, b) => a == b,
}
}
}
pub fn check_route_access(
token_scopes: &[String],
route_path: &str,
http_method: &str,
) -> Result<()> {
// Map HTTP method to scope action (considering route context)
let required_action = map_http_method_to_action(http_method, route_path);
// Find the domain and kind for this route
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
// Backward compatibility: MCP handlers expect unusual scope actions: all, favorites, hub.
if required_domain == ScopeDomain::Mcp {
return Ok(());
}
let mut is_scoped_token = false;
// Check if any token scope grants the required access
for scope_str in token_scopes {
if !scope_str.starts_with("if_jobs:filter_tags:") {
if let Ok(scope) = ScopeDefinition::from_scope_string(scope_str) {
if scope_grants_access(
&scope,
required_domain,
required_action,
required_kind.as_deref(),
route_suffix.as_deref(),
)? {
return Ok(());
}
}
if !is_scoped_token {
is_scoped_token = true;
}
}
}
//Edge case for backward compatibility, if only scopes defined was filter tag then don't treat this we don't treat the token
//as a restricted token
if !is_scoped_token {
return Ok(());
}
let scope_display = if let Some(kind) = required_kind {
format!(
"{}:{}:{}",
required_domain.as_str(),
required_action.as_str(),
kind
)
} else {
format!("{}:{}", required_domain.as_str(), required_action.as_str())
};
Err(Error::NotAuthorized(format!(
"Access denied. Required scope: {}",
scope_display
)))
}
const SCRIPT_JOBS: [&'static str; 8] = [
"jobs/run/p",
"jobs/run/h",
"jobs/run_wait_result/p",
"jobs/run_wait_result/h",
"jobs/run/preview_bundle",
"jobs/run/preview",
"jobs/run_and_stream/p",
"jobs/run_and_stream/h",
];
const FLOW_JOBS: [&'static str; 6] = [
"jobs/run/f",
"jobs/run_wait_result/f",
"jobs/run/preview_flow",
"jobs/restart/f",
"jobs/flow/resume",
"jobs/run_and_stream/f",
];
lazy_static::lazy_static! {
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
v.extend(SCRIPT_JOBS);
v.extend(FLOW_JOBS);
v
};
}
fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
if RUN_PATH_ACTIONS
.iter()
.any(|run_path| route_path.contains(run_path))
{
return ScopeAction::Run;
}
match method.to_uppercase().as_str() {
"GET" | "HEAD" | "OPTIONS" => ScopeAction::Read,
"POST" | "PUT" | "PATCH" | "DELETE" => ScopeAction::Write,
_ => ScopeAction::Read,
}
}
/// Checks the route path to determine the runnable kind (either "flows" or "scripts").
///
/// The order of checks is important:
/// - Flow-related paths are checked first to avoid false positives, as some flow paths
/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
///
/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found.
fn determine_kind_from_route(route_path: &str) -> Option<String> {
if route_path.starts_with("jobs") {
if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("flows".to_string());
} else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("scripts".to_string());
}
}
None
}
fn extract_domain_from_route(
route_path: &str,
) -> Result<(ScopeDomain, Option<String>, Option<String>)> {
let parts: Vec<&str> = route_path.split('/').collect();
let (domain, kind, route_suffix) = if parts.len() >= 5 && parts[1] == "api" && parts[2] == "w" {
let domain_part = parts[4];
let route_suffix = &parts[4..].join("/");
let domain = ScopeDomain::from_str(domain_part);
let kind = determine_kind_from_route(&route_suffix);
(domain, kind, Some(route_suffix.to_owned()))
} else if parts.len() >= 3 && parts[1] == "api" {
(
ScopeDomain::from_str(parts[2]),
None,
Some(parts[2..].join("/")),
)
} else {
(None, None, None)
};
if let Some(domain) = domain {
return Ok((domain, kind, route_suffix));
}
Err(Error::BadRequest(format!(
"Could not extract domain from route: {}",
route_path
)))
}
const RUN_WHITELISTED_GET_PATHS: [&'static str; 19] = [
"jobs_u/get_flow/",
"jobs_u/get_root_job_id/",
"jobs_u/get/",
"jobs_u/get_logs/",
"jobs_u/get_args/",
"jobs_u/get_flow_debug_info/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_log_file/",
"jobs/result_by_id/",
"jobs/resume_urls/",
"jobs/flow/user_states/",
"jobs/job_signature/",
"jobs/completed/get/",
"jobs/completed/get_result/",
"jobs/completed/get_result_maybe/",
];
fn scope_grants_access(
scope: &ScopeDefinition,
required_domain: ScopeDomain,
required_action: ScopeAction,
required_kind: Option<&str>,
route_path: Option<&str>,
) -> Result<bool> {
// Check domain match
let scope_domain = ScopeDomain::from_str(&scope.domain)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope domain: {}", scope.domain)))?;
if scope_domain != required_domain {
return Ok(false);
}
// Check action match (with hierarchical permissions)
let scope_action = ScopeAction::from_str(&scope.action)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
if !scope_action.includes(&required_action)
&& !(scope_domain == ScopeDomain::Jobs
&& required_action == ScopeAction::Read
&& route_path.is_some_and(|p| {
RUN_WHITELISTED_GET_PATHS
.iter()
.any(|path| p.starts_with(path))
}))
{
return Ok(false);
}
if scope_domain == ScopeDomain::Jobs && required_action == ScopeAction::Run {
match (&scope.kind, required_kind) {
(Some(scope_kind), Some(req_kind)) => {
if scope_kind != req_kind {
return Ok(false);
}
}
(None, _) => {}
(Some(_), None) => {
return Ok(false);
}
}
}
// No resource specified means access to entire domain
Ok(true)
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
route_path: &str,
http_method: &str,
) -> Result<()> {
// If no scopes defined, allow access (backward compatibility)
let scopes = match token_scopes {
Some(s) if !s.is_empty() => s,
_ => return Ok(()),
};
check_route_access(scopes, route_path, http_method)
}
/// Check if an authed user's scopes allow a specific action
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> Result<()>
where
F: FnOnce() -> String,
{
if let Some(scopes) = authed.scopes.as_ref() {
let mut is_scoped_token = false;
let required_scope = ScopeDefinition::from_scope_string(&required())?;
for scope in scopes {
if !scope.starts_with("if_jobs:filter_tags:") {
if !is_scoped_token {
is_scoped_token = true;
}
match ScopeDefinition::from_scope_string(scope) {
Ok(scope) if scope.includes(&required_scope) => return Ok(()),
_ => {}
}
}
}
if is_scoped_token {
return Err(Error::NotAuthorized(format!(
"Required scope: {}",
required_scope.as_string()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scope_definition_parsing() {
let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "read");
assert_eq!(scope.kind, None);
assert_eq!(scope.resource, None);
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts:f/folder/*").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("scripts".to_string()));
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
// Test jobs:run:kind parsing
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("scripts".to_string()));
assert_eq!(scope.resource, None);
// Test jobs:run:kind:resource parsing
let scope = ScopeDefinition::from_scope_string("jobs:run:flows:f/folder/*").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("flows".to_string()));
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
// Test comma-separated resources parsing
let scope =
ScopeDefinition::from_scope_string("scripts:read:path1,path2,f/folder/*").unwrap();
assert_eq!(scope.domain, "scripts");
assert_eq!(scope.action, "read");
assert_eq!(scope.kind, None);
assert_eq!(
scope.resource,
Some(vec![
"path1".to_string(),
"path2".to_string(),
"f/folder/*".to_string()
])
);
}
#[test]
fn test_scope_action_hierarchy() {
assert!(ScopeAction::Write.includes(&ScopeAction::Read));
assert!(!ScopeAction::Read.includes(&ScopeAction::Write));
assert!(ScopeAction::Run.includes(&ScopeAction::Read));
assert!(!ScopeAction::Run.includes(&ScopeAction::Write));
}
#[test]
fn test_route_domain_extraction() {
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/jobs/123").unwrap();
assert_eq!(domain, ScopeDomain::Jobs);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("jobs/123".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/scripts/test_script").unwrap();
assert_eq!(domain, ScopeDomain::Scripts);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("scripts/test_script".to_string()));
}
#[test]
fn test_specific_scope_access() {
let scopes = vec!["jobs:read".to_string()];
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "GET").is_ok());
// DELETE now requires write permission, so it should still fail with read-only scope
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "DELETE").is_err());
}
#[test]
fn test_new_domain_parsing() {
// Test that new domains are properly parsed
assert_eq!(ScopeDomain::from_str("acls"), Some(ScopeDomain::Acls));
assert_eq!(
ScopeDomain::from_str("raw_apps"),
Some(ScopeDomain::RawApps)
);
assert_eq!(
ScopeDomain::from_str("agent_workers"),
Some(ScopeDomain::AgentWorkers)
);
// Test that string conversion works both ways
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
assert_eq!(ScopeDomain::AgentWorkers.as_str(), "agent_workers");
}
#[test]
fn test_resource_array_matching() {
// Test wildcard access
let scope_all = ScopeDefinition::new("scripts", "read", None, Some(vec!["*".to_string()]));
let required = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(scope_all.includes(&required));
// Test exact matches
let scope_exact = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
let required_subset =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
assert!(scope_exact.includes(&required_subset));
// Test partial match - should grant access if ANY required resource matches
let scope_limited =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
let required_partial = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(scope_limited.includes(&required_partial)); // path1 matches, so access granted
// Test no match - scope doesn't cover any of the required resources
let scope_different =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path3".to_string()]));
let required_no_match = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(!scope_different.includes(&required_no_match));
// Test pattern matching
let scope_pattern = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/*".to_string()]),
);
let required_in_folder = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/script1".to_string()]),
);
assert!(scope_pattern.includes(&required_in_folder));
// Test mixed patterns and exact matches
let scope_mixed = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["exact_path".to_string(), "f/folder/*".to_string()]),
);
let required_mixed1 = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["exact_path".to_string()]),
);
let required_mixed2 = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/script2".to_string()]),
);
assert!(scope_mixed.includes(&required_mixed1));
assert!(scope_mixed.includes(&required_mixed2));
}
#[test]
fn test_efficiency_small_vs_large_arrays() {
// Test small array optimization path
let scope_small = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
let required_small =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
assert!(scope_small.includes(&required_small));
// Test large array optimization path
let large_scope_vec: Vec<String> = (0..10).map(|i| format!("path{}", i)).collect();
let scope_large = ScopeDefinition::new("scripts", "read", None, Some(large_scope_vec));
let required_large =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path5".to_string()]));
assert!(scope_large.includes(&required_large));
}
#[test]
fn test_user_example_case() {
// User's example: scope has "u/dieri/*", required has ["u/dadad/wqdq", "u/*"]
// Should grant access because scope "u/dieri/*" falls under required pattern "u/*"
let user_scope =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/dieri/*".to_string()]));
let required_mixed = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dadad/wqdq".to_string(), "u/*".to_string()]),
);
assert!(user_scope.includes(&required_mixed)); // Should match because u/dieri/* falls under u/*
// Another example: scope covers one but not both paths
let scope_specific = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["folder/file1".to_string()]),
);
let required_multi = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["folder/file1".to_string(), "other/file2".to_string()]),
);
assert!(scope_specific.includes(&required_multi)); // Should match because folder/file1 matches exactly
// Test bidirectional pattern matching more explicitly
let scope_broad =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
let required_specific = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dieri/script.py".to_string()]),
);
assert!(scope_broad.includes(&required_specific)); // u/* covers u/dieri/script.py
let scope_specific_path = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dieri/script.py".to_string()]),
);
let required_broad =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
assert!(scope_specific_path.includes(&required_broad)); // u/dieri/script.py satisfies u/*
}
}
+125
View File
@@ -0,0 +1,125 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use windmill_audit::audit_oss::AuditAuthorable;
use windmill_common::db::{Authable, Authed, AuthedRef};
#[derive(Default, Clone, Debug)]
pub struct OptJobAuthed {
pub job_id: Option<uuid::Uuid>,
pub authed: ApiAuthed,
}
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
pub struct ApiAuthed {
pub email: String,
pub username: String,
pub is_admin: bool,
pub is_operator: bool,
pub groups: Vec<String>,
// (folder name, can write, is owner)
pub folders: Vec<(String, bool, bool)>,
pub scopes: Option<Vec<String>>,
pub username_override: Option<String>,
pub token_prefix: Option<String>,
}
impl ApiAuthed {
pub fn to_authed_ref<'e>(&'e self) -> AuthedRef<'e> {
AuthedRef {
email: &self.email,
username: &self.username,
is_admin: &self.is_admin,
is_operator: &self.is_operator,
groups: &self.groups,
folders: &self.folders,
scopes: &self.scopes,
token_prefix: &self.token_prefix,
}
}
pub fn display_username(&self) -> &str {
self.username_override.as_ref().unwrap_or(&self.username)
}
}
impl From<ApiAuthed> for Authed {
fn from(value: ApiAuthed) -> Self {
Self {
email: value.email,
username: value.username,
is_admin: value.is_admin,
is_operator: value.is_operator,
groups: value.groups,
folders: value.folders,
scopes: value.scopes,
token_prefix: value.token_prefix,
}
}
}
impl From<Authed> for ApiAuthed {
fn from(value: Authed) -> Self {
Self {
email: value.email,
username: value.username,
is_admin: value.is_admin,
is_operator: value.is_operator,
groups: value.groups,
folders: value.folders,
scopes: value.scopes,
username_override: None, // Authed doesn't have this field, so default to None
token_prefix: value.token_prefix,
}
}
}
impl AuditAuthorable for ApiAuthed {
fn username(&self) -> &str {
self.username.as_str()
}
fn email(&self) -> &str {
self.email.as_str()
}
fn username_override(&self) -> Option<&str> {
self.username_override.as_deref()
}
fn token_prefix(&self) -> Option<&str> {
self.token_prefix.as_deref()
}
}
impl Authable for ApiAuthed {
fn is_admin(&self) -> bool {
self.is_admin
}
fn is_operator(&self) -> bool {
self.is_operator
}
fn groups(&self) -> &[String] {
&self.groups
}
fn folders(&self) -> &[(String, bool, bool)] {
&self.folders
}
fn scopes(&self) -> Option<&[std::string::String]> {
self.scopes.as_ref().map(|x| x.as_slice())
}
fn email(&self) -> &str {
&self.email
}
fn username(&self) -> &str {
&self.username
}
}
+22 -20
View File
@@ -10,41 +10,43 @@ path = "src/lib.rs"
[features]
default = []
private = ["windmill-audit/private", "windmill-common/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise"]
stripe = []
private = ["windmill-audit/private", "windmill-common/private", "windmill-triggers/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise", "windmill-api-auth/enterprise", "windmill-triggers/enterprise"]
stripe = ["windmill-triggers/stripe"]
agent_worker_server = []
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["dep:tinyvector", "dep:hf-hub", "dep:tokenizers", "dep:candle-core", "dep:candle-transformers", "dep:candle-nn"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet", "windmill-triggers/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus", "windmill-triggers/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-triggers/openidconnect"]
tantivy = ["dep:windmill-indexer"]
kafka = ["dep:rdkafka", "dep:rdkafka-sys"]
kafka-gssapi = ["kafka", "rdkafka/gssapi"]
nats = ["dep:async-nats", "dep:nkeys"]
websocket = ["dep:tokio-tungstenite"]
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"]
license = ["dep:rsa"]
kafka = ["dep:rdkafka", "dep:rdkafka-sys", "windmill-triggers/kafka"]
kafka-gssapi = ["kafka", "rdkafka/gssapi", "windmill-triggers/kafka-gssapi"]
nats = ["dep:async-nats", "dep:nkeys", "windmill-triggers/nats"]
websocket = ["dep:tokio-tungstenite", "windmill-triggers/websocket"]
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp", "windmill-triggers/smtp"]
license = ["dep:rsa", "windmill-triggers/license"]
zip = ["dep:async_zip"]
oauth2 = ["dep:windmill-oauth"]
http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq"]
http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq", "windmill-triggers/http_trigger"]
static_frontend = ["dep:rust-embed"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
mqtt_trigger = ["dep:thiserror", "dep:rumqttc"]
native_trigger = ["dep:strum", "dep:backon", "oauth2"]
sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls", "windmill-triggers/postgres_trigger"]
mqtt_trigger = ["dep:thiserror", "dep:rumqttc", "windmill-triggers/mqtt_trigger"]
native_trigger = ["dep:strum", "dep:backon", "oauth2", "windmill-triggers/native_trigger"]
sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config", "windmill-triggers/sqs_trigger"]
deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
cloud = ["windmill-common/cloud"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic", "windmill-triggers/gcp_trigger"]
cloud = ["windmill-common/cloud", "windmill-triggers/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config"]
python = []
no_auth = []
no_auth = ["windmill-api-auth/no_auth", "windmill-triggers/no_auth"]
[dependencies]
windmill-mcp = { workspace = true, optional = true }
windmill-api-auth.workspace = true
windmill-triggers.workspace = true
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-audit.workspace = true
+7 -765
View File
@@ -1,766 +1,8 @@
#[cfg(feature = "enterprise")]
use crate::ee_oss::ExternalJwks;
use axum::{
async_trait,
extract::{FromRequestParts, OriginalUri, Query},
Extension, Json,
// Re-export all auth types from windmill-api-auth
pub use windmill_api_auth::{
extract_token, invalidate_token_from_cache, list_tokens_internal, transform_old_scope_to_new_scope,
AuthCache, ExpiringAuthCache, JwtExtAuthBackend, NoopJwtExtAuth,
OptTokened, Tokened, TruncatedTokenWithEmail,
AUTH_CACHE, API_AUTHED_CACHE,
fetch_api_authed, fetch_api_authed_from_permissioned_as,
};
use chrono::TimeZone;
use http::{request::Parts, StatusCode};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use tower_cookies::Cookies;
use tracing::Span;
use crate::db::{ApiAuthed, OptJobAuthed, DB};
use std::{
str::FromStr,
sync::{
atomic::{AtomicI64, AtomicU64, Ordering},
Arc,
},
};
#[cfg(feature = "enterprise")]
use tokio::sync::RwLock;
use windmill_common::{
auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, TOKEN_PREFIX_LEN},
error::{Error, JsonResult},
jwt,
users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL},
};
lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
}
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {
// Remove all cache entries for this token (across all workspaces)
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token);
tracing::info!(
"Invalidated token from auth cache: {}...",
&token[..token.len().min(8)]
);
}
#[derive(Clone)]
pub struct ExpiringAuthCache {
pub authed: ApiAuthed,
pub expiry: chrono::DateTime<chrono::Utc>,
pub job_id: Option<uuid::Uuid>,
}
pub struct AuthCache {
db: DB,
superadmin_secret: Option<String>,
#[cfg(feature = "enterprise")]
ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
}
impl AuthCache {
pub fn new(
db: DB,
superadmin_secret: Option<String>,
#[cfg(feature = "enterprise")] ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
) -> Self {
AuthCache {
db,
superadmin_secret,
#[cfg(feature = "enterprise")]
ext_jwks,
}
}
pub async fn invalidate(&self, w_id: &str, token: String) {
AUTH_CACHE.remove(&(w_id.to_string(), token));
}
pub async fn get_authed(&self, w_id: Option<String>, token: &str) -> Option<ApiAuthed> {
Some(self.get_opt_job_authed(w_id, token).await?.authed)
}
pub async fn get_opt_job_authed(
&self,
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
);
let s = AUTH_CACHE.get(&key).map(|c| c.to_owned());
match s {
Some(ExpiringAuthCache { authed, expiry, job_id }) if expiry > chrono::Utc::now() => {
Some(OptJobAuthed { authed, job_id })
}
#[cfg(feature = "enterprise")]
_ if token.starts_with("jwt_ext_") => {
let authed_and_exp = match crate::ee_oss::jwt_ext_auth(
w_id.as_ref(),
token.trim_start_matches("jwt_ext_"),
self.ext_jwks.clone(),
&self.db,
)
.await
{
Ok(r) => Some(r),
Err(e) => {
tracing::error!("JWT_EXT auth error: {:?}", e);
None
}
};
if let Some((authed, exp, job_id)) = authed_and_exp.clone() {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc.timestamp_nanos(exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
} else {
None
}
}
_ if token.starts_with("jwt_") => {
let jwt_token = token.trim_start_matches("jwt_");
let jwt_result = jwt::decode_with_internal_secret::<JWTAuthClaims>(jwt_token).await;
match jwt_result {
Ok(claims) => {
if w_id.is_some_and(|w_id| !claims.allowed_in_workspace(&w_id)) {
tracing::error!("JWT auth error: workspace_id mismatch");
return None;
}
let username_override = username_override_from_label(claims.label);
let authed = crate::db::ApiAuthed {
email: claims.email,
username: claims.username,
is_admin: claims.is_admin,
is_operator: claims.is_operator,
groups: claims.groups,
folders: claims.folders,
scopes: None,
username_override,
token_prefix: claims.audit_span,
};
let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok());
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc
.timestamp_nanos(claims.exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
}
Err(err) => {
tracing::error!("JWT auth error: {:?}", err);
None
}
}
}
_ => {
let user_o = sqlx::query!(
"UPDATE token SET last_used_at = now() WHERE
token = $1
AND (expiration > NOW() OR expiration IS NULL)
AND (workspace_id IS NULL OR workspace_id = $2)
RETURNING owner, email, super_admin, scopes, label",
token,
w_id.as_ref(),
)
.map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label))
.fetch_optional(&self.db)
.await
.ok()
.flatten();
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => {
let username_override = username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
let (is_admin, is_operator) = if super_admin {
(true, false)
} else {
let r = sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
.fetch_one(&self.db)
.await
.ok();
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
(false, true)
}
};
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
let folders =
get_folders_for_user(w_id, &name, &groups, &self.db)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
} else {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!("group-{name}"),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
} else {
let groups = vec![];
let folders = vec![];
Some(ApiAuthed {
email: email,
username: owner,
is_admin: super_admin,
is_operator: true,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
}
(_, Some(email), super_admin, scopes, label) => {
let username_override = username_override_from_label(label);
if w_id.is_some() {
let row_o = sqlx::query!(
"SELECT username, is_admin, operator FROM usr WHERE
email = $1 AND workspace_id = $2 AND disabled = false",
&email,
w_id.as_ref().unwrap()
)
.map(|x| (x.username, x.is_admin, x.operator))
.fetch_optional(&self.db)
.await
.unwrap_or(Some(("error".to_string(), false, false)));
match row_o {
Some((username, is_admin, is_operator)) => {
let groups = get_groups_for_user(
&w_id.as_ref().unwrap(),
&username,
&email,
&self.db,
)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
&w_id.unwrap(),
&username,
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email,
username,
is_admin: is_admin || super_admin,
is_operator,
groups,
folders,
scopes,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
})
}
None if super_admin => Some(ApiAuthed {
email: email.clone(),
username: email,
is_admin: super_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes,
username_override,
token_prefix: Some(
token[0..TOKEN_PREFIX_LEN].to_string(),
),
}),
None => None,
}
} else {
Some(ApiAuthed {
email: email.to_string(),
username: email,
is_admin: super_admin,
is_operator: true,
groups: Vec::new(),
folders: Vec::new(),
scopes,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
}
_ => None,
}
};
if let Some(authed) = authed_o.as_ref() {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc::now()
+ chrono::Duration::try_seconds(120).unwrap(),
job_id: None,
},
);
}
authed_o.map(|authed| OptJobAuthed { authed, job_id: None })
} else if self
.superadmin_secret
.as_ref()
.map(|x| x == token)
.unwrap_or(false)
{
let authed = ApiAuthed {
email: SUPERADMIN_SECRET_EMAIL.to_string(),
username: "superadmin_secret".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
};
Some(OptJobAuthed { authed, job_id: None })
} else {
None
}
}
}
}
}
async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let from_cookie = match auth_header {
Some(x) => Some(x.to_owned()),
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
};
#[derive(Deserialize)]
struct Token {
token: Option<String>,
}
match from_cookie {
Some(token) => Some(token),
None => Query::<Token>::from_request_parts(parts, state)
.await
.ok()
.and_then(|token| token.token.clone()),
}
}
#[derive(Clone, Debug)]
pub struct Tokened {
pub token: String,
}
#[derive(Clone, Debug)]
pub struct OptTokened {
#[allow(dead_code)]
pub token: Option<String>,
}
struct BruteForceCounter {
counter: AtomicU64,
last_reset: AtomicI64,
}
lazy_static::lazy_static! {
static ref BRUTE_FORCE_COUNTER: BruteForceCounter =
BruteForceCounter { last_reset: AtomicI64::new(0), counter: AtomicU64::new(0) };
}
impl BruteForceCounter {
async fn increment(&self) {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
if self.counter.fetch_add(1, Ordering::Relaxed) > 10000 {
tracing::error!(
"Brute force attack to find valid token detected, sleeping unauthorized response for 2 seconds"
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if now - self.last_reset.load(Ordering::Relaxed) > 60 {
self.counter.store(0, Ordering::Relaxed);
self.last_reset.store(now, Ordering::Relaxed);
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for Tokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(Tokened { token: "".to_string() });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(tokened.clone())
} else {
let token_o = extract_token(parts, state).await;
if let Some(token) = token_o {
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for OptTokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(OptTokened { token: None });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(OptTokened { token: Some(tokened.token.clone()) })
} else {
let token_o = extract_token(parts, state).await;
Ok(OptTokened { token: token_o })
}
}
}
pub fn transform_old_scope_to_new_scope(scopes: Option<&mut Vec<String>>) {
if let Some(scopes) = scopes {
for scope in scopes.iter_mut() {
if scope.starts_with("run:") {
let (_, part_scope) = scope.split_once(":").unwrap();
if let Some((kind, path)) = part_scope.split_once("/") {
//appending a 's' as runnable kind is singular while new scope format expect it to be plural
*scope = format!("jobs:run:{}s:{}", kind, path);
}
} else if scope.starts_with("jobs:") {
// Map old jobs scopes to new format
let new_scope = match scope.as_str() {
"jobs:listjobs" => "jobs:read",
"jobs:runscript" => "jobs:run:scripts",
"jobs:runflow" => "jobs:run:flows",
"jobs:resumeflow" => "jobs:run:flows",
"jobs:deletejob" => "jobs:write",
_ => continue,
};
*scope = new_scope.to_string();
}
}
}
}
fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
if path_vec.len() >= 5 && path_vec[0] == "" && path_vec[2] == "srch" && path_vec[3] == "w" {
Some(path_vec[4].to_owned())
} else {
None
}
};
workspace_id
}
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let opt_job_authed = OptJobAuthed::from_request_parts(parts, state).await?;
Ok(opt_job_authed.authed)
}
}
#[async_trait]
impl<S> FromRequestParts<S> for OptJobAuthed
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(OptJobAuthed::default());
};
#[cfg(feature = "no_auth")]
{
let authed = ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
};
return Ok(OptJobAuthed { authed, job_id: None });
}
let already_authed = parts.extensions.get::<OptJobAuthed>();
if let Some(authed) = already_authed {
return Ok(authed.clone());
}
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = maybe_get_workspace_id_from_path(&path_vec);
if let Some(mut opt_job_authed) =
cache.get_opt_job_authed(workspace_id.clone(), &token).await
{
let authed = &mut opt_job_authed.authed;
if authed.scopes.is_some() {
transform_old_scope_to_new_scope(authed.scopes.as_mut());
let path = original_uri.path();
let method = parts.method.as_str();
if let Err(err) = crate::scopes::check_scopes_for_route(
authed.scopes.as_deref(),
path,
method,
) {
BRUTE_FORCE_COUNTER.increment().await;
return Err(err);
}
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(opt_job_authed);
}
}
}
BRUTE_FORCE_COUNTER.increment().await;
Err(Error::NotAuthorized("Unauthorized".to_string()))
}
}
fn username_override_from_label(label: Option<String>) -> Option<String> {
match label {
Some(label)
if label.starts_with("webhook-")
|| label.starts_with("http-")
|| label.starts_with("email-")
|| label.starts_with("ws-") =>
{
Some(label)
}
Some(label) if label.starts_with("ephemeral-script-end-user-") => Some(
label
.trim_start_matches("ephemeral-script-end-user-")
.to_string(),
),
Some(label) if label == "Ephemeral lsp token" => Some("lsp".to_string()),
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => {
Some(format!("label-{label}"))
}
_ => None,
}
}
#[derive(FromRow, Serialize)]
pub struct TruncatedTokenWithEmail {
pub label: Option<String>,
pub token_prefix: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub email: Option<String>,
}
pub async fn list_tokens_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let tokens = if is_flow {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
concat(substring(token for 10)) AS token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:flows:' || $2]::text[]
OR scopes @> ARRAY['run:flow/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
} else {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
concat(substring(token for 10)) AS token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]
OR scopes @> ARRAY['run:script/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
};
Ok(Json(tokens))
}
+2 -122
View File
@@ -14,13 +14,9 @@ use sqlx::{
};
use tokio::task::JoinHandle;
use windmill_audit::audit_oss::AuditAuthorable;
pub use windmill_api_auth::{ApiAuthed, OptJobAuthed};
pub use windmill_common::db::DB;
use windmill_common::{
db::{Authable, Authed, AuthedRef},
error::Error,
utils::generate_lock_id,
};
use windmill_common::{error::Error, utils::generate_lock_id};
async fn current_database(conn: &mut PgConnection) -> Result<String, MigrateError> {
// language=SQL
@@ -292,119 +288,3 @@ pub async fn migrate(
Ok(None)
}
#[derive(Default, Clone, Debug)]
pub struct OptJobAuthed {
pub job_id: Option<uuid::Uuid>,
pub authed: ApiAuthed,
}
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
pub struct ApiAuthed {
pub email: String,
pub username: String,
pub is_admin: bool,
pub is_operator: bool,
pub groups: Vec<String>,
// (folder name, can write, is owner)
pub folders: Vec<(String, bool, bool)>,
pub scopes: Option<Vec<String>>,
pub username_override: Option<String>,
pub token_prefix: Option<String>,
}
impl ApiAuthed {
pub fn to_authed_ref<'e>(&'e self) -> AuthedRef<'e> {
AuthedRef {
email: &self.email,
username: &self.username,
is_admin: &self.is_admin,
is_operator: &self.is_operator,
groups: &self.groups,
folders: &self.folders,
scopes: &self.scopes,
token_prefix: &self.token_prefix,
}
}
}
impl From<ApiAuthed> for Authed {
fn from(value: ApiAuthed) -> Self {
Self {
email: value.email,
username: value.username,
is_admin: value.is_admin,
is_operator: value.is_operator,
groups: value.groups,
folders: value.folders,
scopes: value.scopes,
token_prefix: value.token_prefix,
}
}
}
impl From<Authed> for ApiAuthed {
fn from(value: Authed) -> Self {
Self {
email: value.email,
username: value.username,
is_admin: value.is_admin,
is_operator: value.is_operator,
groups: value.groups,
folders: value.folders,
scopes: value.scopes,
username_override: None, // Authed doesn't have this field, so default to None
token_prefix: value.token_prefix,
}
}
}
impl ApiAuthed {
pub fn display_username(&self) -> &str {
self.username_override.as_ref().unwrap_or(&self.username)
}
}
impl AuditAuthorable for ApiAuthed {
fn username(&self) -> &str {
self.username.as_str()
}
fn email(&self) -> &str {
self.email.as_str()
}
fn username_override(&self) -> Option<&str> {
self.username_override.as_deref()
}
fn token_prefix(&self) -> Option<&str> {
self.token_prefix.as_deref()
}
}
impl Authable for ApiAuthed {
fn is_admin(&self) -> bool {
self.is_admin
}
fn is_operator(&self) -> bool {
self.is_operator
}
fn groups(&self) -> &[String] {
&self.groups
}
fn folders(&self) -> &[(String, bool, bool)] {
&self.folders
}
fn scopes(&self) -> Option<&[std::string::String]> {
self.scopes.as_ref().map(|x| x.as_slice())
}
fn email(&self) -> &str {
&self.email
}
fn username(&self) -> &str {
&self.username
}
}
+24
View File
@@ -44,6 +44,30 @@ impl ExternalJwks {
}
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub struct ExternalJwksAuthBackend {
pub ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
#[axum::async_trait]
impl windmill_api_auth::JwtExtAuthBackend for ExternalJwksAuthBackend {
async fn jwt_ext_auth(
&self,
w_id: Option<&String>,
token: &str,
db: &windmill_common::DB,
) -> Option<(crate::db::ApiAuthed, usize, Option<uuid::Uuid>)> {
match jwt_ext_auth(w_id, token, self.ext_jwks.clone(), db).await {
Ok(r) => Some(r),
Err(e) => {
tracing::error!("JWT_EXT auth error: {:?}", e);
None
}
}
}
}
#[cfg(all(
feature = "enterprise",
any(feature = "nats", feature = "kafka", feature = "sqs_trigger"),
+300
View File
@@ -0,0 +1,300 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Implementation of `windmill_triggers::jobs_ext::JobOps` trait.
//! This bridges windmill-triggers back to windmill-api internals.
use axum::response::Response;
use serde_json::value::RawValue;
use sqlx::Postgres;
#[cfg(feature = "parquet")]
use std::sync::Arc;
use uuid::Uuid;
use windmill_api_auth::{ApiAuthed, OptTokened};
use windmill_common::{
db::UserDB,
error,
triggers::TriggerMetadata,
utils::StripPath,
DB,
};
use windmill_queue::PushArgsOwned;
use windmill_triggers::jobs_ext::{JobOps, JobUpdateSSEStream, RunJobQuery};
pub struct JobOpsImpl;
#[axum::async_trait]
impl JobOps for JobOpsImpl {
async fn push_script_job_by_path_into_queue(
&self,
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
script_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'static, Postgres>>,
)> {
// Convert RunJobQuery from windmill-triggers to the windmill-api version
let api_run_query = crate::jobs::RunJobQuery {
scheduled_for: run_query.scheduled_for,
scheduled_in_secs: run_query.scheduled_in_secs,
parent_job: run_query.parent_job,
root_job: run_query.root_job,
invisible_to_owner: run_query.invisible_to_owner,
queue_limit: run_query.queue_limit,
payload: run_query.payload,
job_id: run_query.job_id,
tag: run_query.tag,
timeout: run_query.timeout,
cache_ttl: run_query.cache_ttl,
cache_ignore_s3_path: run_query.cache_ignore_s3_path,
skip_preprocessor: run_query.skip_preprocessor,
poll_delay_ms: run_query.poll_delay_ms,
memory_id: run_query.memory_id,
trigger_external_id: run_query.trigger_external_id,
service_name: run_query.service_name,
suspended_mode: run_query.suspended_mode,
};
crate::jobs::push_script_job_by_path_into_queue(
authed, db, tx_o, user_db, w_id, script_path, api_run_query, args, trigger,
)
.await
}
async fn push_flow_job_by_path_into_queue(
&self,
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
flow_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<String>,
Option<sqlx::Transaction<'static, Postgres>>,
)> {
let api_run_query = crate::jobs::RunJobQuery {
scheduled_for: run_query.scheduled_for,
scheduled_in_secs: run_query.scheduled_in_secs,
parent_job: run_query.parent_job,
root_job: run_query.root_job,
invisible_to_owner: run_query.invisible_to_owner,
queue_limit: run_query.queue_limit,
payload: run_query.payload,
job_id: run_query.job_id,
tag: run_query.tag,
timeout: run_query.timeout,
cache_ttl: run_query.cache_ttl,
cache_ignore_s3_path: run_query.cache_ignore_s3_path,
skip_preprocessor: run_query.skip_preprocessor,
poll_delay_ms: run_query.poll_delay_ms,
memory_id: run_query.memory_id,
trigger_external_id: run_query.trigger_external_id,
service_name: run_query.service_name,
suspended_mode: run_query.suspended_mode,
};
crate::jobs::push_flow_job_by_path_into_queue(
authed, db, tx_o, user_db, w_id, flow_path, api_run_query, args, trigger,
)
.await
}
async fn run_wait_result_internal(
&self,
db: &DB,
uuid: Uuid,
w_id: &str,
node_id_for_empty_return: Option<String>,
username: &str,
) -> error::Result<(Box<RawValue>, bool)> {
crate::jobs::run_wait_result_internal(db, uuid, w_id, node_id_for_empty_return, username)
.await
}
fn result_to_response(
&self,
result: Box<RawValue>,
success: bool,
) -> error::Result<Response> {
crate::jobs::result_to_response(result, success)
}
async fn cancel_jobs(
&self,
jobs: Vec<Uuid>,
db: &DB,
username: &str,
w_id: &str,
force_cancel: bool,
) -> error::JsonResult<Vec<Uuid>> {
crate::jobs::cancel_jobs(jobs, db, username, w_id, force_cancel).await
}
fn start_job_update_sse_stream(
&self,
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
w_id: String,
job_id: Uuid,
initial_log_offset: Option<i32>,
initial_stream_offset: Option<i32>,
get_progress: Option<bool>,
running: Option<bool>,
only_result: Option<bool>,
fast: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
poll_delay_ms: Option<u64>,
) {
// Convert between windmill-api's SSE type and windmill-triggers' SSE type
let (bridge_tx, mut bridge_rx) =
tokio::sync::mpsc::channel::<crate::jobs::JobUpdateSSEStream>(32);
crate::jobs::start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
w_id,
job_id,
initial_log_offset,
initial_stream_offset,
get_progress,
running,
only_result,
fast,
no_logs,
is_flow,
bridge_tx,
poll_delay_ms,
);
// Spawn a task to convert between the two SSE stream types
tokio::spawn(async move {
while let Some(msg) = bridge_rx.recv().await {
let converted = match msg {
crate::jobs::JobUpdateSSEStream::Update(update) => {
JobUpdateSSEStream::Update(
serde_json::to_value(&update).unwrap_or_default(),
)
}
crate::jobs::JobUpdateSSEStream::Error { error } => {
JobUpdateSSEStream::Error { error }
}
crate::jobs::JobUpdateSSEStream::NotFound => JobUpdateSSEStream::NotFound,
crate::jobs::JobUpdateSSEStream::Timeout => JobUpdateSSEStream::Timeout,
crate::jobs::JobUpdateSSEStream::Ping => JobUpdateSSEStream::Ping,
};
if tx.send(converted).await.is_err() {
break;
}
}
});
}
async fn try_get_resource_from_db(
&self,
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
resource_path: &str,
w_id: &str,
) -> error::Result<serde_json::Value> {
use windmill_common::db::DbWithOptAuthed;
let db_with_authed = DbWithOptAuthed::from_authed(authed, db.clone(), user_db);
let resource = crate::resources::get_resource_value_interpolated_internal(
&db_with_authed,
w_id,
resource_path,
None,
None,
false,
)
.await?;
match resource {
Some(v) => Ok(v),
None => Err(error::Error::NotFound(format!(
"resource at path :{} does not exist",
resource_path
))),
}
}
async fn interpolate(
&self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
s: String,
) -> Result<String, anyhow::Error> {
#[cfg(all(
feature = "enterprise",
any(feature = "nats", feature = "kafka", feature = "sqs_trigger"),
))]
{
crate::ee_oss::interpolate(authed, db, w_id, s).await
}
#[cfg(not(all(
feature = "enterprise",
any(feature = "nats", feature = "kafka", feature = "sqs_trigger"),
)))]
{
let _ = (authed, db, w_id);
Ok(s)
}
}
#[cfg(feature = "parquet")]
fn get_random_file_name(&self, ext: Option<String>) -> String {
crate::job_helpers_oss::get_random_file_name(ext)
}
#[cfg(feature = "parquet")]
async fn upload_file_internal(
&self,
s3_client: Arc<dyn object_store::ObjectStore>,
file_key: &str,
bytes_stream: std::pin::Pin<
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
>,
options: object_store::PutMultipartOpts,
) -> error::Result<()> {
crate::job_helpers_oss::upload_file_internal(s3_client, file_key, bytes_stream, options)
.await
.map(|_| ())
}
#[cfg(feature = "parquet")]
async fn get_workspace_s3_resource(
&self,
authed: &ApiAuthed,
db: &DB,
user_db: Option<UserDB>,
w_id: &str,
storage: Option<String>,
) -> error::Result<(
Option<bool>,
Option<windmill_common::s3_helpers::ObjectStoreResource>,
)> {
crate::job_helpers_oss::get_workspace_s3_resource(authed, db, user_db, w_id, storage).await
}
}
+21 -4
View File
@@ -132,7 +132,13 @@ pub mod job_helpers_ee;
#[cfg(feature = "parquet")]
mod job_helpers_oss;
pub mod job_metrics;
pub mod job_ops_impl;
pub mod jobs;
// Re-export windmill-triggers extension modules so EE files that use
// `crate::jobs_ext`, `crate::resource_ext` etc. work in both crates.
pub use windmill_triggers::jobs_ext;
pub use windmill_triggers::resource_ext;
pub mod jobs_export;
#[cfg(all(feature = "oauth2", feature = "private"))]
pub mod oauth2_ee;
@@ -299,19 +305,30 @@ pub async fn run_server(
.expect("could not create initial server dir");
}
#[cfg(feature = "enterprise")]
let ext_jwks = ExternalJwks::load().await;
let jwt_ext_auth: Arc<dyn windmill_api_auth::JwtExtAuthBackend> = {
#[cfg(feature = "enterprise")]
{
let ext_jwks = ExternalJwks::load().await;
Arc::new(crate::ee_oss::ExternalJwksAuthBackend { ext_jwks })
}
#[cfg(not(feature = "enterprise"))]
{
Arc::new(windmill_api_auth::NoopJwtExtAuth)
}
};
let auth_cache = Arc::new(crate::auth::AuthCache::new(
db.clone(),
std::env::var("SUPERADMIN_SECRET").ok(),
#[cfg(feature = "enterprise")]
ext_jwks,
jwt_ext_auth,
));
let argon2 = Arc::new(Argon2::default());
// Initialize debug signing key for debugger authentication
debug::init_debug_signing_key().await;
// Initialize windmill-triggers JobOps with the real implementation
windmill_triggers::jobs_ext::set_ops(Arc::new(job_ops_impl::JobOpsImpl));
let disable_response_logs = std::env::var("DISABLE_RESPONSE_LOGS")
.ok()
.map(|x| x == "true")
+3 -934
View File
@@ -1,934 +1,3 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use windmill_common::error::{Error, Result};
/// Comprehensive scope system for JWT token authorization
///
/// Scopes follow the format: {domain}:{action}[:{resource}]
/// Examples:
/// - "jobs:read" - Read access to jobs
/// - "scripts:write:f/folder/*" - Write access to scripts in a folder
/// - "*" - Full access (superuser)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeDefinition {
pub domain: String,
pub action: String,
pub kind: Option<String>, // For jobs:run:kind (optional)
pub resource: Option<Vec<String>>,
}
impl ScopeDefinition {
pub fn new(
domain: &str,
action: &str,
kind: Option<&str>,
resource: Option<Vec<String>>,
) -> Self {
Self {
domain: domain.to_string(),
action: action.to_string(),
kind: kind.map(|s| s.to_string()),
resource: resource,
}
}
pub fn from_scope_string(scope: &str) -> Result<Self> {
let parts: Vec<&str> = scope.split(':').collect();
let into_owned_vec = |resources: &str| -> Vec<String> {
let resources = resources
.split(",")
.collect_vec()
.into_iter()
.map(ToOwned::to_owned)
.collect_vec();
resources
};
match parts.len() {
2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action
3 => {
if parts[0] == "jobs" && parts[1] == "run" {
Ok(Self::new(parts[0], parts[1], Some(parts[2]), None))
} else {
Ok(Self::new(
parts[0],
parts[1],
None,
Some(into_owned_vec(parts[2])),
))
}
}
4 => {
if parts[0] == "jobs" && parts[1] == "run" {
Ok(Self::new(
parts[0],
parts[1],
Some(parts[2]),
Some(into_owned_vec(parts[3])),
))
} else {
Err(Error::BadRequest(format!(
"Invalid 4-part scope: {}",
scope
)))
}
}
_ => Err(Error::BadRequest(format!(
"Invalid scope format: {}",
scope
))),
}
}
pub fn as_string(&self) -> String {
match (&self.kind, &self.resource) {
(Some(kind), Some(resource)) => {
format!(
"{}:{}:{}:{}",
self.domain,
self.action,
kind,
resource.join(",")
)
}
(Some(kind), None) => {
format!("{}:{}:{}", self.domain, self.action, kind)
}
(None, Some(resource)) => {
format!("{}:{}:{}", self.domain, self.action, resource.join(","))
}
(None, None) => format!("{}:{}", self.domain, self.action),
}
}
pub fn includes(&self, other: &ScopeDefinition) -> bool {
if self.domain != other.domain {
return false;
}
match (self.action.as_str(), other.action.as_str()) {
(a, b) if (a == "write" && b == "read") || (a == b) => {}
_ => return false,
}
if self.domain == "jobs" && self.action == "run" {
match (&self.kind, &other.kind) {
(Some(self_kind), Some(other_kind)) => {
if self_kind != other_kind {
return false;
}
}
(Some(_), None) => {
return false;
}
(None, _) => {
return true;
}
}
}
match (&self.resource, &other.resource) {
(Some(self_resources), Some(other_resources)) => {
resources_match(self_resources, other_resources)
}
(Some(_), None) => false,
(None, _) => true,
}
}
}
fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool {
if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) {
return true;
}
if scope_resources.len() <= 4 && accepted_resources.len() <= 4 {
return resources_match_small(scope_resources, accepted_resources);
}
resources_match_large(scope_resources, accepted_resources)
}
fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool {
for required in accepted_resources {
for scope_resource in scope_resources {
if resource_matches_pattern(scope_resource, required) {
return true;
}
}
}
false
}
fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool {
let mut exact_matches = HashSet::new();
let mut patterns = Vec::new();
for scope_resource in scope_resources {
if scope_resource.contains('*') {
patterns.push(scope_resource);
} else {
exact_matches.insert(scope_resource);
}
}
for accepted_resource in accepted_resources {
if exact_matches.contains(accepted_resource) {
return true;
}
for pattern in &patterns {
if resource_matches_pattern(pattern, accepted_resource) {
return true;
}
}
}
false
}
fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool {
if scope_resource == accepted_resource {
return true;
}
let matches_wildcard = |pattern: &str, resource: &str| -> bool {
if !pattern.ends_with("/*") {
return false;
}
let prefix = &pattern[..pattern.len() - 2];
if !resource.starts_with(prefix) {
return false;
}
// If the resource is exactly the prefix, it matches
if resource.len() == prefix.len() {
return true;
}
// If the resource is longer, the next character must be '/' for a valid match
// This prevents "u/user" from matching "u/use/*"
resource.chars().nth(prefix.len()) == Some('/')
};
// Check if either resource is a wildcard pattern and matches the other
matches_wildcard(scope_resource, accepted_resource)
|| matches_wildcard(accepted_resource, scope_resource)
}
/// Available scope domains (top-level API categories)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeDomain {
// Core resource domains
Jobs,
Scripts,
Flows,
Apps,
Variables,
Resources,
Schedules,
Folders,
Users,
Groups,
Workspaces,
// Trigger domains
HttpTriggers,
WebsocketTriggers,
KafkaTriggers,
NatsTriggers,
MqttTriggers,
SqsTriggers,
GcpTriggers,
PostgresTriggers,
EmailTriggers,
// Native trigger domains
NativeTriggers,
// System domains
Audit,
Settings,
Workers,
ServiceLogs,
Configs,
OAuth,
AI,
Indexer,
Teams, // Microsoft Teams integration
GitSync, // Git synchronization
// Special domains
Capture, // Webhook capture
Drafts, // Draft resources
Favorites, // User favorites
Inputs, // Input templates
JobHelpers, // Job helper functions
ConcurrencyGroups, // Concurrency groups
Oidc, // OpenID Connect
Openapi, // OpenAPI generation
// Additional domains
Acls, // Granular access control lists
RawApps, // Raw application data
AgentWorkers, // Agent workers management
Mcp, // MCP
}
impl ScopeDomain {
pub fn as_str(&self) -> &'static str {
match self {
Self::Jobs => "jobs",
Self::Scripts => "scripts",
Self::Flows => "flows",
Self::Apps => "apps",
Self::Variables => "variables",
Self::Resources => "resources",
Self::Schedules => "schedules",
Self::Folders => "folders",
Self::Users => "users",
Self::Groups => "groups",
Self::Workspaces => "workspaces",
Self::HttpTriggers => "http_triggers",
Self::WebsocketTriggers => "websocket_triggers",
Self::KafkaTriggers => "kafka_triggers",
Self::NatsTriggers => "nats_triggers",
Self::MqttTriggers => "mqtt_triggers",
Self::SqsTriggers => "sqs_triggers",
Self::GcpTriggers => "gcp_triggers",
Self::PostgresTriggers => "postgres_triggers",
Self::EmailTriggers => "email_triggers",
Self::NativeTriggers => "native_triggers",
Self::Audit => "audit",
Self::Settings => "settings",
Self::Workers => "workers",
Self::ServiceLogs => "service_logs",
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::Capture => "capture",
Self::Drafts => "drafts",
Self::Favorites => "favorites",
Self::Inputs => "inputs",
Self::JobHelpers => "job_helpers",
Self::ConcurrencyGroups => "concurrency_groups",
Self::Oidc => "oidc",
Self::Openapi => "openapi",
Self::Acls => "acls",
Self::RawApps => "raw_apps",
Self::AgentWorkers => "agent_workers",
Self::Indexer => "indexer",
Self::Teams => "teams",
Self::GitSync => "git_sync",
Self::Mcp => "mcp",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"jobs" | "jobs_u" => Some(Self::Jobs),
"scripts" => Some(Self::Scripts),
"flows" => Some(Self::Flows),
"apps" | "apps_u" => Some(Self::Apps),
"variables" => Some(Self::Variables),
"resources" => Some(Self::Resources),
"schedules" => Some(Self::Schedules),
"folders" => Some(Self::Folders),
"users" => Some(Self::Users),
"groups" => Some(Self::Groups),
"workspaces" => Some(Self::Workspaces),
"http_triggers" => Some(Self::HttpTriggers),
"websocket_triggers" => Some(Self::WebsocketTriggers),
"kafka_triggers" => Some(Self::KafkaTriggers),
"nats_triggers" => Some(Self::NatsTriggers),
"mqtt_triggers" => Some(Self::MqttTriggers),
"sqs_triggers" => Some(Self::SqsTriggers),
"gcp_triggers" => Some(Self::GcpTriggers),
"postgres_triggers" => Some(Self::PostgresTriggers),
"email_triggers" => Some(Self::EmailTriggers),
"audit" => Some(Self::Audit),
"settings" => Some(Self::Settings),
"workers" => Some(Self::Workers),
"service_logs" => Some(Self::ServiceLogs),
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
"native_triggers" => Some(Self::NativeTriggers),
"git_sync" | "github_app" => Some(Self::GitSync),
"capture" => Some(Self::Capture),
"drafts" => Some(Self::Drafts),
"favorites" => Some(Self::Favorites),
"inputs" => Some(Self::Inputs),
"job_helpers" => Some(Self::JobHelpers),
"concurrency_groups" => Some(Self::ConcurrencyGroups),
"oidc" => Some(Self::Oidc),
"openapi" => Some(Self::Openapi),
"acls" => Some(Self::Acls),
"raw_apps" => Some(Self::RawApps),
"agent_workers" => Some(Self::AgentWorkers),
"mcp" => Some(Self::Mcp),
_ => None,
}
}
}
/// Available scope actions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeAction {
Read, // GET operations, list, view
Write, // POST, PUT, PATCH, DELETE operations, create, update, delete
Run, // Special action for running (scripts, flows, etc.)
}
impl ScopeAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Run => "run",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"read" => Some(Self::Read),
"write" => Some(Self::Write),
"delete" => Some(Self::Write),
"run" => Some(Self::Run),
_ => None,
}
}
/// Check if this action includes another action
/// Write includes Read
pub fn includes(&self, other: &ScopeAction) -> bool {
match (self, other) {
(ScopeAction::Write, ScopeAction::Read) => true,
(ScopeAction::Run, ScopeAction::Read) => true,
(a, b) => a == b,
}
}
}
pub fn check_route_access(
token_scopes: &[String],
route_path: &str,
http_method: &str,
) -> Result<()> {
// Map HTTP method to scope action (considering route context)
let required_action = map_http_method_to_action(http_method, route_path);
// Find the domain and kind for this route
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
// Backward compatibility: MCP handlers expect unusual scope actions: all, favorites, hub.
if required_domain == ScopeDomain::Mcp {
return Ok(());
}
// tracing::error!("Checking route access {:?} {:?} {:?} {:?}", required_action, required_domain, required_kind, route_suffix);
let mut is_scoped_token = false;
// Check if any token scope grants the required access
for scope_str in token_scopes {
if !scope_str.starts_with("if_jobs:filter_tags:") {
if let Ok(scope) = ScopeDefinition::from_scope_string(scope_str) {
// tracing::error!("Checking scope {:?} for required domain {:?} and action {:?} and kind {:?} and route suffix {:?}", scope, required_domain, required_action, required_kind, route_suffix);
if scope_grants_access(
&scope,
required_domain,
required_action,
required_kind.as_deref(),
route_suffix.as_deref(),
)? {
// tracing::error!("Scope grants access: {:?}", scope);
return Ok(());
}
}
if !is_scoped_token {
is_scoped_token = true;
}
}
}
//Edge case for backward compatibility, if only scopes defined was filter tag then don't treat this we don't treat the token
//as a restricted token
if !is_scoped_token {
return Ok(());
}
let scope_display = if let Some(kind) = required_kind {
format!(
"{}:{}:{}",
required_domain.as_str(),
required_action.as_str(),
kind
)
} else {
format!("{}:{}", required_domain.as_str(), required_action.as_str())
};
Err(Error::NotAuthorized(format!(
"Access denied. Required scope: {}",
scope_display
)))
}
const SCRIPT_JOBS: [&'static str; 8] = [
"jobs/run/p",
"jobs/run/h",
"jobs/run_wait_result/p",
"jobs/run_wait_result/h",
"jobs/run/preview_bundle",
"jobs/run/preview",
"jobs/run_and_stream/p",
"jobs/run_and_stream/h",
];
const FLOW_JOBS: [&'static str; 6] = [
"jobs/run/f",
"jobs/run_wait_result/f",
"jobs/run/preview_flow",
"jobs/restart/f",
"jobs/flow/resume",
"jobs/run_and_stream/f",
];
lazy_static::lazy_static! {
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
v.extend(SCRIPT_JOBS);
v.extend(FLOW_JOBS);
v
};
}
fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
if RUN_PATH_ACTIONS
.iter()
.any(|run_path| route_path.contains(run_path))
{
return ScopeAction::Run;
}
match method.to_uppercase().as_str() {
"GET" | "HEAD" | "OPTIONS" => ScopeAction::Read,
"POST" | "PUT" | "PATCH" | "DELETE" => ScopeAction::Write,
_ => ScopeAction::Read,
}
}
/// Checks the route path to determine the runnable kind (either "flows" or "scripts").
///
/// The order of checks is important:
/// - Flow-related paths are checked first to avoid false positives, as some flow paths
/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
///
/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found.
fn determine_kind_from_route(route_path: &str) -> Option<String> {
if route_path.starts_with("jobs") {
if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("flows".to_string());
} else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("scripts".to_string());
}
}
None
}
fn extract_domain_from_route(
route_path: &str,
) -> Result<(ScopeDomain, Option<String>, Option<String>)> {
// Examples:
// - /api/w/workspace/jobs/123 -> jobs domain (workspaced)
// - /api/teams/sync -> teams domain (global)
// - /api/srch/index/search -> indexer domain (global)
let parts: Vec<&str> = route_path.split('/').collect();
let (domain, kind, route_suffix) = if parts.len() >= 5 && parts[1] == "api" && parts[2] == "w" {
let domain_part = parts[4];
let route_suffix = &parts[4..].join("/");
let domain = ScopeDomain::from_str(domain_part);
let kind = determine_kind_from_route(&route_suffix);
(domain, kind, Some(route_suffix.to_owned()))
} else if parts.len() >= 3 && parts[1] == "api" {
(
ScopeDomain::from_str(parts[2]),
None,
Some(parts[2..].join("/")),
)
} else {
(None, None, None)
};
if let Some(domain) = domain {
// tracing::error!("Extracted domain {:?} from route {:?} with kind {:?} and route suffix {:?}", domain, route_path, kind, route_suffix);
return Ok((domain, kind, route_suffix));
}
Err(Error::BadRequest(format!(
"Could not extract domain from route: {}",
route_path
)))
}
const RUN_WHITELISTED_GET_PATHS: [&'static str; 19] = [
"jobs_u/get_flow/",
"jobs_u/get_root_job_id/",
"jobs_u/get/",
"jobs_u/get_logs/",
"jobs_u/get_args/",
"jobs_u/get_flow_debug_info/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_log_file/",
"jobs/result_by_id/",
"jobs/resume_urls/",
"jobs/flow/user_states/",
"jobs/job_signature/",
"jobs/completed/get/",
"jobs/completed/get_result/",
"jobs/completed/get_result_maybe/",
];
fn scope_grants_access(
scope: &ScopeDefinition,
required_domain: ScopeDomain,
required_action: ScopeAction,
required_kind: Option<&str>,
route_path: Option<&str>,
) -> Result<bool> {
// Check domain match
let scope_domain = ScopeDomain::from_str(&scope.domain)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope domain: {}", scope.domain)))?;
if scope_domain != required_domain {
return Ok(false);
}
// Check action match (with hierarchical permissions)
let scope_action = ScopeAction::from_str(&scope.action)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
if !scope_action.includes(&required_action)
&& !(scope_domain == ScopeDomain::Jobs
&& required_action == ScopeAction::Read
&& route_path.is_some_and(|p| {
RUN_WHITELISTED_GET_PATHS
.iter()
.any(|path| p.starts_with(path))
}))
{
return Ok(false);
}
if scope_domain == ScopeDomain::Jobs && required_action == ScopeAction::Run {
match (&scope.kind, required_kind) {
(Some(scope_kind), Some(req_kind)) => {
if scope_kind != req_kind {
return Ok(false);
}
}
(None, _) => {}
(Some(_), None) => {
return Ok(false);
}
}
}
// No resource specified means access to entire domain
Ok(true)
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
route_path: &str,
http_method: &str,
) -> Result<()> {
// If no scopes defined, allow access (backward compatibility)
let scopes = match token_scopes {
Some(s) if !s.is_empty() => s,
_ => return Ok(()),
};
check_route_access(scopes, route_path, http_method)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scope_definition_parsing() {
let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "read");
assert_eq!(scope.kind, None);
assert_eq!(scope.resource, None);
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts:f/folder/*").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("scripts".to_string()));
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
// Test jobs:run:kind parsing
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("scripts".to_string()));
assert_eq!(scope.resource, None);
// Test jobs:run:kind:resource parsing
let scope = ScopeDefinition::from_scope_string("jobs:run:flows:f/folder/*").unwrap();
assert_eq!(scope.domain, "jobs");
assert_eq!(scope.action, "run");
assert_eq!(scope.kind, Some("flows".to_string()));
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
// Test comma-separated resources parsing
let scope =
ScopeDefinition::from_scope_string("scripts:read:path1,path2,f/folder/*").unwrap();
assert_eq!(scope.domain, "scripts");
assert_eq!(scope.action, "read");
assert_eq!(scope.kind, None);
assert_eq!(
scope.resource,
Some(vec![
"path1".to_string(),
"path2".to_string(),
"f/folder/*".to_string()
])
);
}
#[test]
fn test_scope_action_hierarchy() {
assert!(ScopeAction::Write.includes(&ScopeAction::Read));
assert!(!ScopeAction::Read.includes(&ScopeAction::Write));
assert!(ScopeAction::Run.includes(&ScopeAction::Read));
assert!(!ScopeAction::Run.includes(&ScopeAction::Write));
}
#[test]
fn test_route_domain_extraction() {
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/jobs/123").unwrap();
assert_eq!(domain, ScopeDomain::Jobs);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("jobs/123".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/scripts/test_script").unwrap();
assert_eq!(domain, ScopeDomain::Scripts);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("scripts/test_script".to_string()));
}
#[test]
fn test_specific_scope_access() {
let scopes = vec!["jobs:read".to_string()];
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "GET").is_ok());
// DELETE now requires write permission, so it should still fail with read-only scope
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "DELETE").is_err());
}
#[test]
fn test_new_domain_parsing() {
// Test that new domains are properly parsed
assert_eq!(ScopeDomain::from_str("acls"), Some(ScopeDomain::Acls));
assert_eq!(
ScopeDomain::from_str("raw_apps"),
Some(ScopeDomain::RawApps)
);
assert_eq!(
ScopeDomain::from_str("agent_workers"),
Some(ScopeDomain::AgentWorkers)
);
// Test that string conversion works both ways
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
assert_eq!(ScopeDomain::AgentWorkers.as_str(), "agent_workers");
}
#[test]
fn test_resource_array_matching() {
// Test wildcard access
let scope_all = ScopeDefinition::new("scripts", "read", None, Some(vec!["*".to_string()]));
let required = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(scope_all.includes(&required));
// Test exact matches
let scope_exact = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
let required_subset =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
assert!(scope_exact.includes(&required_subset));
// Test partial match - should grant access if ANY required resource matches
let scope_limited =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
let required_partial = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(scope_limited.includes(&required_partial)); // path1 matches, so access granted
// Test no match - scope doesn't cover any of the required resources
let scope_different =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path3".to_string()]));
let required_no_match = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
assert!(!scope_different.includes(&required_no_match));
// Test pattern matching
let scope_pattern = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/*".to_string()]),
);
let required_in_folder = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/script1".to_string()]),
);
assert!(scope_pattern.includes(&required_in_folder));
// Test mixed patterns and exact matches
let scope_mixed = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["exact_path".to_string(), "f/folder/*".to_string()]),
);
let required_mixed1 = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["exact_path".to_string()]),
);
let required_mixed2 = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["f/folder/script2".to_string()]),
);
assert!(scope_mixed.includes(&required_mixed1));
assert!(scope_mixed.includes(&required_mixed2));
}
#[test]
fn test_efficiency_small_vs_large_arrays() {
// Test small array optimization path
let scope_small = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["path1".to_string(), "path2".to_string()]),
);
let required_small =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
assert!(scope_small.includes(&required_small));
// Test large array optimization path
let large_scope_vec: Vec<String> = (0..10).map(|i| format!("path{}", i)).collect();
let scope_large = ScopeDefinition::new("scripts", "read", None, Some(large_scope_vec));
let required_large =
ScopeDefinition::new("scripts", "read", None, Some(vec!["path5".to_string()]));
assert!(scope_large.includes(&required_large));
}
#[test]
fn test_user_example_case() {
// User's example: scope has "u/dieri/*", required has ["u/dadad/wqdq", "u/*"]
// Should grant access because scope "u/dieri/*" falls under required pattern "u/*"
let user_scope =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/dieri/*".to_string()]));
let required_mixed = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dadad/wqdq".to_string(), "u/*".to_string()]),
);
assert!(user_scope.includes(&required_mixed)); // Should match because u/dieri/* falls under u/*
// Another example: scope covers one but not both paths
let scope_specific = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["folder/file1".to_string()]),
);
let required_multi = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["folder/file1".to_string(), "other/file2".to_string()]),
);
assert!(scope_specific.includes(&required_multi)); // Should match because folder/file1 matches exactly
// Test bidirectional pattern matching more explicitly
let scope_broad =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
let required_specific = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dieri/script.py".to_string()]),
);
assert!(scope_broad.includes(&required_specific)); // u/* covers u/dieri/script.py
let scope_specific_path = ScopeDefinition::new(
"scripts",
"read",
None,
Some(vec!["u/dieri/script.py".to_string()]),
);
let required_broad =
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
assert!(scope_specific_path.includes(&required_broad)); // u/dieri/script.py satisfies u/*
}
}
// Re-export all scope types from windmill-api-auth
#[allow(unused_imports)]
pub use windmill_api_auth::scopes::*;
+5 -67
View File
@@ -8,7 +8,6 @@
#![allow(non_snake_case)]
use quick_cache::sync::Cache;
use sqlx::{PgConnection, Postgres, Transaction};
use std::sync::atomic::AtomicBool;
@@ -24,7 +23,7 @@ use crate::utils::{
generate_instance_wide_unique_username, get_instance_username_or_create_pending,
};
use crate::{
auth::ExpiringAuthCache, db::DB, utils::require_super_admin, webhook_util::WebhookShared,
db::DB, utils::require_super_admin, webhook_util::WebhookShared,
COOKIE_DOMAIN, IS_SECURE,
};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
@@ -47,11 +46,11 @@ use tracing::Instrument;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{fetch_authed_from_permissioned_as, TOKEN_PREFIX_LEN};
use windmill_common::auth::TOKEN_PREFIX_LEN;
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{truncate_token, username_to_permissioned_as};
use windmill_common::users::truncate_token;
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::BASE_URL;
@@ -197,70 +196,9 @@ where
}
}
pub use windmill_api_auth::fetch_api_authed;
#[allow(unused)]
pub async fn fetch_api_authed(
username: String,
email: String,
w_id: &str,
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
let permissioned_as = username_to_permissioned_as(username.as_str());
fetch_api_authed_from_permissioned_as(permissioned_as, email, w_id, db, username_override).await
}
lazy_static::lazy_static! {
static ref API_AUTHED_CACHE: Cache<(String,String,String), ExpiringAuthCache> = Cache::new(300);
}
#[allow(unused)]
pub async fn fetch_api_authed_from_permissioned_as(
permissioned_as: String,
email: String,
w_id: &str,
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
Some(expiring_authed) if expiring_authed.expiry > chrono::Utc::now() => {
tracing::debug!("API authed cache hit for user {}", email);
expiring_authed.authed
}
_ => {
tracing::debug!("API authed cache miss for user {}", email);
let authed =
fetch_authed_from_permissioned_as(permissioned_as, email.clone(), w_id, db).await?;
let api_authed = ApiAuthed {
username: authed.username,
email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
groups: authed.groups,
folders: authed.folders,
scopes: authed.scopes,
username_override: None,
token_prefix: authed.token_prefix,
};
API_AUTHED_CACHE.insert(
key,
ExpiringAuthCache {
authed: api_authed.clone(),
expiry: chrono::Utc::now() + chrono::Duration::try_seconds(120).unwrap(),
job_id: None,
},
);
api_authed
}
};
api_authed.username_override = username_override;
Ok(api_authed)
}
pub use windmill_api_auth::fetch_api_authed_from_permissioned_as;
#[derive(FromRow, Serialize)]
pub struct User {
+1 -31
View File
@@ -18,7 +18,7 @@ use windmill_common::{
DB,
};
use crate::{db::ApiAuthed, scopes::ScopeDefinition};
pub use windmill_api_auth::scopes::check_scopes;
#[cfg(feature = "enterprise")]
use windmill_common::error::JsonResult;
@@ -49,36 +49,6 @@ pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
}
}
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
where
F: FnOnce() -> String,
{
if let Some(scopes) = authed.scopes.as_ref() {
let mut is_scoped_token = false;
let required_scope = ScopeDefinition::from_scope_string(&required())?;
for scope in scopes {
if !scope.starts_with("if_jobs:filter_tags:") {
if !is_scoped_token {
is_scoped_token = true;
}
match ScopeDefinition::from_scope_string(scope) {
Ok(scope) if scope.includes(&required_scope) => return Ok(()),
_ => {}
}
}
}
if is_scoped_token {
return Err(Error::NotAuthorized(format!(
"Required scope: {}",
required_scope.as_string()
)));
}
}
Ok(())
}
pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> {
let is_devops = is_devops_email(db, email).await?;
+133
View File
@@ -0,0 +1,133 @@
[package]
name = "windmill-triggers"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_triggers"
path = "src/lib.rs"
[features]
default = []
private = ["windmill-api-auth/private", "windmill-common/private"]
enterprise = ["windmill-api-auth/enterprise", "windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise"]
cloud = ["windmill-api-auth/cloud", "windmill-common/cloud"]
license = []
no_auth = ["windmill-api-auth/no_auth"]
kafka = ["dep:rdkafka", "dep:rdkafka-sys"]
kafka-gssapi = ["kafka", "rdkafka/gssapi"]
nats = ["dep:async-nats", "dep:nkeys"]
websocket = ["dep:tokio-tungstenite"]
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"]
http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
mqtt_trigger = ["dep:thiserror", "dep:rumqttc"]
native_trigger = ["dep:strum", "dep:backon", "oauth2"]
sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config", "windmill-common/aws_auth"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic", "dep:tokio-util"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet"]
oauth2 = ["dep:windmill-oauth"]
openidconnect = ["windmill-common/openidconnect"]
benchmark = []
stripe = []
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus"]
[dependencies]
windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-queue.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
windmill-oauth = { workspace = true, optional = true }
windmill-parser.workspace = true
windmill-parser-ts.workspace = true
windmill-parser-py.workspace = true
# Core framework
tokio.workspace = true
tokio-stream.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
tower-cookies.workspace = true
hyper.workspace = true
http.workspace = true
# Data handling
serde.workspace = true
serde_json.workspace = true
serde_urlencoded.workspace = true
serde_yml.workspace = true
# Database
sqlx.workspace = true
sql-builder.workspace = true
# HTTP
reqwest.workspace = true
# Utilities
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
itertools.workspace = true
futures.workspace = true
rand.workspace = true
bytes.workspace = true
base64.workspace = true
regex.workspace = true
ulid.workspace = true
async-trait.workspace = true
quick_cache.workspace = true
lazy_static.workspace = true
jsonwebtoken.workspace = true
hmac.workspace = true
sha2.workspace = true
url.workspace = true
async-recursion.workspace = true
async-stream.workspace = true
pin-project.workspace = true
# Optional dependencies
tokio-tungstenite = { workspace = true, optional = true }
rust-postgres = { workspace = true, optional = true }
rust-postgres-native-tls = { workspace = true, optional = true }
pg_escape = { workspace = true, optional = true }
byteorder = { workspace = true, optional = true }
native-tls.workspace = true
tokio-native-tls.workspace = true
tokio-postgres.workspace = true
postgres-native-tls.workspace = true
rumqttc = { workspace = true, optional = true }
rdkafka = { workspace = true, optional = true }
rdkafka-sys = { workspace = true, optional = true }
async-nats = { workspace = true, optional = true }
nkeys = { workspace = true, optional = true }
aws-sdk-sqs = { workspace = true, optional = true }
aws-sdk-sts = { workspace = true, optional = true }
aws-sdk-sso = { workspace = true, optional = true }
aws-sdk-ssooidc = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
google-cloud-pubsub = { workspace = true, optional = true }
google-cloud-googleapis = { workspace = true, optional = true }
tonic = { workspace = true, optional = true }
tokio-util = { workspace = true, optional = true }
matchit = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true }
constant_time_eq = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
rust_decimal = { workspace = true, optional = true }
mail-parser = { workspace = true, features = ["serde_support"], optional = true }
openssl = { workspace = true, optional = true }
strum = { workspace = true, optional = true }
backon = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
datafusion = { workspace = true, optional = true }
object_store = { workspace = true, optional = true }
tracing.workspace = true
dashmap.workspace = true
hex.workspace = true
urlencoding.workspace = true
+684
View File
@@ -0,0 +1,684 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
// Re-exported from windmill-api/src/args.rs
// This module handles webhook argument processing for triggers.
use std::collections::HashMap;
use axum::{
extract::{FromRequest, Multipart, Query, Request},
http::HeaderMap,
response::{IntoResponse, Response},
};
use bytes::Bytes;
use http::{header::CONTENT_TYPE, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::types::JsonRawValue;
use windmill_common::{
error::Error,
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
use crate::triggers::trigger_helpers::{get_runnable_format, RunnableId};
use windmill_api_auth::ApiAuthed;
#[derive(Debug)]
pub enum RawBody {
Json(String),
CEJson(String),
Text(String),
Xml(String),
UrlEncoded(Bytes),
Multipart(Multipart),
Empty,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Body {
HashMap(HashMap<String, Box<RawValue>>),
NoHashMap(Box<RawValue>),
}
#[derive(Debug, Clone, Default)]
pub struct WebhookArgsMetadata {
pub raw_string: Option<String>,
pub headers: HeaderMap,
pub query: Option<String>,
pub method: http::Method,
pub query_wrap_body: bool,
pub query_use_raw: bool,
pub query_include_header: Option<String>,
pub query_include_query: Option<String>,
}
pub struct RawWebhookArgs {
pub body: RawBody,
pub metadata: WebhookArgsMetadata,
}
#[derive(Debug, Clone)]
pub struct WebhookArgs {
pub body: Body,
pub metadata: WebhookArgsMetadata,
}
impl RawWebhookArgs {
#[cfg(not(feature = "parquet"))]
pub async fn process_multipart(
_multipart: Multipart,
_authed: &ApiAuthed,
_db: &DB,
_w_id: &str,
) -> Result<HashMap<String, Box<RawValue>>, Error> {
return Err(Error::BadRequest(format!(
"multipart/form-data requires the parquet feature"
)));
}
#[cfg(feature = "parquet")]
async fn process_multipart(
mut multipart: Multipart,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
) -> Result<HashMap<String, Box<RawValue>>, Error> {
use object_store::{Attribute, Attributes};
use windmill_common::s3_helpers::build_object_store_client;
let job_ops = crate::jobs_ext::get_ops();
let (_, s3_resource) = job_ops.get_workspace_s3_resource(authed, db, None, w_id, None).await?;
if let Some(s3_resource) = s3_resource {
let s3_client = build_object_store_client(&s3_resource).await?;
let mut body = HashMap::new();
let mut files = HashMap::new();
while let Some(field) = multipart.next_field().await.map_err(|e| {
Error::BadRequest(format!("Error reading multipart field: {}", e.body_text()))
})? {
if let Some(name) = field.name().map(|x| x.to_string()) {
if let Some(content_type) = field.content_type() {
let ext = field
.file_name()
.map(|x| x.split('.').last())
.flatten()
.map(|x| x.to_string());
let file_key = job_ops.get_random_file_name(ext);
let options = Attributes::from_iter(vec![
(Attribute::ContentType, content_type.to_string()),
(
Attribute::ContentDisposition,
if let Some(filename) = field.file_name() {
format!("inline; filename=\"{}\"", filename)
} else {
"inline".to_string()
},
),
])
.into();
let field_bytes = field.bytes().await.map_err(|e| {
Error::BadRequest(format!("Error reading multipart field bytes: {}", e.body_text()))
})?;
let bytes_stream = futures::stream::once(async move {
Ok::<bytes::Bytes, std::io::Error>(field_bytes)
});
job_ops
.upload_file_internal(s3_client.clone(), &file_key, Box::pin(bytes_stream), options)
.await?;
files.entry(name).or_insert(vec![]).push(serde_json::json!({
"s3": &file_key
}));
} else {
body.insert(name, to_raw_value(&field.text().await.unwrap_or_default()));
}
}
}
for (k, v) in files {
body.insert(k, to_raw_value(&v));
}
Ok(body)
} else {
Err(Error::BadRequest(format!(
"You need to connect your workspace to an S3 bucket to use multipart/form-data"
)))
}
}
pub async fn process_args(
self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
force_use_raw: Option<bool>,
) -> Result<WebhookArgs, Error> {
let use_raw = force_use_raw.unwrap_or(self.metadata.query_use_raw);
match self.body {
RawBody::Multipart(multipart) => {
let body = Self::process_multipart(multipart, authed, db, w_id).await?;
Ok(WebhookArgs { body: Body::HashMap(body), metadata: self.metadata })
}
RawBody::Empty => {
let mut metadata = self.metadata;
if use_raw {
metadata.raw_string = Some("".to_string());
}
Ok(WebhookArgs { body: Body::HashMap(HashMap::new()), metadata })
}
RawBody::Text(s) | RawBody::Xml(s) => Ok(WebhookArgs {
body: Body::HashMap(HashMap::new()),
metadata: WebhookArgsMetadata { raw_string: Some(s), ..self.metadata },
}),
RawBody::UrlEncoded(bytes) => {
let mut metadata = self.metadata;
if use_raw {
let raw_string = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::BadRequest(format!("invalid utf8: {}", e)))?;
metadata.raw_string = Some(raw_string);
}
let payload: HashMap<String, Option<String>> = serde_urlencoded::from_bytes(&bytes)
.map_err(|e| Error::BadRequest(format!("invalid urlencoded data: {}", e)))?;
let payload = payload
.into_iter()
.map(|(k, v)| (k, to_raw_value(&v)))
.collect::<HashMap<_, _>>();
Ok(WebhookArgs { body: Body::HashMap(payload), metadata })
}
RawBody::Json(s) => WebhookArgs::from_json(self.metadata, use_raw, s).await,
RawBody::CEJson(s) => WebhookArgs::from_ce_json(self.metadata, use_raw, s).await,
}
}
pub async fn to_main_args(
self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
) -> Result<PushArgsOwned, Error> {
let args = self.process_args(authed, db, w_id, None).await?;
args.to_main_args()
}
pub async fn to_args_from_runnable(
self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
runnable_id: RunnableId,
skip_preprocessor: Option<bool>,
) -> Result<PushArgsOwned, Error> {
let args = self.process_args(authed, db, w_id, None).await?;
args.to_args_from_runnable(db, w_id, runnable_id, skip_preprocessor)
.await
}
}
#[derive(Serialize)]
struct WebhookPreprocessorEvent {
kind: String,
body: Box<RawValue>,
raw_string: Option<String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
}
impl WebhookArgs {
pub fn to_main_args(self) -> Result<PushArgsOwned, Error> {
self.to_args_from_format(RunnableFormat {
has_preprocessor: false,
version: RunnableFormatVersion::V2,
})
}
pub async fn to_args_from_runnable(
self,
db: &DB,
w_id: &str,
runnable_id: RunnableId,
skip_preprocessor: Option<bool>,
) -> Result<PushArgsOwned, Error> {
if skip_preprocessor.unwrap_or(false) {
self.to_main_args()
} else {
let runnable_format =
get_runnable_format(runnable_id, w_id, db, &TriggerKind::Webhook).await?;
self.to_args_from_format(runnable_format)
}
}
pub fn to_args_from_format(
self,
runnable_format: RunnableFormat,
) -> Result<PushArgsOwned, Error> {
let headers = build_headers(
&self.metadata.headers,
self.metadata.query_include_header,
runnable_format.has_preprocessor,
);
let query = build_query(
self.metadata.query.as_deref(),
self.metadata.query_include_query,
runnable_format.has_preprocessor,
);
match runnable_format {
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => {
let mut args = HashMap::new();
args.insert(
"event".to_string(),
to_raw_value(&WebhookPreprocessorEvent {
kind: "webhook".to_string(),
body: to_raw_value(&self.body),
raw_string: self.metadata.raw_string,
headers,
query,
}),
);
Ok(PushArgsOwned { args, extra: None })
}
RunnableFormat { has_preprocessor, .. } => {
let mut extra = HashMap::new();
let WebhookArgsMetadata { query_wrap_body, raw_string, .. } = self.metadata;
for (k, v) in headers {
extra.insert(k, v);
}
for (k, v) in query {
extra.insert(k, v);
}
if let Some(raw_string) = raw_string {
extra.insert("raw_string".to_string(), to_raw_value(&raw_string));
}
if has_preprocessor {
extra.insert(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": "webhook",
})),
);
}
let extra = if extra.is_empty() { None } else { Some(extra) };
match self.body {
Body::HashMap(mut body) => {
if query_wrap_body {
body = HashMap::from([("body".to_string(), to_raw_value(&body))]);
}
Ok(PushArgsOwned { args: body, extra })
}
Body::NoHashMap(args) => {
let mut hm = HashMap::new();
hm.insert("body".to_string(), args);
Ok(PushArgsOwned { args: hm, extra })
}
}
}
}
}
}
#[derive(Deserialize)]
pub struct RequestQuery {
pub raw: Option<bool>,
pub wrap_body: Option<bool>,
pub include_header: Option<String>,
pub include_query: Option<String>,
}
async fn req_to_string<S: Send + Sync>(
req: Request<axum::body::Body>,
_state: &S,
) -> Result<String, Response> {
let bytes = Bytes::from_request(req, _state)
.await
.map_err(IntoResponse::into_response)?;
String::from_utf8(bytes.to_vec())
.map_err(|e| Error::BadRequest(format!("invalid utf8: {}", e)).into_response())
}
pub async fn try_from_request_body<S>(
request: Request,
_state: &S,
is_http_trigger: bool,
) -> Result<RawWebhookArgs, Response>
where
S: Send + Sync,
{
let (content_type, metadata) = {
let headers_map = request.headers();
let content_type_header = headers_map.get(CONTENT_TYPE);
let content_type = content_type_header.and_then(|value| value.to_str().ok());
let uri = request.uri();
let request_query = Query::<RequestQuery>::try_from_uri(uri).unwrap().0;
let query = uri.query().map(|s| s.to_owned());
let raw = !is_http_trigger && request_query.raw.unwrap_or(false);
let wrap_body = !is_http_trigger && request_query.wrap_body.unwrap_or(false);
(
content_type,
WebhookArgsMetadata {
headers: headers_map.clone(),
query,
method: request.method().clone(),
raw_string: None,
query_wrap_body: wrap_body,
query_use_raw: raw,
query_include_header: request_query.include_header,
query_include_query: request_query.include_query,
},
)
};
let no_content_type = content_type.is_none();
if no_content_type || content_type.unwrap().starts_with("application/json") {
let bytes = Bytes::from_request(request, _state)
.await
.map_err(IntoResponse::into_response)?;
if no_content_type && bytes.is_empty() {
Ok(RawWebhookArgs { body: RawBody::Empty, metadata })
} else {
let str = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::BadRequest(format!("invalid utf8: {}", e)).into_response())?;
Ok(RawWebhookArgs { body: RawBody::Json(str), metadata })
}
} else if content_type
.unwrap()
.starts_with("application/cloudevents+json")
{
let str = req_to_string(request, _state).await?;
Ok(RawWebhookArgs { body: RawBody::CEJson(str), metadata })
} else if content_type
.unwrap()
.starts_with("application/cloudevents-batch+json")
{
Err(
Error::BadRequest(format!("Cloud events batching is not supported yet"))
.into_response(),
)
} else if content_type.unwrap().starts_with("text/plain") {
let str = req_to_string(request, _state).await?;
Ok(RawWebhookArgs { body: RawBody::Text(str), metadata })
} else if content_type
.unwrap()
.starts_with("application/x-www-form-urlencoded")
{
let bytes = Bytes::from_request(request, _state)
.await
.map_err(IntoResponse::into_response)?;
Ok(RawWebhookArgs { body: RawBody::UrlEncoded(bytes), metadata })
} else if content_type.unwrap().starts_with("application/xml")
|| content_type.unwrap().starts_with("text/xml")
{
let str = req_to_string(request, _state).await?;
Ok(RawWebhookArgs { body: RawBody::Xml(str), metadata })
} else if content_type.unwrap().starts_with("multipart/form-data") {
let multipart = Multipart::from_request(request, _state)
.await
.map_err(IntoResponse::into_response)?;
Ok(RawWebhookArgs { body: RawBody::Multipart(multipart), metadata })
} else {
Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
}
}
#[axum::async_trait]
impl<S> FromRequest<S, axum::body::Body> for RawWebhookArgs
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(request: Request, _state: &S) -> Result<Self, Self::Rejection> {
let args = try_from_request_body(request, _state, false).await?;
Ok(args)
}
}
lazy_static::lazy_static! {
static ref INCLUDE_HEADERS: Vec<String> = std::env::var("INCLUDE_HEADERS")
.ok().map(|x| x
.split(',')
.map(|s| s.to_string())
.collect()).unwrap_or_default();
}
pub fn build_headers(
headers: &HeaderMap,
include_header: Option<String>,
include_all_headers: bool,
) -> HashMap<String, Box<RawValue>> {
let mut selected_headers = HashMap::new();
if include_all_headers {
for (k, v) in headers.iter() {
selected_headers.insert(
k.to_string(),
to_raw_value(&v.to_str().unwrap_or("").to_string()),
);
}
} else {
let whitelist = include_header
.map(|s| s.split(",").map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
whitelist
.iter()
.chain(INCLUDE_HEADERS.iter())
.for_each(|h| {
if let Some(v) = headers.get(h) {
selected_headers.insert(
h.to_string().to_lowercase().replace('-', "_"),
to_raw_value(&v.to_str().unwrap_or("").to_string()),
);
}
});
}
selected_headers
}
pub fn build_query(
query: Option<&str>,
include_query: Option<String>,
include_all_query: bool,
) -> HashMap<String, Box<RawValue>> {
let Some(query) = query else {
return HashMap::new();
};
if include_all_query {
let queries =
serde_urlencoded::from_str::<HashMap<String, String>>(&query).unwrap_or_default();
queries
.into_iter()
.map(|(k, v)| (k, to_raw_value(&v)))
.collect()
} else {
let parse_query_args = include_query
.map(|s| s.split(",").map(|p| p.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
let mut args = HashMap::new();
if !parse_query_args.is_empty() {
let queries =
serde_urlencoded::from_str::<HashMap<String, String>>(&query).unwrap_or_default();
parse_query_args.iter().for_each(|h| {
if let Some(v) = queries.get(h) {
args.insert(h.to_string(), to_raw_value(v));
}
});
}
args
}
}
fn restructure_cloudevents_metadata(
mut p: HashMap<String, Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>, Error> {
let data = p
.remove("data")
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
let str = data.to_string();
let wrap_body = str.len() > 0 && str.chars().next().unwrap() != '{';
if wrap_body {
let args = serde_json::from_str::<Option<Box<RawValue>>>(&str)
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
let mut hm = HashMap::new();
hm.insert("body".to_string(), args);
hm.insert("WEBHOOK__METADATA__".to_string(), to_raw_value(&p));
Ok(hm)
} else {
let mut hm = serde_json::from_str::<Option<HashMap<String, Box<JsonRawValue>>>>(&str)
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(HashMap::new);
hm.insert("WEBHOOK__METADATA__".to_string(), to_raw_value(&p));
Ok(hm)
}
}
impl WebhookArgs {
async fn from_json(
mut metadata: WebhookArgsMetadata,
use_raw: bool,
str: String,
) -> Result<Self, Error> {
if use_raw {
metadata.raw_string = Some(str.clone());
}
let no_hashmap = str.len() > 0 && str.chars().next().unwrap() != '{';
if no_hashmap {
let args = serde_json::from_str::<Option<Box<RawValue>>>(&str)
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
Ok(Self { body: Body::NoHashMap(args), metadata })
} else {
let hm = serde_json::from_str::<Option<HashMap<String, Box<JsonRawValue>>>>(&str)
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(HashMap::new);
Ok(Self { body: Body::HashMap(hm), metadata })
}
}
async fn from_ce_json(
mut metadata: WebhookArgsMetadata,
use_raw: bool,
str: String,
) -> Result<Self, Error> {
if use_raw {
metadata.raw_string = Some(str.clone());
}
let hm = serde_json::from_str::<HashMap<String, Box<RawValue>>>(&str)
.map_err(|e| Error::BadRequest(format!("invalid cloudevents+json: {}", e)))?;
let hm = restructure_cloudevents_metadata(hm)?;
Ok(Self { body: Body::HashMap(hm), metadata })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_cloudevents_json_payload() {
let r1 = r#"
{
"specversion" : "1.0",
"type" : "com.example.someevent",
"source" : "/mycontext",
"subject": null,
"id" : "C234-1234-1234",
"time" : "2018-04-05T17:31:00Z",
"comexampleextension1" : "value",
"comexampleothervalue" : 5,
"datacontenttype" : "application/json",
"data" : {
"appinfoA" : "abc",
"appinfoB" : 123,
"appinfoC" : true
}
}
"#;
let r2 = r#"
{
"specversion" : "1.0",
"type" : "com.example.someevent",
"source" : "/mycontext",
"subject": null,
"id" : "C234-1234-1234",
"time" : "2018-04-05T17:31:00Z",
"comexampleextension1" : "value",
"comexampleothervalue" : 5,
"datacontenttype" : "application/json",
"data" : 1.5
}
"#;
let metadata = WebhookArgsMetadata::default();
let a1 = WebhookArgs::from_ce_json(metadata.clone(), false, r1.to_string())
.await
.expect("Failed to parse the cloudevent");
let a2 = WebhookArgs::from_ce_json(metadata.clone(), false, r2.to_string())
.await
.expect("Failed to parse the cloudevent");
match a1.body {
Body::HashMap(body) => {
body.get("WEBHOOK__METADATA__").expect(
"CloudEvents should generate a neighboring `webhook-metadata` field in PushArgs",
);
}
_ => panic!("Expected a HashMap"),
}
match a2.body {
Body::HashMap(body) => {
assert_eq!(
body
.get("body")
.expect("Cloud events with a data field with no wrapping curly brackets should be inside of a `body` field in PushArgs")
.to_string(),
"1.5"
);
}
_ => panic!("Expected a HashMap"),
}
}
}
@@ -0,0 +1,87 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use windmill_common::{
error::Result,
triggers::TriggerKind,
worker::CLOUD_HOSTED,
DB,
};
use windmill_queue::{PushArgs, PushArgsOwned};
const KEEP_LAST: i64 = 20;
async fn clear_captures_history(db: &DB, w_id: &str) -> Result<()> {
if *CLOUD_HOSTED {
sqlx::query!(
r#"
DELETE FROM
capture
WHERE
workspace_id = $1
AND created_at <= (
SELECT
created_at
FROM
capture
WHERE
workspace_id = $1
ORDER BY
created_at DESC
OFFSET $2
LIMIT 1
)
"#,
&w_id,
KEEP_LAST,
)
.execute(db)
.await?;
}
Ok(())
}
pub async fn insert_capture_payload(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
trigger_kind: &TriggerKind,
main_args: PushArgsOwned,
preprocessor_args: PushArgsOwned,
owner: &str,
) -> Result<()> {
sqlx::query!(
r#"
INSERT INTO
capture (
workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by
)
VALUES (
$1, $2, $3, $4, $5, $6, $7
)
"#,
&w_id,
path,
is_flow,
trigger_kind as &TriggerKind,
sqlx::types::Json(PushArgs { args: &main_args.args, extra: main_args.extra })
as sqlx::types::Json<PushArgs>,
sqlx::types::Json(PushArgs {
args: &preprocessor_args.args,
extra: preprocessor_args.extra
}) as sqlx::types::Json<PushArgs>,
owner,
)
.execute(db)
.await?;
clear_captures_history(db, &w_id).await?;
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use windmill_api_auth::ApiAuthed;
use windmill_common::{error::Result, DB};
use crate::require_is_writer_internal;
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
require_is_writer_internal(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2",
"flow",
)
.await
}
+423
View File
@@ -0,0 +1,423 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Job operations trait for windmill-triggers.
//!
//! Complex job functions that have deep dependencies on windmill-api internals
//! are abstracted behind the `JobOps` trait. windmill-api provides the real
//! implementation at startup via `set_ops()`.
use axum::response::Response;
use serde::Deserialize;
use serde_json::value::RawValue;
use sqlx::Postgres;
use std::sync::Arc;
use uuid::Uuid;
use windmill_api_auth::{ApiAuthed, OptTokened};
use windmill_common::{
db::UserDB,
error::{self, Error},
jobs::check_tag_available_for_workspace_internal,
triggers::TriggerMetadata,
utils::StripPath,
DB,
};
use windmill_queue::PushArgsOwned;
#[derive(Debug, Deserialize, Clone, Default)]
pub struct RunJobQuery {
pub scheduled_for: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_in_secs: Option<i64>,
pub parent_job: Option<Uuid>,
pub root_job: Option<Uuid>,
pub invisible_to_owner: Option<bool>,
pub queue_limit: Option<i64>,
pub payload: Option<String>,
pub job_id: Option<Uuid>,
pub tag: Option<String>,
pub timeout: Option<i32>,
pub cache_ttl: Option<i32>,
pub cache_ignore_s3_path: Option<bool>,
pub skip_preprocessor: Option<bool>,
pub poll_delay_ms: Option<u64>,
pub memory_id: Option<Uuid>,
pub trigger_external_id: Option<String>,
pub service_name: Option<String>,
pub suspended_mode: Option<bool>,
}
pub fn get_scope_tags(authed: &ApiAuthed) -> Option<Vec<&str>> {
authed.scopes.as_ref()?.iter().find_map(|s| {
if s.starts_with("if_jobs:filter_tags:") {
Some(
s.trim_start_matches("if_jobs:filter_tags:")
.split(",")
.collect::<Vec<_>>(),
)
} else {
None
}
})
}
pub async fn check_tag_available_for_workspace(
db: &DB,
w_id: &str,
tag: &Option<String>,
authed: &ApiAuthed,
) -> error::Result<()> {
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
let tags = get_scope_tags(authed);
check_tag_available_for_workspace_internal(db, w_id, tag, &authed.email, tags).await
} else {
Ok(())
}
}
pub async fn delete_job_metadata_after_use(db: &DB, job_uuid: Uuid) -> Result<(), Error> {
sqlx::query!(
"UPDATE v2_job SET args = '{}'::jsonb WHERE id = $1",
job_uuid,
)
.execute(db)
.await?;
sqlx::query!(
"UPDATE v2_job_completed SET result = '{}'::jsonb WHERE id = $1",
job_uuid,
)
.execute(db)
.await?;
sqlx::query!(
"UPDATE job_logs SET logs = '##DELETED##' WHERE job_id = $1",
job_uuid,
)
.execute(db)
.await?;
Ok(())
}
#[cfg(feature = "enterprise")]
pub async fn check_license_key_valid() -> error::Result<()> {
use windmill_common::ee_oss::LICENSE_KEY_VALID;
let valid = *LICENSE_KEY_VALID.read().await;
if !valid {
return Err(Error::BadRequest(
"License key is not valid. Go to your superadmin settings to update your license key."
.to_string(),
));
}
Ok(())
}
/// Trait for complex job operations that stay in windmill-api.
/// windmill-api provides the implementation at startup via `set_ops()`.
#[axum::async_trait]
pub trait JobOps: Send + Sync + 'static {
async fn push_script_job_by_path_into_queue(
&self,
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
script_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'static, Postgres>>,
)>;
async fn push_flow_job_by_path_into_queue(
&self,
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
flow_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<String>,
Option<sqlx::Transaction<'static, Postgres>>,
)>;
async fn run_wait_result_internal(
&self,
db: &DB,
uuid: Uuid,
w_id: &str,
node_id_for_empty_return: Option<String>,
username: &str,
) -> error::Result<(Box<RawValue>, bool)>;
fn result_to_response(
&self,
result: Box<RawValue>,
success: bool,
) -> error::Result<Response>;
async fn cancel_jobs(
&self,
jobs: Vec<Uuid>,
db: &DB,
username: &str,
w_id: &str,
force_cancel: bool,
) -> error::JsonResult<Vec<Uuid>>;
fn start_job_update_sse_stream(
&self,
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
w_id: String,
job_id: Uuid,
initial_log_offset: Option<i32>,
initial_stream_offset: Option<i32>,
get_progress: Option<bool>,
running: Option<bool>,
only_result: Option<bool>,
fast: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
poll_delay_ms: Option<u64>,
);
async fn try_get_resource_from_db(
&self,
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
resource_path: &str,
w_id: &str,
) -> error::Result<serde_json::Value>;
async fn interpolate(
&self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
s: String,
) -> Result<String, anyhow::Error>;
#[cfg(feature = "parquet")]
fn get_random_file_name(&self, ext: Option<String>) -> String;
#[cfg(feature = "parquet")]
async fn upload_file_internal(
&self,
s3_client: Arc<dyn object_store::ObjectStore>,
file_key: &str,
bytes_stream: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>>,
options: object_store::PutMultipartOpts,
) -> error::Result<()>;
#[cfg(feature = "parquet")]
async fn get_workspace_s3_resource(
&self,
authed: &ApiAuthed,
db: &DB,
user_db: Option<UserDB>,
w_id: &str,
storage: Option<String>,
) -> error::Result<(Option<bool>, Option<windmill_common::s3_helpers::ObjectStoreResource>)>;
}
// Re-export types used by SSE streaming
#[derive(Debug, Clone, serde::Serialize)]
pub struct JobUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub running: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_logs: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_offset: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_peak: Option<i32>,
}
#[derive(Debug, serde::Serialize)]
pub enum JobUpdateSSEStream {
Update(serde_json::Value),
Error { error: String },
NotFound,
Timeout,
Ping,
}
static JOB_OPS: std::sync::OnceLock<Arc<dyn JobOps>> = std::sync::OnceLock::new();
/// Call this from windmill-api at startup to inject the real implementation.
pub fn set_ops(ops: Arc<dyn JobOps>) {
let _ = JOB_OPS.set(ops);
}
/// Get the job ops implementation. Panics if not initialized.
pub fn get_ops() -> &'static Arc<dyn JobOps> {
JOB_OPS
.get()
.expect("JobOps not initialized. Call jobs_ext::set_ops() at startup.")
}
// Convenience wrapper functions that delegate to the trait
pub async fn push_script_job_by_path_into_queue(
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
script_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'static, Postgres>>,
)> {
get_ops()
.push_script_job_by_path_into_queue(
authed,
db,
tx_o,
user_db,
w_id,
script_path,
run_query,
args,
trigger,
)
.await
}
pub async fn push_flow_job_by_path_into_queue(
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'static, Postgres>>,
user_db: UserDB,
w_id: String,
flow_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<String>,
Option<sqlx::Transaction<'static, Postgres>>,
)> {
get_ops()
.push_flow_job_by_path_into_queue(
authed,
db,
tx_o,
user_db,
w_id,
flow_path,
run_query,
args,
trigger,
)
.await
}
pub async fn run_wait_result_internal(
db: &DB,
uuid: Uuid,
w_id: &str,
node_id_for_empty_return: Option<String>,
username: &str,
) -> error::Result<(Box<RawValue>, bool)> {
get_ops()
.run_wait_result_internal(db, uuid, w_id, node_id_for_empty_return, username)
.await
}
pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result<Response> {
get_ops().result_to_response(result, success)
}
pub async fn cancel_jobs(
jobs: Vec<Uuid>,
db: &DB,
username: &str,
w_id: &str,
force_cancel: bool,
) -> error::JsonResult<Vec<Uuid>> {
get_ops()
.cancel_jobs(jobs, db, username, w_id, force_cancel)
.await
}
pub fn start_job_update_sse_stream(
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
w_id: String,
job_id: Uuid,
initial_log_offset: Option<i32>,
initial_stream_offset: Option<i32>,
get_progress: Option<bool>,
running: Option<bool>,
only_result: Option<bool>,
fast: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
poll_delay_ms: Option<u64>,
) {
get_ops().start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
w_id,
job_id,
initial_log_offset,
initial_stream_offset,
get_progress,
running,
only_result,
fast,
no_logs,
is_flow,
tx,
poll_delay_ms,
)
}
pub async fn interpolate(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
s: String,
) -> Result<String, anyhow::Error> {
get_ops().interpolate(authed, db, w_id, s).await
}
#[cfg(feature = "parquet")]
pub async fn get_workspace_s3_resource(
authed: &ApiAuthed,
db: &DB,
user_db: Option<UserDB>,
w_id: &str,
storage: Option<String>,
) -> error::Result<(Option<bool>, Option<windmill_common::s3_helpers::ObjectStoreResource>)> {
get_ops()
.get_workspace_s3_resource(authed, db, user_db, w_id, storage)
.await
}
+170
View File
@@ -0,0 +1,170 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
pub mod triggers;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
pub mod args_ext;
pub mod capture_ext;
pub mod flow_ext;
pub mod jobs_ext;
pub mod resource_ext;
pub mod script_ext;
pub mod user_ext;
// Shared permission helpers used by flow_ext, script_ext, and native_triggers
use windmill_api_auth::ApiAuthed;
use windmill_common::error::{Error, Result};
use windmill_common::DB;
/// Check if the user is an owner of the given path.
pub fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> {
if authed.is_admin {
return Ok(());
}
if !path.is_empty() {
let splitted = path.split("/").collect::<Vec<&str>>();
if splitted[0] == "u" {
if splitted[1] == authed.username {
Ok(())
} else {
Err(Error::BadRequest(format!(
"only the owner {} is authorized to perform this operation",
splitted[1]
)))
}
} else if splitted[0] == "f" {
require_is_folder_owner(authed, splitted[1])
} else {
Err(Error::BadRequest(format!(
"Not recognized path kind: {}",
path
)))
}
} else {
Err(Error::BadRequest(format!(
"Cannot be owner of an empty path"
)))
}
}
fn is_folder_owner(
ApiAuthed { is_admin, folders, .. }: &ApiAuthed,
name: &str,
) -> bool {
if *is_admin {
true
} else {
folders.into_iter().any(|x| x.0 == name && x.2)
}
}
fn require_is_folder_owner(authed: &ApiAuthed, name: &str) -> Result<()> {
if is_folder_owner(authed, name) {
Ok(())
} else {
Err(Error::NotAuthorized(format!(
"You are not owner of the folder {}",
name
)))
}
}
fn get_perm_in_extra_perms_for_authed(
v: serde_json::Value,
authed: &ApiAuthed,
) -> Option<bool> {
match v {
serde_json::Value::Object(obj) => {
let mut keys = vec![format!("u/{}", authed.username)];
for g in authed.groups.iter() {
keys.push(format!("g/{}", g));
}
let mut res = None;
for k in keys {
if let Some(v) = obj.get(&k) {
if let Some(v) = v.as_bool() {
if v {
return Some(true);
}
res = Some(v);
}
}
}
res
}
_ => None,
}
}
/// Generic require_is_writer with a configurable SQL query.
/// Used by flow_ext and script_ext.
pub async fn require_is_writer_internal(
authed: &ApiAuthed,
path: &str,
w_id: &str,
db: DB,
query: &str,
kind: &str,
) -> Result<()> {
if authed.is_admin {
return Ok(());
}
if !path.is_empty() {
if require_owner_of_path(authed, path).is_ok() {
return Ok(());
}
if path.starts_with("f/") && path.split('/').count() >= 2 {
let folder = path.split('/').nth(1).unwrap();
let extra_perms = sqlx::query_scalar!(
"SELECT extra_perms FROM folder WHERE name = $1 AND workspace_id = $2",
folder,
w_id
)
.fetch_optional(&db)
.await?;
if let Some(perms) = extra_perms {
let is_folder_writer =
get_perm_in_extra_perms_for_authed(perms, authed).unwrap_or(false);
if is_folder_writer {
return Ok(());
}
}
}
let extra_perms = sqlx::query_scalar(query)
.bind(path)
.bind(w_id)
.fetch_optional(&db)
.await?;
if let Some(perms) = extra_perms {
let perm = get_perm_in_extra_perms_for_authed(perms, authed);
match perm {
Some(true) => Ok(()),
Some(false) => Err(Error::BadRequest(format!(
"User {} is not a writer of {kind} path {path}",
authed.username
))),
None => Err(Error::BadRequest(format!(
"User {} has neither read or write permission on {kind} {path}",
authed.username
))),
}
} else {
Err(Error::BadRequest(format!(
"{path} does not exist yet and user {} is not an owner of the parent folder",
authed.username
)))
}
} else {
Err(Error::BadRequest(format!(
"Cannot be writer of an empty path"
)))
}
}
@@ -0,0 +1,565 @@
use crate::{
native_triggers::{
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,
},
user_ext::{create_token_internal, NewToken},
};
use windmill_api_auth::{scopes::check_scopes, ApiAuthed};
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgConnection;
use std::sync::Arc;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::rd_string,
DB,
};
async fn require_is_writer_on_runnable(
authed: &ApiAuthed,
path: &str,
is_flow: bool,
w_id: &str,
db: DB,
) -> Result<()> {
if is_flow {
crate::flow_ext::require_is_writer(authed, path, w_id, db).await
} else {
crate::script_ext::require_is_writer(authed, path, w_id, db).await
}
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct FullTriggerResponse<T: Serialize> {
#[serde(flatten)]
pub windmill_data: NativeTrigger,
pub external_data: T,
}
#[derive(Debug, Serialize)]
pub struct CreateTriggerResponse {
pub external_id: String,
}
async fn new_webhook_token(
tx: &mut PgConnection,
db: &DB,
authed: &ApiAuthed,
script_path: &str,
is_flow: bool,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
let kind = if is_flow { "flows" } else { "scripts" };
let scopes = vec![format!("jobs:run:{kind}:{script_path}")];
let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5));
let token_config = NewToken::new(
Some(label),
None,
None,
Some(scopes),
Some(workspace_id.to_owned()),
);
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
Ok(token)
}
async fn create_native_trigger<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> JsonResult<CreateTriggerResponse> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let webhook_token = new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?;
let 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 resp = handler
.create(
&workspace_id,
&oauth_data,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
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)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.create", service_name),
ActionKind::Create,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(Json(CreateTriggerResponse { external_id }))
}
async fn update_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> Result<String> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
// Look up the full token using the stored prefix (use db, not tx, for token table)
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(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.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
))
})?;
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)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.update", service_name),
ActionKind::Update,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Native trigger updated"))
}
async fn get_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<FullTriggerResponse<T::TriggerData>> {
let mut tx = user_db.begin(&authed).await?;
let windmill_trigger = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:read:{}", &windmill_trigger.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&windmill_trigger.script_path,
windmill_trigger.is_flow,
&workspace_id,
db.clone(),
)
.await?;
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 native_trigger = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await;
let native_trigger_config = match native_trigger {
Ok(native_cfg) => {
// Clear error if it was set
if windmill_trigger.error.is_some() {
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
None,
)
.await?;
}
native_cfg
}
Err(Error::NotFound(_)) => {
let error_msg = "Trigger no longer exists on external service".to_string();
tracing::warn!(
"Native trigger no longer exists on external service {}, setting error",
service_name
);
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
Some(&error_msg),
)
.await?;
tx.commit().await?;
return Err(Error::NotFound(format!(
"Trigger '{}' no longer exists on external service {}",
external_id, service_name
)));
}
Err(e) => return Err(e),
};
let full_resp = Json(FullTriggerResponse {
windmill_data: windmill_trigger,
external_data: native_trigger_config,
});
Ok(full_resp)
}
async fn delete_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:write:{}", &existing.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&existing.script_path,
existing.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let 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
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let deleted =
delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?;
if !deleted {
return Err(Error::NotFound(format!("Native trigger not found")));
}
// Delete the webhook token using its prefix
if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
tracing::warn!(
"Webhook token not found when deleting trigger {} (prefix: {})",
external_id,
existing.webhook_token_prefix
);
}
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.delete", service_name),
ActionKind::Delete,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
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,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Query(query): Query<ListQuery>,
) -> JsonResult<Vec<NativeTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let triggers = list_native_triggers(
&mut *tx,
&workspace_id,
service_name,
query.page,
query.per_page,
query.path.as_deref(),
query.is_flow,
)
.await?;
tx.commit().await?;
Ok(Json(triggers))
}
pub fn service_routes<T: External + 'static>(handler: T) -> Router {
let additional_routes = handler.additional_routes();
let service_name = T::SERVICE_NAME;
let handler_arc = Arc::new(handler);
let standard_routes = Router::new()
.route("/create", post(create_native_trigger::<T>))
.route("/list", get(list_native_triggers_handler::<T>))
.route("/get/:external_id", get(get_native_trigger_handler::<T>))
.route(
"/update/:external_id",
post(update_native_trigger_handler::<T>),
)
.route(
"/delete/:external_id",
delete(delete_native_trigger_handler::<T>),
)
.route(
"/exists/:external_id",
get(exists_native_trigger_handler::<T>),
);
standard_routes
.merge(additional_routes)
.layer(Extension(handler_arc))
.layer(Extension(service_name))
}
/// Generates routes for all registered native trigger services.
/// When adding a new service, add a new `.nest()` call here.
pub fn generate_native_trigger_routers() -> Router {
let router = Router::new();
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
// Register all service routes here
// When adding a new service:
// 1. Import the handler: use crate::native_triggers::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))
}
#[cfg(not(feature = "native_trigger"))]
{
router
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::value::to_raw_value;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::native_triggers::{
generate_webhook_service_url,
nextcloud::{
routes, NextCloud, NextCloudOAuthData, NextCloudTriggerData, NextcloudServiceConfig,
OcsResponse,
},
External, NativeTriggerData, ServiceName,
};
lazy_static::lazy_static! {
pub static ref TOKEN_NEEDED: Box<serde_json::value::RawValue> = to_raw_value(&serde_json::json!({
"user_roles": ["owner", "trigger"]
})).unwrap();
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FullNextcloudPayload {
pub http_method: String,
pub uri: String,
pub token_needed: Box<serde_json::value::RawValue>,
#[serde(flatten)]
service_config: NextcloudServiceConfig,
}
impl FullNextcloudPayload {
async fn new(
w_id: &str,
external_id: Option<&str>,
webhook_token: &str,
data: &NativeTriggerData<NextcloudServiceConfig>,
) -> FullNextcloudPayload {
let base_url = &*BASE_URL.read().await;
let uri = generate_webhook_service_url(
base_url,
w_id,
&data.script_path,
data.is_flow,
external_id,
ServiceName::Nextcloud,
webhook_token,
);
FullNextcloudPayload {
http_method: http::Method::POST.to_string().to_uppercase(),
uri,
token_needed: TOKEN_NEEDED.clone(),
service_config: data.service_config.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RegisterWebhookResponse {
pub id: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub uid: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WebhookPayload {
pub event: EventPayload,
pub user: User,
pub time: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EventPayload {
pub node: Node,
#[serde(rename = "class")]
pub class_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Node {
pub id: i64,
pub path: String,
}
#[async_trait::async_trait]
impl External for NextCloud {
type ServiceConfig = NextcloudServiceConfig;
type TriggerData = NextCloudTriggerData;
type OAuthData = NextCloudOAuthData;
type CreateResponse = RegisterWebhookResponse;
const SERVICE_NAME: ServiceName = ServiceName::Nextcloud;
const DISPLAY_NAME: &'static str = "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";
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> {
// During create, we don't have external_id yet (it comes from NextCloud's response)
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, None, webhook_token, data).await;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<RegisterWebhookResponse>, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(ocs_response.ocs.data)
}
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<()> {
// 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;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(())
}
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!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
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)
.await?;
Ok(ocs_response.ocs.data)
}
async fn delete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
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)
.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> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(true)
}
async fn list_all(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
db: &DB,
tx: &mut PgConnection,
) -> Result<Vec<Self::TriggerData>> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<Vec<NextCloudTriggerData>>, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
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())
}
}
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
pub mod external;
mod routes;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NextCloudOAuthData {
pub base_url: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsResponse<T = String> {
pub ocs: OcsData<T>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
status: String,
#[serde(rename = "statuscode")]
status_code: u16,
message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsData<T> {
pub meta: Meta,
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextCloudEventType {
pub name: String,
pub description: Option<String>,
pub path: Option<String>,
pub parameters: Option<serde_json::Value>,
}
#[derive(Copy, Clone)]
pub struct NextCloud;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextcloudServiceConfig {
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextCloudTriggerData {
#[serde(skip_serializing)]
pub id: i64,
#[serde(skip_serializing)]
pub uri: String,
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
@@ -0,0 +1,65 @@
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,
};
use windmill_api_auth::ApiAuthed;
use crate::native_triggers::{
get_workspace_integration,
nextcloud::{NextCloudEventType, OcsResponse},
External, OAuthConfig, ServiceName,
};
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 auth = serde_json::from_value::<OAuthConfig>(integration.oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?;
let url = format!(
"{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events",
&auth.base_url,
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = handler
.http_client_request::<OcsResponse, ()>(
&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)))?;
Ok(Json(events))
}
pub fn nextcloud_routes<T: External>(service: T) -> Router {
let service = Arc::new(service);
Router::new()
.route("/events", get(list_available_events::<T>))
.layer(Extension(service))
}
@@ -0,0 +1,416 @@
use std::collections::HashMap;
use windmill_common::error::Result;
use windmill_common::DB;
use serde::Serialize;
use crate::native_triggers::{
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
update_native_trigger_service_config, External, ServiceName,
};
#[derive(Debug, Serialize)]
pub struct TriggerSyncInfo {
pub external_id: String,
pub script_path: String,
pub action: SyncAction,
}
#[derive(Debug, Serialize)]
pub enum SyncAction {
ErrorSet(String),
ErrorCleared,
ConfigUpdated,
}
#[derive(Debug, Serialize)]
pub struct SyncError {
pub resource_path: String,
pub error_message: String,
pub error_type: String,
}
#[derive(Debug)]
pub struct BackgroundSyncResult {
pub workspaces_processed: usize,
pub total_synced: usize,
pub total_errors: usize,
pub service_results: HashMap<ServiceName, ServiceSyncResult>,
}
#[derive(Debug)]
pub struct ServiceSyncResult {
pub synced_triggers: Vec<TriggerSyncInfo>,
pub errors: Vec<SyncError>,
}
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
tracing::info!("Starting native triggers sync");
let mut service_results: HashMap<ServiceName, ServiceSyncResult> = HashMap::new();
let mut total_synced = 0;
let mut total_errors = 0;
let mut workspaces_processed = 0;
// Sync all registered services
// Each service only syncs workspaces that have the corresponding integration configured
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
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::native_triggers::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);
}
// Count unique workspaces processed across all services
for result in service_results.values() {
workspaces_processed += result
.synced_triggers
.iter()
.map(|t| &t.external_id)
.collect::<std::collections::HashSet<_>>()
.len();
}
let result =
BackgroundSyncResult { workspaces_processed, total_synced, total_errors, service_results };
tracing::info!(
"Completed native triggers sync: {} updated, {} errors",
result.total_synced,
result.total_errors
);
Ok(result)
}
async fn sync_service_triggers<T: External>(
db: &DB,
handler: T,
) -> (ServiceName, ServiceSyncResult) {
let mut all_synced_triggers = Vec::new();
let mut all_errors = Vec::new();
// Only sync workspaces that have the corresponding integration configured
let workspaces_with_integration = match sqlx::query_scalar!(
r#"
SELECT wi.workspace_id
FROM workspace_integrations wi
JOIN workspace w ON w.id = wi.workspace_id
WHERE wi.service_name = $1
AND wi.oauth_data IS NOT NULL
AND w.deleted = false
"#,
T::SERVICE_NAME as ServiceName
)
.fetch_all(db)
.await
{
Ok(workspaces) => workspaces,
Err(e) => {
tracing::error!(
"Error querying workspaces with {} integration: {:#}",
T::SERVICE_NAME.as_str(),
e
);
all_errors.push(SyncError {
resource_path: "database".to_string(),
error_message: format!("Failed to query workspaces: {}", e),
error_type: "database_error".to_string(),
});
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: all_errors },
);
}
};
if workspaces_with_integration.is_empty() {
tracing::debug!(
"No workspaces with {} integration configured, skipping sync",
T::SERVICE_NAME.as_str()
);
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: Vec::new() },
);
}
tracing::info!(
"Found {} workspaces with {} integration configured",
workspaces_with_integration.len(),
T::SERVICE_NAME.as_str()
);
for workspace_id in workspaces_with_integration {
let sync_result = sync_workspace_triggers::<T>(db, &workspace_id, &handler).await;
match sync_result {
Ok((synced_triggers, errors)) => {
all_synced_triggers.extend(synced_triggers);
all_errors.extend(errors);
}
Err(e) => {
tracing::error!(
"Error syncing {} triggers for workspace {}: {:#}",
T::SERVICE_NAME.as_str(),
workspace_id,
e
);
all_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to sync workspace: {}", e),
error_type: "workspace_sync_error".to_string(),
});
}
}
}
(
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: all_synced_triggers, errors: all_errors },
)
}
#[cfg(feature = "native_trigger")]
pub async fn sync_workspace_triggers<T: External>(
db: &DB,
workspace_id: &str,
handler: &T,
) -> Result<(Vec<TriggerSyncInfo>, Vec<SyncError>)> {
tracing::info!(
"Syncing {} triggers for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
let windmill_triggers =
list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None, None, None).await?;
if windmill_triggers.is_empty() {
tracing::info!(
"No {} triggers found for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
return Ok((Vec::new(), Vec::new()));
}
let mut all_synced_triggers = Vec::new();
let mut all_sync_errors = Vec::new();
let oauth_data = {
match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await {
Ok(oauth_data) => oauth_data,
Err(e) => {
tracing::error!(
"Failed to get workspace integration OAuth data for {}: {}",
workspace_id,
e
);
all_sync_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));
}
}
};
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?;
// 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);
}
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();
if trigger.error.as_deref() != Some(&error_msg) {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') no longer exists in external service, setting error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
Some(&error_msg),
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorSet(error_msg),
});
}
Err(e) => {
tracing::error!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
} else {
// Trigger exists on external service
let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap();
// Clear error if it was set
if trigger.error.is_some() {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') exists on external service, clearing error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
None,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorCleared,
});
}
Err(e) => {
tracing::error!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
// 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 {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_service_config(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
&external_service_config,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
});
}
Err(e) => {
tracing::error!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
} else {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config is the same as external service, no update needed",
trigger.external_id,
trigger.script_path
);
}
}
}
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))
}
@@ -0,0 +1,522 @@
use axum::{
extract::Path,
routing::{delete, get, post},
Extension, Json, Router,
};
#[cfg(feature = "native_trigger")]
use serde_json::to_value;
use sqlx::prelude::FromRow;
use strum::IntoEnumIterator;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
#[cfg(feature = "native_trigger")]
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
variables::{build_crypt, encrypt},
DB,
};
use windmill_api_auth::ApiAuthed;
use crate::native_triggers::ServiceName;
#[cfg(feature = "native_trigger")]
use crate::native_triggers::{delete_workspace_integration, store_workspace_integration};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes
/// Generate a signed OAuth state that is cluster-safe.
/// The state contains: workspace_id, service_name, timestamp, and nonce.
/// It's signed with HMAC-SHA256 using the workspace key.
#[cfg(feature = "native_trigger")]
async fn generate_signed_state(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
use windmill_common::variables::get_workspace_key;
let nonce = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp();
let payload = format!(
"{}:{}:{}:{}",
workspace_id,
service_name.as_str(),
timestamp,
nonce
);
// Get workspace key for signing
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
// Encode as: base64(payload):base64(signature)
let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes());
let encoded_signature = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}:{}", encoded_payload, encoded_signature))
}
/// Validate a signed OAuth state.
/// Returns true if the state is valid (correct signature and not expired).
#[cfg(feature = "native_trigger")]
async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result<bool> {
use windmill_common::variables::get_workspace_key;
let parts: Vec<&str> = state.split(':').collect();
if parts.len() != 2 {
return Ok(false);
}
let encoded_payload = parts[0];
let encoded_signature = parts[1];
// Decode payload
let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
let payload = match String::from_utf8(payload_bytes) {
Ok(s) => s,
Err(_) => return Ok(false),
};
// Parse payload: workspace_id:service_name:timestamp:nonce
let payload_parts: Vec<&str> = payload.split(':').collect();
if payload_parts.len() != 4 {
return Ok(false);
}
let state_workspace_id = payload_parts[0];
let timestamp: i64 = match payload_parts[2].parse() {
Ok(ts) => ts,
Err(_) => return Ok(false),
};
// Verify workspace_id matches
if state_workspace_id != workspace_id {
return Ok(false);
}
// Check expiration
let now = chrono::Utc::now().timestamp();
if now - timestamp > STATE_EXPIRATION_SECONDS {
return Ok(false);
}
// Verify signature
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
Ok(mac.verify_slice(&received_signature).is_ok())
}
#[derive(Debug, Serialize)]
pub struct IntegrationStatusResponse {
pub connected: bool,
pub service_name: ServiceName,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_by: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListIntegrationsResponse {
pub integrations: Vec<IntegrationStatusResponse>,
}
#[derive(Debug, Serialize)]
pub struct ConnectIntegrationResponse {
pub auth_url: String,
}
#[derive(FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceOAuthConfig {
pub client_id: String,
pub client_secret: String,
pub base_url: String,
pub access_token: Option<String>
}
#[derive(Debug, Serialize)]
pub struct OAuthConfigResponse {
pub configured: bool,
pub base_url: Option<String>,
pub redirect_uri: Option<String>,
}
#[cfg(feature = "native_trigger")]
async fn generate_connect_url(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let oauth_config =
get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?;
// Generate a signed state that is cluster-safe
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
let auth_url = build_authorization_url(&oauth_config, &state, &redirect_uri);
Ok(Json(auth_url))
}
#[cfg(feature = "native_trigger")]
async fn delete_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
if !deleted {
return Err(Error::NotFound(format!(
"{} integration not found for workspace",
service_name
)));
}
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.disconnect", service_name),
ActionKind::Delete,
&workspace_id,
Some(&format!("Disconnected {} integration", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration disconnected successfully",
service_name
)))
}
#[derive(FromRow, Debug, Deserialize, Serialize)]
struct WorkspaceIntegrations {
service_name: ServiceName,
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
}
#[cfg(feature = "native_trigger")]
async fn list_integrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<WorkspaceIntegrations>> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = db.begin().await?;
let integrations = sqlx::query_as!(
WorkspaceIntegrations,
r#"
SELECT
oauth_data as "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName"
FROM
workspace_integrations
WHERE
workspace_id = $1
"#,
workspace_id
)
.fetch_all(&mut *tx)
.await?;
let key_value = integrations
.into_iter()
.map(|integration| (integration.service_name, integration.oauth_data))
.collect::<std::collections::HashMap<_, _>>();
let integrations = ServiceName::iter()
.map(|service_name| WorkspaceIntegrations {
service_name: service_name,
oauth_data: key_value.get(&service_name).cloned().flatten(),
})
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(integrations))
}
async fn integration_exist(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1
FROM workspace_integrations
WHERE workspace_id = $1
AND service_name = $2
AND oauth_data IS NOT NULL
)
"#,
workspace_id,
service_name as ServiceName
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Debug, Deserialize)]
struct RedirectUri {
redirect_uri: 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>,
) -> 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?;
if !state_was_valid {
return Err(Error::BadRequest(
"Invalid or expired state parameter".to_string(),
));
}
let oauth_config =
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?;
let mut tx = user_db.begin(&authed).await?;
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());
}
store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?;
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.connect", service_name),
ActionKind::Create,
&workspace_id,
Some(&format!("Connected {} integration via OAuth", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration connected successfully via OAuth",
service_name
)))
}
/// Token response from OAuth token exchange
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
/// Build an OAuth client for native trigger services using windmill-oauth.
#[cfg(feature = "native_trigger")]
fn build_native_oauth_client(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
redirect_uri: &str,
) -> Result<OClient> {
let auth_url = Url::parse(&format!("{}{}", 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()))
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
let redirect = Url::parse(redirect_uri).map_err(|e| {
Error::BadRequest(format!(
"Invalid redirect URI '{}': {}. The redirect URI must be an absolute URL (e.g., https://example.com/callback)",
redirect_uri, e
))
})?;
let mut client = OClient::new(config.client_id.clone(), auth_url, token_url);
client.set_client_secret(config.client_secret.clone());
client.set_redirect_url(redirect);
Ok(client)
}
/// Exchange authorization code for tokens using windmill-oauth.
#[cfg(feature = "native_trigger")]
async fn exchange_code_for_token(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
code: &str,
redirect_uri: &str,
) -> Result<TokenResponse> {
let client = build_native_oauth_client(config, service_name, redirect_uri)?;
let token_response: TokenResponse = client
.exchange_code(code.to_string())
.with_client(&*OAUTH_HTTP_CLIENT)
.execute()
.await
.map_err(|e| Error::InternalErr(format!("Failed to exchange code for token: {:?}", e)))?;
Ok(token_response)
}
async fn get_workspace_oauth_config<T: DeserializeOwned>(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let oauth_configs = 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?
.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)
}
#[cfg(feature = "native_trigger")]
pub async fn create_workspace_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(oauth_data): Json<WorkspaceOAuthConfig>,
) -> Result<()> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
store_workspace_integration(
&mut tx,
&authed,
&workspace_id,
service_name,
to_value(oauth_data).unwrap(),
)
.await?;
tx.commit().await?;
Ok(())
}
#[inline]
async fn get_workspace_oauth_config_as_oauth_config(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<WorkspaceOAuthConfig> {
get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name).await
}
fn build_authorization_url(
config: &WorkspaceOAuthConfig,
state: &str,
redirect_uri: &str,
) -> String {
let params = [
("response_type", "code"),
("client_id", &config.client_id),
("redirect_uri", redirect_uri),
("state", state),
("scope", "read write"),
];
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)
}
pub fn workspaced_service() -> Router {
let router = Router::new()
.route("/list", get(list_integrations))
.route("/:service_name/exists", get(integration_exist))
.route("/:service_name/create", post(create_workspace_integration))
.route(
"/:service_name/generate_connect_url",
post(generate_connect_url),
)
.route("/:service_name/delete", delete(delete_integration))
.route("/:service_name/callback/:code/:state", post(oauth_callback));
Router::new().nest("/integrations", router)
}
@@ -0,0 +1,36 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
// This module provides try_get_resource_from_db_as via the JobOps trait.
// The actual implementation lives in windmill-api and is injected at runtime.
use windmill_api_auth::ApiAuthed;
use windmill_common::{db::UserDB, error::Result, DB};
use crate::jobs_ext::get_ops;
pub async fn try_get_resource_from_db_as<T>(
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
resource_path: &str,
w_id: &str,
) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let ops = get_ops();
let value = ops
.try_get_resource_from_db(authed, user_db, db, resource_path, w_id)
.await?;
serde_json::from_value(value).map_err(|e| {
windmill_common::error::Error::internal_err(format!(
"Error deserializing resource {resource_path}: {e}"
))
})
}
@@ -0,0 +1,24 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use windmill_api_auth::ApiAuthed;
use windmill_common::{error::Result, DB};
use crate::require_is_writer_internal;
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
require_is_writer_internal(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
"script",
)
.await
}
@@ -0,0 +1,66 @@
#[cfg(not(feature = "private"))]
use crate::triggers::TriggerData;
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::EmailTrigger,
crate::triggers::TriggerCrud,
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for EmailTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/email_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::EmailTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,11 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct EmailTrigger;
@@ -0,0 +1,144 @@
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use serde_json::Value;
use std::fmt;
#[derive(Deserialize)]
pub struct JsonFilter {
pub key: String,
pub value: Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Filter {
JsonFilter(JsonFilter),
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
}
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a JSON object with a specific key at the top level")
}
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut result = false;
let mut found = false;
// Must consume entire map to satisfy deserializer contract
while let Some(key) = map.next_key::<String>()? {
if !found && key == self.key {
let json_value: Value = map.next_value()?;
result = is_superset(&json_value, self.value_to_check);
found = true;
} else {
// Skip values we don't need (cheaper than full deserialization)
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
Ok(result)
}
}
pub fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
match (json_value, value_to_check) {
(Value::Object(json_map), Value::Object(check_map)) => {
check_map.iter().all(|(k, v)| {
json_map
.get(k)
.map_or(false, |json_val| is_superset(json_val, v))
})
}
(Value::Array(json_array), Value::Array(check_array)) => {
check_array.iter().all(|check_item| {
json_array
.iter()
.any(|json_item| is_superset(json_item, check_item))
})
}
_ => json_value == value_to_check,
}
}
pub fn is_value_superset<'a, 'de, D>(
deserializer: D,
key: &'a str,
value_to_check: &'a Value,
) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_with_other_top_level_keys() {
let payload = r#"{"event_type": "test", "other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when key exists with correct value");
}
#[test]
fn test_filter_with_key_not_first() {
let payload = r#"{"other": "data", "event_type": "test"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match even when key is not first");
}
#[test]
fn test_filter_with_nested_object() {
let payload = r#"{"data": {"status": "active", "count": 5}, "other": "value"}"#;
let key = "data";
let value = json!({"status": "active"});
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when nested object is superset");
}
#[test]
fn test_filter_no_match() {
let payload = r#"{"event_type": "other", "data": "value"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when value differs");
}
#[test]
fn test_filter_key_not_found() {
let payload = r#"{"other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when key doesn't exist");
}
}
@@ -0,0 +1,63 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::GcpTrigger,
crate::triggers::{TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for GcpTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/gcp_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::GcpTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"GCP triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"GCP triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,41 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::GcpTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for GcpTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Gcp;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -0,0 +1,16 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Clone, Copy)]
pub struct GcpTrigger;
@@ -0,0 +1,325 @@
use crate::{
jobs_ext::cancel_jobs,
triggers::trigger_helpers::trigger_runnable_inner,
};
use windmill_api_auth::ApiAuthed;
use windmill_common::DB;
use axum::{
extract::{Extension, Path},
response::Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::PgConnection;
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::{
db::UserDB,
error::{self, Error, Result},
jobs::JobTriggerKind,
triggers::TriggerMetadata,
};
#[derive(sqlx::FromRow)]
pub struct SuspendedTrigger {
pub script_path: String,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: DateTime<Utc>,
pub error_handler_path: Option<String>,
pub error_handler_args: Option<sqlx::types::Json<HashMap<String, serde_json::Value>>>,
pub retry: Option<sqlx::types::Json<windmill_common::flows::Retry>>,
}
async fn get_suspended_trigger(
tx: &mut PgConnection,
workspace_id: &str,
trigger_kind: &JobTriggerKind,
path: &str,
) -> Result<SuspendedTrigger> {
match trigger_kind {
JobTriggerKind::Webhook | JobTriggerKind::Schedule => {
return Err(Error::BadRequest(format!(
"{} triggers do not support job reassignment",
trigger_kind
)));
}
_ => {}
}
let table_name = format!("{}_trigger", trigger_kind.to_string());
let fields = vec![
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"error_handler_path",
"error_handler_args",
"retry",
];
let sql = format!(
r#"SELECT
{}
FROM
{}
WHERE
workspace_id = $1 AND
path = $2
"#,
fields.join(", "),
table_name
);
sqlx::query_as(&sql)
.bind(workspace_id)
.bind(path)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Trigger not found at path: {}", path)))
}
struct JobWithArgs {
id: Uuid,
args: Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct ReassignJobsBody {
#[serde(default)]
pub job_ids: Option<Vec<Uuid>>,
}
pub async fn resume_suspended_trigger_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, trigger_kind, trigger_path)): Path<(String, JobTriggerKind, String)>,
Json(body): Json<ReassignJobsBody>,
) -> error::Result<Json<String>> {
let mut tx = user_db.clone().begin(&authed).await?;
let trigger = get_suspended_trigger(&mut *tx, &w_id, &trigger_kind, &trigger_path).await?;
let jobs = if let Some(job_ids) = body.job_ids.as_ref() {
if job_ids.is_empty() {
vec![]
} else {
sqlx::query_as!(
JobWithArgs,
r#"
SELECT
id,
args as "args: _",
created_at
FROM v2_job
WHERE workspace_id = $1
AND (
kind = 'unassigned_script'::JOB_KIND OR
kind = 'unassigned_flow'::JOB_KIND OR
kind = 'unassigned_singlestepflow'::JOB_KIND
)
AND trigger_kind = $2
AND trigger = $3
AND id = ANY($4)
"#,
w_id,
trigger_kind as _,
trigger_path,
job_ids as _,
)
.fetch_all(&mut *tx)
.await?
}
} else {
sqlx::query_as!(
JobWithArgs,
r#"
SELECT
id,
args as "args: _",
created_at
FROM v2_job
WHERE workspace_id = $1
AND (
kind = 'unassigned_script'::JOB_KIND OR
kind = 'unassigned_flow'::JOB_KIND OR
kind = 'unassigned_singlestepflow'::JOB_KIND
)
AND trigger_kind = $2
AND trigger = $3
"#,
w_id,
trigger_kind as _,
trigger_path,
)
.fetch_all(&mut *tx)
.await?
};
let trigger_metadata = TriggerMetadata::new(Some(trigger_path.clone()), trigger_kind);
let l = jobs.len();
for job in jobs {
// If job was created before trigger was edited, simply update it to unsuspend
// instead of deleting and repushing
if job.created_at > trigger.edited_at {
let job_kind = if trigger.is_flow {
windmill_common::jobs::JobKind::Flow
} else {
windmill_common::jobs::JobKind::Script
};
sqlx::query!(
"UPDATE v2_job SET kind = $1 WHERE id = $2",
job_kind as _,
job.id
)
.execute(&mut *tx)
.await?;
// Update the job to unsuspend it and set the correct kind
sqlx::query!(
"UPDATE v2_job_queue SET scheduled_for = now() WHERE id = $1",
job.id
)
.execute(&mut *tx)
.await?;
} else {
// Job was created after trigger edit - delete and repush with new configuration
// Pass the transaction to trigger_runnable_inner so everything is in the same transaction
let (_uuid, _delete_after_use, _early_return, tx_o) = trigger_runnable_inner(
&db,
Some(tx),
Some(user_db.clone()),
authed.clone(),
&w_id,
&trigger.script_path,
trigger.is_flow,
windmill_queue::PushArgsOwned {
extra: None,
args: job.args.map(|a| a.0).unwrap_or_default(),
},
trigger.retry.as_ref(),
trigger.error_handler_path.as_deref(),
trigger.error_handler_args.as_ref(),
trigger_path.clone(),
None,
trigger_metadata.clone(),
None,
)
.await?;
tx = match tx_o {
Some(tx) => tx,
None => {
return Err(error::Error::internal_err(
"Transaction should be returned when passed in".to_string(),
));
}
};
// Delete the unassigned job from all related tables
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM v2_job_runtime WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM job_perms WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM concurrency_key WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM debounce_key WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM debounce_stale_data WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM v2_job WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(Json(format!("Reassigned {} jobs", l)))
}
pub async fn cancel_suspended_trigger_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, trigger_kind, trigger_path)): Path<(String, JobTriggerKind, String)>,
Json(body): Json<ReassignJobsBody>,
) -> error::Result<Json<String>> {
let mut tx = user_db.clone().begin(&authed).await?;
// Get the list of job IDs to cancel
let jobs_to_cancel = if let Some(job_ids) = body.job_ids.as_ref() {
if job_ids.is_empty() {
vec![]
} else {
sqlx::query_scalar!(
"SELECT id FROM v2_job
WHERE workspace_id = $1
AND (kind = 'unassigned_script'::JOB_KIND OR kind = 'unassigned_flow'::JOB_KIND OR kind = 'unassigned_singlestepflow'::JOB_KIND)
AND trigger_kind = $2
AND trigger = $3
AND id = ANY($4)",
w_id,
trigger_kind as _,
trigger_path,
job_ids as _,
)
.fetch_all(&mut *tx)
.await?
}
} else {
sqlx::query_scalar!(
"SELECT id FROM v2_job
WHERE workspace_id = $1
AND (kind = 'unassigned_script'::JOB_KIND OR kind = 'unassigned_flow'::JOB_KIND OR kind = 'unassigned_singlestepflow'::JOB_KIND)
AND trigger_kind = $2
AND trigger = $3",
w_id,
trigger_kind as _,
trigger_path,
)
.fetch_all(&mut *tx)
.await?
};
tx.commit().await?;
let count = jobs_to_cancel.len();
if count > 0 {
let cancelled_jobs = cancel_jobs(
jobs_to_cancel,
&db,
authed.username.as_str(),
w_id.as_str(),
true,
)
.await?;
Ok(Json(format!("Canceled {} jobs", cancelled_jobs.0.len())))
} else {
Ok(Json(format!("No jobs to cancel")))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
use std::collections::HashMap;
use axum::{
extract::{FromRequest, Request},
response::Response,
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_common::{
error::Error,
triggers::{RunnableFormat, RunnableFormatVersion},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
use crate::args_ext::{
build_headers, build_query, try_from_request_body, Body, RawWebhookArgs, WebhookArgs,
WebhookArgsMetadata,
};
use windmill_api_auth::ApiAuthed;
pub struct RawHttpTriggerArgs(pub RawWebhookArgs);
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, Hash, Eq, PartialEq)]
#[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
impl TryFrom<&http::Method> for HttpMethod {
type Error = Error;
fn try_from(method: &http::Method) -> Result<Self, Self::Error> {
match method {
&http::Method::GET => Ok(HttpMethod::Get),
&http::Method::POST => Ok(HttpMethod::Post),
&http::Method::PUT => Ok(HttpMethod::Put),
&http::Method::DELETE => Ok(HttpMethod::Delete),
&http::Method::PATCH => Ok(HttpMethod::Patch),
_ => Err(Error::BadRequest("Invalid HTTP method".to_string())),
}
}
}
#[axum::async_trait]
impl<S> FromRequest<S, axum::body::Body> for RawHttpTriggerArgs
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(request: Request, _state: &S) -> Result<Self, Self::Rejection> {
let args = try_from_request_body(request, _state, true).await?;
Ok(Self(args))
}
}
#[derive(Debug, Clone)]
pub struct HttpTriggerArgs(pub WebhookArgs);
impl RawHttpTriggerArgs {
pub async fn process_args(
self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
use_raw: bool,
) -> Result<HttpTriggerArgs, Error> {
if self.0.metadata.query_use_raw || self.0.metadata.query_wrap_body {
return Err(Error::BadRequest(
"Specifying use raw or wrap body with query args is not supported anymore on http routes, please set it in the trigger config".to_string(),
)
.into());
}
let args = self.0.process_args(authed, db, w_id, Some(use_raw)).await?;
Ok(HttpTriggerArgs(args))
}
}
#[derive(Serialize)]
struct HttpTriggerPreprocessorEvent<'a> {
kind: String,
route: &'a str,
path: &'a str,
body: Box<RawValue>,
raw_string: Option<String>,
params: &'a HashMap<String, String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
method: HttpMethod,
}
#[derive(Serialize)]
struct HttpTriggerWmTrigger<'a> {
route: &'a str,
path: &'a str,
params: &'a HashMap<String, String>,
query: HashMap<String, Box<RawValue>>,
headers: HashMap<String, Box<RawValue>>,
method: HttpMethod,
}
impl HttpTriggerArgs {
pub fn to_main_args(self, wrap_body: bool) -> Result<PushArgsOwned, Error> {
let mut extra = HashMap::new();
let WebhookArgsMetadata { raw_string, .. } = self.0.metadata;
if let Some(raw_string) = raw_string {
extra.insert("raw_string".to_string(), to_raw_value(&raw_string));
}
let extra = if extra.is_empty() { None } else { Some(extra) };
match self.0.body {
Body::HashMap(mut body) => {
if wrap_body {
body = HashMap::from([("body".to_string(), to_raw_value(&body))]);
}
Ok(PushArgsOwned { args: body, extra })
}
Body::NoHashMap(args) => {
let mut hm = HashMap::new();
hm.insert("body".to_string(), args);
Ok(PushArgsOwned { args: hm, extra })
}
}
}
pub fn to_args_from_format(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
format: RunnableFormat,
wrap_body: bool,
) -> Result<PushArgsOwned, Error> {
let headers = build_headers(&self.0.metadata.headers, None, true);
let query = build_query(self.0.metadata.query.as_deref(), None, true);
match format {
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => {
// we don't care about wrap_body in v2
self.to_v2_preprocessor_args(route_path, called_path, params, headers, query)
}
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V1 } => self
.to_v1_preprocessor_args(
route_path,
called_path,
params,
wrap_body,
headers,
query,
),
RunnableFormat { has_preprocessor: false, .. } => self.to_main_args(wrap_body),
}
}
fn to_v1_preprocessor_args(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
wrap_body: bool,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
) -> Result<PushArgsOwned, Error> {
let mut extra = HashMap::new();
let mut wm_trigger = HashMap::new();
wm_trigger.insert("kind".to_string(), to_raw_value(&"http".to_string()));
wm_trigger.insert(
"http".to_string(),
to_raw_value(&HttpTriggerWmTrigger {
route: route_path,
path: called_path,
method: (&self.0.metadata.method).try_into()?,
params,
query,
headers,
}),
);
extra.insert("wm_trigger".to_string(), to_raw_value(&wm_trigger));
let mut args = self.to_main_args(wrap_body)?;
args.extra.get_or_insert_default().extend(extra);
Ok(args)
}
pub fn to_v2_preprocessor_args(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
) -> Result<PushArgsOwned, Error> {
let mut args = HashMap::new();
args.insert(
"event".to_string(),
to_raw_value(&HttpTriggerPreprocessorEvent {
kind: "http".to_string(),
body: to_raw_value(&self.0.body),
raw_string: self.0.metadata.raw_string,
headers,
query,
method: (&self.0.metadata.method).try_into()?,
route: route_path,
path: called_path,
params,
}),
);
Ok(PushArgsOwned { args, extra: None })
}
}
@@ -0,0 +1,751 @@
use axum::response::{IntoResponse, Response};
use base64::{
prelude::{BASE64_STANDARD, BASE64_URL_SAFE},
Engine,
};
use hmac::{Hmac, Mac};
use http::{header, HeaderMap, HeaderValue, StatusCode};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use std::{borrow::Cow, collections::HashMap};
pub type HmacSha256 = Hmac<Sha256>;
pub type HmacSha512 = Hmac<Sha512>;
pub type HmacSha1 = Hmac<Sha1>;
mod github {
use super::*;
pub struct Github;
impl WebhookHandler for Github {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let github_secret_header = headers.try_get_webhook_header("X-Hub-Signature-256")?;
let authentication_data = SignatureAuthenticationData::new(
Cow::Borrowed(raw_payload),
github_secret_header,
Some("sha256="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
);
Ok(authentication_data)
}
}
}
mod slack {
use super::*;
pub struct Slack;
impl WebhookHandler for Slack {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let slack_secret_signature = headers.try_get_webhook_header("X-Slack-Signature")?;
let slack_timestamp_header =
headers.try_get_webhook_header("X-Slack-Request-Timestamp")?;
let signed_payload = format!("v0:{}:{}", slack_timestamp_header, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
slack_secret_signature,
Some("v0="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod stripe {
use super::*;
pub struct Stripe;
impl WebhookHandler for Stripe {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let stripe_signature_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?;
let stripe_signature = parse_signature(stripe_signature_header, (",", "="));
let timestamp = *stripe_signature
.get("t")
.ok_or(AuthenticationError::InvalidTimestamp)?;
let v1 = *stripe_signature
.get("v1")
.ok_or(AuthenticationError::InvalidSignature)?;
let signed_payload = format!("{}.{}", timestamp, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
v1,
None,
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod tiktok {
use super::*;
pub struct TikTok;
impl WebhookHandler for TikTok {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let tiktok_secret_signature = headers.try_get_webhook_header("TikTok-Signature")?;
let stripe_signature = parse_signature(tiktok_secret_signature, (",", "="));
let timestamp = *stripe_signature
.get("t")
.ok_or(AuthenticationError::InvalidTimestamp)?;
let s = *stripe_signature
.get("s")
.ok_or(AuthenticationError::InvalidSignature)?;
let signed_payload = format!("{}.{}", timestamp, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
s,
None,
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod twitch {
use super::*;
use http::header;
use serde_json::value::RawValue;
#[derive(Debug, Deserialize)]
struct TwitchCrcBody {
challenge: String,
#[allow(unused)]
subscription: Box<RawValue>,
}
pub struct Twitch;
impl WebhookHandler for Twitch {
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let twitch_secret_signature =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Signature")?;
let twitch_message_id_header =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Id")?;
let twitch_timestamp_header =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?;
let message = format!(
"{}{}{}",
twitch_message_id_header, twitch_timestamp_header, raw_payload
);
Ok(SignatureAuthenticationData::new(
Cow::Owned(message),
twitch_secret_signature,
Some("sha256="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn handle_challenge_request<'header>(
&self,
headers: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError> {
let authentication_data = self.get_hmac_authentication_data(headers, raw_payload)?;
verify_hmac_signature(authentication_data, &signature_config_data.secret_key)?;
let twitch_eventsub_message_type =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Type")?;
if twitch_eventsub_message_type != "webhook_callback_verification" {
return Ok(None);
}
let twitch_crc_body =
serde_json::from_str::<TwitchCrcBody>(raw_payload).map_err(|e| {
AuthenticationError::InvalidChallengeResponse(format!(
"Twitch :{}",
e.to_string()
))
})?;
let response = (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain")],
twitch_crc_body.challenge.to_string(),
);
Ok(Some(response.into_response()))
}
}
}
mod zoom {
use axum::Json;
use super::*;
#[derive(Debug, Deserialize)]
struct ZoomPayload {
#[serde(rename = "plainToken")]
plain_token: String,
}
#[derive(Debug, Deserialize)]
#[allow(unused)]
struct ZoomChallengeResponse {
payload: ZoomPayload,
event_ts: u64,
event: String,
}
pub struct Zoom;
impl WebhookHandler for Zoom {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError> {
let Ok(zoom_request_body) = serde_json::from_str::<ZoomChallengeResponse>(raw_payload)
else {
return Ok(None);
};
if zoom_request_body.event != "endpoint.url_validation" {
return Ok(None);
}
let hmac_signature = calculate_hmac_signature(
HmacAlgorithm::Sha256,
&signature_config_data.secret_key,
&zoom_request_body.payload.plain_token,
);
let encoded_hmac_signature = encode_hmac_signature(Encoding::Hex, &hmac_signature);
let response = (
StatusCode::OK,
Json(json!({
"plainToken": zoom_request_body.payload.plain_token,
"encryptedToken": encoded_hmac_signature
})),
);
Ok(Some(response.into_response()))
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let zoom_signature_header = headers.try_get_webhook_header("x-zm-signature")?;
let zoom_timestamp_header = headers.try_get_webhook_header("x-zm-request-timestamp")?;
let message = format!("v0:{}:{}", zoom_timestamp_header, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(message),
zoom_signature_header,
Some("v0="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
use constant_time_eq::constant_time_eq;
use github::Github;
use slack::Slack;
use stripe::Stripe;
use tiktok::TikTok;
use twitch::Twitch;
use zoom::Zoom;
#[derive(Debug)]
pub struct SignatureAuthenticationDetails {
pub algorithm_to_use: HmacAlgorithm,
pub header_key_encoding: Encoding,
}
impl SignatureAuthenticationDetails {
#[inline]
fn new(algorithm_to_use: HmacAlgorithm, header_key_encoding: Encoding) -> Self {
Self { algorithm_to_use, header_key_encoding }
}
}
fn parse_signature<'header>(
signature: &'header str,
splitters: (&str, &str),
) -> HashMap<&'header str, &'header str> {
let headers: HashMap<&str, &str> = signature
.split(splitters.0)
.map(|header| {
let mut key_and_value = header.split(splitters.1);
let key = key_and_value.next();
let value = key_and_value.next();
(key, value)
})
.filter_map(|(key, value)| match (key, value) {
(Some(key), Some(value)) => Some((key, value)),
_ => None,
})
.collect();
headers
}
#[derive(Debug)]
pub struct SignatureAuthenticationData<'payload, 'header, 'prefix> {
pub signed_payload: Cow<'payload, str>,
pub header_key_value: &'header str,
pub signature_prefix: Option<&'prefix str>,
pub config: SignatureAuthenticationDetails,
}
impl<'payload, 'header, 'prefix> SignatureAuthenticationData<'payload, 'header, 'prefix> {
pub fn new(
signed_payload: Cow<'payload, str>,
header_key_value: &'header str,
signature_prefix: Option<&'prefix str>,
config: SignatureAuthenticationDetails,
) -> Self {
Self { signed_payload, header_key_value, signature_prefix, config }
}
}
pub trait WebhookHandler {
fn handle_challenge_request<'header>(
&self,
headers: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError>;
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>;
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HmacAlgorithm {
Sha1,
Sha256,
Sha512,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Encoding {
Base64,
Base64Uri,
Hex,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SignatureAuthenticationMethod {
algorithm: HmacAlgorithm,
encoding: Encoding,
signature_header_name: String,
signature_prefix: Option<String>,
}
pub struct SignatureConfigData<'config> {
secret_key: &'config str,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SignatureAuthentication {
signature_provider: WebhookType,
secret_key: String,
authentication_config: Option<SignatureAuthenticationMethod>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BasicAuthAuthentication {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiKeyAuthentication {
pub api_key_header: String,
pub api_key_secret: String,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WebhookType {
Github,
Slack,
Stripe,
TikTok,
Twitch,
Zoom,
Custom,
}
impl WebhookType {
pub fn get_webhook_handler(&self) -> Option<&'static dyn WebhookHandler> {
let handler: &'static dyn WebhookHandler = match *self {
WebhookType::Github => &Github,
WebhookType::Slack => &Slack,
WebhookType::Stripe => &Stripe,
WebhookType::TikTok => &TikTok,
WebhookType::Twitch => &Twitch,
WebhookType::Zoom => &Zoom,
WebhookType::Custom => return None,
};
Some(handler)
}
}
trait TryGetWebhookHeader {
fn try_get_webhook_header<'header>(
&'header self,
header_name: &str,
) -> Result<&'header str, AuthenticationError>;
}
impl TryGetWebhookHeader for HeaderMap<HeaderValue> {
fn try_get_webhook_header<'header>(
&'header self,
header_name: &str,
) -> Result<&'header str, AuthenticationError> {
let Some(signature_header) = self.get(header_name) else {
return Err(AuthenticationError::MissingHeader(header_name.to_string()));
};
let Some(signature_header) = signature_header.to_str().ok() else {
return Err(AuthenticationError::InvalidHeader(header_name.to_string()));
};
Ok(signature_header)
}
}
pub fn calculate_hmac_signature(algorithm: HmacAlgorithm, secret: &str, payload: &str) -> Vec<u8> {
match algorithm {
HmacAlgorithm::Sha1 => {
let mut mac =
HmacSha1::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
HmacAlgorithm::Sha256 => {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
HmacAlgorithm::Sha512 => {
let mut mac = HmacSha512::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
}
}
pub fn encode_hmac_signature(encoding: Encoding, hmac_signature: &[u8]) -> String {
match encoding {
Encoding::Hex => hex::encode(hmac_signature),
Encoding::Base64 => BASE64_STANDARD.encode(hmac_signature),
Encoding::Base64Uri => BASE64_URL_SAFE.encode(hmac_signature),
}
}
pub fn verify_hmac_signature(
authentication_data: SignatureAuthenticationData,
webhook_signing_secret: &str,
) -> Result<(), AuthenticationError> {
let hmac_signature = calculate_hmac_signature(
authentication_data.config.algorithm_to_use,
&webhook_signing_secret,
&authentication_data.signed_payload,
);
let encoded_signature = encode_hmac_signature(
authentication_data.config.header_key_encoding,
&hmac_signature,
);
let final_expected_signature =
if let Some(signature_prefix) = authentication_data.signature_prefix {
format!("{}{}", signature_prefix, encoded_signature)
} else {
encoded_signature
};
if !constant_time_eq(
final_expected_signature.as_bytes(),
authentication_data.header_key_value.as_bytes(),
) {
return Err(AuthenticationError::InvalidSignature);
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum AuthenticationMethod {
Signature(SignatureAuthentication),
BasicAuth(BasicAuthAuthentication),
ApiKey(ApiKeyAuthentication),
}
impl AuthenticationMethod {
pub fn authenticate_http_request(
&self,
headers: &HeaderMap,
raw_payload: Option<&String>,
) -> Result<Option<Response>, AuthenticationError> {
match self {
AuthenticationMethod::Signature(SignatureAuthentication {
secret_key,
authentication_config,
signature_provider,
}) => {
let raw_payload = raw_payload.ok_or(AuthenticationError::InvalidPayload)?;
let config_data = SignatureConfigData { secret_key: &secret_key };
let handler = signature_provider.get_webhook_handler();
let challenge_response = handler
.map(|handler| {
handler.handle_challenge_request(headers, &config_data, raw_payload)
})
.transpose()?
.flatten();
if let Some(challenge_response) = challenge_response {
return Ok(Some(challenge_response));
}
let authentication_data = match handler {
Some(handler) => handler.get_hmac_authentication_data(headers, raw_payload)?,
None => {
let authentication_config = authentication_config
.as_ref()
.ok_or(AuthenticationError::InvalidCustomConfig)?;
let signature_header_value = headers
.try_get_webhook_header(&authentication_config.signature_header_name)?;
SignatureAuthenticationData::new(
Cow::Borrowed(raw_payload),
signature_header_value,
authentication_config.signature_prefix.as_deref(),
SignatureAuthenticationDetails::new(
authentication_config.algorithm,
authentication_config.encoding,
),
)
}
};
verify_hmac_signature(authentication_data, &secret_key)?;
}
AuthenticationMethod::ApiKey(ApiKeyAuthentication {
api_key_header,
api_key_secret,
}) => {
let api_key_to_cmp = headers
.try_get_webhook_header(&api_key_header)
.map_err(|_| AuthenticationError::InvalidApiKey)?;
if api_key_to_cmp != api_key_secret {
return Err(AuthenticationError::InvalidApiKey);
}
}
AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username, password }) => {
let mut credentials_store = headers
.try_get_webhook_header("Authorization")
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?
.split(' ');
let _ = credentials_store
.next()
.filter(|r#type| *r#type == "Basic")
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_as_base64 = credentials_store
.next()
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_from_base64_as_bytes = BASE64_STANDARD
.decode(credentials_as_base64.as_bytes())
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_separated_with_colon =
String::from_utf8(credentials_from_base64_as_bytes)
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials = credentials_separated_with_colon.split(':').collect_vec();
if credentials.len() != 2 {
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
}
if credentials.get(0).unwrap() != username
|| credentials.get(1).unwrap() != password
{
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
}
}
}
Ok(None)
}
}
#[derive(thiserror::Error, Debug)]
#[allow(unused)]
pub enum AuthenticationError {
#[error("failed to parse timestamp")]
InvalidTimestamp,
#[error("invalid secret")]
InvalidSecret(#[from] base64::DecodeError),
#[error("invalid header `{0}`")]
InvalidHeader(String),
#[error("signature timestamp too old")]
TimestampTooOldError,
#[error("signature timestamp too far in future")]
FutureTimestampError,
#[error("missing header {0}")]
MissingHeader(String),
#[error("signature invalid")]
InvalidSignature,
#[error("payload invalid")]
InvalidPayload,
#[error("invalid custom config")]
InvalidCustomConfig,
#[error("invalid auth header: {0}")]
InvalidAuthHeader(String),
#[error("invalid api key")]
InvalidApiKey,
#[error("invalid challenge response: {0}")]
InvalidChallengeResponse(String),
#[error("")]
UnauthorizedBasicHttpAuth,
}
impl IntoResponse for AuthenticationError {
fn into_response(self) -> Response {
let (status, error_message) = match &self {
AuthenticationError::InvalidTimestamp
| AuthenticationError::InvalidPayload
| AuthenticationError::InvalidHeader(_)
| AuthenticationError::MissingHeader(_)
| AuthenticationError::TimestampTooOldError
| AuthenticationError::FutureTimestampError
| AuthenticationError::InvalidCustomConfig
| AuthenticationError::InvalidChallengeResponse(_) => {
(StatusCode::BAD_REQUEST, self.to_string())
}
AuthenticationError::InvalidSecret(_)
| AuthenticationError::InvalidSignature
| AuthenticationError::InvalidAuthHeader(_) => {
(StatusCode::UNAUTHORIZED, self.to_string())
}
AuthenticationError::UnauthorizedBasicHttpAuth => {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, r#"Basic realm="Restricted Area""#)],
"Unauthorized",
)
.into_response()
}
AuthenticationError::InvalidApiKey => {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
};
let body = json!({ "error": error_message });
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
(status, headers, body.to_string()).into_response()
}
}
@@ -0,0 +1,412 @@
use std::collections::HashMap;
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sqlx::{types::Json as SqlxJson, FromRow};
use tokio::sync::{RwLock, RwLockReadGuard};
use windmill_common::{
error::{Error, Result},
flows::Retry,
s3_helpers::S3Object,
worker::CLOUD_HOSTED,
DB,
};
use crate::triggers::TriggerMode;
use windmill_api_auth::ApiAuthed;
use windmill_common::utils::ExpiringCacheEntry;
pub mod handler;
pub mod http_trigger_args;
pub mod http_trigger_auth;
lazy_static::lazy_static! {
static ref HTTP_ACCESS_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<()>> = Cache::new(100);
static ref HTTP_AUTH_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<http_trigger_auth::AuthenticationMethod>> = Cache::new(100);
static ref HTTP_ROUTERS_CACHE: RwLock<RoutersCache> = RwLock::new(RoutersCache {
routers: HashMap::new(),
version: 0,
});
}
#[derive(Debug, Deserialize, Clone)]
pub struct TriggerRoute {
path: String,
script_path: String,
is_flow: bool,
route_path: String,
workspace_id: String,
request_type: RequestType,
authentication_method: AuthenticationMethod,
edited_by: String,
email: String,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
authentication_resource_path: Option<String>,
workspaced_route: bool,
wrap_body: bool,
raw_string: bool,
error_handler_path: Option<String>,
error_handler_args: Option<sqlx::types::Json<HashMap<String, serde_json::Value>>>,
retry: Option<sqlx::types::Json<Retry>>,
mode: TriggerMode,
}
pub struct RoutersCache {
routers: HashMap<HttpMethod, matchit::Router<TriggerRoute>>,
version: i64,
}
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, Hash, Eq, PartialEq)]
#[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, PartialEq)]
#[sqlx(type_name = "REQUEST_TYPE", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RequestType {
Sync,
Async,
SyncSse,
}
impl TryFrom<&http::Method> for HttpMethod {
type Error = Error;
fn try_from(method: &http::Method) -> Result<Self> {
match method {
&http::Method::GET => Ok(HttpMethod::Get),
&http::Method::POST => Ok(HttpMethod::Post),
&http::Method::PUT => Ok(HttpMethod::Put),
&http::Method::DELETE => Ok(HttpMethod::Delete),
&http::Method::PATCH => Ok(HttpMethod::Patch),
_ => Err(Error::BadRequest("Invalid HTTP method".to_string())),
}
}
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
#[sqlx(type_name = "AUTHENTICATION_METHOD", rename_all = "snake_case")]
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
pub enum AuthenticationMethod {
None,
Windmill,
ApiKey,
BasicHttp,
CustomScript,
Signature,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct HttpConfig {
pub route_path: String,
pub route_path_key: String,
pub request_type: RequestType,
pub authentication_method: AuthenticationMethod,
pub http_method: HttpMethod,
pub summary: Option<String>,
pub description: Option<String>,
pub static_asset_config: Option<SqlxJson<S3Object>>,
pub is_static_website: bool,
pub authentication_resource_path: Option<String>,
pub workspaced_route: bool,
pub wrap_body: bool,
pub raw_string: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct HttpConfigRequest {
#[serde(default)]
pub route_path: String,
pub request_type: RequestType,
pub authentication_method: AuthenticationMethod,
pub http_method: HttpMethod,
pub summary: Option<String>,
pub description: Option<String>,
pub static_asset_config: Option<SqlxJson<S3Object>>,
pub is_static_website: bool,
pub authentication_resource_path: Option<String>,
pub workspaced_route: Option<bool>,
pub wrap_body: Option<bool>,
pub raw_string: Option<bool>,
}
#[derive(Deserialize)]
struct HttpConfigRequestHelper {
#[serde(default)]
route_path: String,
request_type: Option<RequestType>,
is_async: Option<bool>,
authentication_method: AuthenticationMethod,
http_method: HttpMethod,
summary: Option<String>,
description: Option<String>,
static_asset_config: Option<SqlxJson<S3Object>>,
is_static_website: bool,
authentication_resource_path: Option<String>,
workspaced_route: Option<bool>,
wrap_body: Option<bool>,
raw_string: Option<bool>,
}
impl<'de> Deserialize<'de> for HttpConfigRequest {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let helper = HttpConfigRequestHelper::deserialize(deserializer)?;
// Determine request_type with backward compatibility
let request_type = if let Some(mode) = helper.request_type {
mode
} else if let Some(is_async) = helper.is_async {
if is_async {
RequestType::Async
} else {
RequestType::Sync
}
} else {
RequestType::Sync
};
Ok(HttpConfigRequest {
route_path: helper.route_path,
request_type,
authentication_method: helper.authentication_method,
http_method: helper.http_method,
summary: helper.summary,
description: helper.description,
static_asset_config: helper.static_asset_config,
is_static_website: helper.is_static_website,
authentication_resource_path: helper.authentication_resource_path,
workspaced_route: helper.workspaced_route,
wrap_body: helper.wrap_body,
raw_string: helper.raw_string,
})
}
}
// Regex patterns for route validation
lazy_static::lazy_static! {
// Matches named params like :id or wildcards like :* or *
static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"(/)?(:|\*)[-\w]+").unwrap();
static ref VALID_ROUTE_PATH_RE: regex::Regex = regex::Regex::new(r"^(\*[-\w]+$|:?[-\w]+)(/(\*[-\w]+$|:?[-\w]+))*$").unwrap();
}
#[derive(Deserialize)]
pub struct RouteExists {
pub route_path: String,
pub http_method: HttpMethod,
pub trigger_path: Option<String>,
pub workspaced_route: Option<bool>,
}
pub fn validate_authentication_method(
authentication_method: AuthenticationMethod,
raw_string: Option<bool>,
) -> Result<()> {
match (authentication_method, raw_string) {
(AuthenticationMethod::CustomScript, raw) if !raw.unwrap_or(false) => {
Err(Error::BadRequest(
"To use custom script authentication, please enable the raw body option."
.to_string(),
))
}
_ => Ok(()),
}
}
pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> {
let version = sqlx::query_scalar!("SELECT last_value FROM http_trigger_version_seq",)
.fetch_one(db)
.await?;
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
if routers_cache.version == 0 || version > routers_cache.version {
drop(routers_cache);
let mut routers = HashMap::new();
for http_method in [
HttpMethod::Get,
HttpMethod::Post,
HttpMethod::Put,
HttpMethod::Patch,
HttpMethod::Delete,
] {
let triggers = sqlx::query_as!(
TriggerRoute,
r#"
SELECT
path,
script_path,
is_flow,
route_path,
authentication_resource_path,
workspace_id,
request_type AS "request_type: _",
authentication_method AS "authentication_method: _",
edited_by,
email,
static_asset_config AS "static_asset_config: _",
wrap_body,
raw_string,
workspaced_route,
is_static_website,
error_handler_path,
error_handler_args as "error_handler_args: _",
retry as "retry: _",
mode as "mode: _"
FROM
http_trigger
WHERE
http_method = $1 AND
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
"#,
&http_method as &HttpMethod
)
.fetch_all(db)
.await?;
let mut router = matchit::Router::new();
for trigger in triggers {
let full_path = if trigger.workspaced_route || *CLOUD_HOSTED {
format!("/{}/{}", trigger.workspace_id, trigger.route_path)
} else {
format!("/{}", trigger.route_path)
};
if trigger.is_static_website {
router
.insert(format!("{}/*wm_subpath", full_path), trigger.clone())
.unwrap_or_else(|e| {
tracing::warn!(
"Failed to consider HTTP route {}/*wm_subpath: {:?}",
full_path,
e,
);
});
}
router
.insert(full_path.clone(), trigger.clone())
.unwrap_or_else(|e| {
tracing::warn!("Failed to consider HTTP route {}: {:?}", full_path, e,);
});
}
routers.insert(http_method, router);
}
let mut routers_cache = HTTP_ROUTERS_CACHE.write().await;
*routers_cache = RoutersCache { routers, version };
Ok((true, routers_cache.downgrade()))
} else {
tracing::debug!("No HTTP routers refresh needed");
Ok((false, routers_cache))
}
}
pub async fn refresh_routers_loop(
db: &DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match refresh_routers(db).await {
Ok(_) => {
tracing::info!("Loaded HTTP routers");
}
Err(err) => {
tracing::error!("Error loading HTTP routers: {err:#}");
}
};
let db = db.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
match refresh_routers(&db).await {
Ok((true, _)) => {
tracing::info!("Refreshed HTTP routers");
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers: {err:#}");
}
_ => {}
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_request_type_backward_compatibility() {
// Test with new request_type field
let json_new = r#"{
"route_path": "/test",
"request_type": "sync_sse",
"authentication_method": "none",
"http_method": "get",
"is_static_website": false
}"#;
let config: HttpConfigRequest = serde_json::from_str(json_new).unwrap();
assert_eq!(config.request_type, RequestType::SyncSse);
// Test with legacy is_async = true
let json_legacy_async = r#"{
"route_path": "/test",
"is_async": true,
"authentication_method": "none",
"http_method": "get",
"is_static_website": false
}"#;
let config: HttpConfigRequest = serde_json::from_str(json_legacy_async).unwrap();
assert_eq!(config.request_type, RequestType::Async);
// Test with legacy is_async = false
let json_legacy_sync = r#"{
"route_path": "/test",
"is_async": false,
"authentication_method": "none",
"http_method": "get",
"is_static_website": false
}"#;
let config: HttpConfigRequest = serde_json::from_str(json_legacy_sync).unwrap();
assert_eq!(config.request_type, RequestType::Sync);
// Test with neither field (default to sync)
let json_default = r#"{
"route_path": "/test",
"authentication_method": "none",
"http_method": "get",
"is_static_website": false
}"#;
let config: HttpConfigRequest = serde_json::from_str(json_default).unwrap();
assert_eq!(config.request_type, RequestType::Sync);
// Test that request_type takes precedence over is_async
let json_both = r#"{
"route_path": "/test",
"request_type": "sync_sse",
"is_async": true,
"authentication_method": "none",
"http_method": "get",
"is_static_website": false
}"#;
let config: HttpConfigRequest = serde_json::from_str(json_both).unwrap();
assert_eq!(config.request_type, RequestType::SyncSse);
}
}
@@ -0,0 +1,66 @@
#[cfg(not(feature = "private"))]
use crate::triggers::TriggerData;
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::KafkaTrigger,
crate::triggers::TriggerCrud,
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for KafkaTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/kafka_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::KafkaTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Kafka triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Kafka triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,41 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::KafkaTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for KafkaTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Kafka;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -0,0 +1,15 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct KafkaTrigger;
@@ -0,0 +1,927 @@
use std::{collections::HashMap, fmt::Debug, sync::Arc};
use crate::{
capture_ext::insert_capture_payload,
triggers::{
handler::TriggerCrud,
trigger_helpers::{trigger_runnable, TriggerJobArgs},
Trigger, TriggerErrorHandling, TriggerMode,
},
};
use windmill_api_auth::{fetch_api_authed, ApiAuthed};
use async_trait::async_trait;
use itertools::Itertools;
use rand::seq::SliceRandom;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sql_builder::SqlBuilder;
use sqlx::{FromRow, Row};
use tokio::sync::RwLock;
use windmill_common::{
error::{Error, Result},
jobs::JobTriggerKind,
triggers::{TriggerKind, TriggerMetadata},
utils::report_critical_error,
DB, INSTANCE_NAME,
};
#[allow(unused)]
#[async_trait]
pub trait Listener: TriggerCrud + TriggerJobArgs {
type Consumer: Send;
type Extra: Send + Sync;
type ExtraState: Send + Sync;
const JOB_TRIGGER_KIND: JobTriggerKind;
const EXTRA_TRIGGER_AND_WHERE_CLAUSE: &[&'static str] = &[];
const EXTRA_CAPTURE_AND_WHERE_CLAUSE: &[&'static str] = &[];
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>>;
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
extra: Option<&Self::ExtraState>,
);
async fn fetch_enabled_unlistened_triggers(
&self,
db: &DB,
) -> Result<Vec<ListeningTrigger<Self::TriggerConfig>>> {
let mut fields = vec![
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"extra_perms",
"mode",
"error_handler_path",
"error_handler_args",
"retry",
];
fields.extend_from_slice(Self::ADDITIONAL_SELECT_FIELDS);
let mut sqlb = SqlBuilder::select_from(Self::TABLE_NAME);
sqlb.fields(&fields)
.and_where("(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)")
.and_where(
"(last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')",
);
for where_clause in Self::EXTRA_TRIGGER_AND_WHERE_CLAUSE {
sqlb.and_where(where_clause);
}
let sql = sqlb
.sql()
.map_err(|e| Error::InternalErr(format!("SQL error: {}", e)))?;
let triggers: Vec<Trigger<Self::TriggerConfig>> =
sqlx::query_as(&sql).fetch_all(db).await?;
let triggers = triggers
.into_iter()
.map(|trigger| ListeningTrigger {
path: trigger.base.path,
workspace_id: trigger.base.workspace_id,
is_flow: trigger.base.is_flow,
username: trigger.base.edited_by,
email: trigger.base.email,
script_path: trigger.base.script_path,
trigger_config: trigger.config,
error_handling: Some(trigger.error_handling),
trigger_mode: true,
suspended_mode: trigger.base.mode == TriggerMode::Suspended,
})
.collect_vec();
Ok(triggers)
}
async fn fetch_unlistened_captures(
&self,
db: &DB,
) -> Result<Vec<ListeningTrigger<Self::TriggerConfig>>> {
let fields = vec![
"path",
"is_flow",
"workspace_id",
"owner AS username",
"email",
"trigger_config",
];
let mut sqlb = SqlBuilder::select_from("capture_config");
sqlb.fields(&fields)
.and_where(format!("trigger_kind = '{}'", Self::TRIGGER_KIND.to_key()))
.and_where("last_client_ping > NOW() - INTERVAL '10 seconds'")
.and_where("trigger_config IS NOT NULL")
.and_where(
"(last_server_ping IS NULL OR last_server_ping < NOW() - INTERVAL '15 seconds')",
);
for where_clause in Self::EXTRA_CAPTURE_AND_WHERE_CLAUSE {
sqlb.and_where(where_clause);
}
let sql = sqlb.sql().expect("failed to build SQL");
let captures: Vec<Capture<Self::TriggerConfig>> =
sqlx::query_as(&sql).fetch_all(db).await?;
let captures = captures
.into_iter()
.map(|capture| ListeningTrigger {
username: capture.username,
path: capture.path,
workspace_id: capture.workspace_id,
script_path: "".to_string(),
email: capture.email,
trigger_config: capture.trigger_config,
trigger_mode: false,
is_flow: capture.is_flow,
error_handling: None,
suspended_mode: false,
})
.collect_vec();
Ok(captures)
}
async fn get_extra_state(&self) -> Option<Self::ExtraState> {
None
}
async fn cleanup(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra: Option<&Self::ExtraState>,
) -> Result<()> {
Ok(())
}
async fn loop_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
status: Arc<RwLock<Option<String>>>,
error_message: Option<String>,
) {
update_rw_lock(status.clone(), error_message).await;
loop {
if let None = self
.update_ping(db, listening_trigger, status.read().await.as_deref())
.await
{
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
async fn update_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
if listening_trigger.trigger_mode {
self.update_trigger_ping(db, listening_trigger, error).await
} else {
self.update_capture_ping(db, listening_trigger, error).await
}
}
async fn update_ping_and_loop_ping_status(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
loop_ping_status: Arc<RwLock<Option<String>>>,
error: Option<String>,
) -> Option<()> {
// update immediately the ping status and update the loop ping status so that the next loop pings will display the new status
update_rw_lock(loop_ping_status.clone(), error.clone()).await;
if let None = self
.update_ping(db, listening_trigger, error.as_deref())
.await
{
return None;
}
Some(())
}
async fn update_trigger_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
let updated = sqlx::query_scalar::<_, i32>(&format!(
r#"
UPDATE
{}
SET
last_server_ping = now(), error = $1
WHERE
workspace_id = $2 AND
path = $3 AND
server_id = $4 AND
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
RETURNING 1
"#,
Self::TABLE_NAME
))
.bind(error)
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.bind(&*INSTANCE_NAME)
.fetch_optional(db)
.await;
self.handle_ping_result(updated, db, listening_trigger, "trigger")
.await
}
async fn update_capture_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
let updated = sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
last_server_ping = now(), error = $1
WHERE
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5 AND
server_id = $6 AND
last_client_ping > NOW() - INTERVAL '10 seconds'
RETURNING 1
"#,
error,
&listening_trigger.workspace_id,
&listening_trigger.path,
&listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind,
&*INSTANCE_NAME
)
.fetch_optional(db)
.await
.map(|result| result.flatten());
self.handle_ping_result(updated, db, listening_trigger, "capture")
.await
}
async fn handle_ping_result(
&self,
result: sqlx::Result<Option<i32>>,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
entity_type: &str,
) -> Option<()> {
match result {
Ok(updated) => {
if updated.is_none() {
self.reset_ping_for_restart(db, listening_trigger).await;
tracing::info!(
"{} {} {} changed, disabled, or deleted, stopping...",
Self::TRIGGER_KIND,
entity_type,
listening_trigger.path
);
return None;
}
}
Err(error) => {
tracing::warn!(
"Error updating ping of {} {} {}: {:?}",
Self::TRIGGER_KIND,
entity_type,
&listening_trigger.path,
error
);
}
}
Some(())
}
async fn reset_ping_for_restart(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
) {
if listening_trigger.trigger_mode {
let _ = sqlx::query(&format!(
r#"
UPDATE
{}
SET
last_server_ping = NULL
WHERE
workspace_id = $1 AND
path = $2 AND
server_id IS NULL
"#,
Self::TABLE_NAME
))
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.execute(db)
.await;
} else {
let _ = sqlx::query!(
r#"
UPDATE
capture_config
SET
last_server_ping = NULL
WHERE
workspace_id = $1 AND
path = $2 AND
is_flow = $3 AND
trigger_kind = $4 AND
server_id IS NULL
"#,
&listening_trigger.workspace_id,
&listening_trigger.path,
&listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind
)
.execute(db)
.await;
}
}
async fn disable_with_error(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: String,
) {
if listening_trigger.trigger_mode {
let report_status = sqlx::query(&format!(
r#"
UPDATE
{}
SET
mode = 'disabled'::TRIGGER_MODE,
error = $1,
server_id = NULL,
last_server_ping = NULL
WHERE
workspace_id = $2 AND
path = $3
"#,
Self::TABLE_NAME
))
.bind(&error)
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.execute(db)
.await;
match report_status {
Ok(_) => {
report_critical_error(
format!(
"Disabling {} trigger {} because of error: {}",
Self::TRIGGER_KIND,
listening_trigger.path,
error
),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
)
.await;
}
Err(disable_err) => {
report_critical_error(
format!("Could not disable {} trigger {} with err {}, disabling because of error {}", Self::TRIGGER_KIND, listening_trigger.path, disable_err, error),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
).await;
}
}
return;
}
let report_status = sqlx::query!(
r#"
UPDATE
capture_config
SET
error = $1,
server_id = NULL,
last_server_ping = NULL
WHERE
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5
"#,
error,
listening_trigger.workspace_id,
listening_trigger.path,
listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind
)
.execute(db)
.await;
if let Err(disable_err) = report_status {
tracing::error!(
"Could not disable {} capture {} ({}) with err {}, disabling because of error {}",
Self::TRIGGER_KIND,
listening_trigger.path,
listening_trigger.workspace_id,
disable_err,
error
)
}
}
async fn handle_trigger(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
_extra: Option<Self::Extra>,
) -> Result<()> {
let args = Self::build_job_args(
&listening_trigger.script_path,
listening_trigger.is_flow,
&listening_trigger.workspace_id,
db,
payload,
trigger_info,
)
.await?;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let (retry, error_handler_path, error_handler_args) =
match listening_trigger.error_handling.as_ref() {
Some(error_handling) => (
error_handling.retry.as_ref(),
error_handling.error_handler_path.as_deref(),
error_handling.error_handler_args.as_ref(),
),
None => (None, None, None),
};
tracing::debug!(
"Triggering job from {} event {} with args {:?}",
Self::TRIGGER_KIND,
listening_trigger.path,
args
);
trigger_runnable(
db,
None,
authed,
&listening_trigger.workspace_id,
&listening_trigger.script_path,
listening_trigger.is_flow,
args,
retry,
error_handler_path.as_deref(),
error_handler_args,
format!("{}_trigger/{}", Self::TRIGGER_KIND, listening_trigger.path),
None,
listening_trigger.suspended_mode,
TriggerMetadata::new(Some(listening_trigger.path.clone()), Self::JOB_TRIGGER_KIND),
)
.await?;
Ok(())
}
async fn handle_event(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
extra: Option<Self::Extra>,
) -> Result<()> {
if listening_trigger.trigger_mode {
if let Err(err) = self
.handle_trigger(db, listening_trigger, payload, trigger_info, extra)
.await
{
report_critical_error(
format!(
"Failed to trigger job from {} event {}: {:?}",
Self::TRIGGER_KIND,
listening_trigger.path,
err
),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
)
.await;
return Err(err);
};
return Ok(());
}
let (main_args, preprocessor_args) = Self::build_capture_payloads(&payload, trigger_info);
if let Err(err) = insert_capture_payload(
db,
&listening_trigger.workspace_id,
&listening_trigger.path,
listening_trigger.is_flow,
&Self::TRIGGER_KIND,
main_args,
preprocessor_args,
&listening_trigger.username,
)
.await
{
tracing::error!("Error inserting capture payload: {:?}", err);
return Err(err);
}
Ok(())
}
}
#[allow(unused)]
async fn listening<T: Listener>(
db: DB,
listener: T,
listening_trigger: ListeningTrigger<T::TriggerConfig>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let killpill_rx_consumer = killpill_rx.resubscribe();
let killpill_rx_get_consumer = killpill_rx.resubscribe();
let loop_ping_status = Arc::new(RwLock::new(None));
let extra_state = listener.get_extra_state().await;
let path = listening_trigger.path.clone();
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), Some("Connecting...".to_string())) => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
consumer = {
tracing::info!("[{}] Getting consumer for trigger {}", T::TRIGGER_KIND, path);
listener.get_consumer(&db, &listening_trigger, loop_ping_status.clone(), killpill_rx_get_consumer)
} => {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
tracing::info!("[{}] Killing pill received, stopping consumer for trigger {}", T::TRIGGER_KIND, path);
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), None) => {
tracing::info!("[{}] Loop ping exited, stopping consumer for trigger {}", T::TRIGGER_KIND, path);
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = async {
match consumer {
Ok(Some(consumer)) => {
listener.update_ping_and_loop_ping_status(&db, &listening_trigger, loop_ping_status.clone(), None).await;
tracing::info!("[{}] Starting consumer for trigger {}", T::TRIGGER_KIND, path);
listener.consume(&db, consumer, &listening_trigger, loop_ping_status.clone(), killpill_rx_consumer, extra_state.as_ref()).await;
tracing::info!("[{}] Consumer stopped for trigger {}", T::TRIGGER_KIND, path);
}
Err(error) => {
tracing::error!("[{}] Disabling trigger {} due to consumer error: {}", T::TRIGGER_KIND, path, error);
listener.disable_with_error(&db, &listening_trigger, error.to_string()).await;
}
Ok(None) => {
tracing::error!("[{}] Consumer is None for trigger {}", T::TRIGGER_KIND, path);
}
}
} => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
}
}
}
}
#[allow(unused)]
async fn listen_to_unlistened_events<T: Copy + Listener>(
listener: T,
db: DB,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) {
let unlistend_enabled_triggers = listener.fetch_enabled_unlistened_triggers(&db).await;
match unlistend_enabled_triggers {
Ok(mut unlistend_enabled_triggers) => {
unlistend_enabled_triggers.shuffle(&mut rand::rng());
for trigger in unlistend_enabled_triggers {
let has_lock = sqlx::query_scalar(&format!(
r#"
UPDATE
{}
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
AND workspace_id = $2
AND path = $3
AND (last_server_ping IS NULL
OR last_server_ping < now() - INTERVAL '15 seconds'
)
RETURNING true
"#,
T::TABLE_NAME,
))
.bind(&*INSTANCE_NAME)
.bind(&trigger.workspace_id)
.bind(&trigger.path)
.fetch_optional(&db)
.await;
match has_lock {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tracing::info!(
"Spawning new task to listen for {} event",
T::TABLE_NAME
);
tokio::spawn({
let db = db.clone();
let killpill_rx = killpill_rx.resubscribe();
async move { listening(db, listener, trigger, killpill_rx).await }
});
} else {
tracing::info!(
"{} trigger {} already being listened to",
T::TRIGGER_KIND,
trigger.path
);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for {} trigger {}: {:?}",
T::TRIGGER_KIND,
trigger.path,
err
);
}
};
}
}
Err(err) => {
tracing::error!("Error fetching {} triggers: {:?}", T::TRIGGER_KIND, err,);
}
}
let unlisted_captures = listener.fetch_unlistened_captures(&db).await;
match unlisted_captures {
Ok(unlistened_captures) => {
for capture in unlistened_captures {
let has_lock = sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
last_client_ping > NOW() - INTERVAL '10 seconds' AND
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5 AND
(last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')
RETURNING true
"#,
*INSTANCE_NAME,
&capture.workspace_id,
&capture.path,
&capture.is_flow,
T::TRIGGER_KIND as TriggerKind
)
.fetch_optional(&db)
.await;
match has_lock {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn({
let db = db.clone();
let killpill_rx = killpill_rx.resubscribe();
async move { listening(db, listener, capture, killpill_rx).await }
});
} else {
tracing::info!(
"{} capture {} already being listened to",
T::TRIGGER_KIND.to_string(),
capture.path
);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for capture {} {}: {:?}",
T::TRIGGER_KIND,
capture.path,
err
);
}
};
}
}
Err(err) => {
tracing::error!(
"Error fetching captures {} triggers: {:?}",
T::TRIGGER_KIND,
err
);
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Capture<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
path: String,
is_flow: bool,
workspace_id: String,
username: String,
email: String,
#[serde(flatten)]
trigger_config: T,
}
impl<T> FromRow<'_, sqlx::postgres::PgRow> for Capture<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow> + DeserializeOwned,
{
fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result<Self, sqlx::Error> {
let trigger_config_value = row.try_get("trigger_config")?;
let trigger_config: T = serde_json::from_value(trigger_config_value)
.map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
Ok(Capture {
path: row.try_get("path")?,
is_flow: row.try_get("is_flow")?,
workspace_id: row.try_get("workspace_id")?,
username: row.try_get("username")?,
email: row.try_get("email")?,
trigger_config,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListeningTrigger<T> {
pub path: String,
pub is_flow: bool,
pub workspace_id: String,
pub username: String,
pub email: String,
pub trigger_config: T,
pub script_path: String,
pub trigger_mode: bool,
pub error_handling: Option<TriggerErrorHandling>,
pub suspended_mode: bool,
}
impl<T> ListeningTrigger<T> {
pub async fn authed(&self, db: &DB, username: &str) -> Result<ApiAuthed> {
fetch_api_authed(
self.username.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("{}-{}", username, self.path)),
)
.await
}
}
#[allow(unused)]
pub async fn update_rw_lock<T>(lock: std::sync::Arc<tokio::sync::RwLock<T>>, value: T) -> () {
let mut w = lock.write().await;
*w = value;
}
#[allow(unused)]
fn listen_to<T: Copy + Listener>(
trigger: T,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tokio::spawn(async move {
listen_to_unlistened_events(trigger, db.clone(), &killpill_rx).await;
loop {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(15)) => {
listen_to_unlistened_events(trigger, db.clone(), &killpill_rx).await
}
}
}
});
}
#[allow(unused)]
pub fn start_all_listeners(db: DB, killpill_rx: &tokio::sync::broadcast::Receiver<()>) {
tracing::info!("Starting trigger listeners based on available features...");
#[cfg(feature = "postgres_trigger")]
{
let postgres_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::postgres::PostgresTrigger;
listen_to(PostgresTrigger, db.clone(), postgres_killpill_rx)
}
#[cfg(feature = "mqtt_trigger")]
{
let mqtt_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::mqtt::MqttTrigger;
listen_to(MqttTrigger, db.clone(), mqtt_killpill_rx)
}
#[cfg(feature = "websocket")]
{
let mqtt_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::websocket::WebsocketTrigger;
listen_to(WebsocketTrigger, db.clone(), mqtt_killpill_rx)
}
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::gcp::GcpTrigger;
listen_to(GcpTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::sqs::SqsTrigger;
listen_to(SqsTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "nats", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::nats::NatsTrigger;
listen_to(NatsTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "kafka", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::kafka::KafkaTrigger;
listen_to(KafkaTrigger, db.clone(), gcp_killpill_rx);
}
tracing::info!("All available trigger listeners have been started");
}
@@ -0,0 +1,182 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{types::Json as SqlxJson, FromRow};
use std::{collections::HashMap, fmt::Debug};
use windmill_common::jobs::JobTriggerKind;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HandlerAction {
Trigger { path: String, trigger_kind: JobTriggerKind },
// Future variants can be added here (e.g., Script, Flow, etc.)
}
#[cfg(all(feature = "smtp", feature = "private"))]
pub mod email;
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
pub mod gcp;
#[cfg(feature = "http_trigger")]
pub mod http;
#[cfg(all(feature = "kafka", feature = "enterprise", feature = "private"))]
pub mod kafka;
#[cfg(feature = "mqtt_trigger")]
pub mod mqtt;
#[cfg(all(feature = "nats", feature = "enterprise", feature = "private"))]
pub mod nats;
#[cfg(feature = "postgres_trigger")]
pub mod postgres;
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
pub mod sqs;
#[cfg(feature = "websocket")]
pub mod websocket;
pub mod filter;
pub mod global_handler;
mod handler;
mod listener;
pub mod trigger_helpers;
#[allow(unused)]
pub(crate) use handler::TriggerCrud;
pub use handler::{generate_trigger_routers, get_triggers_count_internal, TriggersCount};
pub use listener::start_all_listeners;
#[allow(unused)]
pub(crate) use listener::Listener;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandardTriggerQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
pub path_start: Option<String>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct BaseTrigger {
pub workspace_id: String,
pub path: String,
pub script_path: String,
pub mode: TriggerMode,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: DateTime<Utc>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct ServerState {
#[serde(skip_serializing_if = "Option::is_none")]
pub server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_server_ping: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct TriggerErrorHandling {
#[serde(skip_serializing_if = "Option::is_none")]
pub error_handler_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_handler_args: Option<SqlxJson<HashMap<String, serde_json::Value>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<sqlx::types::Json<windmill_common::flows::Retry>>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Trigger<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
#[serde(flatten)]
pub base: BaseTrigger,
#[serde(flatten)]
pub config: T,
#[serde(flatten)]
pub server_state: Option<ServerState>,
#[serde(flatten)]
pub error_handling: TriggerErrorHandling,
}
impl<T> FromRow<'_, sqlx::postgres::PgRow> for Trigger<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result<Self, sqlx::Error> {
let base = BaseTrigger::from_row(row)?;
Ok(Trigger {
base,
config: T::from_row(row)?,
server_state: ServerState::from_row(row).ok(),
error_handling: TriggerErrorHandling::from_row(row)?,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseTriggerData {
pub path: String,
pub script_path: String,
pub is_flow: bool,
#[deprecated(note = "Use mode instead")]
enabled: Option<bool>, // Kept for backwards compatibility, use mode instead
mode: Option<TriggerMode>,
}
impl BaseTriggerData {
pub fn mode(&self) -> &TriggerMode {
self.mode.as_ref().unwrap_or(
#[allow(deprecated)]
if self.enabled.unwrap_or(true) {
&TriggerMode::Enabled
} else {
&TriggerMode::Disabled
},
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerData<T: Debug> {
#[serde(flatten)]
pub base: BaseTriggerData,
#[serde(flatten)]
pub config: T,
#[serde(flatten)]
pub error_handling: TriggerErrorHandling,
}
impl StandardTriggerQuery {
pub fn offset(&self) -> i64 {
let page = self.page.unwrap_or(0);
let per_page = self.per_page.unwrap_or(100);
(page * per_page) as i64
}
pub fn limit(&self) -> i64 {
self.per_page.unwrap_or(100) as i64
}
}
impl Default for StandardTriggerQuery {
fn default() -> Self {
Self { page: Some(0), per_page: Some(100), path: None, path_start: None, is_flow: None }
}
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
#[sqlx(type_name = "TRIGGER_MODE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum TriggerMode {
Enabled,
Disabled,
Suspended,
}
@@ -0,0 +1,238 @@
use crate::{
resource_ext::try_get_resource_from_db_as,
triggers::{Trigger, TriggerCrud, TriggerData},
};
use windmill_api_auth::ApiAuthed;
use windmill_common::DB;
use axum::async_trait;
use itertools::Itertools;
use sqlx::{types::Json as SqlxJson, PgConnection};
use windmill_common::{
db::UserDB,
error::{Error, Result},
};
use windmill_git_sync::DeployedObject;
use super::{
MqttClientBuilder, MqttClientVersion, MqttConfig, MqttConfigRequest, MqttResource, MqttTrigger,
MqttV3Config, MqttV5Config, SubscribeTopic, TestMqttConfig,
};
#[async_trait]
impl TriggerCrud for MqttTrigger {
type TriggerConfig = MqttConfig;
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfigRequest = MqttConfigRequest;
type TestConnectionConfig = TestMqttConfig;
const TABLE_NAME: &'static str = "mqtt_trigger";
const TRIGGER_TYPE: &'static str = "mqtt";
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/mqtt_triggers";
const DEPLOYMENT_NAME: &'static str = "MQTT trigger";
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
"mqtt_resource_path",
"subscribe_topics",
"v3_config",
"v5_config",
"client_id",
"client_version",
];
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::MqttTrigger { path }
}
async fn validate_config(
&self,
_db: &DB,
config: &Self::TriggerConfigRequest,
_workspace_id: &str,
) -> Result<()> {
if config.mqtt_resource_path.trim().is_empty() {
return Err(Error::BadRequest(
"MQTT resource path cannot be empty".to_string(),
));
}
if config.subscribe_topics.is_empty() {
return Err(Error::BadRequest(
"At least one subscribe topic must be specified".to_string(),
));
}
Ok(())
}
async fn create_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let subscribe_topics = trigger
.config
.subscribe_topics
.into_iter()
.map(SqlxJson)
.collect_vec();
let v3_config = trigger.config.v3_config.map(SqlxJson);
let v5_config = trigger.config.v5_config.map(SqlxJson);
sqlx::query!(
r#"
INSERT INTO mqtt_trigger (
mqtt_resource_path,
subscribe_topics,
client_version,
client_id,
v3_config,
v5_config,
workspace_id,
path,
script_path,
is_flow,
email,
mode,
edited_by,
error_handler_path,
error_handler_args,
retry
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
)"#,
trigger.config.mqtt_resource_path,
subscribe_topics.as_slice() as &[SqlxJson<SubscribeTopic>],
trigger.config.client_version as Option<MqttClientVersion>,
trigger.config.client_id,
v3_config as Option<SqlxJson<MqttV3Config>>,
v5_config as Option<SqlxJson<MqttV5Config>>,
w_id,
trigger.base.path,
trigger.base.script_path,
trigger.base.is_flow,
authed.email,
trigger.base.mode() as _,
authed.username,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn update_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
workspace_id: &str,
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let subscribe_topics = trigger
.config
.subscribe_topics
.into_iter()
.map(SqlxJson)
.collect_vec();
let v3_config = trigger.config.v3_config.map(SqlxJson);
let v5_config = trigger.config.v5_config.map(SqlxJson);
// Important to set server_id to NULL to stop current mqtt listener
sqlx::query!(
r#"
UPDATE
mqtt_trigger
SET
mqtt_resource_path = $1,
subscribe_topics = $2,
client_version = $3,
client_id = $4,
v3_config = $5,
v5_config = $6,
is_flow = $7,
edited_by = $8,
email = $9,
script_path = $10,
path = $11,
edited_at = now(),
error = NULL,
server_id = NULL,
error_handler_path = $14,
error_handler_args = $15,
retry = $16
WHERE
workspace_id = $12 AND
path = $13
"#,
trigger.config.mqtt_resource_path,
subscribe_topics.as_slice() as &[SqlxJson<SubscribeTopic>],
trigger.config.client_version as Option<MqttClientVersion>,
trigger.config.client_id,
v3_config as Option<SqlxJson<MqttV3Config>>,
v5_config as Option<SqlxJson<MqttV5Config>>,
trigger.base.is_flow,
authed.username,
authed.email,
trigger.base.script_path,
trigger.base.path,
workspace_id,
path,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn test_connection(
&self,
db: &DB,
authed: &ApiAuthed,
user_db: &UserDB,
workspace_id: &str,
config: Self::TestConnectionConfig,
) -> Result<()> {
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
Some(user_db.clone()),
db,
&config.mqtt_resource_path,
workspace_id,
)
.await?;
let connect_f = async {
let client_builder = MqttClientBuilder::new(
mqtt_resource,
Some(""),
vec![],
config.v3_config.as_ref(),
config.v5_config.as_ref(),
config.client_version.as_ref(),
);
client_builder.build_client().await.map_err(|err| {
Error::BadConfig(format!(
"Error connecting to mqtt broker: {}",
err.to_string()
))
})
};
connect_f.await?;
Ok(())
}
}
@@ -0,0 +1,376 @@
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use bytes::Bytes;
use rumqttc::{
v5::{
mqttbytes::v5::PublishProperties, Event as V5Event, EventLoop as V5EventLoop,
Incoming as V5Incoming,
},
Event as V3Event, EventLoop as V3EventLoop, Incoming as V3Incoming,
};
use std::time::Duration;
use tokio::sync::RwLock;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
worker::to_raw_value,
DB,
};
use crate::{
resource_ext::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
mqtt::{
MqttClientBuilder, MqttClientResult, MqttConfig, MqttResource, MqttTrigger,
V3MqttHandler, V5MqttHandler,
},
trigger_helpers::TriggerJobArgs,
Listener,
},
};
#[async_trait]
impl Listener for MqttTrigger {
type Consumer = MqttClientResult;
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Mqtt;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let ListeningTrigger::<Self::TriggerConfig> { workspace_id, trigger_config, .. } =
listening_trigger;
let MqttConfig {
mqtt_resource_path,
subscribe_topics,
v3_config,
v5_config,
client_id,
client_version,
..
} = trigger_config;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
&authed,
Some(UserDB::new(db.clone())),
&db,
mqtt_resource_path,
workspace_id,
)
.await?;
let subscribe_topics = subscribe_topics
.iter()
.map(|topic| topic.0.clone())
.collect();
let client_builder = MqttClientBuilder::new(
mqtt_resource,
client_id.as_deref(),
subscribe_topics,
v3_config.as_ref().map(|c| &c.0),
v5_config.as_ref().map(|c| &c.0),
client_version.as_ref(),
);
let client_result = client_builder
.build_client()
.await
.map_err(|e| Error::BadConfig(format!("Failed to build MQTT client: {}", e)))?;
Ok(Some(client_result))
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
tracing::info!(
"Starting to listen for MQTT trigger {}",
&listening_trigger.path
);
match consumer {
MqttClientResult::V3((v3_handler, event_loop)) => {
handle_event(&db, self, listening_trigger, v3_handler, event_loop).await
}
MqttClientResult::V5((v5_handler, event_loop)) => {
handle_event(&db, self, listening_trigger, v5_handler, event_loop).await
}
}
}
}
const TIMEOUT_DURATION: u64 = 10;
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(TIMEOUT_DURATION);
fn convert_disconnect_packet_into_string(
disconnect: rumqttc::v5::mqttbytes::v5::Disconnect,
) -> String {
let err_message = disconnect
.properties
.map(|properties| properties.reason_string)
.flatten();
let reason_code = disconnect.reason_code as u8;
format!(
"Disconnected by the broker, reason code: {}, {}",
reason_code,
err_message
.map(|err| format!("message: {}", err))
.unwrap_or("".to_string())
)
}
#[async_trait]
pub trait EventLoop {
type Event;
type Error;
async fn poll(&mut self) -> Result<Self::Event>;
async fn verify_connection(&mut self) -> Result<()>;
}
#[async_trait]
impl EventLoop for V5EventLoop {
type Event = V5Event;
type Error = rumqttc::v5::ConnectionError;
async fn poll(&mut self) -> Result<Self::Event> {
self.poll().await.map_err(|err| to_anyhow(err).into())
}
async fn verify_connection(&mut self) -> Result<()> {
let start = std::time::Instant::now();
while start.elapsed() < CONNECTION_TIMEOUT {
match self.poll().await.map_err(to_anyhow)? {
Self::Event::Incoming(V5Incoming::ConnAck(_)) => return Ok(()),
Self::Event::Incoming(V5Incoming::Disconnect(disconnect)) => {
return Err(Error::BadConfig(convert_disconnect_packet_into_string(
disconnect,
)));
}
_ => continue,
}
}
Err(Error::BadConfig(format!(
"Timeout occurred while trying to connect to mqtt broker after {} seconds",
TIMEOUT_DURATION
)))
}
}
#[async_trait]
impl EventLoop for V3EventLoop {
type Event = V3Event;
type Error = rumqttc::ConnectionError;
async fn poll(&mut self) -> Result<Self::Event> {
self.poll().await.map_err(|err| to_anyhow(err).into())
}
async fn verify_connection(&mut self) -> Result<()> {
let start = std::time::Instant::now();
while start.elapsed() < CONNECTION_TIMEOUT {
match self.poll().await.map_err(to_anyhow)? {
Self::Event::Incoming(rumqttc::Packet::ConnAck(_)) => return Ok(()),
_ => continue,
}
}
Err(Error::BadConfig(format!(
"Timeout occurred while trying to connect to mqtt broker after {} seconds",
TIMEOUT_DURATION
)))
}
}
async fn handle_event<T, E, H>(
db: &DB,
listener: &T,
listening_trigger: &ListeningTrigger<T::TriggerConfig>,
handler: H,
mut event_loop: E,
) -> ()
where
T: Listener,
H: MqttEvent,
E: EventLoop<Event = H::Event>,
E::Error: ToString,
<T as TriggerJobArgs>::Payload: From<Bytes>,
{
loop {
let event = event_loop.poll().await;
match event {
Ok(event) => {
let publish_data = handler.handle_event(event);
if let Ok(Some((payload, publish_data))) = publish_data {
let trigger_info = HashMap::from([
("topic".to_string(), to_raw_value(&publish_data.topic)),
("retain".to_string(), to_raw_value(&publish_data.retain)),
("pkid".to_string(), to_raw_value(&publish_data.pkid)),
("qos".to_string(), to_raw_value(&publish_data.qos)),
(
"v5".to_string(),
to_raw_value(&publish_data.v5.map(|properties| {
serde_json::json!({
"payload_format_indicator": properties.payload_format_indicator,
"topic_alias": properties.topic_alias,
"response_topic": properties.response_topic,
"correlation_data": properties.correlation_data.as_deref(),
"user_properties": properties.user_properties,
"subscription_identifiers": properties.subscription_identifiers,
"content_type": properties.content_type,
})
})),
),
]);
let _ = listener
.handle_event(db, listening_trigger, payload.into(), trigger_info, None)
.await;
}
}
Err(err) => {
let error = err.to_string();
tracing::debug!("Error: {}", &err);
listener
.disable_with_error(db, listening_trigger, error)
.await;
return;
}
}
}
}
#[derive(Clone)]
#[allow(unused)]
pub struct PublishData {
topic: String,
retain: bool,
pkid: u16,
v5: Option<PublishProperties>,
qos: u8,
}
impl PublishData {
pub fn new(
topic: String,
retain: bool,
pkid: u16,
v5: Option<PublishProperties>,
qos: u8,
) -> PublishData {
PublishData { topic, retain, pkid, v5, qos }
}
}
trait MqttEvent {
type IncomingPacket;
type PublishPacket;
type Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData;
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>>;
}
impl MqttEvent for V5MqttHandler {
type IncomingPacket = V5Incoming;
type PublishPacket = rumqttc::v5::mqttbytes::v5::Publish;
type Event = V5Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData {
PublishData::new(
String::from_utf8(publish_packet.topic.as_ref().to_vec()).unwrap_or("".to_string()),
publish_packet.retain,
publish_packet.pkid,
publish_packet.properties,
publish_packet.qos as u8,
)
}
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>> {
tracing::debug!("Inside V5 event");
match event {
Self::Event::Incoming(packet) => match packet {
Self::IncomingPacket::Publish(publish_packet) => {
return Ok(Some((
publish_packet.payload.clone(),
Self::handle_publish_packet(publish_packet),
)))
}
Self::IncomingPacket::Disconnect(disconnect) => {
return Err(
anyhow::anyhow!(convert_disconnect_packet_into_string(disconnect)).into(),
);
}
packet => {
tracing::debug!("Received = {:#?}", packet);
}
},
Self::Event::Outgoing(packet) => {
tracing::debug!("Outgoing Received = {:#?}", packet);
}
}
Ok(None)
}
}
impl MqttEvent for V3MqttHandler {
type IncomingPacket = V3Incoming;
type PublishPacket = rumqttc::mqttbytes::v4::Publish;
type Event = V3Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData {
PublishData::new(
publish_packet.topic,
publish_packet.retain,
publish_packet.pkid,
None,
publish_packet.qos as u8,
)
}
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>> {
tracing::debug!("Inside V3 event");
match event {
Self::Event::Incoming(packet) => match packet {
Self::IncomingPacket::Publish(publish_packet) => {
return Ok(Some((
publish_packet.payload.clone(),
Self::handle_publish_packet(publish_packet),
)))
}
packet => {
tracing::debug!("Received = {:?}", packet);
}
},
Self::Event::Outgoing(packet) => {
tracing::debug!("Outgoing Received = {:?}", packet);
}
}
Ok(None)
}
}
@@ -0,0 +1,346 @@
use base64::{engine, prelude::*};
use itertools::Itertools;
use rumqttc::{
v5::{
mqttbytes::{
v5::{ConnectProperties, Filter},
QoS as V5QoS,
},
AsyncClient as V5AsyncClient, EventLoop as V5EventLoop, MqttOptions as V5MqttOptions,
},
AsyncClient as V3AsyncClient, EventLoop as V3EventLoop, MqttOptions as V3MqttOptions,
QoS as V3QoS, SubscribeFilter, TlsConfiguration, Transport,
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow, Type};
use std::{collections::HashMap, time::Duration};
use windmill_common::{
error::{to_anyhow, Error},
triggers::TriggerKind,
worker::to_raw_value,
};
use crate::triggers::{mqtt::listener::EventLoop, trigger_helpers::TriggerJobArgs};
pub mod handler;
pub mod listener;
#[derive(Clone, Copy)]
pub struct MqttTrigger;
impl TriggerJobArgs for MqttTrigger {
type Payload = Vec<u8>;
const TRIGGER_KIND: TriggerKind = TriggerKind::Mqtt;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("payload".to_string(), to_raw_value(&payload))])
}
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
let base64_payload = engine::general_purpose::STANDARD.encode(payload);
HashMap::from([("payload".to_string(), to_raw_value(&base64_payload))])
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum QualityOfService {
Qos0,
Qos1,
Qos2,
}
impl From<QualityOfService> for V3QoS {
fn from(value: QualityOfService) -> Self {
match value {
QualityOfService::Qos0 => V3QoS::AtMostOnce,
QualityOfService::Qos1 => V3QoS::AtLeastOnce,
QualityOfService::Qos2 => V3QoS::ExactlyOnce,
}
}
}
impl From<QualityOfService> for V5QoS {
fn from(value: QualityOfService) -> Self {
match value {
QualityOfService::Qos0 => V5QoS::AtMostOnce,
QualityOfService::Qos1 => V5QoS::AtLeastOnce,
QualityOfService::Qos2 => V5QoS::ExactlyOnce,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MqttV3Config {
clean_session: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MqttV5Config {
clean_start: Option<bool>,
session_expiry_interval: Option<u32>,
topic_alias_maximum: Option<u16>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
#[sqlx(type_name = "MQTT_CLIENT_VERSION")]
#[sqlx(rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum MqttClientVersion {
V3,
V5,
}
#[derive(Debug, Deserialize)]
pub struct Tls {
enabled: bool,
ca_certificate: String,
pkcs12_client_certificate: Option<String>,
pkcs12_certificate_password: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Credentials {
username: Option<String>,
password: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MqttResource {
broker: String,
port: u16,
credentials: Option<Credentials>,
tls: Option<Tls>,
}
#[derive(Clone, Debug, FromRow, Serialize, Deserialize)]
pub struct SubscribeTopic {
qos: QualityOfService,
topic: String,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct MqttConfig {
pub mqtt_resource_path: String,
pub subscribe_topics: Vec<SqlxJson<SubscribeTopic>>,
pub v3_config: Option<SqlxJson<MqttV3Config>>,
pub v5_config: Option<SqlxJson<MqttV5Config>>,
pub client_id: Option<String>,
pub client_version: Option<MqttClientVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MqttConfigRequest {
pub mqtt_resource_path: String,
pub subscribe_topics: Vec<SubscribeTopic>,
pub v3_config: Option<MqttV3Config>,
pub v5_config: Option<MqttV5Config>,
pub client_id: Option<String>,
pub client_version: Option<MqttClientVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestMqttConfig {
pub mqtt_resource_path: String,
pub client_version: Option<MqttClientVersion>,
pub v3_config: Option<MqttV3Config>,
pub v5_config: Option<MqttV5Config>,
}
// Constants
pub const KEEP_ALIVE: u64 = 60;
pub const CLIENT_CONNECTION_TIMEOUT: u64 = 60;
pub const TOPIC_ALIAS_MAXIMUM: u16 = 65535;
pub const TIMEOUT_DURATION: u64 = 10;
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(TIMEOUT_DURATION);
pub struct V3MqttHandler;
pub struct V5MqttHandler;
pub enum MqttClientResult {
V3((V3MqttHandler, V3EventLoop)),
V5((V5MqttHandler, V5EventLoop)),
}
#[derive(Debug, thiserror::Error)]
pub enum MqttError {
#[error("{0}")]
Common(#[from] Error),
#[error("{0}")]
V5RumqttClient(#[from] rumqttc::v5::ClientError),
#[error("{0}")]
V5ConnectionError(#[from] rumqttc::v5::ConnectionError),
#[error("{0}")]
V3RumqttClient(#[from] rumqttc::ClientError),
#[error("{0}")]
V3ConnectionError(#[from] rumqttc::ConnectionError),
#[error("{0}")]
Base64Decode(#[from] base64::DecodeError),
}
pub struct MqttClientBuilder<'client> {
mqtt_resource: MqttResource,
client_id: &'client str,
subscribe_topics: Vec<SubscribeTopic>,
v3_config: Option<&'client MqttV3Config>,
v5_config: Option<&'client MqttV5Config>,
mqtt_client_version: Option<&'client MqttClientVersion>,
}
impl<'client> MqttClientBuilder<'client> {
pub fn new(
mqtt_resource: MqttResource,
client_id: Option<&'client str>,
subscribe_topics: Vec<SubscribeTopic>,
v3_config: Option<&'client MqttV3Config>,
v5_config: Option<&'client MqttV5Config>,
mqtt_client_version: Option<&'client MqttClientVersion>,
) -> Self {
Self {
mqtt_resource,
client_id: client_id.unwrap_or(""),
subscribe_topics,
v3_config,
v5_config,
mqtt_client_version,
}
}
pub async fn build_client(&self) -> Result<MqttClientResult, Error> {
match self.mqtt_client_version {
Some(MqttClientVersion::V5) | None => self.build_v5_client().await,
Some(MqttClientVersion::V3) => self.build_v3_client().await,
}
}
fn get_tls_configuration(&self) -> Result<Option<Transport>, Error> {
let transport = match self.mqtt_resource.tls {
Some(ref tls) if tls.enabled => {
let transport = match tls.ca_certificate.trim().is_empty() {
true => rumqttc::Transport::Tls(TlsConfiguration::Native),
false => rumqttc::Transport::Tls(TlsConfiguration::SimpleNative {
ca: tls.ca_certificate.as_bytes().to_vec(),
client_auth: {
match tls.pkcs12_client_certificate.as_ref() {
Some(client_certificate)
if !client_certificate.trim().is_empty() =>
{
let client_certificate = BASE64_STANDARD
.decode(client_certificate)
.map_err(to_anyhow)?;
let password = tls
.pkcs12_certificate_password
.clone()
.unwrap_or("".to_string());
Some((client_certificate, password))
}
_ => None,
}
},
}),
};
Some(transport)
}
_ => None,
};
Ok(transport)
}
async fn build_v5_client(&self) -> Result<MqttClientResult, Error> {
let mut mqtt_options = V5MqttOptions::new(
self.client_id,
&self.mqtt_resource.broker,
self.mqtt_resource.port,
);
if let Some(credentials) = &self.mqtt_resource.credentials {
let username = credentials.username.as_deref().unwrap_or("");
let password = credentials.password.as_deref().unwrap_or("");
mqtt_options.set_credentials(username, password);
}
if let Some(transport) = self.get_tls_configuration()? {
mqtt_options.set_transport(transport);
}
mqtt_options.set_connection_timeout(CLIENT_CONNECTION_TIMEOUT);
mqtt_options.set_keep_alive(Duration::from_secs(KEEP_ALIVE));
if let Some(v5_config) = self.v5_config {
mqtt_options.set_clean_start(v5_config.clean_start.unwrap_or(true));
mqtt_options.set_connect_properties(ConnectProperties {
session_expiry_interval: v5_config.session_expiry_interval,
receive_maximum: None,
max_packet_size: None,
topic_alias_max: v5_config.topic_alias_maximum.or(Some(TOPIC_ALIAS_MAXIMUM)),
request_response_info: None,
request_problem_info: None,
user_properties: vec![],
authentication_method: None,
authentication_data: None,
});
}
let (async_client, mut event_loop) =
V5AsyncClient::new(mqtt_options, self.subscribe_topics.len());
event_loop.verify_connection().await?;
if !self.subscribe_topics.is_empty() {
let subscribe_filters = self
.subscribe_topics
.iter()
.map(|topic| Filter::new(topic.topic.clone(), topic.qos.clone().into()))
.collect_vec();
async_client
.subscribe_many(subscribe_filters)
.await
.map_err(to_anyhow)?;
}
Ok(MqttClientResult::V5((V5MqttHandler, event_loop)))
}
async fn build_v3_client(&self) -> Result<MqttClientResult, Error> {
let mut mqtt_options = V3MqttOptions::new(
self.client_id,
&self.mqtt_resource.broker,
self.mqtt_resource.port,
);
if let Some(credentials) = &self.mqtt_resource.credentials {
let username = credentials.username.as_deref().unwrap_or("");
let password = credentials.password.as_deref().unwrap_or("");
mqtt_options.set_credentials(username, password);
}
if let Some(transport) = self.get_tls_configuration()? {
mqtt_options.set_transport(transport);
}
mqtt_options.set_keep_alive(Duration::from_secs(KEEP_ALIVE));
if let Some(v3_config) = self.v3_config {
mqtt_options.set_clean_session(v3_config.clean_session.unwrap_or(true));
}
let (async_client, mut event_loop) =
V3AsyncClient::new(mqtt_options, self.subscribe_topics.len());
event_loop.verify_connection().await?;
if !self.subscribe_topics.is_empty() {
let subscribe_filters = self
.subscribe_topics
.iter()
.map(|topic| SubscribeFilter::new(topic.topic.clone(), topic.qos.clone().into()))
.collect_vec();
async_client
.subscribe_many(subscribe_filters)
.await
.map_err(to_anyhow)?;
}
Ok(MqttClientResult::V3((V3MqttHandler, event_loop)))
}
}
@@ -0,0 +1,63 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::NatsTrigger,
crate::triggers::{TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for NatsTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/nats_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::NatsTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"NATS triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"NATS triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,41 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::NatsTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for NatsTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Nats;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -0,0 +1,15 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct NatsTrigger;
@@ -0,0 +1,24 @@
use thiserror::Error;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/bool.rs
*
*/
#[derive(Debug, Error)]
pub enum ParseBoolError {
#[error("invalid input value: {0}")]
InvalidInput(String),
}
pub fn parse_bool(s: &str) -> Result<bool, ParseBoolError> {
match s {
"t" => Ok(true),
"f" => Ok(false),
_ => Err(ParseBoolError::InvalidInput(s.to_string())),
}
}
@@ -0,0 +1,255 @@
use core::str;
use std::{
num::{ParseFloatError, ParseIntError},
str::FromStr,
};
use super::{
bool::{parse_bool, ParseBoolError},
hex::{from_bytea_hex, ByteaHexParseError},
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use rust_decimal::Decimal;
use rust_postgres::types::Type;
use serde_json::{to_value, Number, Value};
use thiserror::Error;
use uuid::Uuid;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/text.rs
*
*/
#[derive(Debug, Error)]
pub enum ConverterError {
#[error("invalid bool value")]
InvalidBool(#[from] ParseBoolError),
#[error("invalid int value")]
InvalidInt(#[from] ParseIntError),
#[error("invalid float value")]
InvalidFloat(#[from] ParseFloatError),
#[error("invalid numeric: {0}")]
InvalidNumeric(#[from] rust_decimal::Error),
#[error("invalid bytea: {0}")]
InvalidBytea(#[from] ByteaHexParseError),
#[error("invalid uuid: {0}")]
InvalidUuid(#[from] uuid::Error),
#[error("invalid json: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("invalid timestamp: {0} ")]
InvalidTimestamp(#[from] chrono::ParseError),
#[error("invalid array: {0}")]
InvalidArray(#[from] ArrayParseError),
#[error("{0}")]
Custom(String),
}
fn convert_into<T>(number: T) -> Number
where
T: Sized,
serde_json::Number: From<T>,
{
serde_json::Number::from(number)
}
pub struct Converter;
#[derive(Debug, Error)]
pub enum ArrayParseError {
#[error("input too short")]
InputTooShort,
#[error("missing braces")]
MissingBraces,
}
fn f64_to_json_number(raw_val: f64) -> Result<Value, ConverterError> {
let temp = serde_json::Number::from_f64(raw_val.into())
.ok_or(ConverterError::Custom("invalid json-float".to_string()))?;
Ok(Value::Number(temp))
}
impl Converter {
pub fn try_from_str(typ: Option<Type>, str: &str) -> Result<Value, ConverterError> {
let value = match typ.unwrap_or(Type::TEXT) {
Type::BOOL => Value::Bool(parse_bool(str)?),
Type::BOOL_ARRAY => {
Converter::parse_array(str, |str| Ok(Value::Bool(parse_bool(str)?)))?
}
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => {
Value::String(str.to_string())
}
Type::CHAR_ARRAY
| Type::BPCHAR_ARRAY
| Type::VARCHAR_ARRAY
| Type::NAME_ARRAY
| Type::TEXT_ARRAY => {
Converter::parse_array(str, |str| Ok(Value::String(str.to_string())))?
}
Type::INT2 => Value::Number(convert_into(str.parse::<i16>()?)),
Type::INT2_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i16>()?)))
})?,
Type::INT4 => Value::Number(convert_into(str.parse::<i32>()?)),
Type::INT4_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i32>()?)))
})?,
Type::INT8 => Value::Number(convert_into(str.parse::<i64>()?)),
Type::INT8_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i64>()?)))
})?,
Type::FLOAT4 => f64_to_json_number(str.parse::<f64>()?)?,
Type::FLOAT4_ARRAY => {
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
}
Type::FLOAT8 => f64_to_json_number(str.parse::<f64>()?)?,
Type::FLOAT8_ARRAY => {
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
}
Type::NUMERIC => serde_json::json!(Decimal::from_str(str)?),
Type::NUMERIC_ARRAY => {
Converter::parse_array(str, |str| Ok(serde_json::json!(Decimal::from_str(str)?)))?
}
Type::BYTEA => to_value(from_bytea_hex(str)?).unwrap(),
Type::BYTEA_ARRAY => {
Converter::parse_array(str, |str| Ok(to_value(from_bytea_hex(str)?).unwrap()))?
}
Type::DATE => {
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
Value::String(date.to_string())
}
Type::DATE_ARRAY => Converter::parse_array(str, |str| {
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
Ok(Value::String(date.to_string()))
})?,
Type::TIME => {
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
Value::String(time.to_string())
}
Type::TIME_ARRAY => Converter::parse_array(str, |str| {
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
Ok(Value::String(time.to_string()))
})?,
Type::TIMESTAMP => {
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
Value::String(timestamp.to_string())
}
Type::TIMESTAMP_ARRAY => Converter::parse_array(str, |str| {
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
Ok(Value::String(timestamp.to_string()))
})?,
Type::TIMESTAMPTZ => {
let val =
match DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z") {
Ok(val) => val,
Err(_) => {
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%:z")?
}
};
let utc: DateTime<Utc> = val.into();
Value::String(utc.to_string())
}
Type::TIMESTAMPTZ_ARRAY => {
match Converter::parse_array(str, |str| {
let utc: DateTime<Utc> =
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z")?
.into();
Ok(Value::String(utc.to_string()))
}) {
Ok(val) => val,
Err(_) => Converter::parse_array(str, |str| {
let utc: DateTime<Utc> = DateTime::<FixedOffset>::parse_from_str(
str,
"%Y-%m-%d %H:%M:%S%.f%#z",
)?
.into();
Ok(Value::String(utc.to_string()))
})?,
}
}
Type::UUID => Value::String(Uuid::parse_str(str)?.to_string()),
Type::UUID_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::String(Uuid::parse_str(str)?.to_string()))
})?,
Type::JSON | Type::JSONB => serde_json::from_str::<serde_json::Value>(str)?,
Type::JSON_ARRAY | Type::JSONB_ARRAY => Converter::parse_array(str, |str| {
Ok(serde_json::from_str::<serde_json::Value>(str)?)
})?,
Type::OID => Value::Number(convert_into(str.parse::<u32>()?)),
Type::OID_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<u32>()?)))
})?,
_ => Value::String(str.to_string()),
};
Ok(value)
}
fn parse_array<P>(str: &str, mut parse: P) -> Result<Value, ConverterError>
where
P: FnMut(&str) -> Result<Value, ConverterError>,
{
if str.len() < 2 {
return Err(ArrayParseError::InputTooShort.into());
}
if !str.starts_with('{') || !str.ends_with('}') {
return Err(ArrayParseError::MissingBraces.into());
}
let mut res = vec![];
let str = &str[1..(str.len() - 1)];
let mut val_str = String::with_capacity(10);
let mut in_quotes = false;
let mut in_escape = false;
let mut chars = str.chars();
let mut done = str.is_empty();
while !done {
loop {
match chars.next() {
Some(c) => match c {
c if in_escape => {
val_str.push(c);
in_escape = false;
}
'"' => in_quotes = !in_quotes,
'\\' => in_escape = true,
',' if !in_quotes => {
break;
}
c => {
val_str.push(c);
}
},
None => {
done = true;
break;
}
}
}
let val = if val_str.to_lowercase() == "null" {
Value::Null
} else {
parse(&val_str)?
};
res.push(val);
val_str.clear();
}
let arr = Value::Array(res);
Ok(arr)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
use std::num::ParseIntError;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/hex.rs
*
*/
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ByteaHexParseError {
#[error("missing prefix '\\x'")]
InvalidPrefix,
#[error("invalid byte")]
OddNumerOfDigits,
#[error("parse int result: {0}")]
ParseInt(#[from] ParseIntError),
}
pub fn from_bytea_hex(s: &str) -> Result<Vec<u8>, ByteaHexParseError> {
if s.len() < 2 || &s[..2] != "\\x" {
return Err(ByteaHexParseError::InvalidPrefix);
}
let mut result = Vec::with_capacity((s.len() - 2) / 2);
let s = &s[2..];
if s.len() % 2 != 0 {
return Err(ByteaHexParseError::OddNumerOfDigits);
}
for i in (0..s.len()).step_by(2) {
let val = u8::from_str_radix(&s[i..i + 2], 16)?;
result.push(val);
}
Ok(result)
}
@@ -0,0 +1,441 @@
use std::{collections::HashMap, pin::Pin, sync::Arc};
use bytes::{BufMut, Bytes, BytesMut};
use chrono::TimeZone;
use futures::{pin_mut, SinkExt};
use pg_escape::{quote_identifier, quote_literal};
use rust_postgres::{Client, CopyBothDuplex, SimpleQueryMessage};
use tokio::sync::RwLock;
use tokio_stream::StreamExt;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
worker::to_raw_value,
DB,
};
use crate::{
resource_ext::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
postgres::{
drop_publication, get_default_pg_connection, get_raw_postgres_connection,
handler::drop_logical_replication_slot,
relation::RelationConverter,
replication_message::{
LogicalReplicationMessage::{
Begin, Commit, Delete, Insert, Relation, Type, Update,
},
PrimaryKeepAliveBody, ReplicationMessage,
},
Postgres, PostgresConfig, PostgresTrigger, ERROR_PUBLICATION_NAME_NOT_EXISTS,
},
trigger_helpers::TriggerJobArgs,
Listener,
},
};
const ERROR_REPLICATION_SLOT_NOT_EXISTS: &str = r#"The replication slot associated with this trigger no longer exists. Recreate a new replication slot or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
pub struct LogicalReplicationSettings {
pub streaming: bool,
}
impl LogicalReplicationSettings {
pub fn new(streaming: bool) -> Self {
Self { streaming }
}
}
pub struct PostgresSimpleClient(Client);
trait RowExist {
fn row_exist(&self) -> bool;
}
impl RowExist for Vec<SimpleQueryMessage> {
fn row_exist(&self) -> bool {
self.iter()
.find_map(|element| {
if let SimpleQueryMessage::CommandComplete(value) = element {
Some(*value)
} else {
None
}
})
.is_some_and(|value| value > 0)
}
}
impl PostgresSimpleClient {
async fn new(database: &Postgres) -> Result<Self> {
let client = get_raw_postgres_connection(database, true).await?;
Ok(PostgresSimpleClient(client))
}
async fn execute_query(
&self,
query: &str,
) -> std::result::Result<Vec<SimpleQueryMessage>, rust_postgres::Error> {
self.0.simple_query(query).await
}
async fn get_logical_replication_stream(
&self,
publication_name: &str,
logical_replication_slot_name: &str,
) -> Result<(CopyBothDuplex<Bytes>, LogicalReplicationSettings)> {
let options = format!(
r#"("proto_version" '2', "publication_names" {})"#,
quote_literal(publication_name),
);
let query = format!(
r#"START_REPLICATION SLOT {} LOGICAL 0/0 {}"#,
quote_identifier(logical_replication_slot_name),
options
);
Ok((
self.0
.copy_both_simple::<bytes::Bytes>(query.as_str())
.await
.map_err(to_anyhow)?,
LogicalReplicationSettings::new(false),
))
}
async fn send_status_update(
primary_keep_alive: PrimaryKeepAliveBody,
copy_both_stream: &mut Pin<&mut CopyBothDuplex<Bytes>>,
) {
let mut buf = BytesMut::new();
let ts = chrono::Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
let ts = chrono::Utc::now()
.signed_duration_since(ts)
.num_microseconds()
.unwrap_or(0);
buf.put_u8(b'r');
buf.put_u64(primary_keep_alive.wal_end);
buf.put_u64(primary_keep_alive.wal_end);
buf.put_u64(primary_keep_alive.wal_end);
buf.put_i64(ts);
buf.put_u8(0);
copy_both_stream.send(buf.freeze()).await.unwrap();
tracing::debug!("Send update status message");
}
}
#[async_trait::async_trait]
impl Listener for PostgresTrigger {
type Consumer = (CopyBothDuplex<Bytes>, LogicalReplicationSettings);
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Postgres;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let ListeningTrigger::<Self::TriggerConfig> { workspace_id, trigger_config, .. } =
listening_trigger;
let PostgresConfig {
postgres_resource_path, publication_name, replication_slot_name, ..
} = trigger_config;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let database = try_get_resource_from_db_as::<Postgres>(
&authed,
Some(UserDB::new(db.clone())),
&db,
postgres_resource_path,
workspace_id,
)
.await?;
let client = PostgresSimpleClient::new(&database).await?;
let publication = client
.execute_query(&format!(
"SELECT pubname FROM pg_publication WHERE pubname = {}",
quote_literal(&publication_name)
))
.await
.map_err(to_anyhow)?;
if !publication.row_exist() {
return Err(Error::BadConfig(
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
));
}
let replication_slot = client
.execute_query(&format!(
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}",
quote_literal(&replication_slot_name)
))
.await
.map_err(to_anyhow)?;
if !replication_slot.row_exist() {
return Err(Error::BadConfig(
ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(),
));
}
let (logical_replication_stream, logical_replication_settings) = client
.get_logical_replication_stream(&publication_name, &replication_slot_name)
.await
.map_err(to_anyhow)?;
Ok(Some((
logical_replication_stream,
logical_replication_settings,
)))
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let (logical_replication_stream, logical_replication_settings) = consumer;
pin_mut!(logical_replication_stream);
let mut relations = RelationConverter::new();
tracing::info!(
"Starting to listen for postgres trigger {}",
&listening_trigger.path
);
loop {
let message = logical_replication_stream.next().await;
let message = match message {
Some(message) => message,
None => {
tracing::error!(
"Stream for postgres trigger {} closed",
&listening_trigger.path
);
if let None = self
.update_ping_and_loop_ping_status(
db,
listening_trigger,
err_message.clone(),
Some("Stream closed".to_string()),
)
.await
{
return;
}
return;
}
};
let message = match message {
Ok(message) => message,
Err(err) => {
let err = format!(
"Postgres trigger named {} had an error while receiving a message : {}",
&listening_trigger.path,
err.to_string()
);
self.disable_with_error(db, listening_trigger, err).await;
return;
}
};
let logical_message = match ReplicationMessage::parse(message) {
Ok(logical_message) => logical_message,
Err(err) => {
let err = format!(
"Postgres trigger named: {} had an error while parsing message: {}",
&listening_trigger.path,
err.to_string()
);
self.disable_with_error(db, listening_trigger, err).await;
return;
}
};
match logical_message {
ReplicationMessage::PrimaryKeepAlive(primary_keep_alive) => {
if primary_keep_alive.reply {
PostgresSimpleClient::send_status_update(
primary_keep_alive,
&mut logical_replication_stream,
)
.await;
}
}
ReplicationMessage::XLogData(x_log_data) => {
let logical_replication_message = match x_log_data
.parse(&logical_replication_settings)
{
Ok(logical_replication_message) => logical_replication_message,
Err(err) => {
tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", &listening_trigger.path, err.to_string());
continue;
}
};
let json = match logical_replication_message {
Relation(relation_body) => {
relations.add_relation(relation_body);
None
}
Begin | Type | Commit => None,
Insert(insert) => Some((
insert.o_id,
Ok(None),
relations.row_to_json((insert.o_id, insert.tuple)),
"insert",
)),
Update(update) => {
let old_row = update
.old_tuple
.map(|old_tuple| relations.row_to_json((update.o_id, old_tuple)))
.transpose();
let row = relations.row_to_json((update.o_id, update.new_tuple));
Some((update.o_id, old_row, row, "update"))
}
Delete(delete) => {
let row = delete
.old_tuple
.unwrap_or_else(|| delete.key_tuple.unwrap());
Some((
delete.o_id,
Ok(None),
relations.row_to_json((delete.o_id, row)),
"delete",
))
}
};
match json {
Some((o_id, Ok(old_row), Ok(row), transaction_type)) => {
let relation = match relations.get_relation(o_id) {
Ok(relation) => relation,
Err(err) => {
tracing::error!(
"Postgres trigger named: {}, error: {}",
&listening_trigger.path,
err.to_string()
);
continue;
}
};
let database_info = HashMap::from([
("schema_name".to_string(), to_raw_value(&relation.namespace)),
("table_name".to_string(), to_raw_value(&relation.name)),
(
"transaction_type".to_string(),
to_raw_value(&transaction_type),
),
("old_row".to_string(), to_raw_value(&old_row)),
("row".to_string(), to_raw_value(&row)),
]);
let _ = self
.handle_event(
db,
listening_trigger,
database_info,
HashMap::new(),
None,
)
.await;
}
Some((o_id, old_row, row, transaction_type)) => {
let relation = match relations.get_relation(o_id) {
Ok(relation) => relation,
Err(err) => {
tracing::error!(
"Postgres trigger named: {}, error: {}",
&listening_trigger.path,
err.to_string()
);
continue;
}
};
if let Err(err) = old_row {
tracing::error!(
transaction_type = ?transaction_type,
schema = %relation.namespace,
table = %relation.name,
error = %err,
"Failed to decode OLD row for {} transaction on {}.{}",
transaction_type,
relation.namespace,
relation.name,
);
}
if let Err(err) = row {
tracing::error!(
transaction_type = ?transaction_type,
schema = %relation.namespace,
table = %relation.name,
error = %err,
"Failed to decode NEW row for {} transaction on {}.{}",
transaction_type,
relation.namespace,
relation.name,
);
}
}
_ => {}
}
}
}
}
}
async fn cleanup(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra_state: Option<&Self::ExtraState>,
) -> Result<()> {
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let user_db = UserDB::new(db.clone());
let mut pg_connection = get_default_pg_connection(
authed,
Some(user_db),
&db,
&listening_trigger.trigger_config.postgres_resource_path,
&listening_trigger.workspace_id,
)
.await?;
if listening_trigger.trigger_config.basic_mode.unwrap_or(false) {
drop_logical_replication_slot(
&mut pg_connection,
&listening_trigger.trigger_config.replication_slot_name,
)
.await?;
drop_publication(
&mut pg_connection,
&listening_trigger.trigger_config.publication_name,
)
.await?;
}
Ok(())
}
}
@@ -0,0 +1,136 @@
use std::collections::HashMap;
use rust_postgres::types::Type;
use super::Language;
fn postgres_to_typescript_type(postgres_type: Option<Type>) -> String {
let data_type = match postgres_type {
Some(postgres_type) => match postgres_type {
Type::BOOL => "boolean",
Type::BOOL_ARRAY => "Array<boolean>",
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => "string",
Type::CHAR_ARRAY
| Type::BPCHAR_ARRAY
| Type::VARCHAR_ARRAY
| Type::NAME_ARRAY
| Type::TEXT_ARRAY => "Array<string>",
Type::INT2 | Type::INT4 | Type::INT8 | Type::NUMERIC => "number",
Type::INT2_ARRAY | Type::INT4_ARRAY | Type::INT8_ARRAY => "Array<number>",
Type::FLOAT4 | Type::FLOAT8 => "number",
Type::FLOAT8_ARRAY | Type::FLOAT4_ARRAY => "Array<number>",
Type::NUMERIC_ARRAY => "Array<number>",
Type::BYTEA => "Array<number>",
Type::BYTEA_ARRAY => "Array<Array<number>>",
Type::DATE => "string",
Type::DATE_ARRAY => "Array<string>",
Type::TIME => "string",
Type::TIME_ARRAY => "Array<string>",
Type::TIMESTAMPTZ | Type::TIMESTAMP => "string",
Type::TIMESTAMPTZ_ARRAY | Type::TIMESTAMP_ARRAY => "Array<string>",
Type::UUID => "string",
Type::UUID_ARRAY => "Array<string>",
Type::JSON | Type::JSONB | Type::JSON_ARRAY | Type::JSONB_ARRAY => "unknown",
Type::OID => "number",
Type::OID_ARRAY => "Array<number>",
_ => "string",
},
None => "string",
};
data_type.to_string()
}
fn into_body_struct(language: Language, mapped_info: Vec<MappingInfo>) -> String {
let mut block = String::new();
match language {
Language::Typescript => {
block.push_str("{\r\n");
for field in mapped_info {
let typescript_type = postgres_to_typescript_type(field.data_type);
let mut key = field.column_name;
if field.is_nullable {
key.push('?');
}
let full_field = format!("\t\t{}: {},\r\n", key, typescript_type);
block.push_str(&full_field);
}
block.push_str("\t}");
}
}
block
}
#[derive(Debug)]
pub struct MappingInfo {
data_type: Option<Type>,
is_nullable: bool,
column_name: String,
}
impl MappingInfo {
pub fn new(column_name: String, data_type: Option<Type>, is_nullable: bool) -> Self {
Self { column_name, data_type, is_nullable }
}
}
pub struct Mapper {
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
language: Language,
}
impl Mapper {
pub fn new(
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
language: Language,
) -> Self {
Self { to_template, language }
}
fn into_typescript_template(self) -> Vec<String> {
let mut struct_definitions = Vec::new();
for (_, mapping_info) in self.to_template {
let last_elem = mapping_info.len() - 1;
for (i, (_, mapped_info)) in mapping_info.into_iter().enumerate() {
let mut struct_body = into_body_struct(Language::Typescript, mapped_info);
let struct_body = if i != last_elem {
struct_body.push_str("\r\n");
struct_body
} else {
struct_body
};
struct_definitions.push(struct_body);
}
}
struct_definitions
}
pub fn get_template(self) -> String {
let struct_definition = match self.language {
Language::Typescript => self.into_typescript_template(),
};
let struct_definition = if struct_definition.is_empty() {
"any".to_string()
} else {
struct_definition.join("\t| ")
};
format!(
r#"
export async function main(
transaction_type: "insert" | "update" | "delete",
schema_name: string,
table_name: string,
row: {},
old_row?: {}
) {{
}}
"#,
&struct_definition,
&struct_definition
)
}
}
@@ -0,0 +1,554 @@
use std::collections::HashMap;
use crate::{
resource_ext::try_get_resource_from_db_as,
triggers::trigger_helpers::TriggerJobArgs,
};
use windmill_api_auth::ApiAuthed;
use windmill_common::DB;
use chrono::Utc;
use itertools::Itertools;
use native_tls::{Certificate, TlsConnector};
use pg_escape::quote_identifier;
use rand::Rng;
use rust_postgres::{config::SslMode, Client, Config, NoTls};
use rust_postgres_native_tls::MakeTlsConnector;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::value::RawValue;
use sqlx::FromRow;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
triggers::TriggerKind,
utils::empty_as_none,
};
mod bool;
mod converter;
pub mod handler;
mod hex;
pub mod listener;
mod mapper;
mod relation;
mod replication_message;
#[derive(Clone, Copy)]
pub struct PostgresTrigger;
impl TriggerJobArgs for PostgresTrigger {
type Payload = HashMap<String, Box<RawValue>>;
const TRIGGER_KIND: TriggerKind = TriggerKind::Postgres;
fn v1_payload_fn(payload: &HashMap<String, Box<RawValue>>) -> HashMap<String, Box<RawValue>> {
payload.to_owned()
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct PostgresConfig {
pub postgres_resource_path: String,
pub replication_slot_name: String,
pub publication_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub basic_mode: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostgresConfigRequest {
postgres_resource_path: String,
#[serde(default)]
replication_slot_name: String,
#[serde(default)]
publication_name: String,
publication: Option<PublicationData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestPostgresConfig {
pub postgres_resource_path: String,
}
fn check_if_valid_relation<'de, D>(
relations: D,
) -> std::result::Result<Option<Vec<Relations>>, D::Error>
where
D: Deserializer<'de>,
{
let relations: Option<Vec<Relations>> = Option::deserialize(relations)?;
let mut track_all_table_in_schema = false;
let mut track_specific_columns_in_table = false;
match relations {
Some(relations) => {
for relation in relations.iter() {
if relation.schema_name.is_empty() {
return Err(serde::de::Error::custom(
"Schema Name must not be empty".to_string(),
));
}
if !track_all_table_in_schema && relation.table_to_track.is_empty() {
track_all_table_in_schema = true;
continue;
}
for table_to_track in relation.table_to_track.iter() {
if table_to_track.table_name.trim().is_empty() {
return Err(serde::de::Error::custom(
"Table name must not be empty".to_string(),
));
}
if !track_specific_columns_in_table && table_to_track.columns_name.is_some() {
track_specific_columns_in_table = true;
}
}
if track_all_table_in_schema && track_specific_columns_in_table {
return Err(serde::de::Error::custom("Incompatible tracking options. Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations."));
}
}
if !relations
.iter()
.map(|relation| relation.schema_name.as_str())
.all_unique()
{
return Err(serde::de::Error::custom(
"You cannot choose a schema more than one time".to_string(),
));
}
Ok(Some(relations))
}
None => Ok(None),
}
}
fn check_if_valid_transaction_type<'de, D>(
transaction_type: D,
) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let mut transaction_type: Vec<String> = Vec::deserialize(transaction_type)?;
if transaction_type.len() > 3 {
return Err(serde::de::Error::custom(
"More than 3 transaction type which is not authorized, you are only allowed to those 3 transaction types: Insert, Update and Delete"
.to_string(),
));
}
transaction_type.sort_unstable();
transaction_type.dedup();
for transaction in transaction_type.iter() {
match transaction.to_lowercase().as_ref() {
"insert" => {},
"update" => {},
"delete" => {},
_ => {
return Err(serde::de::Error::custom(
"Only the following transaction types are allowed: Insert, Update and Delete (case insensitive)"
.to_string(),
))
}
}
}
Ok(transaction_type)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PublicationData {
#[serde(default, deserialize_with = "check_if_valid_relation")]
pub table_to_track: Option<Vec<Relations>>,
#[serde(deserialize_with = "check_if_valid_transaction_type")]
pub transaction_to_track: Vec<String>,
}
impl PublicationData {
pub fn new(
table_to_track: Option<Vec<Relations>>,
transaction_to_track: Vec<String>,
) -> PublicationData {
PublicationData { table_to_track, transaction_to_track }
}
}
// Slot list struct
#[derive(FromRow, Debug, Serialize)]
pub struct SlotList {
pub slot_name: Option<String>,
pub active: Option<bool>,
}
// Slot struct
#[derive(Debug, Serialize, Deserialize)]
pub struct Slot {
pub name: String,
}
// Template script struct
#[derive(Debug, Deserialize)]
pub struct TemplateScript {
pub postgres_resource_path: String,
#[serde(deserialize_with = "check_if_valid_relation")]
pub relations: Option<Vec<Relations>>,
pub language: Language,
}
// Language enum
#[derive(Deserialize, Debug)]
pub enum Language {
#[serde(rename = "typescript", alias = "Typescript")]
Typescript,
}
// Test postgres struct
#[derive(Serialize, Deserialize)]
pub struct TestPostgres {
pub postgres_resource_path: String,
}
// PostgreSQL publication replication struct
#[derive(Serialize, Deserialize)]
pub struct PostgresPublicationReplication {
pub publication_name: String,
pub replication_slot_name: String,
}
impl PostgresPublicationReplication {
pub fn new(
publication_name: String,
replication_slot_name: String,
) -> PostgresPublicationReplication {
PostgresPublicationReplication { publication_name, replication_slot_name }
}
}
pub const ERROR_PUBLICATION_NAME_NOT_EXISTS: &str = r#"The publication associated with this trigger no longer exists. Recreate a new publication or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
#[derive(FromRow, Serialize, Deserialize, Debug)]
pub struct Postgres {
pub user: String,
pub password: String,
pub host: String,
pub port: Option<u16>,
pub dbname: String,
#[serde(default)]
pub sslmode: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub root_certificate_pem: Option<String>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct TableToTrack {
pub table_name: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub where_clause: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
pub columns_name: Option<Vec<String>>,
}
impl TableToTrack {
pub fn new(
table_name: String,
where_clause: Option<String>,
columns_name: Option<Vec<String>>,
) -> TableToTrack {
TableToTrack { table_name, where_clause, columns_name }
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct Relations {
pub schema_name: String,
pub table_to_track: Vec<TableToTrack>,
}
impl Relations {
pub fn new(schema_name: String, table_to_track: Vec<TableToTrack>) -> Relations {
Relations { schema_name, table_to_track }
}
pub fn add_new_table(&mut self, table_to_track: TableToTrack) {
self.table_to_track.push(table_to_track);
}
}
fn build_tls_connector(
ssl_mode: SslMode,
root_certificate_pem: Option<&String>,
) -> Result<Option<MakeTlsConnector>> {
let get_tls_builder_for_verify = |root_certificate: Option<&String>| {
let mut builder = TlsConnector::builder();
if let Some(root_certificate) = root_certificate {
let root_certificate_pem =
Certificate::from_pem(root_certificate.as_bytes()).map_err(to_anyhow)?;
builder.add_root_certificate(root_certificate_pem);
}
Ok::<_, Error>(builder)
};
let connector = match ssl_mode {
SslMode::Disable => return Ok(None),
SslMode::Require | SslMode::Prefer => {
let mut builder = TlsConnector::builder();
builder.danger_accept_invalid_certs(true);
builder.danger_accept_invalid_hostnames(true);
builder
}
SslMode::VerifyCa => {
let mut builder = get_tls_builder_for_verify(root_certificate_pem)?;
builder.danger_accept_invalid_hostnames(true);
builder
}
SslMode::VerifyFull => {
let builder = get_tls_builder_for_verify(root_certificate_pem)?;
builder
}
_ => unreachable!(),
};
Ok(Some(MakeTlsConnector::new(
connector.build().map_err(to_anyhow)?,
)))
}
pub async fn get_raw_postgres_connection(
database: &Postgres,
logical_mode: bool,
) -> Result<Client> {
let ssl_mode = match database.sslmode.as_ref() {
"disable" => SslMode::Disable,
"" | "prefer" | "allow" => SslMode::Prefer,
"require" => SslMode::Require,
"verify-ca" => SslMode::VerifyCa,
"verify-full" => SslMode::VerifyFull,
ssl_mode => {
return Err(Error::BadRequest(
format!("Invalid ssl mode for postgres: {}, please put a valid ssl_mode among the following available ssl mode: ['disable', 'allow', 'prefer', 'verify-ca', 'verify-full']", ssl_mode),
))
}
};
let mut config = Config::new();
config
.dbname(&database.dbname)
.host(&database.host)
.user(&database.user)
.ssl_mode(ssl_mode);
if logical_mode {
config.replication_mode(rust_postgres::config::ReplicationMode::Logical);
}
if let Some(port) = database.port {
config.port(port);
};
if !database.password.is_empty() {
config.password(&database.password);
}
let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?;
let client = if let Some(connector) = connector {
let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?;
tokio::spawn(async move {
tracing::info!("Successfully connected to PostgreSQL database for trigger execution");
if let Err(e) = connection.await {
tracing::debug!("Error during PostgreSQL trigger connection: {:#?}", e);
};
tracing::info!("PostgreSQL trigger connection closed");
});
client
} else {
let (client, connection) = config.connect(NoTls).await.map_err(to_anyhow)?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::debug!("{:#?}", e);
};
tracing::info!("Successfully Connected into database");
});
client
};
Ok(client)
}
pub async fn get_pg_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
logical_mode: bool,
) -> Result<Client> {
let database =
try_get_resource_from_db_as::<Postgres>(&authed, user_db, db, postgres_resource_path, w_id)
.await?;
Ok(get_raw_postgres_connection(&database, logical_mode).await?)
}
pub async fn get_default_pg_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
) -> Result<Client> {
get_pg_connection(authed, user_db, db, postgres_resource_path, w_id, false).await
}
pub async fn create_logical_replication_slot(tx: &Client, slot_name: &str) -> Result<()> {
tx.execute(
&format!("SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"),
&[&slot_name],
)
.await
.map_err(to_anyhow)?;
Ok(())
}
pub async fn check_if_valid_publication_for_postgres_version(
pg_connection: &Client,
table_to_track: Option<&[Relations]>,
) -> Result<bool> {
use crate::triggers::postgres::handler::get_postgres_version_internal;
let postgres_version = get_postgres_version_internal(pg_connection).await?;
let pg_14 = postgres_version.starts_with("14");
if pg_14 {
let unsupported_publication = table_to_track
.and_then(|relations| {
relations.iter().find(|relation| {
let invalid_relation = relation.table_to_track.iter().find(|table_to_track| {
table_to_track.where_clause.is_some()
|| table_to_track.columns_name.is_some()
});
relation.table_to_track.is_empty() || invalid_relation.is_some()
})
})
.is_some();
if unsupported_publication {
return Err(Error::BadRequest(
"Your PostgreSQL database is running version 14, which does not support the following publication features: \
- WHERE clause filtering, \
- selective column tracking, and \
- tracking all tables within a schema.\n\
These features are only available in PostgreSQL 15 and above.".to_string(),
));
}
}
Ok(pg_14)
}
pub async fn create_pg_publication(
pg_connection: &Client,
publication_name: &str,
table_to_track: Option<&[Relations]>,
transaction_to_track: &[String],
) -> Result<()> {
let pg_14 =
check_if_valid_publication_for_postgres_version(pg_connection, table_to_track).await?;
let mut query = String::from("CREATE PUBLICATION ");
query.push_str(&quote_identifier(publication_name));
match table_to_track {
Some(database_component) if !database_component.is_empty() => {
query.push_str(" FOR");
let mut first = true;
for (i, schema) in database_component.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push_str(" TABLES IN SCHEMA ");
query.push_str(&quote_identifier(&schema.schema_name));
} else {
if pg_14 && first {
query.push_str(" TABLE ONLY ");
first = false
} else if !pg_14 {
query.push_str(" TABLE ONLY ");
}
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", &schema_name, &table_name);
query.push_str(&full_name);
if let Some(columns) = table.columns_name.as_ref() {
query.push_str(" (");
let columns = columns
.iter()
.map(|column| quote_identifier(column))
.join(", ");
query.push_str(&columns);
query.push_str(")");
}
if let Some(where_clause) = &table.where_clause {
query.push_str(" WHERE (");
query.push_str(where_clause);
query.push(')');
}
if j + 1 != schema.table_to_track.len() {
query.push_str(", ");
}
}
}
if i < database_component.len() - 1 {
query.push_str(", ");
}
}
}
_ => {
query.push_str(" FOR ALL TABLES ");
}
};
if !transaction_to_track.is_empty() {
let transactions = || transaction_to_track.iter().join(", ");
query.push_str(" WITH (publish = '");
query.push_str(&transactions());
query.push_str("');");
}
pg_connection
.execute(&query, &[])
.await
.map_err(to_anyhow)?;
Ok(())
}
pub async fn drop_publication(pg_connection: &Client, publication_name: &str) -> Result<()> {
let mut query = String::from("DROP PUBLICATION IF EXISTS ");
let quoted_publication_name = quote_identifier(publication_name);
query.push_str(&quoted_publication_name);
pg_connection
.execute(&query, &[])
.await
.map_err(to_anyhow)?;
Ok(())
}
pub fn generate_random_string() -> String {
let timestamp = Utc::now().timestamp_millis().to_string();
let mut rng = rand::rng();
let charset = "abcdefghijklmnopqrstuvwxyz0123456789";
let random_part = (0..10)
.map(|_| {
charset
.chars()
.nth(rng.random_range(0..charset.len()))
.unwrap()
})
.collect::<String>();
format!("{}_{}", timestamp, random_part)
}
@@ -0,0 +1,74 @@
use core::str;
use serde_json::{Map, Value};
use std::{collections::HashMap, str::Utf8Error};
use super::{
converter::{Converter, ConverterError},
replication_message::{Columns, RelationBody, TupleData},
};
use rust_postgres::types::Oid;
#[derive(Debug, thiserror::Error)]
pub enum RelationConversionError {
#[error("Could not find matching table")]
FailToFindMatchingTable,
#[error("Binary data not supported")]
BinaryFormatNotSupported,
#[error("decode error: {0}")]
FromBytes(#[from] ConverterError),
#[error("invalid string value")]
InvalidStr(#[from] Utf8Error),
}
pub struct RelationConverter(HashMap<Oid, RelationBody>);
impl RelationConverter {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn add_relation(&mut self, relation: RelationBody) {
self.0.insert(relation.o_id, relation);
}
pub fn get_columns(&self, o_id: Oid) -> Result<&Columns, RelationConversionError> {
self.0
.get(&o_id)
.map(|relation_body| &relation_body.columns)
.ok_or(RelationConversionError::FailToFindMatchingTable)
}
pub fn get_relation(&self, o_id: Oid) -> Result<&RelationBody, RelationConversionError> {
self.0
.get(&o_id)
.ok_or(RelationConversionError::FailToFindMatchingTable)
}
pub fn row_to_json(
&self,
to_decode: (Oid, Vec<TupleData>),
) -> Result<Map<String, Value>, RelationConversionError> {
let (o_id, tuple_data) = to_decode;
let mut object: Map<String, Value> = Map::new();
let columns = self.get_columns(o_id)?;
for (i, column) in columns.iter().enumerate() {
let value = match &tuple_data[i] {
TupleData::Null | TupleData::UnchangedToast => Value::Null,
TupleData::Binary(_) => {
return Err(RelationConversionError::BinaryFormatNotSupported)
}
TupleData::Text(bytes) => {
let str = str::from_utf8(&bytes[..])?;
Converter::try_from_str(column.type_o_id.clone(), str)?
}
};
object.insert(column.name.clone(), value);
}
Ok(object)
}
}
@@ -0,0 +1,510 @@
#![allow(unused)]
use core::str;
use std::{
cmp,
io::{self, Cursor, Read},
str::Utf8Error,
};
use byteorder::{BigEndian, ReadBytesExt};
use bytes::Bytes;
use rust_postgres::types::{Oid, Type};
use thiserror::Error;
use super::listener::LogicalReplicationSettings;
const PRIMARY_KEEPALIVE_BYTE: u8 = b'k';
const X_LOG_DATA_BYTE: u8 = b'w';
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/cdc_event.rs
*
*/
#[derive(Debug)]
pub struct PrimaryKeepAliveBody {
pub wal_end: u64,
pub timestamp: i64,
pub reply: bool,
}
impl PrimaryKeepAliveBody {
pub fn new(wal_end: u64, timestamp: i64, reply: bool) -> PrimaryKeepAliveBody {
PrimaryKeepAliveBody { wal_end, timestamp, reply }
}
}
const BEGIN_BYTE: u8 = b'B';
const COMMIT_BYTE: u8 = b'C';
const ORIGIN_BYTE: u8 = b'O';
const RELATION_BYTE: u8 = b'R';
const TYPE_BYTE: u8 = b'Y';
const INSERT_BYTE: u8 = b'I';
const UPDATE_BYTE: u8 = b'U';
const DELETE_BYTE: u8 = b'D';
const TUPLE_NEW_BYTE: u8 = b'N';
const TUPLE_KEY_BYTE: u8 = b'K';
const TUPLE_OLD_BYTE: u8 = b'O';
const TUPLE_DATA_NULL_BYTE: u8 = b'n';
const TUPLE_DATA_TOAST_BYTE: u8 = b'u';
const TUPLE_DATA_TEXT_BYTE: u8 = b't';
const TUPLE_DATA_BINARY_BYTE: u8 = b'b';
const REPLICA_IDENTITY_DEFAULT_BYTE: i8 = 0x64;
const REPLICA_IDENTITY_NOTHING_BYTE: i8 = 0x6E;
const REPLICA_IDENTITY_FULL_BYTE: i8 = 0x66;
const REPLICA_IDENTITY_INDEX_BYTE: i8 = 0x69;
#[derive(Debug)]
pub enum ReplicaIdentity {
Default,
Nothing,
Full,
Index,
}
#[derive(Debug)]
pub struct Column {
pub flags: i8,
pub name: String,
pub type_o_id: Option<Type>,
pub type_modifier: i32,
}
impl Column {
pub fn new(flags: i8, name: String, type_o_id: Option<Type>, type_modifier: i32) -> Self {
Self { flags, name, type_o_id, type_modifier }
}
}
pub type Columns = Vec<Column>;
#[derive(Debug)]
pub struct RelationBody {
pub transaction_id: Option<i32>,
pub o_id: Oid,
pub namespace: String,
pub name: String,
pub replica_identity: ReplicaIdentity,
pub columns: Columns,
}
impl RelationBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
namespace: String,
name: String,
replica_identity: ReplicaIdentity,
columns: Columns,
) -> Self {
Self { transaction_id, o_id, namespace, name, replica_identity, columns }
}
}
#[derive(Debug)]
pub struct InsertBody {
pub transaction_id: Option<i32>,
pub o_id: Oid,
pub tuple: Vec<TupleData>,
}
impl InsertBody {
pub fn new(transaction_id: Option<i32>, o_id: Oid, tuple: Vec<TupleData>) -> Self {
Self { transaction_id, o_id, tuple }
}
}
#[derive(Debug)]
pub struct UpdateBody {
transaction_id: Option<i32>,
pub o_id: Oid,
pub old_tuple: Option<Vec<TupleData>>,
pub key_tuple: Option<Vec<TupleData>>,
pub new_tuple: Vec<TupleData>,
}
impl UpdateBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
old_tuple: Option<Vec<TupleData>>,
key_tuple: Option<Vec<TupleData>>,
new_tuple: Vec<TupleData>,
) -> Self {
Self { transaction_id, o_id, old_tuple, key_tuple, new_tuple }
}
}
#[derive(Debug)]
pub struct DeleteBody {
transaction_id: Option<i32>,
pub o_id: Oid,
pub old_tuple: Option<Vec<TupleData>>,
pub key_tuple: Option<Vec<TupleData>>,
}
impl DeleteBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
old_tuple: Option<Vec<TupleData>>,
key_tuple: Option<Vec<TupleData>>,
) -> Self {
Self { transaction_id, o_id, old_tuple, key_tuple }
}
}
#[derive(Debug)]
pub enum TupleData {
Null,
UnchangedToast,
Text(Bytes),
Binary(Bytes),
}
impl TupleData {
fn parse(buf: &mut Buffer) -> Result<Vec<TupleData>, ConversionError> {
let number_of_columns = buf.read_i16::<BigEndian>()?;
let mut tuples = Vec::with_capacity(number_of_columns as usize);
for _ in 0..number_of_columns {
let byte = buf.read_u8()?;
let tuple_data = match byte {
TUPLE_DATA_NULL_BYTE => TupleData::Null,
TUPLE_DATA_TOAST_BYTE => TupleData::UnchangedToast,
TUPLE_DATA_TEXT_BYTE => {
let len = buf.read_i32::<BigEndian>()?;
let mut data = vec![0; len as usize];
buf.read_exact(&mut data)?;
TupleData::Text(data.into())
}
TUPLE_DATA_BINARY_BYTE => {
let len = buf.read_i32::<BigEndian>()?;
let mut data = vec![0; len as usize];
buf.read_exact(&mut data)?;
TupleData::Binary(data.into())
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message byte `{}`", byte),
)));
}
};
tuples.push(tuple_data);
}
Ok(tuples)
}
}
#[derive(Debug)]
pub enum TransactionBody {
Insert(InsertBody),
Update(UpdateBody),
Delete(DeleteBody),
}
#[non_exhaustive]
#[derive(Debug)]
pub enum LogicalReplicationMessage {
Begin,
Commit,
Relation(RelationBody),
Type,
Insert(InsertBody),
Update(UpdateBody),
Delete(DeleteBody),
}
#[derive(Debug)]
pub struct XLogDataBody {
pub wal_start: u64,
pub wal_end: u64,
pub timestamp: i64,
pub data: Bytes,
}
#[derive(Error, Debug)]
pub enum ConversionError {
#[error("Error: {0}")]
Io(#[from] io::Error),
#[error("Utf8Error conversion: {0}")]
Utf8(#[from] Utf8Error),
}
struct Buffer {
bytes: Bytes,
idx: usize,
}
impl Buffer {
pub fn new(bytes: Bytes, idx: usize) -> Buffer {
Buffer { bytes, idx }
}
fn slice(&self) -> &[u8] {
&self.bytes[self.idx..]
}
fn read_cstr(&mut self) -> Result<String, ConversionError> {
match self.slice().iter().position(|&x| x == 0) {
Some(pos) => {
let start = self.idx;
let end = start + pos;
let cstr = str::from_utf8(&self.bytes[start..end])?.to_owned();
self.idx = end + 1;
Ok(cstr)
}
None => Err(ConversionError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF",
))),
}
}
}
impl Read for Buffer {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let len = {
let slice = self.slice();
let len = cmp::min(slice.len(), buf.len());
buf[..len].copy_from_slice(&slice[..len]);
len
};
self.idx += len;
Ok(len)
}
}
impl XLogDataBody {
pub fn new(wal_start: u64, wal_end: u64, timestamp: i64, data: Bytes) -> XLogDataBody {
XLogDataBody { wal_start, wal_end, timestamp, data }
}
pub fn parse(
self,
logical_replication_settings: &LogicalReplicationSettings,
) -> Result<LogicalReplicationMessage, ConversionError> {
let mut buf = Buffer::new(self.data.clone(), 0);
let byte = buf.read_u8()?;
let logical_replication_message = match byte {
BEGIN_BYTE => {
buf.read_i64::<BigEndian>()?;
buf.read_i64::<BigEndian>()?;
buf.read_i32::<BigEndian>()?;
LogicalReplicationMessage::Begin
}
COMMIT_BYTE => {
buf.read_i8()?;
buf.read_u64::<BigEndian>()?;
buf.read_u64::<BigEndian>()?;
buf.read_i64::<BigEndian>()?;
LogicalReplicationMessage::Commit
}
RELATION_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let namespace = buf.read_cstr()?;
let name = buf.read_cstr()?;
let replica_identity = match buf.read_i8()? {
REPLICA_IDENTITY_DEFAULT_BYTE => ReplicaIdentity::Default,
REPLICA_IDENTITY_NOTHING_BYTE => ReplicaIdentity::Nothing,
REPLICA_IDENTITY_FULL_BYTE => ReplicaIdentity::Full,
REPLICA_IDENTITY_INDEX_BYTE => ReplicaIdentity::Index,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replica identity byte `{}`", byte),
)));
}
};
let num_of_column = buf.read_i16::<BigEndian>()?;
let mut columns = Vec::with_capacity(num_of_column as usize);
for _ in 0..num_of_column {
let flags = buf.read_i8()?;
let name = buf.read_cstr()?;
let o_id = buf.read_u32::<BigEndian>()?;
let type_modifier = buf.read_i32::<BigEndian>()?;
let type_o_id = Type::from_oid(o_id);
let column = Column::new(flags, name, type_o_id, type_modifier);
columns.push(column);
}
LogicalReplicationMessage::Relation(RelationBody::new(
transaction_id,
o_id,
namespace,
name,
replica_identity,
columns,
))
}
TYPE_BYTE => {
buf.read_u32::<BigEndian>()?;
buf.read_cstr()?;
buf.read_cstr()?;
LogicalReplicationMessage::Type
}
INSERT_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let byte = buf.read_u8()?;
let tuple = match byte {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unexpected tuple byte `{}`", byte),
)));
}
};
LogicalReplicationMessage::Insert(InsertBody::new(transaction_id, o_id, tuple))
}
UPDATE_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let byte = buf.read_u8()?;
let mut key_tuple = None;
let mut old_tuple = None;
let new_tuple = match byte {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
TUPLE_OLD_BYTE | TUPLE_KEY_BYTE => {
if byte == TUPLE_OLD_BYTE {
old_tuple = Some(TupleData::parse(&mut buf)?);
} else {
key_tuple = Some(TupleData::parse(&mut buf)?);
}
match buf.read_u8()? {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unexpected tuple byte `{}`", byte),
)));
}
}
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown tuple byte `{}`", byte),
)));
}
};
LogicalReplicationMessage::Update(UpdateBody::new(
transaction_id,
o_id,
old_tuple,
key_tuple,
new_tuple,
))
}
DELETE_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let tag = buf.read_u8()?;
let mut key_tuple = None;
let mut old_tuple = None;
match tag {
TUPLE_OLD_BYTE => old_tuple = Some(TupleData::parse(&mut buf)?),
TUPLE_KEY_BYTE => key_tuple = Some(TupleData::parse(&mut buf)?),
tag => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown tuple tag `{}`", tag),
)));
}
}
LogicalReplicationMessage::Delete(DeleteBody::new(
transaction_id,
o_id,
old_tuple,
key_tuple,
))
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message tag `{}`", byte),
)));
}
};
Ok(logical_replication_message)
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum ReplicationMessage {
XLogData(XLogDataBody),
PrimaryKeepAlive(PrimaryKeepAliveBody),
}
impl ReplicationMessage {
pub fn parse(buf: Bytes) -> io::Result<Self> {
let (byte, mut message) = buf.split_first().unwrap();
let replication_message = match *byte {
X_LOG_DATA_BYTE => {
let len = buf.len();
let wal_start = message.read_u64::<BigEndian>()?;
let wal_end = message.read_u64::<BigEndian>()?;
let timestamp = message.read_i64::<BigEndian>()?;
let len = len - message.len();
let data = buf.slice(len..);
ReplicationMessage::XLogData(XLogDataBody::new(wal_start, wal_end, timestamp, data))
}
PRIMARY_KEEPALIVE_BYTE => {
let wal_end = message.read_u64::<BigEndian>()?;
let timestamp = message.read_i64::<BigEndian>()?;
let reply = message.read_u8()?;
ReplicationMessage::PrimaryKeepAlive(PrimaryKeepAliveBody::new(
wal_end,
timestamp,
reply == 1,
))
}
byte => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message byte `{}`", byte),
));
}
};
Ok(replication_message)
}
}
@@ -0,0 +1,64 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::SqsTrigger,
crate::triggers::{Trigger, TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
#[cfg(not(feature = "private"))]
impl TriggerCrud for SqsTrigger {
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/sqs_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::SqsTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"SQS triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"SQS triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,41 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::SqsTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for SqsTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Sqs;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -0,0 +1,15 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct SqsTrigger;
@@ -0,0 +1,957 @@
use anyhow::Context;
use axum::response::IntoResponse;
use http::StatusCode;
use serde::Deserialize;
use serde_json::value::RawValue;
use sqlx::types::Json;
use std::collections::HashMap;
use std::future::Future;
use uuid::Uuid;
use windmill_common::{
db::{UserDB, UserDbWithAuthed},
error::Result,
flows::{FlowModuleValue, Retry},
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
jobs::{get_has_preprocessor_from_content_and_lang, script_path_to_payload, JobPayload},
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
triggers::{
HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind, TriggerMetadata,
RUNNABLE_FORMAT_VERSION_CACHE,
},
users::username_to_permissioned_as,
utils::StripPath,
worker::to_raw_value,
};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
#[cfg(feature = "enterprise")]
use crate::jobs_ext::check_license_key_valid;
use crate::jobs_ext::{
check_tag_available_for_workspace, delete_job_metadata_after_use,
push_flow_job_by_path_into_queue, push_script_job_by_path_into_queue, result_to_response,
run_wait_result_internal, RunJobQuery,
};
use windmill_api_auth::{scopes::check_scopes, ApiAuthed};
use windmill_common::utils::HTTP_CLIENT;
use windmill_common::DB;
struct ScriptInfo {
has_preprocessor: Option<bool>,
language: ScriptLang,
content: String,
schema: Option<sqlx::types::Json<PartialSchema>>,
}
#[derive(Debug, Deserialize)]
struct PropertyDefinition {
r#type: Option<Box<RawValue>>,
}
#[derive(Debug, Deserialize)]
struct PartialSchema {
properties: Option<HashMap<String, PropertyDefinition>>,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum RunnableId {
FlowId(FlowId),
ScriptId(ScriptId),
HubScript(String),
}
impl RunnableId {
pub fn from_script_hash(hash: ScriptHash) -> Self {
Self::ScriptId(ScriptId::ScriptHash(hash))
}
pub fn from_script_path(path: &str) -> Self {
if path.starts_with("hub/") {
Self::HubScript(path.to_string())
} else {
Self::ScriptId(ScriptId::ScriptPath(path.to_string()))
}
}
pub fn from_flow_path(path: &str) -> Self {
Self::FlowId(FlowId::FlowPath(path.to_string()))
}
pub fn from_flow_version(version: i64) -> Self {
Self::FlowId(FlowId::FlowVersion(version))
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum FlowId {
FlowPath(String),
FlowVersion(i64),
}
impl FlowId {
async fn get_flow_version_id(self, workspace_id: &str, db: &DB) -> Result<i64> {
let version_id = match self {
FlowId::FlowPath(path) => {
let info =
get_latest_flow_version_info_for_path(None, db, workspace_id, &path, true)
.await?;
info.version
}
FlowId::FlowVersion(version) => version,
};
Ok(version_id)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum ScriptId {
ScriptPath(String),
ScriptHash(ScriptHash),
}
impl ScriptId {
async fn get_script_hash(self, workspace_id: &str, db: &DB) -> Result<i64> {
let hash = match self {
ScriptId::ScriptPath(path) => {
let info = get_latest_deployed_hash_for_path(None, db.clone(), workspace_id, &path)
.await?;
info.hash
}
ScriptId::ScriptHash(hash) => hash.0,
};
Ok(hash)
}
}
async fn get_script_info(
db: &DB,
workspace_id: &str,
hash: i64,
) -> std::result::Result<ScriptInfo, sqlx::Error> {
sqlx::query_as!(ScriptInfo, "SELECT has_preprocessor, language as \"language: _\", content, schema as \"schema: _\" FROM script WHERE workspace_id = $1 AND hash = $2", workspace_id, hash)
.fetch_one(db)
.await
}
fn runnable_format_from_schema_without_preprocessor(
trigger_kind: &TriggerKind,
schema: Option<sqlx::types::Json<PartialSchema>>,
) -> RunnableFormat {
match trigger_kind {
TriggerKind::Mqtt
if schema.as_ref().is_some_and(|schema| {
schema.properties.as_ref().is_some_and(|properties| {
properties.iter().any(|(key, def)| {
key == "payload"
&& def.r#type.as_ref().is_some_and(|t| {
let typ = t.get().trim();
typ == "array" || (typ.starts_with('[') && typ.ends_with(']'))
})
})
})
}) =>
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
}
TriggerKind::Kafka | TriggerKind::Nats
if schema.as_ref().is_some_and(|schema| {
schema
.properties
.as_ref()
.is_some_and(|properties| properties.keys().any(|key| key == "msg"))
}) =>
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
}
_ => RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: false },
}
}
fn runnable_format_from_preprocessor_args(
args: Option<Vec<windmill_parser::Arg>>,
) -> RunnableFormat {
if let Some(args) = args {
if args.iter().any(|arg| arg.name == "wm_trigger")
|| (args.len() > 0 && args.iter().all(|arg| arg.name != "event"))
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: true }
} else {
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
}
} else {
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
}
}
enum PreprocessorInfo {
Preprocessor { content: String, language: ScriptLang },
NoPreprocessor { schema: Option<sqlx::types::Json<PartialSchema>> },
}
#[derive(Debug, Deserialize)]
struct FlowInfo {
preprocessor_module: Option<sqlx::types::Json<FlowModuleValue>>,
schema: Option<sqlx::types::Json<PartialSchema>>,
}
fn get_preprocessor_args_from_content_and_language(
content: &str,
language: &ScriptLang,
) -> Result<Option<Vec<windmill_parser::Arg>>> {
let args = match language {
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
let args = windmill_parser_ts::parse_deno_signature(
&content,
true,
false,
Some("preprocessor".to_string()),
)?;
Some(args.args)
}
ScriptLang::Python3 => {
let args = windmill_parser_py::parse_python_signature(
&content,
Some("preprocessor".to_string()),
false,
)?;
Some(args.args)
}
_ => None,
};
Ok(args)
}
pub async fn get_runnable_format(
runnable_id: RunnableId,
workspace_id: &str,
db: &DB,
trigger_kind: &TriggerKind,
) -> Result<RunnableFormat> {
let (key, preprocessor_info) = match runnable_id {
RunnableId::HubScript(path) => {
let Some(version) = path.split("/").nth(1) else {
return Err(windmill_common::error::Error::internal_err(
"Invalid hub script path".to_string(),
));
};
let version = match version.parse::<i64>() {
Ok(version) => version,
Err(_) => {
return Err(windmill_common::error::Error::internal_err(
"Invalid hub script version".to_string(),
));
}
};
let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone());
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for hub script {path}");
return Ok(runnable_format);
}
let hub_script =
get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db))
.await?;
let has_preprocessor = get_has_preprocessor_from_content_and_lang(
&hub_script.content,
&hub_script.language,
)?;
let partial_schema = serde_json::from_str(hub_script.schema.get())?;
(
key,
if has_preprocessor {
PreprocessorInfo::Preprocessor {
content: hub_script.content,
language: hub_script.language,
}
} else {
PreprocessorInfo::NoPreprocessor {
schema: Some(sqlx::types::Json(partial_schema)),
}
},
)
}
RunnableId::FlowId(flow_id) => {
let version = flow_id.get_flow_version_id(workspace_id, db).await?;
let key = (
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
version,
trigger_kind.clone(),
);
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for flow version {version}");
return Ok(runnable_format);
}
let flow_info = sqlx::query_as!(
FlowInfo,
"SELECT
value->'preprocessor_module'->'value' as \"preprocessor_module: _\",
schema as \"schema: _\"
FROM flow_version
WHERE
id = $1
AND workspace_id = $2",
version,
workspace_id,
)
.fetch_one(db)
.await?;
if let Some(preprocessor_module) = flow_info.preprocessor_module {
match preprocessor_module.0 {
FlowModuleValue::RawScript { content, language, .. } => {
(key, PreprocessorInfo::Preprocessor { content, language })
}
FlowModuleValue::Script { path, hash, .. } => {
let hash = if let Some(hash) = hash {
hash.0
} else {
let script_hash = get_latest_deployed_hash_for_path(
None,
db.clone(),
workspace_id,
&path,
)
.await?;
script_hash.hash
};
let script_info = get_script_info(db, workspace_id, hash).await?;
(
key,
PreprocessorInfo::Preprocessor {
content: script_info.content,
language: script_info.language,
},
)
}
_ => {
return Err(windmill_common::error::Error::internal_err(
"Unsupported preprocessor module".to_string(),
));
}
}
} else {
(
key,
PreprocessorInfo::NoPreprocessor { schema: flow_info.schema },
)
}
}
RunnableId::ScriptId(script_id) => {
let hash = script_id.get_script_hash(workspace_id, db).await?;
let key = (
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
hash,
trigger_kind.clone(),
);
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for script {hash}");
return Ok(runnable_format);
}
let script_info = get_script_info(db, workspace_id, hash).await?;
if script_info.has_preprocessor.unwrap_or(false) {
(
key,
PreprocessorInfo::Preprocessor {
content: script_info.content,
language: script_info.language,
},
)
} else {
(
key,
PreprocessorInfo::NoPreprocessor { schema: script_info.schema },
)
}
}
};
let runnable_format = match preprocessor_info {
PreprocessorInfo::Preprocessor { content, language } => {
let args = get_preprocessor_args_from_content_and_language(&content, &language)?;
runnable_format_from_preprocessor_args(args)
}
PreprocessorInfo::NoPreprocessor { schema } => {
runnable_format_from_schema_without_preprocessor(trigger_kind, schema)
}
};
RUNNABLE_FORMAT_VERSION_CACHE.insert(key, runnable_format);
Ok(runnable_format)
}
#[allow(dead_code)]
pub trait TriggerJobArgs {
type Payload: Send + Sync;
const TRIGGER_KIND: TriggerKind;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>>;
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
Self::v1_payload_fn(payload)
}
fn build_job_args_v2(
has_preprocessor: bool,
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> PushArgsOwned {
let mut args = Self::v2_payload_fn(payload);
if has_preprocessor {
args.insert(
"kind".to_string(),
to_raw_value(&Self::TRIGGER_KIND.to_key()),
);
args.extend(info);
let args = HashMap::from([("event".to_string(), to_raw_value(&args))]);
PushArgsOwned { args, extra: None }
} else {
PushArgsOwned { args, extra: None }
}
}
fn build_job_args_v1(
has_preprocessor: bool,
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> PushArgsOwned {
let trigger_key = Self::TRIGGER_KIND.to_key();
let args = Self::v1_payload_fn(payload);
let extra = if has_preprocessor {
Some(HashMap::from([(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": trigger_key,
trigger_key: info
})),
)]))
} else {
None
};
PushArgsOwned { args, extra }
}
fn build_job_args(
runnable_path: &str,
is_flow: bool,
w_id: &str,
db: &DB,
payload: Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
async move {
let runnable_id = if is_flow {
RunnableId::from_flow_path(runnable_path)
} else {
RunnableId::from_script_path(runnable_path)
};
Self::build_job_args_from_runnable_id(runnable_id, w_id, db, payload, info).await
}
}
fn build_job_args_from_runnable_id(
runnable_id: RunnableId,
w_id: &str,
db: &DB,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
async move {
tracing::debug!("Building job args for {runnable_id:?}");
let runnable_format =
get_runnable_format(runnable_id, w_id, db, &Self::TRIGGER_KIND).await?;
let job_args = match runnable_format {
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor } => {
Self::build_job_args_v1(has_preprocessor, &payload, trigger_info)
}
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor } => {
Self::build_job_args_v2(has_preprocessor, &payload, trigger_info)
}
};
Ok(job_args)
}
}
fn build_capture_payloads(
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> (PushArgsOwned, PushArgsOwned) {
let main_args = Self::build_job_args_v2(false, payload, info.clone());
let preprocessor_args = Self::build_job_args_v2(true, payload, info);
(main_args, preprocessor_args)
}
}
#[allow(dead_code)]
pub async fn trigger_runnable_inner(
db: &DB,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<String>,
Option<sqlx::Transaction<'static, sqlx::Postgres>>,
)> {
let error_handler_args = error_handler_args.map(|args| {
let args = args
.0
.iter()
.map(|(key, value)| (key.to_owned(), to_raw_value(&value)))
.collect::<HashMap<String, Box<RawValue>>>();
Json(args)
});
let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone()));
let (uuid, delete_after_use, early_return, tx_out) = if is_flow {
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
let path = StripPath(runnable_path.to_string());
let (uuid, early_return, tx_out) = push_flow_job_by_path_into_queue(
authed,
db.clone(),
tx_o,
user_db,
workspace_id.to_string(),
path,
run_query,
args,
Some(trigger),
)
.await?;
(uuid, None, early_return, tx_out)
} else {
let (uuid, delete_after_use, tx_out) = trigger_script_internal(
db,
tx_o,
user_db,
authed,
workspace_id,
runnable_path,
args,
retry,
error_handler_path,
error_handler_args.as_ref(),
trigger_path,
job_id,
trigger,
suspended_mode,
)
.await?;
(uuid, delete_after_use, None, tx_out)
};
Ok((uuid, delete_after_use, early_return, tx_out))
}
#[allow(dead_code)]
pub async fn trigger_runnable(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
job_id: Option<Uuid>,
suspended_mode: bool,
trigger: TriggerMetadata,
) -> Result<axum::response::Response> {
let uuid = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
job_id,
trigger,
Some(suspended_mode),
)
.await?
.0;
Ok((StatusCode::CREATED, uuid.to_string()).into_response())
}
#[allow(dead_code)]
pub async fn trigger_runnable_and_wait_for_result(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<axum::response::Response> {
let username = authed.username.clone();
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
None,
trigger,
None,
)
.await?;
let (result, success) =
run_wait_result_internal(db, uuid, &workspace_id, early_return, &username).await?;
if delete_after_use.unwrap_or(false) {
delete_job_metadata_after_use(&db, uuid).await?;
}
result_to_response(result, success)
}
#[allow(dead_code)]
pub async fn trigger_runnable_and_wait_for_raw_result(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<(Box<RawValue>, bool)> {
let username = authed.username.clone();
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
None,
trigger,
None,
)
.await?;
let (result, success) =
run_wait_result_internal(db, uuid, &workspace_id, early_return, &username)
.await
.with_context(|| {
format!(
"Error fetching job result for {} {}",
if is_flow { "flow" } else { "script" },
runnable_path
)
})?;
if delete_after_use.unwrap_or(false) {
delete_job_metadata_after_use(&db, uuid).await?;
}
Ok((result, success))
}
pub async fn trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<Box<RawValue>> {
let (result, success) = trigger_runnable_and_wait_for_raw_result(
db,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
trigger,
)
.await?;
if !success {
Err(windmill_common::error::Error::internal_err(format!(
"{} {runnable_path} failed: {:?}",
if is_flow { "Flow" } else { "Script" },
result
)))
} else {
Ok(result)
}
}
async fn trigger_script_internal(
db: &DB,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
script_path: &str,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'static, sqlx::Postgres>>,
)> {
if retry.is_none() && error_handler_path.is_none() {
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
let path = StripPath(script_path.to_string());
let (uuid, delete_after_use, tx_out) = push_script_job_by_path_into_queue(
authed,
db.clone(),
tx_o,
user_db,
workspace_id.to_string(),
path,
run_query,
args,
Some(trigger),
)
.await?;
Ok((uuid, delete_after_use, tx_out))
} else {
let (uuid, delete_after_use, tx_out) = trigger_script_with_retry_and_error_handler(
db,
tx_o,
user_db,
authed,
workspace_id,
script_path,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
job_id,
trigger,
suspended_mode,
)
.await?;
Ok((uuid, delete_after_use, tx_out))
}
}
async fn trigger_script_with_retry_and_error_handler(
db: &DB,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
script_path: &str,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'static, sqlx::Postgres>>,
)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
let retry = retry.map(|r| r.0.clone());
let error_handler_path = error_handler_path.map(|p| p.to_string());
let error_handler_args = error_handler_args.map(|args| args.0.clone());
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = {
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
script_path_to_payload(
script_path,
Some(db_authed),
db.clone(),
&workspace_id,
Some(false),
)
.await?
};
check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?;
let return_tx = tx_o.is_some();
let (email, permissioned_as, push_authed, tx) = if let Some(tx) = tx_o {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Transaction(tx),
)
} else if let Some(on_behalf_of) = on_behalf_of.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let push_args = PushArgs { args: &args.args, extra: args.extra };
let retryable_job_payload = match job_payload {
JobPayload::ScriptHash {
hash,
path,
concurrency_settings,
debouncing_settings,
cache_ttl,
cache_ignore_s3_path,
priority,
apply_preprocessor,
..
} => JobPayload::SingleStepFlow {
path,
hash: Some(hash),
flow_version: None,
args: HashMap::from(&push_args),
retry,
error_handler_path,
error_handler_args,
skip_handler: None,
cache_ttl,
cache_ignore_s3_path,
priority,
tag_override: tag.clone(),
apply_preprocessor,
trigger_path: Some(trigger_path.clone()),
concurrency_settings,
debouncing_settings,
},
_ => {
return Err(windmill_common::error::Error::internal_err(format!(
"Unsupported job payload: {:?}",
job_payload
)))
}
};
let (uuid, tx) = push(
&db,
tx,
&workspace_id,
retryable_job_payload,
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
None,
None,
None,
job_id,
false,
false,
None,
true,
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
None,
Some(trigger),
suspended_mode,
)
.await?;
// If we were given a transaction, return it; otherwise commit it
if return_tx {
Ok((uuid, delete_after_use, Some(tx)))
} else {
tx.commit().await?;
Ok((uuid, delete_after_use, None))
}
}
@@ -0,0 +1,262 @@
use std::borrow::Cow;
use crate::triggers::{Trigger, TriggerCrud, TriggerData};
use windmill_api_auth::ApiAuthed;
use windmill_common::DB;
use axum::async_trait;
use itertools::Itertools;
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, PgConnection};
use tokio_tungstenite::connect_async;
use windmill_common::{
db::UserDB,
error::{Error, Result},
worker::to_raw_value,
};
use windmill_git_sync::DeployedObject;
use super::{
get_url_from_runnable_value, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest,
WebsocketTrigger,
};
#[async_trait]
impl TriggerCrud for WebsocketTrigger {
type TriggerConfig = WebsocketConfig;
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfigRequest = WebsocketConfigRequest;
type TestConnectionConfig = TestWebsocketConfig;
const TABLE_NAME: &'static str = "websocket_trigger";
const TRIGGER_TYPE: &'static str = "websocket";
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/websocket_triggers";
const DEPLOYMENT_NAME: &'static str = "WebSocket trigger";
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
"url",
"filters",
"initial_messages",
"url_runnable_args",
"can_return_message",
"can_return_error_result",
];
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::WebsocketTrigger { path }
}
async fn validate_config(
&self,
_db: &DB,
config: &Self::TriggerConfigRequest,
_workspace_id: &str,
) -> Result<()> {
if config.url.trim().is_empty() {
return Err(Error::BadRequest(
"WebSocket URL cannot be empty".to_string(),
));
}
if let Some(args) = &config.url_runnable_args {
if !args.is_object() {
return Err(Error::BadRequest(
"url_runnable_args must be an object".to_string(),
));
}
}
Ok(())
}
async fn create_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let filters = trigger
.config
.filters
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
let initial_messages = trigger
.config
.initial_messages
.unwrap_or_default()
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
workspace_id,
path,
url,
script_path,
is_flow,
mode,
filters,
initial_messages,
url_runnable_args,
edited_by,
can_return_message,
can_return_error_result,
email,
edited_at,
error_handler_path,
error_handler_args,
retry
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14, $15, $16
)
"#,
w_id,
trigger.base.path,
trigger.config.url,
trigger.base.script_path,
trigger.base.is_flow,
trigger.base.mode() as _,
&filters as _,
&initial_messages as _,
trigger
.config
.url_runnable_args
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap())) as _,
authed.username,
trigger.config.can_return_message,
trigger.config.can_return_error_result,
authed.email,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(&mut *tx)
.await?;
Ok(())
}
async fn update_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let filters = trigger
.config
.filters
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
let initial_messages = trigger
.config
.initial_messages
.unwrap_or_default()
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
// important to update server_id to NULL to stop current websocket listener
sqlx::query!(
"
UPDATE
websocket_trigger
SET
url = $1,
script_path = $2,
path = $3,
is_flow = $4,
filters = $5,
initial_messages = $6,
url_runnable_args = $7,
edited_by = $8,
email = $9,
can_return_message = $10,
can_return_error_result = $11,
edited_at = now(),
server_id = NULL,
error = NULL,
error_handler_path = $14,
error_handler_args = $15,
retry = $16
WHERE
workspace_id = $12 AND path = $13
",
trigger.config.url,
trigger.base.script_path,
trigger.base.path,
trigger.base.is_flow,
filters.as_slice() as &[SqlxJson<Box<RawValue>>],
initial_messages.as_slice() as &[SqlxJson<Box<RawValue>>],
trigger
.config
.url_runnable_args
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
as Option<SqlxJson<Box<RawValue>>>,
&authed.username,
&authed.email,
trigger.config.can_return_message,
trigger.config.can_return_error_result,
w_id,
path,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(&mut *tx)
.await?;
Ok(())
}
async fn test_connection(
&self,
db: &DB,
authed: &ApiAuthed,
_user_db: &UserDB,
workspace_id: &str,
config: Self::TestConnectionConfig,
) -> Result<()> {
let url = &config.url;
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
let path = url.splitn(2, ':').nth(1).unwrap();
Cow::Owned(
get_url_from_runnable_value(
path,
url.starts_with("$flow:"),
&db,
authed.clone(),
config.url_runnable_args.as_ref().map(to_raw_value).as_ref(),
&workspace_id,
)
.await?,
)
} else {
return Err(Error::BadConfig(format!(
"Invalid WebSocket runnable path: {}",
url
)));
}
} else {
Cow::Borrowed(&url)
};
connect_async(&*connect_url).await.map_err(|err| {
Error::BadConfig(format!(
"Error connecting to WebSocket: {}",
err.to_string()
))
})?;
Ok(())
}
}
@@ -0,0 +1,477 @@
use super::WebsocketTrigger;
use crate::triggers::{
filter::{is_value_superset, Filter, JsonFilter},
listener::ListeningTrigger,
trigger_helpers::{
trigger_runnable, trigger_runnable_and_wait_for_raw_result,
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
},
websocket::{get_url_from_runnable_value, WebsocketConfig},
Listener,
};
use anyhow::Context;
use async_trait::async_trait;
use futures::{stream::SplitSink, SinkExt, StreamExt};
use http::Response;
use itertools::Itertools;
use serde::Deserialize;
use serde_json::value::RawValue;
use std::{borrow::Cow, collections::HashMap, sync::Arc};
use tokio::{net::TcpStream, sync::RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use windmill_common::{
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
triggers::TriggerMetadata,
utils::report_critical_error,
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
impl ListeningTrigger<WebsocketConfig> {
async fn send_initial_messages(
&self,
writer: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
db: &DB,
) -> Result<()> {
let initial_messages: Vec<InitialMessage> = self
.trigger_config
.initial_messages
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec();
let WebsocketConfig { ref url, .. } = self.trigger_config;
let runnable_kind = if self.is_flow { "flow" } else { "script" };
let mut authed_o = None;
for start_message in initial_messages {
match start_message {
InitialMessage::RawMessage(msg) => {
let msg = if msg.starts_with("\"") && msg.ends_with("\"") {
msg[1..msg.len() - 1].to_string()
} else {
msg
};
tracing::info!(
"Sending raw message initial message to WebSocket {}: {}",
url,
msg
);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(msg))
.await
.map_err(to_anyhow)
.with_context(|| "failed to send raw message")?;
}
InitialMessage::RunnableResult { path, is_flow, args } => {
tracing::info!(
"Running {} {} for initial message to WebSocket {}",
runnable_kind,
path,
url,
);
let args = raw_value_to_args_hashmap(Some(&args))?;
if authed_o.is_none() {
authed_o = Some(self.authed(db, "ws").await?);
}
let authed = authed_o.clone().unwrap();
let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db,
None,
authed.clone(),
&self.workspace_id,
&path,
is_flow,
PushArgsOwned { args, extra: None },
None,
None,
None,
"".to_string(), // doesn't matter as no retry/error handler
TriggerMetadata::new(Some(self.path.to_owned()), JobTriggerKind::Websocket),
)
.await
.map(|r| r.get().to_owned())?;
tracing::info!(
"Sending {} {} result to WebSocket {}",
runnable_kind,
path,
url
);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(result))
.await
.map_err(to_anyhow)
.with_context(|| {
format!("Failed to send {} {} result", runnable_kind, path)
})?;
}
}
}
Ok(())
}
}
#[async_trait]
impl Listener for WebsocketTrigger {
type Consumer = (
WebSocketStream<MaybeTlsStream<TcpStream>>,
Response<Option<Vec<u8>>>,
);
type Extra = ReturnMessageChannels;
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Websocket;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let url = &listening_trigger.trigger_config.url;
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return Ok(None);
},
_ = self.loop_ping(&db, listening_trigger, err_message.clone(), Some(
"Waiting on runnable to return WebSocket URL...".to_string()
)) => {
return Ok(None);
},
url_result = {
let authed = listening_trigger.authed(db, "ws").await?;
let args = listening_trigger.trigger_config.url_runnable_args.as_ref().map(|r| &r.0);
let path = url.splitn(2, ':').nth(1).unwrap();
get_url_from_runnable_value(path, url.starts_with("$flow:"), db, authed, args, &listening_trigger.workspace_id)
} => match url_result {
Ok(url) => Cow::Owned(url),
Err(err) => {
return Err(anyhow::anyhow!("Error getting WebSocket URL from runnable after 5 tries: {:?}", err).into());
}
},
}
} else {
return Err(anyhow::anyhow!("Invalid WebSocket runnable path: {}", url).into());
}
} else {
Cow::Borrowed(&url)
};
let connection = connect_async(&*connect_url)
.await
.map(|conn| Some(conn))
.map_err(|err| to_anyhow(err).into());
connection
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let WebsocketConfig { ref url, .. } = listening_trigger.trigger_config;
tracing::info!("Connected to WebSocket {}", url);
let (ws_stream, _) = consumer;
let (mut writer, mut reader) = ws_stream.split();
// send initial messages
if listening_trigger.trigger_mode {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
},
_ = self.loop_ping(db, listening_trigger, err_message.clone(), Some("Sending initial messages...".to_string())) => {
return;
},
result = listening_trigger.send_initial_messages(&mut writer, &db) => {
if let Err(err) = result {
self.disable_with_error(&db, listening_trigger, format!("Error sending initial messages: {:?}", err)).await;
return
} else {
tracing::debug!("Initial messages sent successfully to WebSocket {}", url);
}
}
}
}
let (return_message_channels, message_sender_handle) = if listening_trigger.trigger_mode
&& listening_trigger.trigger_config.can_return_message
{
let (send_message_tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
let w_id = listening_trigger.workspace_id.clone();
let url = url.clone();
let db = db.clone();
let handle = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if let Err(err) = writer
.send(tokio_tungstenite::tungstenite::Message::Text(message))
.await
{
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db.clone(), Some(&w_id), None).await;
}
}
});
let killpill_rx = killpill_rx.resubscribe();
let return_message_channels = ReturnMessageChannels { send_message_tx, killpill_rx };
(Some(return_message_channels), Some(handle))
} else {
(None, None)
};
tokio::select! {
biased;
_ = killpill_rx.recv() => {
},
_ = self.loop_ping(db, listening_trigger, err_message.clone(), None) => {
},
_ = async {
let filters: Vec<Filter> = if listening_trigger.trigger_mode {
listening_trigger
.trigger_config
.filters
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec()
} else {
vec![]
};
loop {
if let Some(msg) = reader.next().await {
match msg {
Ok(msg) => {
match msg {
tokio_tungstenite::tungstenite::Message::Text(text) => {
tracing::debug!("Received text message from WebSocket {}: {}", url, text);
let mut should_handle = true;
for filter in &filters {
match filter {
Filter::JsonFilter(JsonFilter { key, value }) => {
let mut deserializer = serde_json::Deserializer::from_str(text.as_str());
should_handle = match is_value_superset(&mut deserializer, key, &value) {
Ok(filter_match) => {
filter_match
},
Err(err) => {
tracing::warn!("Error deserializing filter for WebSocket {}: {:?}", url, err);
false
}
};
}
}
if !should_handle {
break;
}
}
if should_handle {
let trigger_info = HashMap::from([
("url".to_string(), to_raw_value(&listening_trigger.trigger_config.url)),
]);
let _ = self.handle_event(db, listening_trigger, text, trigger_info, return_message_channels.clone()).await;
}
},
a @ _ => {
tracing::debug!("Received non text-message from WebSocket {}: {:?}", url, a);
}
}
},
Err(err) => {
tracing::error!("Error reading from WebSocket {}: {:?}", url, err);
}
}
} else {
tracing::error!("WebSocket {} closed", url);
self.update_ping_and_loop_ping_status(db, listening_trigger, err_message.clone(), Some("WebSocket closed".to_string())).await;
break;
}
}
} => {}
}
// make sure to stop return message handler
if let Some(message_sender_handle) = message_sender_handle {
message_sender_handle.abort();
}
}
async fn handle_trigger(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
extra: Option<Self::Extra>,
) -> Result<()> {
let ListeningTrigger {
path,
is_flow,
workspace_id,
trigger_config,
script_path,
error_handling,
suspended_mode,
..
} = listening_trigger;
let WebsocketConfig { url, .. } = trigger_config;
let args = WebsocketTrigger::build_job_args(
&script_path,
*is_flow,
workspace_id,
db,
payload,
trigger_info,
)
.await;
let args = match args {
Ok(args) => args,
Err(err) => {
return Err(err);
}
};
let authed = listening_trigger.authed(db, "ws").await?;
let (retry, error_handler_path, error_handler_args) = match error_handling.as_ref() {
Some(error_handling) => (
error_handling.retry.as_ref(),
error_handling.error_handler_path.as_deref(),
error_handling.error_handler_args.as_ref(),
),
None => (None, None, None),
};
let trigger = TriggerMetadata::new(Some(path.to_owned()), Self::JOB_TRIGGER_KIND);
if *suspended_mode || extra.is_none() {
trigger_runnable(
db,
None,
authed,
&workspace_id,
&script_path,
*is_flow,
args,
retry,
error_handler_path,
error_handler_args,
format!("websocket_trigger/{}", listening_trigger.path),
None,
*suspended_mode,
trigger,
)
.await?;
} else if let Some(ReturnMessageChannels { send_message_tx, mut killpill_rx }) = extra {
let db_ = db.clone();
let url = url.to_owned();
let script_path = script_path.to_owned();
let is_flow = *is_flow;
let w_id = workspace_id.to_owned();
let retry = retry.cloned();
let error_handler_path = error_handler_path.map(|s| s.to_string());
let error_handler_args = error_handler_args.cloned();
let trigger_path = path.clone();
let can_return_error_result = trigger_config.can_return_error_result;
let handle_response_f = async move {
tokio::select! {
_ = killpill_rx.recv() => {
return;
},
result = trigger_runnable_and_wait_for_raw_result(
&db_,
None,
authed,
&w_id,
&script_path,
is_flow,
args,
retry.as_ref(),
error_handler_path.as_deref(),
error_handler_args.as_ref(),
format!("websocket_trigger/{}", trigger_path),
trigger,
) => {
if let Ok((result, success)) = result {
if !success && !can_return_error_result {
return;
}
let result = result.get().to_owned();
// only send the result if it's not null
if result != "null" {
tracing::info!("Sending job result to WebSocket {}", url);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
if let Err(err) = send_message_tx.send(result).await {
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db_.clone(), Some(&w_id), None).await;
}
}
}
}
};
};
tokio::spawn(handle_response_f);
}
Ok(())
}
}
pub struct ReturnMessageChannels {
send_message_tx: tokio::sync::mpsc::Sender<String>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
}
impl Clone for ReturnMessageChannels {
fn clone(&self) -> Self {
Self {
send_message_tx: self.send_message_tx.clone(),
killpill_rx: self.killpill_rx.resubscribe(),
}
}
}
#[derive(Debug, Deserialize)]
enum InitialMessage {
#[serde(rename = "raw_message")]
RawMessage(String),
#[serde(rename = "runnable_result")]
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
}
fn raw_value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
serde_json::from_str::<Option<HashMap<String, Box<RawValue>>>>(args.get())
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(HashMap::new)
} else {
HashMap::new()
};
Ok(args)
}
@@ -0,0 +1,126 @@
use std::collections::HashMap;
use crate::triggers::trigger_helpers::{
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
};
use windmill_api_auth::ApiAuthed;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow};
use windmill_common::{
error::{Error, Result},
jobs::JobTriggerKind,
triggers::{TriggerMetadata, TriggerKind},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
mod handler;
mod listener;
#[derive(Copy, Clone)]
pub struct WebsocketTrigger;
impl TriggerJobArgs for WebsocketTrigger {
type Payload = String;
const TRIGGER_KIND: TriggerKind = TriggerKind::Websocket;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("msg".to_string(), to_raw_value(&payload))])
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct WebsocketConfig {
pub url: String,
#[serde(default)]
pub filters: Vec<SqlxJson<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<SqlxJson<Box<RawValue>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_runnable_args: Option<SqlxJson<Box<RawValue>>>,
#[serde(default)]
pub can_return_message: bool,
#[serde(default)]
pub can_return_error_result: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebsocketConfigRequest {
url: String,
filters: Vec<serde_json::Value>,
initial_messages: Option<Vec<serde_json::Value>>,
url_runnable_args: Option<serde_json::Value>,
can_return_message: bool,
can_return_error_result: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestWebsocketConfig {
url: String,
url_runnable_args: Option<serde_json::Value>,
}
pub fn value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
let args_map: Option<HashMap<String, serde_json::Value>> = serde_json::from_str(args.get())
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?;
args_map
.unwrap_or_else(HashMap::new)
.into_iter()
.map(|(k, v)| {
let raw_value = serde_json::value::to_raw_value(&v).map_err(|e| {
Error::BadRequest(format!("failed to convert to raw value: {}", e))
})?;
Ok((k, raw_value))
})
.collect::<Result<HashMap<String, Box<RawValue>>>>()
} else {
Ok(HashMap::new())
}?;
Ok(args)
}
pub async fn get_url_from_runnable_value(
path: &str,
is_flow: bool,
db: &DB,
authed: ApiAuthed,
args: Option<&Box<RawValue>>,
workspace_id: &str,
) -> Result<String> {
tracing::info!(
"Running {} {} to get WebSocket URL",
if is_flow { "flow" } else { "script" },
path
);
let args = value_to_args_hashmap(args)?;
let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db,
None,
authed,
workspace_id,
path,
is_flow,
PushArgsOwned { args, extra: None },
None,
None,
None,
"".to_string(), // doesn't matter as no retry/error handler
TriggerMetadata::new(Some(path.to_owned()), JobTriggerKind::Websocket),
)
.await?;
serde_json::from_str::<String>(result.get()).map_err(|_| {
Error::BadConfig(format!(
"{} {} did not return a string",
if is_flow { "Flow" } else { "Script" },
path,
))
})
}
+96
View File
@@ -0,0 +1,96 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use sqlx::PgConnection;
use tracing::Instrument;
use windmill_api_auth::ApiAuthed;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
error::{Error, Result},
utils::rd_string,
worker::CLOUD_HOSTED,
DB,
};
#[derive(serde::Deserialize)]
pub struct NewToken {
pub label: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub impersonate_email: Option<String>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
}
impl NewToken {
pub fn new(
label: Option<String>,
expiration: Option<chrono::DateTime<chrono::Utc>>,
impersonate_email: Option<String>,
scopes: Option<Vec<String>>,
workspace_id: Option<String>,
) -> NewToken {
NewToken { label, expiration, impersonate_email, scopes, workspace_id }
}
}
pub async fn create_token_internal(
tx: &mut PgConnection,
db: &DB,
authed: &ApiAuthed,
token_config: NewToken,
) -> Result<String> {
let token = rd_string(32);
let is_super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
authed.email
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false);
if *CLOUD_HOSTED {
let nb_tokens =
sqlx::query_scalar!("SELECT COUNT(*) FROM token WHERE email = $1", &authed.email)
.fetch_one(db)
.await?;
if nb_tokens.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of tokens (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
}
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin, scopes, workspace_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
token,
authed.email,
token_config.label,
token_config.expiration,
is_super_admin,
token_config.scopes.as_ref().map(|x| x.as_slice()),
token_config.workspace_id,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
authed,
"users.token.create",
ActionKind::Create,
&"global",
Some(&token[0..10]),
None,
)
.instrument(tracing::info_span!("token", email = &authed.email))
.await?;
Ok(token)
}