Compare commits

...

11 Commits

Author SHA1 Message Date
Ruben Fiszel
68569998c8 all 2026-02-06 19:11:35 +00:00
Ruben Fiszel
20541bcb5d all sqlx 2026-02-06 15:49:52 +00:00
Ruben Fiszel
e8806c2733 refactor: remove unnecessary user_ext.rs re-export shim
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:45:52 +00:00
Ruben Fiszel
3e1abaac93 all sqlx 2026-02-06 15:42:32 +00:00
Ruben Fiszel
ad5044267d all 2026-02-06 15:40:01 +00:00
Ruben Fiszel
ad83be499a fix: align cfg guards for check_license_key_valid and improve SSE bridge error handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:41:27 +00:00
Ruben Fiszel
755e36b00b chore: regenerate sqlx query cache after crate extraction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:38:51 +00:00
Ruben Fiszel
2b8af5ddd4 with cc 2026-02-06 13:56:30 +00:00
Ruben Fiszel
5aaecf0da3 refactor: complete trigger extraction by removing originals from windmill-api
Remove the original triggers/ and native_triggers/ directories from
windmill-api/src/ and replace `pub mod` declarations with `pub use`
re-exports from the windmill-triggers crate. This completes the extraction
by ensuring trigger code is only compiled as part of windmill-triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:42:24 +00:00
Ruben Fiszel
9b165361d9 with ee ref 2026-02-06 12:33:40 +00:00
Ruben Fiszel
b838e6caff 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>
2026-02-06 12:33:20 +00:00
88 changed files with 6235 additions and 3326 deletions

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM\n capture\n WHERE\n workspace_id = $1\n AND created_at <= (\n SELECT\n created_at\n FROM\n capture\n WHERE\n workspace_id = $1\n ORDER BY\n created_at DESC\n OFFSET $2\n LIMIT 1\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "0574df3e18f626dd8b3f83fbff8b0ee99cf8483a8fe66fa9311cb96e3f5a0ee2"
}

View File

@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM \n capture\n WHERE \n workspace_id = $1\n AND created_at <= (\n SELECT \n created_at\n FROM \n capture\n WHERE \n workspace_id = $1\n ORDER BY \n created_at DESC\n OFFSET $2\n LIMIT 1\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "e04a8a9f1e9cc3bb5c990194585e08f4c248e1af1c6580bdf8f2735ae1388981"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO \n capture (\n workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ",
"query": "\n INSERT INTO\n capture (\n workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -36,5 +36,5 @@
},
"nullable": []
},
"hash": "eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423"
"hash": "ed8facbf29ebb670d05fe8aa34b50d6a6935420fbedc83aa3ad1e9be7465c8dd"
}

File diff suppressed because it is too large Load Diff

116
backend/Cargo.lock generated
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"
@@ -16060,6 +16084,7 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"windmill-api-auth",
"windmill-common",
]
@@ -16337,6 +16362,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"

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 }

View File

@@ -1 +1 @@
4a6595bda18bf6ec2b01a9263847e65fd28959a1
1d99602f37538b50a3f66475216911d9c33a3af7

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

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)
}

View File

@@ -0,0 +1,16 @@
/*
* 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 permissions;
pub mod scopes;
pub mod tokens;
mod types;
pub use auth::*;
pub use types::*;

View File

@@ -0,0 +1,156 @@
/*
* 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::{Error, Result};
use windmill_common::DB;
use crate::ApiAuthed;
/// 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"
)))
}
}
pub 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)
}
}
pub 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
)))
}
}
pub 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.
pub async fn require_is_writer(
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"
)))
}
}

View File

@@ -0,0 +1,989 @@
/*
* 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::{self, Error, Result};
use windmill_common::jobs::check_tag_available_for_workspace_internal;
use windmill_common::DB;
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(())
}
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(())
}
}
#[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/*
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
error::{Error, Result},
utils::rd_string,
worker::CLOUD_HOSTED,
DB,
};
use crate::ApiAuthed;
#[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)
}

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
}
}

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"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
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", "windmill-mcp/windmill-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

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))
}

View File

@@ -82,12 +82,11 @@ use windmill_common::{
error::{JsonResult, Result},
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
utils::{not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
worker::{to_raw_value, CLOUD_HOSTED},
worker::to_raw_value,
};
use windmill_queue::{PushArgs, PushArgsOwned};
const KEEP_LAST: i64 = 20;
pub fn workspaced_service() -> Router {
Router::new()
@@ -802,73 +801,7 @@ async fn get_capture_trigger_config_and_owner<T: DeserializeOwned>(
))
}
async fn clear_captures_history(db: &DB, w_id: &str) -> Result<()> {
if *CLOUD_HOSTED {
/* Retain only KEEP_LAST most recent captures in this workspace. */
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,
SqlxJson(PushArgs { args: &main_args.args, extra: main_args.extra }) as SqlxJson<PushArgs>,
SqlxJson(PushArgs { args: &preprocessor_args.args, extra: preprocessor_args.extra })
as SqlxJson<PushArgs>,
owner,
)
.execute(db)
.await?;
clear_captures_history(db, &w_id).await?;
Ok(())
}
pub use windmill_triggers::capture_ext::insert_capture_payload;
async fn webhook_payload(
Extension(db): Extension<DB>,

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
}
}

View File

@@ -10,7 +10,7 @@ use {crate::db::ApiAuthed, windmill_common::DB};
#[cfg(not(feature = "private"))]
use anyhow::anyhow;
#[cfg(all(feature = "enterprise", not(feature = "private")))]
#[cfg(feature = "enterprise")]
use {std::sync::Arc, tokio::sync::RwLock};
#[cfg(not(feature = "private"))]
pub async fn validate_license_key(
@@ -44,6 +44,30 @@ impl ExternalJwks {
}
}
#[cfg(feature = "enterprise")]
pub struct ExternalJwksAuthBackend {
pub ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
}
#[cfg(feature = "enterprise")]
#[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"),

View File

@@ -13,7 +13,7 @@ use crate::db::ApiAuthed;
use windmill_common::{
db::{UserDB, DB},
error::{JsonResult, Result},
flow_conversations::MessageType,
flow_conversations::{FlowConversation, MessageType},
utils::{not_found_if_none, paginate, Pagination},
};
@@ -24,17 +24,6 @@ pub fn workspaced_service() -> Router {
.route("/:conversation_id/messages", get(list_messages))
}
#[derive(Serialize, FromRow, Debug)]
pub struct FlowConversation {
pub id: Uuid,
pub workspace_id: String,
pub flow_path: String,
pub title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
}
#[derive(Serialize, FromRow, Debug)]
pub struct FlowConversationMessage {
pub id: Uuid,
@@ -104,55 +93,6 @@ async fn list_conversations(
Ok(Json(conversations))
}
pub async fn get_or_create_conversation_with_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
flow_path: &str,
username: &str,
title: &str,
conversation_id: Uuid,
) -> Result<FlowConversation> {
// Check if conversation already exists
let existing_conversation = sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2",
conversation_id,
w_id
)
.fetch_optional(&mut **tx)
.await?;
if let Some(existing) = existing_conversation {
return Ok(existing);
}
// Truncate title to 25 char characters max
let title = if title.len() > 25 {
format!("{}...", &title[..25])
} else {
title.to_string()
};
// Create new conversation with provided ID
let conversation = sqlx::query_as!(
FlowConversation,
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
conversation_id,
w_id,
flow_path,
username,
title
)
.fetch_one(&mut **tx)
.await?;
Ok(conversation)
}
async fn delete_conversation(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,

View File

@@ -306,24 +306,8 @@ pub async fn is_owner_api(
Ok(Json(is_owner(&authed, &name)))
}
pub fn is_owner(ApiAuthed { is_admin, folders, .. }: &ApiAuthed, name: &str) -> bool {
if *is_admin {
true
} else {
folders.into_iter().any(|x| x.0 == name && x.2)
}
}
pub fn require_is_owner(authed: &ApiAuthed, name: &str) -> Result<()> {
if is_owner(authed, name) {
Ok(())
} else {
Err(windmill_common::error::Error::NotAuthorized(format!(
"You are not owner of the folder {}",
name
)))
}
}
pub use windmill_api_auth::permissions::is_folder_owner as is_owner;
pub use windmill_api_auth::permissions::require_is_folder_owner as require_is_owner;
async fn update_folder(
authed: ApiAuthed,

View File

@@ -0,0 +1,160 @@
/*
* 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 uuid::Uuid;
use windmill_api_auth::{ApiAuthed, OptTokened};
use windmill_common::{
db::UserDB,
error,
DB,
};
use windmill_triggers::jobs_ext::{JobOps, JobUpdateSSEStream};
pub struct JobOpsImpl;
#[axum::async_trait]
impl JobOps for JobOpsImpl {
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) => {
match serde_json::to_value(&update) {
Ok(v) => JobUpdateSSEStream::Update(v),
Err(e) => {
tracing::error!("Failed to serialize SSE job update: {e}");
continue;
}
}
}
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")]
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ pub mod agent_workers_ee;
mod agent_workers_oss;
mod ai;
mod apps;
pub mod args;
pub use windmill_triggers::args_ext as args;
mod assets;
mod audit;
pub mod auth;
@@ -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;
@@ -165,7 +171,7 @@ pub mod teams_approvals_ee;
mod teams_approvals_oss;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
pub use windmill_triggers::native_triggers;
mod public_app_layer;
mod public_app_rate_limit;
mod static_assets;
@@ -181,7 +187,7 @@ pub mod teams_ee;
mod teams_oss;
mod token;
mod tracing_init;
pub mod triggers;
pub use windmill_triggers::triggers;
mod users;
#[cfg(feature = "private")]
pub mod users_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")

View File

@@ -11,7 +11,7 @@ use windmill_mcp::common::transform::apply_key_transformation;
use windmill_mcp::common::types::{
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo,
};
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpAuth, McpBackend};
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend};
use crate::db::ApiAuthed;
use crate::jobs::{
@@ -39,37 +39,6 @@ use axum::{
};
use windmill_common::error::JsonResult;
/// Implement McpAuth for ApiAuthed
impl McpAuth for ApiAuthed {
fn username(&self) -> &str {
&self.username
}
fn email(&self) -> &str {
&self.email
}
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<&[String]> {
self.scopes.as_deref()
}
}
/// Windmill's MCP backend implementation
#[derive(Clone)]
pub struct WindmillBackend {

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::*;

View File

@@ -8,8 +8,7 @@
#![allow(non_snake_case)]
use quick_cache::sync::Cache;
use sqlx::{PgConnection, Postgres, Transaction};
use sqlx::{Postgres, Transaction};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -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;
@@ -162,19 +161,7 @@ pub async fn maybe_refresh_folders(
}
}
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 use windmill_api_auth::scopes::get_scope_tags;
#[derive(Clone, Debug)]
pub struct OptAuthed(pub Option<ApiAuthed>);
@@ -197,70 +184,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 {
@@ -381,27 +307,7 @@ pub struct TruncatedToken {
pub scopes: Option<Vec<String>>,
}
#[derive(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>,
}
#[cfg(feature = "native_trigger")]
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 use windmill_api_auth::tokens::NewToken;
#[derive(Deserialize)]
pub struct Login {
@@ -914,35 +820,7 @@ pub async fn is_owner_of_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" {
crate::folders::require_is_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"
)))
}
}
pub use windmill_api_auth::permissions::require_owner_of_path;
/// Checks that a user has at least read access to the path for preview jobs.
/// This prevents privilege escalation where a user could run preview code
@@ -1001,95 +879,7 @@ pub fn require_path_read_access_for_preview(
}
}
pub 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,
}
}
pub async fn require_is_writer(
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"
)))
}
}
pub use windmill_api_auth::permissions::require_is_writer;
async fn whois(
Extension(db): Extension<DB>,
Path((w_id, username)): Path<(String, String)>,
@@ -2169,62 +1959,7 @@ pub async fn create_session_token<'c>(
Ok(token)
}
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)
}
pub use windmill_api_auth::tokens::create_token_internal;
async fn create_token(
Extension(db): Extension<DB>,

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?;

View File

@@ -6,7 +6,7 @@ pub use crate::ee::*;
use crate::db::DB;
#[cfg(not(feature = "private"))]
use crate::ee_oss::LicensePlan::Community;
#[cfg(all(feature = "enterprise", not(feature = "private")))]
#[cfg(feature = "enterprise")]
use crate::error;
#[cfg(not(feature = "private"))]
use serde::Deserialize;
@@ -122,3 +122,15 @@ pub async fn low_disk_alerts(
) {
// Implementation is not open source
}
#[cfg(feature = "enterprise")]
pub async fn check_license_key_valid() -> error::Result<()> {
let valid = *LICENSE_KEY_VALID.read().await;
if !valid {
return Err(error::Error::BadRequest(
"License key is not valid. Go to your superadmin settings to update your license key."
.to_string(),
));
}
Ok(())
}

View File

@@ -1,5 +1,6 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx;
use sqlx::{self, FromRow};
use uuid::Uuid;
use crate::error::Result;
@@ -14,6 +15,63 @@ pub enum MessageType {
Tool,
}
#[derive(Serialize, Deserialize, FromRow, Debug)]
pub struct FlowConversation {
pub id: Uuid,
pub workspace_id: String,
pub flow_path: String,
pub title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
}
pub async fn get_or_create_conversation_with_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
flow_path: &str,
username: &str,
title: &str,
conversation_id: Uuid,
) -> Result<FlowConversation> {
let existing_conversation = sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2",
conversation_id,
w_id
)
.fetch_optional(&mut **tx)
.await?;
if let Some(existing) = existing_conversation {
return Ok(existing);
}
let title = if title.len() > 25 {
format!("{}...", &title[..25])
} else {
title.to_string()
};
let conversation = sqlx::query_as!(
FlowConversation,
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
conversation_id,
w_id,
flow_path,
username,
title
)
.fetch_one(&mut **tx)
.await?;
Ok(conversation)
}
/// Add a message to a conversation using an existing transaction
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
/// This allows memory_id to be used for agent memory without requiring a conversation

View File

@@ -917,3 +917,47 @@ pub struct WorkerInternalServerInlineUtils {
// The server cannot call the worker functions directly because they are independent crates
pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell<WorkerInternalServerInlineUtils> =
OnceCell::new();
#[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 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(())
}

View File

@@ -1349,3 +1349,67 @@ pub fn duckdb_connection_settings_internal(
lazy_static::lazy_static! {
pub static ref S3_PROXY_LAST_ERRORS_CACHE: Cache<String, String> = Cache::new(4);
}
pub fn get_random_file_name(file_extension: Option<String>) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
format!(
"windmill_uploads/upload_{}_{}.{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
rand::random::<u16>(),
file_extension.unwrap_or("file".to_string())
)
}
#[cfg(feature = "parquet")]
pub async fn upload_file_internal(
s3_client: Arc<dyn ObjectStore>,
file_key: &str,
mut bytes_stream: std::pin::Pin<
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
>,
options: object_store::PutMultipartOpts,
) -> error::Result<()> {
use futures::StreamExt;
let path = object_store::path::Path::parse(file_key)
.map_err(|e| error::Error::InternalErr(format!("Error parsing file key: {}", e)))?;
let upload = s3_client
.put_multipart_opts(&path, options)
.await
.map_err(|err| {
tracing::error!("Error initializing multipart upload: {:?}", err);
error::Error::InternalErr(format!("Error initializing multipart upload: {}", err))
})?;
let mut parts_writer = object_store::WriteMultipart::new(upload);
while let Some(chunk) = bytes_stream.next().await {
let chunk = chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
if let Err(e) = chunk {
if let Err(e) = parts_writer.abort().await {
tracing::error!("Error aborting multipart upload: {:?}", e);
}
return Err(error::Error::InternalErr(format!(
"Error reading request body: {} (could be because of the request size limit)",
e
)));
}
let chunk = chunk.unwrap();
if let Err(e) = parts_writer.wait_for_capacity(8).await {
if let Err(e) = parts_writer.abort().await {
tracing::error!("Error aborting multipart upload: {:?}", e);
}
return Err(error::Error::InternalErr(format!(
"Error waiting for capacity in multipart upload: {}",
e
)));
}
parts_writer.write(&chunk);
}
parts_writer.finish().await.map_err(|err| {
tracing::error!("Error completing multipart upload: {:?}", err);
error::Error::InternalErr(format!("Error completing multipart upload: {}", err))
})?;
Ok(())
}

View File

@@ -12,10 +12,12 @@ path = "src/lib.rs"
default = []
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker", "dep:sqlx", "dep:async-trait", "dep:http", "dep:tokio-util", "dep:tokio"]
auth = ["rmcp/auth", "dep:oauth2"]
windmill-auth = ["dep:windmill-api-auth"]
[dependencies]
oauth2 = { version = "5.0", optional = true }
windmill-common = { workspace = true, default-features = false }
windmill-api-auth = { workspace = true, default-features = false, optional = true }
anyhow.workspace = true
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
serde.workspace = true

View File

@@ -41,6 +41,37 @@ pub trait McpAuth: Send + Sync + Clone + 'static {
}
}
#[cfg(feature = "windmill-auth")]
impl McpAuth for windmill_api_auth::ApiAuthed {
fn username(&self) -> &str {
&self.username
}
fn email(&self) -> &str {
&self.email
}
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<&[String]> {
self.scopes.as_deref()
}
}
/// The core backend trait that windmill-api implements
///
/// This trait abstracts the windmill-api specific operations needed by the MCP server.

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

View File

@@ -1,3 +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.
*/
// Re-exported from windmill-api/src/args.rs
// This module handles webhook argument processing for triggers.
use std::collections::HashMap;
use axum::{
@@ -18,10 +29,8 @@ use windmill_common::{
};
use windmill_queue::PushArgsOwned;
use crate::{
db::ApiAuthed,
triggers::trigger_helpers::{get_runnable_format, RunnableId},
};
use crate::triggers::trigger_helpers::{get_runnable_format, RunnableId};
use windmill_api_auth::ApiAuthed;
#[derive(Debug)]
pub enum RawBody {
@@ -64,9 +73,6 @@ pub struct WebhookArgs {
pub metadata: WebhookArgsMetadata,
}
// capture
//
impl RawWebhookArgs {
#[cfg(not(feature = "parquet"))]
pub async fn process_multipart(
@@ -87,14 +93,13 @@ impl RawWebhookArgs {
db: &DB,
w_id: &str,
) -> Result<HashMap<String, Box<RawValue>>, Error> {
use crate::job_helpers_oss::{
get_random_file_name, get_workspace_s3_resource, upload_file_internal,
};
use futures::TryStreamExt;
use object_store::{Attribute, Attributes};
use windmill_common::s3_helpers::build_object_store_client;
use windmill_common::s3_helpers::{
build_object_store_client, get_random_file_name, upload_file_internal,
};
let (_, s3_resource) = get_workspace_s3_resource(authed, db, None, w_id, None).await?;
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?;
@@ -128,11 +133,14 @@ impl RawWebhookArgs {
])
.into();
let bytes_stream = field
.into_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
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)
});
upload_file_internal(s3_client.clone(), &file_key, bytes_stream, options)
upload_file_internal(s3_client.clone(), &file_key, Box::pin(bytes_stream), options)
.await?;
files.entry(name).or_insert(vec![]).push(serde_json::json!({
@@ -311,7 +319,6 @@ impl WebhookArgs {
}
if has_preprocessor {
// if has preprocessor, it has to be v1
extra.insert(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({

View File

@@ -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(())
}

View File

@@ -0,0 +1,904 @@
/*
* 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 for windmill-triggers.
//!
//! Functions that only depend on windmill-common, windmill-queue, and
//! windmill-api-auth live here as direct implementations.
//!
//! Functions with deep dependencies on windmill-api internals (resource
//! interpolation, secret backends, OAuth2, SSE streaming) are abstracted
//! behind the `JobOps` trait. windmill-api provides the real implementation
//! at startup via `set_ops()`.
use axum::extract::Json;
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderName, HeaderValue};
use hyper::StatusCode;
use serde::Deserialize;
use serde_json::value::RawValue;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use windmill_api_auth::scopes::check_scopes;
use windmill_api_auth::{ApiAuthed, OptTokened};
use windmill_common::db::{UserDB, UserDbWithAuthed};
use windmill_common::error::{self, Error};
use windmill_common::flow_conversations::{
add_message_to_conversation_tx, get_or_create_conversation_with_id, MessageType,
};
use windmill_common::jobs::{format_result, script_path_to_payload, JobPayload};
use windmill_common::triggers::TriggerMetadata;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::utils::{now_from_db, StripPath};
use windmill_common::{get_latest_flow_version_info_for_path, FlowVersionInfo, DB};
use windmill_queue::{
cancel_job, get_result_and_success_by_id_from_flow, push, PushArgs, PushArgsOwned,
PushIsolationLevel,
};
// Re-export shared types/functions from windmill-common
pub use windmill_common::jobs::{delete_job_metadata_after_use, RunJobQuery};
#[cfg(feature = "enterprise")]
pub use windmill_common::ee_oss::check_license_key_valid;
// Re-export scope helpers from windmill-api-auth
pub use windmill_api_auth::scopes::{check_tag_available_for_workspace, get_scope_tags};
/// 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 {
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")]
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>)>;
}
#[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 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
}
// ---------------------------------------------------------------------------
// Direct implementations (no longer on the trait)
// ---------------------------------------------------------------------------
lazy_static::lazy_static! {
pub static ref TIMEOUT_WAIT_RESULT: Arc<RwLock<Option<u64>>> = Arc::new(RwLock::new(
std::env::var("TIMEOUT_WAIT_RESULT")
.ok()
.and_then(|x| x.parse::<u64>().ok())
));
pub static ref WAIT_RESULT_FAST_POLL_INTERVAL_MS: u64 = std::env::var("WAIT_RESULT_FAST_POLL_INTERVAL_MS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(50);
pub static ref WAIT_RESULT_FAST_POLL_DURATION_SECS: u16 = std::env::var("WAIT_RESULT_FAST_POLL_DURATION_SECS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(2);
pub static ref WAIT_RESULT_SLOW_POLL_INTERVAL_MS: u64 = std::env::var("WAIT_RESULT_SLOW_POLL_INTERVAL_MS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(200);
}
struct Guard {
done: bool,
id: Uuid,
w_id: String,
db: DB,
username: String,
}
impl Drop for Guard {
fn drop(&mut self) {
if !&self.done {
let id = self.id;
let w_id = self.w_id.clone();
let db = self.db.clone();
let username = self.username.clone();
tracing::info!("http connection broke, marking job {id} as canceled");
tokio::spawn(async move {
let cancel_f = async {
let tx = db.begin().await?;
let (tx, _) = cancel_job(
&username,
Some("http connection broke".to_string()),
id,
&w_id,
tx,
&db,
false,
false,
)
.await?;
tx.commit().await?;
Ok::<_, anyhow::Error>(())
};
if let Err(e) = cancel_f.await {
tracing::error!(
"Error marking job as canceled after http connection broke: {e}"
);
}
});
}
}
}
#[derive(Deserialize)]
pub struct WindmillCompositeResult {
windmill_status_code: Option<u16>,
windmill_content_type: Option<String>,
windmill_headers: Option<HashMap<String, String>>,
result: Option<Box<RawValue>>,
}
pub async fn cancel_jobs(
jobs: Vec<Uuid>,
db: &DB,
username: &str,
w_id: &str,
force_cancel: bool,
) -> error::JsonResult<Vec<Uuid>> {
let mut uuids = vec![];
tracing::info!("Cancelling jobs: {:?}", jobs);
let mut tx = db.begin().await?;
let trivial_jobs = sqlx::query!("INSERT INTO v2_job_completed AS cj
( workspace_id
, id
, duration_ms
, result
, canceled_by
, canceled_reason
, flow_status
, status
, worker
)
SELECT q.workspace_id
, q.id
, 0
, $4
, $1
, 'cancel all'
, (SELECT flow_status FROM v2_job_status WHERE id = q.id)
, 'canceled'::job_status
, worker
FROM v2_job_queue q
JOIN v2_job USING (id)
WHERE q.id = any($2) AND running = false AND parent_job IS NULL AND q.workspace_id = $3 AND trigger_kind IS DISTINCT FROM 'schedule'
FOR UPDATE SKIP LOCKED
ON CONFLICT (id) DO NOTHING RETURNING id AS \"id!\"", username, &jobs, w_id, serde_json::json!({"error": { "message": format!("Job canceled: cancel all by {username}"), "name": "Canceled", "reason": "cancel all", "canceler": username}}))
.fetch_all(&mut *tx)
.await?.into_iter().map(|x| x.id).collect::<Vec<Uuid>>();
sqlx::query!(
"DELETE FROM v2_job_queue WHERE id = any($1) AND workspace_id = $2",
&trivial_jobs,
w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
for job_id in jobs.into_iter() {
if trivial_jobs.contains(&job_id) {
continue;
}
match tokio::time::timeout(tokio::time::Duration::from_secs(5), async move {
let tx = db.begin().await?;
let (tx, _) = cancel_job(
username,
None,
job_id.clone(),
w_id,
tx,
db,
force_cancel,
false,
)
.await?;
tx.commit().await?;
Ok::<_, anyhow::Error>(())
})
.await
{
Ok(result) => match result {
Ok(_) => {
uuids.push(job_id);
}
Err(e) => {
tracing::error!("Failed to cancel job {:?}: {:?}", job_id, e);
}
},
Err(_) => {
tracing::error!(
"Timeout while trying to cancel job {:?} after 5 seconds",
job_id
);
}
}
}
uuids.extend(trivial_jobs);
Ok(Json(uuids))
}
pub async fn run_query_get_scheduled_for(
run_query: &RunJobQuery,
db: &DB,
) -> error::Result<Option<chrono::DateTime<chrono::Utc>>> {
if let Some(scheduled_for) = run_query.scheduled_for {
Ok(Some(scheduled_for))
} else if let Some(scheduled_in_secs) = run_query.scheduled_in_secs {
let now = now_from_db(db).await?;
Ok(Some(
now + chrono::Duration::try_seconds(scheduled_in_secs).unwrap_or_default(),
))
} else {
Ok(None)
}
}
pub async fn set_flow_memory_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
job_id: Uuid,
memory_id: Uuid,
) -> error::Result<()> {
sqlx::query!(
"UPDATE v2_job_status
SET flow_status = jsonb_set(
flow_status,
'{memory_id}',
to_jsonb($2::uuid)
)
WHERE id = $1",
job_id,
memory_id
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn process_flow_run_query_params(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
job_id: Uuid,
run_query: &RunJobQuery,
) -> error::Result<()> {
if let Some(memory_id) = run_query.memory_id {
set_flow_memory_id(tx, job_id, memory_id).await?;
}
Ok(())
}
pub async fn handle_chat_conversation_messages(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
authed: &ApiAuthed,
w_id: &str,
flow_path: &str,
run_query: &RunJobQuery,
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
) -> error::Result<()> {
let memory_id = run_query.memory_id.ok_or_else(|| {
windmill_common::error::Error::BadRequest(
"memory_id is required for chat-enabled flows".to_string(),
)
})?;
let user_message_raw = user_message_raw.ok_or_else(|| {
windmill_common::error::Error::BadRequest(
"user_message argument is required for chat-enabled flows".to_string(),
)
})?;
let user_message: String = serde_json::from_str(user_message_raw.get()).map_err(|e| {
windmill_common::error::Error::BadRequest(format!(
"Failed to deserialize user_message: {}",
e
))
})?;
get_or_create_conversation_with_id(
tx,
w_id,
flow_path,
&authed.username,
&user_message,
memory_id,
)
.await?;
add_message_to_conversation_tx(
tx,
memory_id,
None,
&user_message,
MessageType::User,
None,
true,
)
.await?;
Ok(())
}
pub async fn run_flow<'c>(
authed: &ApiAuthed,
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
user_db: UserDB,
w_id: &str,
flow_path: &str,
flow_version_info: FlowVersionInfo,
run_query: RunJobQuery,
args: PushArgsOwned,
trigger: Option<TriggerMetadata>,
) -> error::Result<(
Uuid,
Option<String>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
)> {
let FlowVersionInfo {
version,
tag,
dedicated_worker,
has_preprocessor,
chat_input_enabled,
on_behalf_of_email,
edited_by,
early_return,
..
} = flow_version_info;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let scheduled_for = run_query_get_scheduled_for(&run_query, &db).await?;
let return_tx = tx_o.is_some();
let (email, permissioned_as, push_authed, tx) = if let Some(tx) = tx_o {
(
&authed.email,
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Transaction(tx),
)
} else if let Some(on_behalf_of_email) = on_behalf_of_email.as_ref() {
(
on_behalf_of_email,
username_to_permissioned_as(&edited_by),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
&authed.email,
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
)
};
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
JobPayload::Flow {
path: flow_path.to_string(),
dedicated_worker,
version,
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
&& has_preprocessor.unwrap_or(false),
},
PushArgs { args: &args.args, extra: args.extra },
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
None,
None,
None,
push_authed.as_ref(),
false,
None,
trigger,
run_query.suspended_mode,
)
.await?;
if let Some(memory_id) = run_query.memory_id {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
if chat_input_enabled.unwrap_or(false) {
handle_chat_conversation_messages(
&mut tx,
&authed,
&w_id,
&flow_path.to_string(),
&run_query,
args.args.get("user_message"),
)
.await?;
}
if return_tx {
Ok((uuid, early_return, Some(tx)))
} else {
tx.commit().await?;
Ok((uuid, early_return, None))
}
}
pub async fn push_flow_job_by_path_into_queue<'c>(
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::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<'c, sqlx::Postgres>>,
)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let flow_path = flow_path.to_path();
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let flow_version_info =
get_latest_flow_version_info_for_path(Some(userdb_authed), &db, &w_id, &flow_path, true)
.await?;
run_flow(
&authed,
&db,
tx_o,
user_db,
&w_id,
flow_path,
flow_version_info,
run_query,
args,
trigger,
)
.await
}
pub async fn push_script_job_by_path_into_queue<'c>(
authed: ApiAuthed,
db: DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::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<'c, sqlx::Postgres>>,
)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let script_path = script_path.to_path();
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = script_path_to_payload(
script_path,
Some(userdb_authed),
db.clone(),
&w_id,
run_query.skip_preprocessor,
)
.await?;
let scheduled_for = run_query_get_scheduled_for(&run_query, &db).await?;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_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 (uuid, tx) = push(
&db,
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
None,
run_query.root_job,
run_query.job_id,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
tag,
timeout,
None,
if run_query.parent_job.is_some() || run_query.root_job.is_some() {
Some(2)
} else {
None
},
push_authed.as_ref(),
false,
None,
trigger,
run_query.suspended_mode,
)
.await?;
if return_tx {
Ok((uuid, delete_after_use, Some(tx)))
} else {
tx.commit().await?;
Ok((uuid, delete_after_use, None))
}
}
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)> {
let mut result = None;
let mut success = false;
let timeout = TIMEOUT_WAIT_RESULT.read().await.clone().unwrap_or(600);
let timeout_ms = if timeout <= 0 {
2000
} else {
(timeout * 1000) as u64
};
let mut g = Guard {
done: false,
id: uuid,
w_id: w_id.to_string(),
db: db.clone(),
username: username.to_string(),
};
let fast_poll_duration = *WAIT_RESULT_FAST_POLL_DURATION_SECS as u64 * 1000;
let mut accumulated_delay = 0 as u64;
loop {
if let Some(node_id_for_empty_return) = node_id_for_empty_return.as_ref() {
let result_and_success = get_result_and_success_by_id_from_flow(
&db,
w_id,
&uuid,
node_id_for_empty_return,
None,
)
.await
.ok();
if let Some((r, s)) = result_and_success {
result = Some(r);
success = s;
}
}
if result.is_none() {
let row = sqlx::query!(
"
SELECT
result AS \"result: sqlx::types::Json<Box<RawValue>>\",
result_columns,
status = 'success' AS \"success!\"
FROM
v2_job_completed
WHERE
id = $1 AND
workspace_id = $2
",
uuid,
&w_id
)
.fetch_optional(db)
.await?;
if let Some(mut raw_result) = row {
format_result(
raw_result.result_columns.as_ref(),
raw_result.result.as_mut(),
);
result = raw_result.result.map(|x| x.0);
success = raw_result.success;
}
}
if result.is_some() {
break;
}
let delay = if accumulated_delay <= fast_poll_duration {
*WAIT_RESULT_FAST_POLL_INTERVAL_MS
} else {
*WAIT_RESULT_SLOW_POLL_INTERVAL_MS
};
accumulated_delay += delay;
if accumulated_delay > timeout_ms {
break;
};
tokio::time::sleep(core::time::Duration::from_millis(delay)).await;
}
if let Some(result) = result {
g.done = true;
Ok((result, success))
} else {
Err(Error::ExecutionErr(format!("timeout after {}s", timeout)))
}
}
pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result<Response> {
let composite_result = serde_json::from_str::<WindmillCompositeResult>(result.get());
match composite_result {
Ok(WindmillCompositeResult {
windmill_status_code,
windmill_content_type,
windmill_headers,
result: result_value,
}) => {
if windmill_content_type.is_none()
&& windmill_status_code.is_none()
&& windmill_headers.is_none()
{
return Ok((
if success {
StatusCode::OK
} else {
StatusCode::UNPROCESSABLE_ENTITY
},
Json(result),
)
.into_response());
}
let status_code_or_default = windmill_status_code
.map(|val| match StatusCode::from_u16(val) {
Ok(sc) => Ok(sc),
Err(_) => Err(Error::ExecutionErr("Invalid status code".to_string())),
})
.unwrap_or_else(|| {
if !success {
Ok(StatusCode::UNPROCESSABLE_ENTITY)
} else if result_value.is_some() {
Ok(StatusCode::OK)
} else {
Ok(StatusCode::NO_CONTENT)
}
})?;
let mut headers = HeaderMap::new();
if let Some(windmill_headers) = windmill_headers {
for (k, v) in windmill_headers {
let k = HeaderName::from_str(k.as_str()).map_err(|err| {
Error::internal_err(format!("Invalid header name {k}: {err}"))
})?;
let v = HeaderValue::from_str(v.as_str()).map_err(|err| {
Error::internal_err(format!("Invalid header value {v}: {err}"))
})?;
headers.insert(k, v);
}
}
if let Some(content_type) = windmill_content_type {
let serialized_json_result = result_value
.map(|val| val.get().to_owned())
.unwrap_or_else(String::new);
let serialized_result =
serde_json::from_str::<String>(serialized_json_result.as_str())
.ok()
.unwrap_or(serialized_json_result);
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_str(content_type.as_str()).map_err(|err| {
Error::internal_err(format!("Invalid content type {content_type}: {err}"))
})?,
);
return Ok((status_code_or_default, headers, serialized_result).into_response());
}
if let Some(result_value) = result_value {
return Ok((status_code_or_default, headers, Json(result_value)).into_response());
} else {
Ok((status_code_or_default, headers).into_response())
}
}
_ => Ok((
if success {
StatusCode::OK
} else {
StatusCode::UNPROCESSABLE_ENTITY
},
Json(result),
)
.into_response()),
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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 jobs_ext;
pub mod resource_ext;

View File

@@ -1,14 +1,10 @@
use crate::{
db::ApiAuthed,
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,
},
users::{create_token_internal, NewToken},
utils::check_scopes,
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,
};
use windmill_api_auth::{scopes::check_scopes, tokens::{create_token_internal, NewToken}, ApiAuthed};
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
@@ -32,11 +28,12 @@ async fn require_is_writer_on_runnable(
w_id: &str,
db: DB,
) -> Result<()> {
if is_flow {
crate::flows::require_is_writer(authed, path, w_id, db).await
let (query, kind) = if is_flow {
("SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", "flow")
} else {
crate::scripts::require_is_writer(authed, path, w_id, db).await
}
("SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", "script")
};
windmill_api_auth::permissions::require_is_writer(authed, path, w_id, db, query, kind).await
}
#[derive(Debug, Deserialize)]

View File

@@ -55,7 +55,7 @@ use windmill_queue::PushArgsOwned;
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT};
use crate::db::ApiAuthed;
use windmill_api_auth::ApiAuthed;
pub mod handler;
pub mod sync;
pub mod workspace_integrations;

View File

@@ -8,13 +8,12 @@ use windmill_common::{
DB,
};
use crate::{
db::ApiAuthed,
native_triggers::{
get_workspace_integration,
nextcloud::{NextCloudEventType, OcsResponse},
External, OAuthConfig, ServiceName,
},
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>(

View File

@@ -20,11 +20,11 @@ use windmill_common::{
DB,
};
use windmill_api_auth::ApiAuthed;
use crate::native_triggers::ServiceName;
#[cfg(feature = "native_trigger")]
use crate::{
db::ApiAuthed,
native_triggers::{delete_workspace_integration, store_workspace_integration, ServiceName},
};
use crate::native_triggers::{delete_workspace_integration, store_workspace_integration};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};

View File

@@ -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}"
))
})
}

View File

@@ -8,10 +8,9 @@ pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::EmailTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::TriggerCrud,
},
crate::triggers::TriggerCrud,
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},

View File

@@ -5,10 +5,9 @@ pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::GcpTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{TriggerCrud, TriggerData},
},
crate::triggers::{TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},

View File

@@ -1,8 +1,9 @@
use crate::{
db::{ApiAuthed, DB},
jobs::cancel_jobs,
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,

View File

@@ -1,7 +1,5 @@
use crate::{
db::ApiAuthed,
triggers::{StandardTriggerQuery, TriggerData, TriggerMode},
};
use crate::triggers::{StandardTriggerQuery, TriggerData, TriggerMode};
use windmill_api_auth::ApiAuthed;
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sql_builder::{bind::Bind, SqlBuilder};
@@ -26,7 +24,7 @@ use std::sync::Arc;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_git_sync::handle_deployment_metadata;
use crate::utils::check_scopes;
use windmill_api_auth::scopes::check_scopes;
#[async_trait]
pub trait TriggerCrud: Send + Sync + 'static {

View File

@@ -3,10 +3,8 @@ use super::{
TriggerRoute, HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE,
};
use crate::{
auth::{AuthCache, OptTokened},
db::{ApiAuthed, DB},
jobs::start_job_update_sse_stream,
resources::try_get_resource_from_db_as,
jobs_ext::start_job_update_sse_stream,
resource_ext::try_get_resource_from_db_as,
triggers::{
http::{
refresh_routers, validate_authentication_method, HttpConfig, HttpConfigRequest,
@@ -18,9 +16,9 @@ use crate::{
},
Trigger, TriggerCrud, TriggerData, TriggerMode,
},
users::fetch_api_authed,
utils::{check_scopes, ExpiringCacheEntry},
};
use windmill_api_auth::{fetch_api_authed, scopes::check_scopes, ApiAuthed, AuthCache, OptTokened};
use windmill_common::{utils::ExpiringCacheEntry, DB};
use axum::{
async_trait,
extract::Path,
@@ -49,7 +47,7 @@ use windmill_git_sync::handle_deployment_metadata;
#[cfg(feature = "parquet")]
use {
crate::job_helpers_oss::get_workspace_s3_resource,
crate::jobs_ext::get_workspace_s3_resource,
windmill_common::s3_helpers::build_object_store_client,
};

View File

@@ -14,13 +14,11 @@ use windmill_common::{
};
use windmill_queue::PushArgsOwned;
use crate::{
args::{
build_headers, build_query, try_from_request_body, Body, RawWebhookArgs, WebhookArgs,
WebhookArgsMetadata,
},
db::ApiAuthed,
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);

View File

@@ -12,7 +12,9 @@ use windmill_common::{
DB,
};
use crate::{db::ApiAuthed, triggers::TriggerMode, utils::ExpiringCacheEntry};
use crate::triggers::TriggerMode;
use windmill_api_auth::ApiAuthed;
use windmill_common::utils::ExpiringCacheEntry;
pub mod handler;
pub mod http_trigger_args;

View File

@@ -8,10 +8,9 @@ pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::KafkaTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::TriggerCrud,
},
crate::triggers::TriggerCrud,
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},

View File

@@ -1,15 +1,14 @@
use std::{collections::HashMap, fmt::Debug, sync::Arc};
use crate::{
capture::insert_capture_payload,
db::ApiAuthed,
capture_ext::insert_capture_payload,
triggers::{
handler::TriggerCrud,
trigger_helpers::{trigger_runnable, TriggerJobArgs},
Trigger, TriggerErrorHandling, TriggerMode,
},
users::fetch_api_authed,
};
use windmill_api_auth::{fetch_api_authed, ApiAuthed};
use async_trait::async_trait;
use itertools::Itertools;
use rand::seq::SliceRandom;

View File

@@ -36,8 +36,7 @@ mod handler;
mod listener;
pub mod trigger_helpers;
#[allow(unused)]
pub(crate) use handler::TriggerCrud;
pub use handler::TriggerCrud;
pub use handler::{generate_trigger_routers, get_triggers_count_internal, TriggersCount};
pub use listener::start_all_listeners;
#[allow(unused)]

View File

@@ -1,8 +1,9 @@
use crate::{
db::{ApiAuthed, DB},
resources::try_get_resource_from_db_as,
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};

View File

@@ -20,7 +20,7 @@ use windmill_common::{
};
use crate::{
resources::try_get_resource_from_db_as,
resource_ext::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
mqtt::{

View File

@@ -5,10 +5,9 @@ pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::NatsTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{TriggerCrud, TriggerData},
},
crate::triggers::{TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},

View File

@@ -19,10 +19,9 @@ use windmill_common::{
};
use windmill_git_sync::DeployedObject;
use crate::{
db::{ApiAuthed, DB},
triggers::{postgres::PostgresTrigger, Trigger, TriggerCrud, TriggerData},
};
use crate::triggers::{postgres::PostgresTrigger, Trigger, TriggerCrud, TriggerData};
use windmill_api_auth::ApiAuthed;
use windmill_common::DB;
use super::{
check_if_valid_publication_for_postgres_version, create_logical_replication_slot,

View File

@@ -16,7 +16,7 @@ use windmill_common::{
};
use crate::{
resources::try_get_resource_from_db_as,
resource_ext::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
postgres::{

View File

@@ -1,10 +1,11 @@
use std::collections::HashMap;
use crate::{
db::{ApiAuthed, DB},
resources::try_get_resource_from_db_as,
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};

View File

@@ -5,10 +5,9 @@ pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::SqsTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{Trigger, TriggerCrud, TriggerData},
},
crate::triggers::{Trigger, TriggerCrud, TriggerData},
windmill_api_auth::ApiAuthed,
windmill_common::DB,
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},

View File

@@ -25,17 +25,15 @@ use windmill_common::{
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
#[cfg(feature = "enterprise")]
use crate::jobs::check_license_key_valid;
use crate::{
db::{ApiAuthed, DB},
jobs::{
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,
},
utils::check_scopes,
HTTP_CLIENT,
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>,
@@ -504,9 +502,9 @@ pub trait TriggerJobArgs {
}
#[allow(dead_code)]
pub async fn trigger_runnable_inner<'c>(
pub async fn trigger_runnable_inner(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
@@ -524,7 +522,7 @@ pub async fn trigger_runnable_inner<'c>(
Uuid,
Option<bool>,
Option<String>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
Option<sqlx::Transaction<'static, sqlx::Postgres>>,
)> {
let error_handler_args = error_handler_args.map(|args| {
let args = args
@@ -753,9 +751,9 @@ pub async fn trigger_runnable_and_wait_for_raw_result_with_error_ctx(
}
}
async fn trigger_script_internal<'c>(
async fn trigger_script_internal(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
@@ -771,7 +769,7 @@ async fn trigger_script_internal<'c>(
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
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() };
@@ -811,9 +809,9 @@ async fn trigger_script_internal<'c>(
}
}
async fn trigger_script_with_retry_and_error_handler<'c>(
async fn trigger_script_with_retry_and_error_handler(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
tx_o: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
@@ -829,7 +827,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
Option<sqlx::Transaction<'static, sqlx::Postgres>>,
)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;

View File

@@ -1,9 +1,8 @@
use std::borrow::Cow;
use crate::{
db::{ApiAuthed, DB},
triggers::{Trigger, TriggerCrud, TriggerData},
};
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;

View File

@@ -1,11 +1,9 @@
use std::collections::HashMap;
use crate::{
db::ApiAuthed,
triggers::trigger_helpers::{
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
},
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};