refactor: extract windmill-api-scripts and windmill-api-users subcrates (#7850)

* refactor: extract windmill-api-scripts and windmill-api-users subcrates

Split the monolithic windmill-api crate by extracting scripts.rs, flows.rs,
users.rs, and users_oss.rs into dedicated subcrates. This reduces incremental
rebuild times when editing these modules.

Changes:
- Create windmill-api-scripts crate (scripts.rs + flows.rs, ~4.3K lines)
- Create windmill-api-users crate (users.rs + users_oss.rs, ~2.4K lines)
- Move clear_schedule to windmill-queue (shared by scripts, flows, workspaces)
- Move username utilities (VALID_USERNAME, INVALID_USERNAME_CHARS,
  generate_instance_wide_unique_username) to windmill-common/src/usernames.rs
- Move COOKIE_DOMAIN, IS_SECURE, WithStarredInfoQuery, BulkDeleteRequest,
  WebhookShared to windmill-common for cross-crate access
- Original files in windmill-api become thin stubs with pub use re-exports
- EE-dependent route handlers remain in windmill-api (create_user, rename_user,
  set_password, reset_password, etc.)
- Feature forwarding for enterprise, private, parquet, no_auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract windmill-api-workspaces subcrate (Step 3)

Move workspaces.rs, workspaces_extra.rs, workspaces_oss.rs, and
workspaces_ee.rs into a new windmill-api-workspaces crate (~7K lines).

Routes that depend on windmill-api internals (AI copilot, teams,
tarball export, critical alerts, stripe) remain in the windmill-api
stub. The subcrate handles all other workspace management routes.

Also moved send_email_if_possible to windmill-common/email_oss.rs
to make it available across subcrates without circular deps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* refactor: extract windmill-api-groups subcrate (groups.rs + folders.rs)

Extract groups.rs (1,093 lines) and folders.rs (833 lines) into a new
windmill-api-groups subcrate. Both modules had clean dependencies on
already-extracted crates (windmill-api-auth, windmill-common,
windmill-api-workspaces). Also removes unused re-exports of
get_instance_username_or_create_pending and INVALID_USERNAME_CHARS
from windmill-api/src/utils.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: add granular_acls.rs and folder_history.rs to windmill-api-groups

Extract granular_acls.rs (395 lines) and folder_history.rs (68 lines) into
the windmill-api-groups subcrate. Both modules only depend on already-extracted
crates and belong to the same access-control domain as groups and folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused imports and dead code from subcrate extraction

- Remove unused BASE_URL import from lib.rs
- Remove workspaces_extra.rs and workspaces_oss.rs re-export stubs (no consumers in windmill-api)
- Remove dead send_email_if_possible OSS stub (callers moved to windmill-api-users)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* chore: bust CI cargo cache for subcrate split

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: re-export BASE_URL for EE files that use crate::BASE_URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: forward no_auth feature to windmill-api-users, remove dead code

- Add "windmill-api-users/no_auth" to windmill-api's no_auth feature
  so the login bypass in users.rs:1600 activates correctly
- Remove dead send_email_if_possible from windmill-api-users/users_oss.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: re-enable cargo cache for backend tests

Cache was disabled to bust stale entries from before subcrate split.
Now that a clean build has run, re-enable for faster CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: install mold+clang in CI workflows

The .cargo/config.toml uses mold linker for x86_64-linux.
Build scripts require linking even during cargo check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: increase cargo test timeout to 30 min

Exit code 143 (SIGTERM) means the 20-min timeout was hit during
compilation without cache. Bump to 30 min as safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: limit cargo build jobs to 4 to prevent OOM in CI

Exit code 143 (SIGTERM) after 8 min = OOM kill during compilation.
8 parallel LLVM codegen jobs exhaust memory on ubicloud-standard-8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-08 12:12:03 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6a3c91ba74
commit bc119011ae
81 changed files with 14707 additions and 14247 deletions
+8 -2
View File
@@ -17,6 +17,9 @@ jobs:
with:
fetch-depth: 0
- name: Install mold and clang
run: sudo apt-get update && sudo apt-get install -y mold clang
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
@@ -36,7 +39,7 @@ jobs:
- name: install xmlsec1 and gssapi
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev mold clang
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -72,6 +75,9 @@ jobs:
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Install mold and clang
run: sudo apt-get update && sudo apt-get install -y mold clang
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
@@ -103,7 +109,7 @@ jobs:
- name: install xmlsec1 and gssapi
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev mold clang
- name: Substitute EE code (EE logic is behind feature flag)
run: |
+4 -3
View File
@@ -69,9 +69,9 @@ jobs:
with:
ruby-version: '3.3'
bundler-cache: false
- name: Install PowerShell
- name: Install PowerShell, mold and clang
run: |
sudo apt-get update && sudo apt-get install -y powershell
sudo apt-get update && sudo apt-get install -y powershell mold clang
working-directory: /
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -205,7 +205,7 @@ jobs:
restore-keys: |
${{ runner.os }}-duckdb-ffi-
- name: cargo test
timeout-minutes: 20
timeout-minutes: 30
env:
SQLX_OFFLINE: true
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
@@ -213,6 +213,7 @@ jobs:
RUST_LOG: info
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 4
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -30,8 +30,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -122,8 +122,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -40,8 +40,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -40,8 +40,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -30,8 +30,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -37,8 +37,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -32,8 +32,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -71,8 +70,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -245,8 +245,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -35,8 +35,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -29,8 +29,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -40,8 +40,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -27,8 +27,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -35,8 +35,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -32,8 +32,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -30,8 +30,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -155,8 +155,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -185,8 +185,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -160,8 +160,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -105,8 +105,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -31,8 +31,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -105,8 +105,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -25,8 +25,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -185,8 +185,7 @@
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -31,8 +31,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
@@ -24,8 +24,7 @@
"mqtt",
"gcp",
"default_email",
"nextcloud",
"google"
"nextcloud"
]
}
}
+104
View File
@@ -15803,8 +15803,12 @@ dependencies = [
"uuid",
"windmill-api-auth",
"windmill-api-embeddings",
"windmill-api-groups",
"windmill-api-jobs",
"windmill-api-scripts",
"windmill-api-sse",
"windmill-api-users",
"windmill-api-workspaces",
"windmill-audit",
"windmill-autoscaling",
"windmill-common",
@@ -15894,6 +15898,26 @@ dependencies = [
"windmill-store",
]
[[package]]
name = "windmill-api-groups"
version = "1.628.3"
dependencies = [
"axum 0.7.9",
"chrono",
"lazy_static",
"regex",
"serde",
"serde_json",
"sql-builder",
"sqlx",
"uuid",
"windmill-api-auth",
"windmill-api-workspaces",
"windmill-audit",
"windmill-common",
"windmill-git-sync",
]
[[package]]
name = "windmill-api-jobs"
version = "1.628.3"
@@ -15919,6 +15943,35 @@ dependencies = [
"windmill-queue",
]
[[package]]
name = "windmill-api-scripts"
version = "1.628.3"
dependencies = [
"axum 0.7.9",
"chrono",
"futures",
"http 1.4.0",
"hyper 1.8.1",
"itertools 0.14.0",
"lazy_static",
"quick_cache",
"reqwest 0.13.1",
"serde",
"serde_json",
"sql-builder",
"sqlx",
"tokio",
"tracing",
"windmill-api-auth",
"windmill-audit",
"windmill-common",
"windmill-dep-map",
"windmill-git-sync",
"windmill-parser-py",
"windmill-parser-ts",
"windmill-queue",
]
[[package]]
name = "windmill-api-sse"
version = "1.628.3"
@@ -15931,6 +15984,57 @@ dependencies = [
"windmill-common",
]
[[package]]
name = "windmill-api-users"
version = "1.628.3"
dependencies = [
"argon2",
"axum 0.7.9",
"chrono",
"http 1.4.0",
"hyper 1.8.1",
"lazy_static",
"serde",
"serde_json",
"sqlx",
"time",
"tokio",
"tower-cookies",
"tracing",
"windmill-api-auth",
"windmill-audit",
"windmill-common",
"windmill-git-sync",
]
[[package]]
name = "windmill-api-workspaces"
version = "1.628.3"
dependencies = [
"axum 0.7.9",
"chrono",
"hex",
"http 1.4.0",
"hyper 1.8.1",
"lazy_static",
"regex",
"serde",
"serde_json",
"sha2 0.10.9",
"sqlx",
"tokio",
"tracing",
"uuid",
"windmill-api-auth",
"windmill-api-jobs",
"windmill-api-users",
"windmill-audit",
"windmill-common",
"windmill-dep-map",
"windmill-git-sync",
"windmill-queue",
]
[[package]]
name = "windmill-audit"
version = "1.628.3"
+8
View File
@@ -8,6 +8,10 @@ edition.workspace = true
resolver = "2"
members = [
"./windmill-api",
"./windmill-api-scripts",
"./windmill-api-users",
"./windmill-api-workspaces",
"./windmill-api-groups",
"./windmill-api-auth",
"./windmill-api-sse",
"./windmill-api-jobs",
@@ -251,6 +255,10 @@ windmill-mcp = {path = "./windmill-mcp"}
windmill-oauth = {path = "./windmill-oauth"}
windmill-macros = {path = "./windmill-macros"}
windmill-api-auth = { path = "./windmill-api-auth" }
windmill-api-scripts = { path = "./windmill-api-scripts" }
windmill-api-users = { path = "./windmill-api-users" }
windmill-api-workspaces = { path = "./windmill-api-workspaces" }
windmill-api-groups = { path = "./windmill-api-groups" }
windmill-api-sse = { path = "./windmill-api-sse" }
windmill-api-jobs = { path = "./windmill-api-jobs" }
windmill-trigger = { path = "./windmill-trigger" }
+1 -1
View File
@@ -1 +1 @@
327cf1bff1c5a61f6ea2bd81f1476bee51d152c5
9f9dfab164c9a53bdf8750970f9ccf49693ca342
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "windmill-api-groups"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_groups"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private", "windmill-api-workspaces/private"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-api-auth.workspace = true
windmill-api-workspaces.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
axum.workspace = true
chrono.workspace = true
lazy_static.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
sql-builder.workspace = true
sqlx.workspace = true
uuid.workspace = true
@@ -0,0 +1,68 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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::{
extract::{Extension, Path, Query},
routing::get,
Router,
};
use windmill_api_auth::ApiAuthed;
use windmill_common::{
db::UserDB,
error::JsonResult,
utils::{paginate, Pagination},
};
use serde::Serialize;
use sqlx::FromRow;
pub fn workspaced_service() -> Router {
Router::new().route("/get/:name", get(get_folder_permission_history))
}
#[derive(Serialize, FromRow)]
pub struct FolderPermissionChange {
pub id: i64,
pub changed_by: String,
pub changed_at: chrono::DateTime<chrono::Utc>,
pub change_type: String,
pub affected: Option<String>,
}
async fn get_folder_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FolderPermissionChange>> {
// Check if user is owner of the folder (before starting transaction for performance)
crate::folders::require_is_owner(&authed, &name)?;
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(pagination);
let history = sqlx::query_as!(
FolderPermissionChange,
"SELECT id, changed_by, changed_at, change_type, affected
FROM folder_permission_history
WHERE workspace_id = $1 AND folder_name = $2
ORDER BY id DESC
LIMIT $3 OFFSET $4",
w_id,
name,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(axum::Json(history))
}
+829
View File
@@ -0,0 +1,829 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 std::sync::Arc;
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
Json, Router,
};
use lazy_static::lazy_static;
use regex::Regex;
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::webhook::{WebhookMessage, WebhookShared};
use windmill_common::DB;
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, JsonResult, Result},
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_folders))
.route("/listnames", get(list_foldernames))
.route("/create", post(create_folder))
.route("/get/:name", get(get_folder))
.route("/exists/:name", get(exists_folder))
.route("/update/:name", post(update_folder))
.route("/getusage/:name", get(get_folder_usage))
.route("/delete/:name", delete(delete_folder))
.route("/addowner/:name", post(add_owner))
.route("/removeowner/:name", post(remove_owner))
.route("/is_owner/*path", get(is_owner_api))
}
#[derive(FromRow, Serialize, Deserialize, Clone)]
pub struct Folder {
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub owners: Vec<String>,
pub extra_perms: serde_json::Value,
pub summary: Option<String>,
pub created_by: Option<String>,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
pub struct NewFolder {
pub name: String,
pub summary: Option<String>,
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct UpdateFolder {
pub summary: Option<String>,
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct Owner {
pub owner: String,
pub write: Option<bool>,
}
async fn list_folders(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<Folder>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn list_foldernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<String>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_scalar!(
"SELECT name FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
fn validate_owner(owner: &str) -> Result<()> {
if !owner
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/' || c == '-')
{
return Err(error::Error::BadRequest(
"Invalid owner: must contain only alphanumeric characters, underscores, hyphens, or slashes".to_string(),
));
}
Ok(())
}
async fn check_name_conflict<'c>(
tx: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<()> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
name,
w_id
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder {} already exists",
name
)));
}
return Ok(());
}
lazy_static! {
static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap();
}
async fn create_folder(
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(cache): Extension<Arc<AuthCache>>,
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
let mut tx = user_db.clone().begin(&authed).await?;
if !VALID_FOLDER_NAME.is_match(&ng.name) {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder name can only contain alphanumeric characters, underscores"
)));
}
check_name_conflict(&mut tx, &w_id, &ng.name).await?;
cache.invalidate(&w_id, token).await;
let owner = username_to_permissioned_as(&authed.username);
let owners = ng.owners.unwrap_or_else(|| vec![owner.clone()]);
let owners = if owners.contains(&owner) {
owners.clone()
} else {
owners
.iter()
.cloned()
.chain(std::iter::once(owner))
.collect()
};
let mut extra_perms = ng
.extra_perms
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if extra_perms.is_object() {
let extra_mut = extra_perms.as_object_mut().unwrap();
for o in &owners {
extra_mut.insert(o.clone(), serde_json::json!(true));
}
} else {
return Err(error::Error::BadRequest(
"extra_perms must be an object".to_string(),
));
}
if let Err(e) =
sqlx::query_as!(
Folder,
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
w_id,
ng.name,
ng.display_name.unwrap_or(ng.name.clone()),
&owners,
extra_perms,
ng.summary,
authed.username
)
.execute(&mut *tx)
.await {
drop(tx);
let mut tx = user_db.begin(&authed).await?;
let exists_for_user = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2 AND $3 = ANY(owners))",
ng.name,
w_id,
authed.username
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
ng.name,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !exists_for_user && exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder '{}' already exists in workspace '{}' but you do not have permission to read to it", ng.name, w_id
)));
} else if exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder '{}' already exists in workspace '{}'", ng.name, w_id
)));
} else {
return Err(windmill_common::error::Error::InternalErr(format!(
"Failed to create folder: {}", e
)));
}
}
audit_log(
&mut *tx,
&authed,
"folder.create",
ActionKind::Create,
&w_id,
Some(&ng.name.to_string()),
None,
)
.await?;
log_folder_permission_change(&mut *tx, &w_id, &ng.name, &authed.username, "create", None)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", ng.name) },
Some(format!("Folder '{}' created", ng.name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFolder { workspace: w_id, name: ng.name.clone() },
);
Ok(format!("Created folder {}", ng.name))
}
pub async fn is_owner_api(
authed: ApiAuthed,
Path((_w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
Ok(Json(is_owner(&authed, &name)))
}
use windmill_api_auth::is_owner;
pub use windmill_api_auth::require_is_owner;
async fn update_folder(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(mut ng): Json<UpdateFolder>,
) -> Result<String> {
use sql_builder::prelude::*;
let mut sqlb = SqlBuilder::update_table("folder");
sqlb.and_where_eq("name", "?".bind(&name));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
if let Some(display_name) = ng.display_name {
sqlb.set("display_name", "?".bind(&display_name));
}
if let Some(summary) = ng.summary {
sqlb.set("summary", "?".bind(&summary));
}
sqlb.set("edited_at", "now()");
// Track whether permission-related fields are being updated
let owners_changed = ng.owners.is_some();
let extra_perms_changed = ng.extra_perms.is_some();
if !authed.is_admin {
let prefixed_username = format!("u/{}", authed.username);
if ng.owners.as_ref().is_some_and(|x| {
!x.contains(&prefixed_username)
&& !authed.groups.iter().any(|g| x.contains(&format!("g/{g}")))
}) {
ng.owners.as_mut().unwrap().push(prefixed_username.clone());
if ng.extra_perms.is_none() {
ng.extra_perms = Some(serde_json::Value::Object(serde_json::Map::new()));
}
ng.extra_perms
.as_mut()
.unwrap()
.as_object_mut()
.unwrap()
.insert(prefixed_username, serde_json::json!(true));
}
}
if let Some(owners) = ng.owners {
sqlb.set(
"owners",
"?".bind(&format!(
"{{{}}}",
owners
.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(","),
)),
);
}
if let Some(extra_perms) = ng.extra_perms {
if !extra_perms.is_object() {
return Err(windmill_common::error::Error::BadRequest(format!(
"extra_perms must be an object, received {}",
extra_perms.to_string()
)));
}
sqlb.set(
"extra_perms",
"?".bind(&serde_json::to_string(&extra_perms).map_err(to_anyhow)?),
);
}
sqlb.returning("*");
let mut tx = user_db.begin(&authed).await?;
let sql = sqlb
.sql()
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let nfolder = sqlx::query_as::<_, Folder>(&sql)
.fetch_optional(&mut *tx)
.await?;
let nfolder = nfolder.ok_or_else(|| {
windmill_common::error::Error::NotAuthorized(format!(
"You are not an owner of {} and hence cannot modify it",
name
))
})?;
if let Some(extra_perms) = nfolder.extra_perms.as_object() {
for o in nfolder.owners {
if !extra_perms
.get(&o)
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return Err(windmill_common::error::Error::BadRequest(format!(
"Owner {} would not have permission to write to folder and that is an invalid state",
o
)));
}
}
}
audit_log(
&mut *tx,
&authed,
"folder.update",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
// Log permission changes if owners or extra_perms were updated
if owners_changed {
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"update_owners",
None,
)
.await?;
}
if extra_perms_changed {
log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, "update_acl", None)
.await?;
}
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' updated", name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.to_owned() },
);
Ok(format!("Updated folder {}", name))
}
pub async fn get_folderopt<'c>(
db: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<Option<Folder>> {
let folderopt = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE name = $1 AND workspace_id = $2",
name,
w_id
)
.fetch_optional(&mut **db)
.await?;
Ok(folderopt)
}
async fn get_folder(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Folder> {
check_scopes(&authed, || format!("folders:read:f/{}", name))?;
let mut tx = user_db.begin(&authed).await?;
let folder = not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
tx.commit().await?;
Ok(Json(folder))
}
async fn exists_folder(
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
name,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Serialize)]
struct FolderUsage {
pub scripts: i64,
pub schedules: i64,
pub flows: i64,
pub apps: i64,
pub resources: i64,
pub variables: i64,
}
async fn get_folder_usage(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<FolderUsage> {
check_scopes(&authed, || format!("folders:read:f/{}", name))?;
let mut tx = user_db.begin(&authed).await?;
let scripts = sqlx::query_scalar!(
"SELECT count(path) FROM script WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let schedules = sqlx::query_scalar!(
"SELECT count(path) FROM schedule WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let flows = sqlx::query_scalar!(
"SELECT count(path) FROM flow WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let apps = sqlx::query_scalar!(
"SELECT count(path) FROM app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let raw_apps = sqlx::query_scalar!(
"SELECT count(path) FROM raw_app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let resources = sqlx::query_scalar!(
"SELECT count(path) FROM resource WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let variables = sqlx::query_scalar!(
"SELECT count(path) FROM variable WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
tx.commit().await?;
Ok(Json(FolderUsage {
scripts,
flows,
schedules,
apps: apps + raw_apps,
resources,
variables,
}))
}
async fn delete_folder(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
let del = sqlx::query_scalar!(
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
name,
w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
if del.is_none() {
return Err(windmill_common::error::Error::NotAuthorized(format!(
"Not authorized to delete folder {}",
name
)));
}
audit_log(
&mut *tx,
&authed,
"folder.delete",
ActionKind::Delete,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' deleted", name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("delete folder at name {}", name))
}
async fn add_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, .. }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_append(owners::text[], $1) WHERE name = $2 AND workspace_id = $3 AND NOT $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
validate_owner(&owner)?;
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
.bind(true)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"folder.add_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"grant_admin",
Some(&owner),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Added {} to folder {}", owner, name))
}
async fn remove_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, write }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
validate_owner(&owner)?;
let folder = sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if folder.is_none() && write.is_none() {
return Ok(format!(
"Owner {} is already not a member of folder {}",
owner, name
));
}
if let Some(write) = write {
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
))
.bind(write)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
if folder.is_none() && old_write.is_none_or(|ow| ow == write) {
return Ok(format!(
"Owner {} is already not a member of folder {} and write permission was already {}",
owner, name, write
));
}
}
audit_log(
&mut *tx,
&authed,
"folder.remove_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
let change_type = match write {
Some(true) => "grant_writer_only",
Some(false) => "grant_viewer_only",
None => "revoke_all",
};
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
change_type,
Some(&owner),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Removed {} to folder {}", owner, name))
}
pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
workspace_id: &str,
folder_name: &str,
changed_by: &str,
change_type: &str,
affected: Option<&str>,
) -> Result<()> {
sqlx::query!(
"INSERT INTO folder_permission_history
(workspace_id, folder_name, changed_by, change_type, affected)
VALUES ($1, $2, $3, $4, $5)",
workspace_id,
folder_name,
changed_by,
change_type,
affected
)
.execute(db)
.await?;
Ok(())
}
@@ -0,0 +1,396 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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::{
extract::{Extension, Path},
routing::{get, post},
Json, Router,
};
use windmill_api_auth::require_owner_of_path;
use windmill_common::DB;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_api_auth::ApiAuthed;
use serde::{Deserialize, Serialize};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::{not_found_if_none, StripPath},
};
const KINDS: [&str; 18] = [
"script",
"group_",
"resource",
"schedule",
"variable",
"flow",
"folder",
"app",
"raw_app",
"http_trigger",
"websocket_trigger",
"kafka_trigger",
"nats_trigger",
"postgres_trigger",
"mqtt_trigger",
"gcp_trigger",
"sqs_trigger",
"email_trigger",
];
pub fn workspaced_service() -> Router {
Router::new()
.route("/get/*path", get(get_granular_acls))
.route("/add/*path", post(add_granular_acl))
.route("/remove/*path", post(remove_granular_acl))
}
#[derive(Serialize, Deserialize)]
pub struct GranularAcl {
pub owner: String,
pub write: Option<bool>,
}
async fn add_granular_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, write }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
"name"
} else {
"path"
};
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
if kind == "folder" {
if let Some(obj) = sqlx::query_scalar!(
"SELECT owners FROM folder WHERE name = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?
{
if obj.contains(&owner) {
if write != Some(true) {
return Err(Error::BadRequest(
"Cannot remove write permission for folder owner".to_string(),
));
}
}
}
}
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
))
.bind(vec![owner.clone()])
.bind(write.unwrap_or(false))
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let _ = not_found_if_none(obj_o, &kind, &path)?;
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
};
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
} else if kind == "group_" {
let change_type = if write.unwrap_or(false) {
"grant_admin"
} else {
"grant_member_only"
};
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
None,
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
Ok("Successfully modified granular acl".to_string())
}
async fn remove_granular_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, .. }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
"name"
} else {
"path"
};
if identifier == "path" {
require_owner_of_path(&authed, path)?;
}
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {kind}
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
)
UPDATE {kind} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
))
.bind(&owner)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
// Only log if something was actually removed (obj_o is Some)
if let Some(write) = obj_o {
// Log permission changes for folders and groups
if kind == "folder" {
let change_type = if write { "revoke_write" } else { "revoke_read" };
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
} else if kind == "group_" {
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
"revoke_admin",
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
None,
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
}
Ok("Successfully removed granular acl".to_string())
}
async fn get_granular_acls(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<serde_json::Value> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" { "name" } else { "path" };
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
))
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
let obj = not_found_if_none(obj_o, &kind, &path)?;
tx.commit().await?;
Ok(Json(obj))
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
pub mod folder_history;
pub mod folders;
pub mod granular_acls;
pub mod groups;
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "windmill-api-scripts"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_scripts"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-api-auth.workspace = true
windmill-queue.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
windmill-dep-map.workspace = true
windmill-parser-ts.workspace = true
axum.workspace = true
futures.workspace = true
http.workspace = true
hyper.workspace = true
itertools.workspace = true
quick_cache.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sql-builder.workspace = true
sqlx.workspace = true
tracing.workspace = true
chrono.workspace = true
lazy_static.workspace = true
tokio.workspace = true
windmill-parser-py.workspace = true
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 flows;
pub mod scripts;
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "windmill-api-users"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_users"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private"]
parquet = []
no_auth = ["windmill-api-auth/no_auth"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-api-auth.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
argon2.workspace = true
axum.workspace = true
chrono.workspace = true
http.workspace = true
hyper.workspace = true
lazy_static.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
+2
View File
@@ -0,0 +1,2 @@
pub mod users;
pub mod users_oss;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
use std::sync::Arc;
use windmill_api_auth::ApiAuthed;
use crate::users::{EditPassword, NewUser};
use windmill_common::webhook::WebhookShared;
use windmill_common::DB;
use argon2::Argon2;
use axum::{extract::Extension, Json};
use http::StatusCode;
use serde::Deserialize;
use windmill_common::error::{Error, Result};
pub async fn create_user(
_authed: ApiAuthed,
_db: DB,
_webhook: WebhookShared,
_argon2: Arc<Argon2<'_>>,
mut _nu: NewUser,
) -> Result<(StatusCode, String)> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
pub async fn set_password(
_db: DB,
_argon2: Arc<Argon2<'_>>,
_authed: ApiAuthed,
_user_email: &str,
_ep: EditPassword,
) -> Result<String> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<String> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct OnboardingData {
pub touch_point: String,
pub use_case: String,
}
pub async fn submit_onboarding_data(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Json(_data): Json<OnboardingData>,
) -> Result<String> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
@@ -0,0 +1,41 @@
[package]
name = "windmill-api-workspaces"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_workspaces"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private"]
cloud = ["windmill-common/cloud"]
no_auth = ["windmill-api-auth/no_auth"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-api-auth.workspace = true
windmill-api-users.workspace = true
windmill-audit.workspace = true
windmill-git-sync.workspace = true
windmill-queue.workspace = true
windmill-api-jobs.workspace = true
windmill-dep-map.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
http.workspace = true
hyper.workspace = true
lazy_static.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
@@ -0,0 +1,6 @@
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
#[cfg(feature = "private")]
pub mod workspaces_ee;
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
use std::collections::HashMap;
use crate::db::ApiAuthed;
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_common::DB;
use crate::workspaces::{
archive_workspace_impl, check_w_id_conflict, CREATE_WORKSPACE_REQUIRE_SUPERADMIN,
WM_FORK_PREFIX,
};
use crate::{db::DB, utils::require_super_admin};
use axum::extract::Query;
use axum::{
@@ -487,7 +487,7 @@ pub(crate) async fn change_workspace_id(
);
for schedule_path in &enabled_schedule_paths {
crate::schedule::clear_schedule(&mut tx, schedule_path, &old_id).await?;
windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, &old_id).await?;
}
// Move queued jobs (not running) to new workspace using skip lock
@@ -3,10 +3,11 @@
pub use crate::workspaces_ee::*;
#[cfg(not(feature = "private"))]
use crate::{
db::{ApiAuthed, DB},
workspaces::EditAutoInvite,
};
use crate::workspaces::EditAutoInvite;
#[cfg(not(feature = "private"))]
use windmill_api_auth::ApiAuthed;
#[cfg(not(feature = "private"))]
use windmill_common::DB;
#[cfg(not(feature = "private"))]
pub async fn edit_auto_invite(
+9 -5
View File
@@ -10,15 +10,15 @@ path = "src/lib.rs"
[features]
default = []
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-email?/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise"]
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-email?/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise"]
stripe = []
inline_preview = ["dep:windmill-worker"]
agent_worker_server = ["dep:windmill-worker"]
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["windmill-api-embeddings/embedding"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker?/parquet"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
tantivy = ["dep:windmill-indexer"]
@@ -37,15 +37,19 @@ mqtt_trigger = ["dep:windmill-trigger-mqtt", "windmill-store/mqtt_trigger"]
native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger", "dep:strum", "oauth2"]
sqs_trigger = ["dep:windmill-trigger-sqs", "windmill-store/sqs_trigger"]
gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud"]
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config"]
python = ["windmill-dep-map/python"]
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth"]
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"]
[dependencies]
windmill-mcp = { workspace = true, optional = true }
windmill-api-auth.workspace = true
windmill-api-scripts.workspace = true
windmill-api-users.workspace = true
windmill-api-workspaces.workspace = true
windmill-api-groups.workspace = true
windmill-api-sse.workspace = true
windmill-api-jobs.workspace = true
windmill-trigger.workspace = true
File diff suppressed because it is too large Load Diff
+1 -68
View File
@@ -1,68 +1 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 crate::db::ApiAuthed;
use axum::{
extract::{Extension, Path, Query},
routing::get,
Router,
};
use windmill_common::{
db::UserDB,
error::JsonResult,
utils::{paginate, Pagination},
};
use serde::Serialize;
use sqlx::FromRow;
pub fn workspaced_service() -> Router {
Router::new().route("/get/:name", get(get_folder_permission_history))
}
#[derive(Serialize, FromRow)]
pub struct FolderPermissionChange {
pub id: i64,
pub changed_by: String,
pub changed_at: chrono::DateTime<chrono::Utc>,
pub change_type: String,
pub affected: Option<String>,
}
async fn get_folder_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FolderPermissionChange>> {
// Check if user is owner of the folder (before starting transaction for performance)
crate::folders::require_is_owner(&authed, &name)?;
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(pagination);
let history = sqlx::query_as!(
FolderPermissionChange,
"SELECT id, changed_by, changed_at, change_type, affected
FROM folder_permission_history
WHERE workspace_id = $1 AND folder_name = $2
ORDER BY id DESC
LIMIT $3 OFFSET $4",
w_id,
name,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(axum::Json(history))
}
pub use windmill_api_groups::folder_history::*;
+1 -833
View File
@@ -1,833 +1 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 std::sync::Arc;
use crate::{
auth::AuthCache,
db::{ApiAuthed, DB},
users::Tokened,
utils::check_scopes,
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
Json, Router,
};
use lazy_static::lazy_static;
use regex::Regex;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, JsonResult, Result},
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_folders))
.route("/listnames", get(list_foldernames))
.route("/create", post(create_folder))
.route("/get/:name", get(get_folder))
.route("/exists/:name", get(exists_folder))
.route("/update/:name", post(update_folder))
.route("/getusage/:name", get(get_folder_usage))
.route("/delete/:name", delete(delete_folder))
.route("/addowner/:name", post(add_owner))
.route("/removeowner/:name", post(remove_owner))
.route("/is_owner/*path", get(is_owner_api))
}
#[derive(FromRow, Serialize, Deserialize, Clone)]
pub struct Folder {
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub owners: Vec<String>,
pub extra_perms: serde_json::Value,
pub summary: Option<String>,
pub created_by: Option<String>,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
pub struct NewFolder {
pub name: String,
pub summary: Option<String>,
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct UpdateFolder {
pub summary: Option<String>,
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct Owner {
pub owner: String,
pub write: Option<bool>,
}
async fn list_folders(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<Folder>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn list_foldernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<String>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_scalar!(
"SELECT name FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
fn validate_owner(owner: &str) -> Result<()> {
if !owner
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/' || c == '-')
{
return Err(error::Error::BadRequest(
"Invalid owner: must contain only alphanumeric characters, underscores, hyphens, or slashes".to_string(),
));
}
Ok(())
}
async fn check_name_conflict<'c>(
tx: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<()> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
name,
w_id
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder {} already exists",
name
)));
}
return Ok(());
}
lazy_static! {
static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap();
}
async fn create_folder(
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(cache): Extension<Arc<AuthCache>>,
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
let mut tx = user_db.clone().begin(&authed).await?;
if !VALID_FOLDER_NAME.is_match(&ng.name) {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder name can only contain alphanumeric characters, underscores"
)));
}
check_name_conflict(&mut tx, &w_id, &ng.name).await?;
cache.invalidate(&w_id, token).await;
let owner = username_to_permissioned_as(&authed.username);
let owners = ng.owners.unwrap_or_else(|| vec![owner.clone()]);
let owners = if owners.contains(&owner) {
owners.clone()
} else {
owners
.iter()
.cloned()
.chain(std::iter::once(owner))
.collect()
};
let mut extra_perms = ng
.extra_perms
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if extra_perms.is_object() {
let extra_mut = extra_perms.as_object_mut().unwrap();
for o in &owners {
extra_mut.insert(o.clone(), serde_json::json!(true));
}
} else {
return Err(error::Error::BadRequest(
"extra_perms must be an object".to_string(),
));
}
if let Err(e) =
sqlx::query_as!(
Folder,
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
w_id,
ng.name,
ng.display_name.unwrap_or(ng.name.clone()),
&owners,
extra_perms,
ng.summary,
authed.username
)
.execute(&mut *tx)
.await {
drop(tx);
let mut tx = user_db.begin(&authed).await?;
let exists_for_user = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2 AND $3 = ANY(owners))",
ng.name,
w_id,
authed.username
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
ng.name,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !exists_for_user && exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder '{}' already exists in workspace '{}' but you do not have permission to read to it", ng.name, w_id
)));
} else if exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder '{}' already exists in workspace '{}'", ng.name, w_id
)));
} else {
return Err(windmill_common::error::Error::InternalErr(format!(
"Failed to create folder: {}", e
)));
}
}
audit_log(
&mut *tx,
&authed,
"folder.create",
ActionKind::Create,
&w_id,
Some(&ng.name.to_string()),
None,
)
.await?;
log_folder_permission_change(&mut *tx, &w_id, &ng.name, &authed.username, "create", None)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", ng.name) },
Some(format!("Folder '{}' created", ng.name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFolder { workspace: w_id, name: ng.name.clone() },
);
Ok(format!("Created folder {}", ng.name))
}
pub async fn is_owner_api(
authed: ApiAuthed,
Path((_w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
Ok(Json(is_owner(&authed, &name)))
}
use windmill_api_auth::is_owner;
pub use windmill_api_auth::require_is_owner;
async fn update_folder(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(mut ng): Json<UpdateFolder>,
) -> Result<String> {
use sql_builder::prelude::*;
let mut sqlb = SqlBuilder::update_table("folder");
sqlb.and_where_eq("name", "?".bind(&name));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
if let Some(display_name) = ng.display_name {
sqlb.set("display_name", "?".bind(&display_name));
}
if let Some(summary) = ng.summary {
sqlb.set("summary", "?".bind(&summary));
}
sqlb.set("edited_at", "now()");
// Track whether permission-related fields are being updated
let owners_changed = ng.owners.is_some();
let extra_perms_changed = ng.extra_perms.is_some();
if !authed.is_admin {
let prefixed_username = format!("u/{}", authed.username);
if ng.owners.as_ref().is_some_and(|x| {
!x.contains(&prefixed_username)
&& !authed.groups.iter().any(|g| x.contains(&format!("g/{g}")))
}) {
ng.owners.as_mut().unwrap().push(prefixed_username.clone());
if ng.extra_perms.is_none() {
ng.extra_perms = Some(serde_json::Value::Object(serde_json::Map::new()));
}
ng.extra_perms
.as_mut()
.unwrap()
.as_object_mut()
.unwrap()
.insert(prefixed_username, serde_json::json!(true));
}
}
if let Some(owners) = ng.owners {
sqlb.set(
"owners",
"?".bind(&format!(
"{{{}}}",
owners
.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(","),
)),
);
}
if let Some(extra_perms) = ng.extra_perms {
if !extra_perms.is_object() {
return Err(windmill_common::error::Error::BadRequest(format!(
"extra_perms must be an object, received {}",
extra_perms.to_string()
)));
}
sqlb.set(
"extra_perms",
"?".bind(&serde_json::to_string(&extra_perms).map_err(to_anyhow)?),
);
}
sqlb.returning("*");
let mut tx = user_db.begin(&authed).await?;
let sql = sqlb
.sql()
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let nfolder = sqlx::query_as::<_, Folder>(&sql)
.fetch_optional(&mut *tx)
.await?;
let nfolder = nfolder.ok_or_else(|| {
windmill_common::error::Error::NotAuthorized(format!(
"You are not an owner of {} and hence cannot modify it",
name
))
})?;
if let Some(extra_perms) = nfolder.extra_perms.as_object() {
for o in nfolder.owners {
if !extra_perms
.get(&o)
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return Err(windmill_common::error::Error::BadRequest(format!(
"Owner {} would not have permission to write to folder and that is an invalid state",
o
)));
}
}
}
audit_log(
&mut *tx,
&authed,
"folder.update",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
// Log permission changes if owners or extra_perms were updated
if owners_changed {
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"update_owners",
None,
)
.await?;
}
if extra_perms_changed {
log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, "update_acl", None)
.await?;
}
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' updated", name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.to_owned() },
);
Ok(format!("Updated folder {}", name))
}
pub async fn get_folderopt<'c>(
db: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<Option<Folder>> {
let folderopt = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE name = $1 AND workspace_id = $2",
name,
w_id
)
.fetch_optional(&mut **db)
.await?;
Ok(folderopt)
}
async fn get_folder(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Folder> {
check_scopes(&authed, || format!("folders:read:f/{}", name))?;
let mut tx = user_db.begin(&authed).await?;
let folder = not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
tx.commit().await?;
Ok(Json(folder))
}
async fn exists_folder(
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
name,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Serialize)]
struct FolderUsage {
pub scripts: i64,
pub schedules: i64,
pub flows: i64,
pub apps: i64,
pub resources: i64,
pub variables: i64,
}
async fn get_folder_usage(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<FolderUsage> {
check_scopes(&authed, || format!("folders:read:f/{}", name))?;
let mut tx = user_db.begin(&authed).await?;
let scripts = sqlx::query_scalar!(
"SELECT count(path) FROM script WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let schedules = sqlx::query_scalar!(
"SELECT count(path) FROM schedule WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let flows = sqlx::query_scalar!(
"SELECT count(path) FROM flow WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let apps = sqlx::query_scalar!(
"SELECT count(path) FROM app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let raw_apps = sqlx::query_scalar!(
"SELECT count(path) FROM raw_app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let resources = sqlx::query_scalar!(
"SELECT count(path) FROM resource WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let variables = sqlx::query_scalar!(
"SELECT count(path) FROM variable WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
tx.commit().await?;
Ok(Json(FolderUsage {
scripts,
flows,
schedules,
apps: apps + raw_apps,
resources,
variables,
}))
}
async fn delete_folder(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
let del = sqlx::query_scalar!(
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
name,
w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
if del.is_none() {
return Err(windmill_common::error::Error::NotAuthorized(format!(
"Not authorized to delete folder {}",
name
)));
}
audit_log(
&mut *tx,
&authed,
"folder.delete",
ActionKind::Delete,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' deleted", name)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("delete folder at name {}", name))
}
async fn add_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, .. }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_append(owners::text[], $1) WHERE name = $2 AND workspace_id = $3 AND NOT $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
validate_owner(&owner)?;
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
.bind(true)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"folder.add_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"grant_admin",
Some(&owner),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Added {} to folder {}", owner, name))
}
async fn remove_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, write }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
validate_owner(&owner)?;
let folder = sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if folder.is_none() && write.is_none() {
return Ok(format!(
"Owner {} is already not a member of folder {}",
owner, name
));
}
if let Some(write) = write {
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
))
.bind(write)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
if folder.is_none() && old_write.is_none_or(|ow| ow == write) {
return Ok(format!(
"Owner {} is already not a member of folder {} and write permission was already {}",
owner, name, write
));
}
}
audit_log(
&mut *tx,
&authed,
"folder.remove_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
let change_type = match write {
Some(true) => "grant_writer_only",
Some(false) => "grant_viewer_only",
None => "revoke_all",
};
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
change_type,
Some(&owner),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Removed {} to folder {}", owner, name))
}
pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
workspace_id: &str,
folder_name: &str,
changed_by: &str,
change_type: &str,
affected: Option<&str>,
) -> Result<()> {
sqlx::query!(
"INSERT INTO folder_permission_history
(workspace_id, folder_name, changed_by, change_type, affected)
VALUES ($1, $2, $3, $4, $5)",
workspace_id,
folder_name,
changed_by,
change_type,
affected
)
.execute(db)
.await?;
Ok(())
}
pub use windmill_api_groups::folders::*;
+1 -395
View File
@@ -1,395 +1 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 crate::{db::DB, users::require_owner_of_path};
use axum::{
extract::{Extension, Path},
routing::{get, post},
Json, Router,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use crate::db::ApiAuthed;
use serde::{Deserialize, Serialize};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::{not_found_if_none, StripPath},
};
const KINDS: [&str; 18] = [
"script",
"group_",
"resource",
"schedule",
"variable",
"flow",
"folder",
"app",
"raw_app",
"http_trigger",
"websocket_trigger",
"kafka_trigger",
"nats_trigger",
"postgres_trigger",
"mqtt_trigger",
"gcp_trigger",
"sqs_trigger",
"email_trigger",
];
pub fn workspaced_service() -> Router {
Router::new()
.route("/get/*path", get(get_granular_acls))
.route("/add/*path", post(add_granular_acl))
.route("/remove/*path", post(remove_granular_acl))
}
#[derive(Serialize, Deserialize)]
pub struct GranularAcl {
pub owner: String,
pub write: Option<bool>,
}
async fn add_granular_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, write }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
"name"
} else {
"path"
};
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
if kind == "folder" {
if let Some(obj) = sqlx::query_scalar!(
"SELECT owners FROM folder WHERE name = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?
{
if obj.contains(&owner) {
if write != Some(true) {
return Err(Error::BadRequest(
"Cannot remove write permission for folder owner".to_string(),
));
}
}
}
}
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
))
.bind(vec![owner.clone()])
.bind(write.unwrap_or(false))
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let _ = not_found_if_none(obj_o, &kind, &path)?;
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
};
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
} else if kind == "group_" {
let change_type = if write.unwrap_or(false) {
"grant_admin"
} else {
"grant_member_only"
};
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
None,
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
Ok("Successfully modified granular acl".to_string())
}
async fn remove_granular_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, .. }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
"name"
} else {
"path"
};
if identifier == "path" {
require_owner_of_path(&authed, path)?;
}
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {kind}
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
)
UPDATE {kind} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
))
.bind(&owner)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
// Only log if something was actually removed (obj_o is Some)
if let Some(write) = obj_o {
// Log permission changes for folders and groups
if kind == "folder" {
let change_type = if write { "revoke_write" } else { "revoke_read" };
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
} else if kind == "group_" {
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
"revoke_admin",
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
None,
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
}
Ok("Successfully removed granular acl".to_string())
}
async fn get_granular_acls(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<serde_json::Value> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !KINDS.contains(&kind) {
return Err(Error::BadRequest("Invalid kind".to_string()));
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" { "name" } else { "path" };
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
))
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
let obj = not_found_if_none(obj_o, &kind, &path)?;
tx.commit().await?;
Ok(Json(obj))
}
pub use windmill_api_groups::granular_acls::*;
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -56,9 +56,11 @@ use tower_http::{
};
use windmill_common::db::UserDB;
use windmill_common::worker::CLOUD_HOSTED;
#[allow(unused_imports)]
pub(crate) use windmill_common::BASE_URL;
use windmill_common::{
utils::{configure_client, GIT_VERSION},
BASE_URL, INSTANCE_NAME,
INSTANCE_NAME,
};
use crate::scim_oss::has_scim_token;
@@ -189,8 +191,6 @@ mod workspaces;
#[cfg(feature = "private")]
pub mod workspaces_ee;
mod workspaces_export;
mod workspaces_extra;
mod workspaces_oss;
#[cfg(feature = "mcp")]
mod mcp;
@@ -210,9 +210,7 @@ lazy_static::lazy_static! {
pub static ref SAML_METADATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
pub static ref IS_SECURE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
// COOKIE_DOMAIN and IS_SECURE are now in windmill_common::utils
pub static ref HTTP_CLIENT: Client = configure_client(reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
@@ -224,6 +222,8 @@ lazy_static::lazy_static! {
}
pub use windmill_common::utils::{COOKIE_DOMAIN, IS_SECURE};
#[cfg(feature = "oauth2")]
pub use windmill_oauth::OAUTH_CLIENTS;
+1 -28
View File
@@ -1060,34 +1060,7 @@ pub struct EditSchedule {
pub dynamic_skip: Option<String>,
}
pub async fn clear_schedule<'c>(
tx: &mut Transaction<'c, Postgres>,
path: &str,
w_id: &str,
) -> Result<()> {
tracing::info!("Clearing schedule {}", path);
sqlx::query!(
"WITH to_delete AS (
SELECT id FROM v2_job_queue
JOIN v2_job j USING (id)
WHERE trigger_kind = 'schedule'
AND trigger = $1
AND j.workspace_id = $2
AND flow_step_id IS NULL
AND running = false
FOR UPDATE
), deleted AS (
DELETE FROM v2_job_queue
WHERE id IN (SELECT id FROM to_delete)
RETURNING id
) DELETE FROM v2_job WHERE id IN (SELECT id FROM deleted)",
path,
w_id
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub use windmill_queue::schedule::clear_schedule;
#[derive(Deserialize)]
pub struct SetEnabled {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-7
View File
@@ -62,13 +62,6 @@ pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<Stri
))
}
#[cfg(not(feature = "private"))]
pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) {
tracing::warn!(
"send_email_if_possible is not implemented in Windmill's Open Source repository"
);
}
#[cfg(not(feature = "private"))]
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
+3 -97
View File
@@ -7,13 +7,11 @@
*/
use axum::{body::Body, response::Response};
use regex::Regex;
use serde::{Deserialize, Deserializer};
use sqlx::{Postgres, Transaction};
#[cfg(feature = "enterprise")]
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::{
error::{self, Error},
error::{self},
DB,
};
@@ -25,61 +23,8 @@ use windmill_common::error::JsonResult;
#[cfg(feature = "enterprise")]
use axum::Json;
#[derive(Deserialize)]
pub struct WithStarredInfoQuery {
pub with_starred_info: Option<bool>,
}
// Shared structs for bulk delete operations
#[derive(Deserialize)]
pub struct BulkDeleteRequest {
pub paths: Vec<String>,
}
lazy_static::lazy_static! {
pub static ref INVALID_USERNAME_CHARS: Regex = Regex::new(r"[^A-Za-z0-9_]").unwrap();
}
pub async fn generate_instance_wide_unique_username<'c>(
tx: &mut Transaction<'c, Postgres>,
email: &str,
) -> error::Result<String> {
let mut username = email.split('@').next().unwrap().to_string();
username = INVALID_USERNAME_CHARS
.replace_all(&mut username, "")
.to_string();
if username.is_empty() {
username = "user".to_string()
}
let base_username = username.clone();
let mut username_conflict = true;
let mut i = 1;
while username_conflict {
if i > 1000 {
return Err(Error::internal_err(format!(
"too many username conflicts for {}",
email
)));
}
if i > 1 {
username = format!("{}{}", base_username, i)
}
username_conflict = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 and email != $2 UNION SELECT 1 FROM password WHERE username = $1 UNION SELECT 1 FROM pending_user WHERE username = $1)",
&username,
&email
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
i += 1;
}
Ok(username)
}
pub use windmill_common::usernames::generate_instance_wide_unique_username;
pub use windmill_common::utils::WithStarredInfoQuery;
pub async fn generate_instance_username_for_all_users(db: &DB) -> error::Result<()> {
let mut tx = db.begin().await?;
@@ -119,45 +64,6 @@ pub async fn generate_instance_username_for_all_users(db: &DB) -> error::Result<
Ok(())
}
pub async fn get_instance_username_or_create_pending<'c>(
tx: &mut Transaction<'c, Postgres>,
email: &str,
) -> error::Result<String> {
let user = sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", email)
.fetch_optional(&mut **tx)
.await?;
if let Some(opt_username) = user {
if let Some(username) = opt_username {
Ok(username)
} else {
Err(Error::BadRequest(format!("No instance-wide username found for {email}. The user has different usernames for different workspaces. Ask the instance administrator to solve the conflict in the instance settings.")))
}
} else {
let pending_username =
sqlx::query_scalar!("SELECT username FROM pending_user WHERE email = $1", email)
.fetch_optional(&mut **tx)
.await?;
if let Some(username) = pending_username {
Ok(username)
} else {
let username = generate_instance_wide_unique_username(&mut *tx, email).await?;
sqlx::query!(
"INSERT INTO pending_user (email, username) VALUES ($1, $2)",
email,
username
)
.execute(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("creating pending user: {e:#}")))?;
Ok(username)
}
}
}
pub fn content_plain(body: Body) -> Response {
use axum::http::header;
Response::builder()
File diff suppressed because it is too large Load Diff
+7
View File
@@ -37,3 +37,10 @@ pub async fn send_email_plain_text(
) -> crate::error::Result<()> {
Ok(())
}
#[cfg(not(feature = "private"))]
pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) {
tracing::warn!(
"send_email_if_possible is not implemented in Windmill's Open Source repository"
);
}
+1
View File
@@ -97,6 +97,7 @@ pub mod teams_ee;
pub mod teams_oss;
pub mod tracing_init;
pub mod triggers;
pub mod usernames;
pub mod users;
pub mod utils;
pub mod variables;
+97
View File
@@ -0,0 +1,97 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* 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 regex::Regex;
use sqlx::{Postgres, Transaction};
use crate::error::{self, Error};
lazy_static::lazy_static! {
pub static ref INVALID_USERNAME_CHARS: Regex = Regex::new(r"[^A-Za-z0-9_]").unwrap();
pub static ref VALID_USERNAME: Regex = Regex::new(r#"^[a-zA-Z][a-zA-Z_0-9]*$"#).unwrap();
}
pub async fn generate_instance_wide_unique_username<'c>(
tx: &mut Transaction<'c, Postgres>,
email: &str,
) -> error::Result<String> {
let mut username = email.split('@').next().unwrap().to_string();
username = INVALID_USERNAME_CHARS
.replace_all(&mut username, "")
.to_string();
if username.is_empty() {
username = "user".to_string()
}
let base_username = username.clone();
let mut username_conflict = true;
let mut i = 1;
while username_conflict {
if i > 1000 {
return Err(Error::internal_err(format!(
"too many username conflicts for {}",
email
)));
}
if i > 1 {
username = format!("{}{}", base_username, i)
}
username_conflict = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 and email != $2 UNION SELECT 1 FROM password WHERE username = $1 UNION SELECT 1 FROM pending_user WHERE username = $1)",
&username,
&email
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
i += 1;
}
Ok(username)
}
pub async fn get_instance_username_or_create_pending<'c>(
tx: &mut Transaction<'c, Postgres>,
email: &str,
) -> error::Result<String> {
let user = sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", email)
.fetch_optional(&mut **tx)
.await?;
if let Some(opt_username) = user {
if let Some(username) = opt_username {
Ok(username)
} else {
Err(Error::BadRequest(format!("No instance-wide username found for {email}. The user has different usernames for different workspaces. Ask the instance administrator to solve the conflict in the instance settings.")))
}
} else {
let pending_username =
sqlx::query_scalar!("SELECT username FROM pending_user WHERE email = $1", email)
.fetch_optional(&mut **tx)
.await?;
if let Some(username) = pending_username {
Ok(username)
} else {
let username = generate_instance_wide_unique_username(&mut *tx, email).await?;
sqlx::query!(
"INSERT INTO pending_user (email, username) VALUES ($1, $2)",
email,
username
)
.execute(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("creating pending user: {e:#}")))?;
Ok(username)
}
}
}
+3
View File
@@ -51,6 +51,9 @@ use std::sync::atomic::Ordering;
use crate::worker::CLOUD_HOSTED;
lazy_static::lazy_static! {
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
pub static ref IS_SECURE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
pub static ref FORCE_IPV4: bool = std::env::var("FORCE_IPV4")
.map(|v| v.to_lowercase() == "true" || v == "1")
.unwrap_or(false);
+33 -17
View File
@@ -209,9 +209,7 @@ mod tests {
#[test]
fn test_webhook_message_resource_type() {
let msg = WebhookMessage::CreateResourceType {
name: "postgresql".to_string(),
};
let msg = WebhookMessage::CreateResourceType { name: "postgresql".to_string() };
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["type"], "CreateResourceType");
assert_eq!(json["name"], "postgresql");
@@ -224,9 +222,17 @@ mod tests {
let messages: Vec<WebhookMessage> = vec![
WebhookMessage::CreateApp { workspace: "w".into(), path: "p".into() },
WebhookMessage::DeleteApp { workspace: "w".into(), path: "p".into() },
WebhookMessage::UpdateApp { workspace: "w".into(), old_path: "o".into(), new_path: "n".into() },
WebhookMessage::UpdateApp {
workspace: "w".into(),
old_path: "o".into(),
new_path: "n".into(),
},
WebhookMessage::CreateFlow { workspace: "w".into(), path: "p".into() },
WebhookMessage::UpdateFlow { workspace: "w".into(), old_path: "o".into(), new_path: "n".into() },
WebhookMessage::UpdateFlow {
workspace: "w".into(),
old_path: "o".into(),
new_path: "n".into(),
},
WebhookMessage::ArchiveFlow { workspace: "w".into(), path: "p".into() },
WebhookMessage::DeleteFlow { workspace: "w".into(), path: "p".into() },
WebhookMessage::CreateFolder { workspace: "w".into(), name: "n".into() },
@@ -234,16 +240,32 @@ mod tests {
WebhookMessage::DeleteFolder { workspace: "w".into(), name: "n".into() },
WebhookMessage::DeleteResource { workspace: "w".into(), path: "p".into() },
WebhookMessage::CreateResource { workspace: "w".into(), path: "p".into() },
WebhookMessage::UpdateResource { workspace: "w".into(), old_path: "o".into(), new_path: "n".into() },
WebhookMessage::UpdateResource {
workspace: "w".into(),
old_path: "o".into(),
new_path: "n".into(),
},
WebhookMessage::CreateResourceType { name: "n".into() },
WebhookMessage::DeleteResourceType { name: "n".into() },
WebhookMessage::UpdateResourceType { name: "n".into() },
WebhookMessage::CreateScript { workspace: "w".into(), path: "p".into(), hash: "h".into() },
WebhookMessage::UpdateScript { workspace: "w".into(), path: "p".into(), hash: "h".into() },
WebhookMessage::CreateScript {
workspace: "w".into(),
path: "p".into(),
hash: "h".into(),
},
WebhookMessage::UpdateScript {
workspace: "w".into(),
path: "p".into(),
hash: "h".into(),
},
WebhookMessage::DeleteScript { workspace: "w".into(), hash: "h".into() },
WebhookMessage::DeleteScriptPath { workspace: "w".into(), path: "p".into() },
WebhookMessage::CreateVariable { workspace: "w".into(), path: "p".into() },
WebhookMessage::UpdateVariable { workspace: "w".into(), old_path: "o".into(), new_path: "n".into() },
WebhookMessage::UpdateVariable {
workspace: "w".into(),
old_path: "o".into(),
new_path: "n".into(),
},
WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() },
];
@@ -259,17 +281,11 @@ mod tests {
#[test]
fn test_webhook_message_type_tags_are_variant_names() {
let msg = WebhookMessage::CreateApp {
workspace: "w".into(),
path: "p".into(),
};
let msg = WebhookMessage::CreateApp { workspace: "w".into(), path: "p".into() };
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["type"], "CreateApp");
let msg = WebhookMessage::DeleteVariable {
workspace: "w".into(),
path: "p".into(),
};
let msg = WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() };
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["type"], "DeleteVariable");
}
+2 -2
View File
@@ -42,10 +42,10 @@ pub use oauth2::{
// Re-export reqwest Client (version 0.12 compatible with async-oauth2)
pub use reqwest::Client as HttpClient;
pub use windmill_common::utils::{COOKIE_DOMAIN, IS_SECURE};
lazy_static::lazy_static! {
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
pub static ref IS_SECURE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
/// HTTP client for OAuth operations (reqwest 0.12, compatible with async-oauth2)
pub static ref OAUTH_HTTP_CLIENT: reqwest::Client = reqwest::ClientBuilder::new()
+29
View File
@@ -581,3 +581,32 @@ pub async fn exists_schedule(
Ok(exists)
}
pub async fn clear_schedule<'c>(
tx: &mut Transaction<'c, Postgres>,
path: &str,
w_id: &str,
) -> Result<()> {
tracing::info!("Clearing schedule {}", path);
sqlx::query!(
"WITH to_delete AS (
SELECT id FROM v2_job_queue
JOIN v2_job j USING (id)
WHERE trigger_kind = 'schedule'
AND trigger = $1
AND j.workspace_id = $2
AND flow_step_id IS NULL
AND running = false
FOR UPDATE
), deleted AS (
DELETE FROM v2_job_queue
WHERE id IN (SELECT id FROM to_delete)
RETURNING id
) DELETE FROM v2_job WHERE id IN (SELECT id FROM deleted)",
path,
w_id
)
.execute(&mut **tx)
.await?;
Ok(())
}