From bc119011ae4d6f49e5b4cc83808ae9bb50c0fdea Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 8 Feb 2026 13:12:03 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * all * chore: bust CI cargo cache for subcrate split Co-Authored-By: Claude Opus 4.6 * fix: re-export BASE_URL for EE files that use crate::BASE_URL Co-Authored-By: Claude Opus 4.6 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/backend-check.yml | 10 +- .github/workflows/backend-test.yml | 7 +- ...1f7f387f5055c47f493271d26731336257384.json | 10 +- ...bc7e9ee85289ff5c52af43166928337c257e7.json | 3 +- ...7b23d6dcf2e5364852a5e85b3c41c417bace2.json | 3 +- ...9e8c0bb132b70668301b535872d1632753e30.json | 3 +- ...66f38ffe2f3a0958adea53e67757c6ac463ec.json | 3 +- ...243ad2e0aef485a780daea5d76b0be2bb2ea2.json | 3 +- ...fbe7db4887c4ac5295052c796bd78a7edc50b.json | 3 +- ...f518b42f4cdb33e034441015494bd26c172d2.json | 3 +- ...cad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json | 3 +- ...2f7175cc6f2e02d9c565bb5159cae3e0b6835.json | 3 +- ...08532a1020cabaf5e22325a1e05f0491d785c.json | 3 +- ...e35adf640d44d607e62a1fb296e38ac3fdd36.json | 6 +- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json | 3 +- ...1a803e1bced2f8e1a12f8933b7598ee85a147.json | 3 +- ...4f412c57af931f80621aee5012e9cb3535626.json | 3 +- ...8d92a983470adcd3cc850734960c905e61e83.json | 3 +- ...ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json | 3 +- ...a22e4276c1da653475628889ce17dc904fbaa.json | 3 +- ...6eb9016207975af1e37042d81df8cb6ae2c53.json | 3 +- ...506bf08c28efe82b68b3d44bafbd3d0e91c29.json | 3 +- ...d2393e297043e4e58555d872b5c4dd89c196a.json | 3 +- ...3a960b89e02ec40b292f3b5198baf2a1d3dbf.json | 3 +- ...fbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json | 3 +- ...2ede46b4471e21b11f607573a844013911abe.json | 3 +- ...e33244fe8ab9a803248fc23f334034d24aad4.json | 3 +- ...bb6a8f961c119516e4c33dc928cd3b4f4edbc.json | 3 +- ...9e637e9e156cd85b69126c87dfff89a47711d.json | 3 +- ...9746147aa9b8d0a81642a7b7cb4738a0cad66.json | 3 +- ...d434f860922fbc021185beffb9827647feb8e.json | 3 +- ...13f1f882b695f896f94e5cf91d205633355a1.json | 3 +- ...e1ea2fe96a1b94f5a92953fb1c1642d15c016.json | 3 +- ...1a8a9cc35f362197c0766a5787436af141106.json | 3 +- ...9352d4d1e3cb3a8a08f1c9c03055a1cab1235.json | 3 +- ...f03f72ef81db302d727e18e63a307ac902022.json | 3 +- ...c35c7342ac9a814c73f6290e1d6ebd3a55423.json | 3 +- ...50d6a6935420fbedc83aa3ad1e9be7465c8dd.json | 3 +- backend/Cargo.lock | 104 + backend/Cargo.toml | 8 + backend/ee-repo-ref.txt | 2 +- backend/windmill-api-groups/Cargo.toml | 31 + .../windmill-api-groups/src/folder_history.rs | 68 + backend/windmill-api-groups/src/folders.rs | 829 +++ .../windmill-api-groups/src/granular_acls.rs | 396 ++ backend/windmill-api-groups/src/groups.rs | 1092 ++++ backend/windmill-api-groups/src/lib.rs | 4 + backend/windmill-api-scripts/Cargo.toml | 40 + backend/windmill-api-scripts/src/flows.rs | 1945 +++++++ backend/windmill-api-scripts/src/lib.rs | 10 + backend/windmill-api-scripts/src/scripts.rs | 2318 ++++++++ backend/windmill-api-users/Cargo.toml | 36 + backend/windmill-api-users/src/lib.rs | 2 + backend/windmill-api-users/src/users.rs | 2351 ++++++++ backend/windmill-api-users/src/users_oss.rs | 65 + backend/windmill-api-workspaces/Cargo.toml | 41 + backend/windmill-api-workspaces/src/lib.rs | 6 + .../windmill-api-workspaces/src/workspaces.rs | 5040 ++++++++++++++++ .../src/workspaces_extra.rs | 6 +- .../src/workspaces_oss.rs | 9 +- backend/windmill-api/Cargo.toml | 14 +- backend/windmill-api/src/flows.rs | 1942 +------ backend/windmill-api/src/folder_history.rs | 69 +- backend/windmill-api/src/folders.rs | 834 +-- backend/windmill-api/src/granular_acls.rs | 396 +- backend/windmill-api/src/groups.rs | 1093 +--- backend/windmill-api/src/lib.rs | 12 +- backend/windmill-api/src/schedule.rs | 29 +- backend/windmill-api/src/scripts.rs | 2315 +------- backend/windmill-api/src/users.rs | 2346 +------- backend/windmill-api/src/users_oss.rs | 7 - backend/windmill-api/src/utils.rs | 100 +- backend/windmill-api/src/workspaces.rs | 5066 +---------------- backend/windmill-common/src/email_oss.rs | 7 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/usernames.rs | 97 + backend/windmill-common/src/utils.rs | 3 + backend/windmill-common/src/webhook.rs | 50 +- backend/windmill-oauth/src/lib.rs | 4 +- backend/windmill-queue/src/schedule.rs | 29 + 81 files changed, 14707 insertions(+), 14247 deletions(-) create mode 100644 backend/windmill-api-groups/Cargo.toml create mode 100644 backend/windmill-api-groups/src/folder_history.rs create mode 100644 backend/windmill-api-groups/src/folders.rs create mode 100644 backend/windmill-api-groups/src/granular_acls.rs create mode 100644 backend/windmill-api-groups/src/groups.rs create mode 100644 backend/windmill-api-groups/src/lib.rs create mode 100644 backend/windmill-api-scripts/Cargo.toml create mode 100644 backend/windmill-api-scripts/src/flows.rs create mode 100644 backend/windmill-api-scripts/src/lib.rs create mode 100644 backend/windmill-api-scripts/src/scripts.rs create mode 100644 backend/windmill-api-users/Cargo.toml create mode 100644 backend/windmill-api-users/src/lib.rs create mode 100644 backend/windmill-api-users/src/users.rs create mode 100644 backend/windmill-api-users/src/users_oss.rs create mode 100644 backend/windmill-api-workspaces/Cargo.toml create mode 100644 backend/windmill-api-workspaces/src/lib.rs create mode 100644 backend/windmill-api-workspaces/src/workspaces.rs rename backend/{windmill-api => windmill-api-workspaces}/src/workspaces_extra.rs (99%) rename backend/{windmill-api => windmill-api-workspaces}/src/workspaces_oss.rs (71%) create mode 100644 backend/windmill-common/src/usernames.rs diff --git a/.github/workflows/backend-check.yml b/.github/workflows/backend-check.yml index 2625049141..e0ab0a7317 100644 --- a/.github/workflows/backend-check.yml +++ b/.github/workflows/backend-check.yml @@ -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: | diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 6eda2a1213..2d137b787e 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -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 diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json index b925141065..a032a87239 100644 --- a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json +++ b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json @@ -30,8 +30,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-0f7e01b613a94b29784aae6d7b17b23d6dcf2e5364852a5e85b3c41c417bace2.json b/backend/.sqlx/query-0f7e01b613a94b29784aae6d7b17b23d6dcf2e5364852a5e85b3c41c417bace2.json index de4dd4a7e3..1648882c1a 100644 --- a/backend/.sqlx/query-0f7e01b613a94b29784aae6d7b17b23d6dcf2e5364852a5e85b3c41c417bace2.json +++ b/backend/.sqlx/query-0f7e01b613a94b29784aae6d7b17b23d6dcf2e5364852a5e85b3c41c417bace2.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json index bed99ef1b7..d4f7afa966 100644 --- a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json +++ b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json @@ -122,8 +122,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-16d438374b03a9c515f4c2d638366f38ffe2f3a0958adea53e67757c6ac463ec.json b/backend/.sqlx/query-16d438374b03a9c515f4c2d638366f38ffe2f3a0958adea53e67757c6ac463ec.json index 1fdc78c472..c091114374 100644 --- a/backend/.sqlx/query-16d438374b03a9c515f4c2d638366f38ffe2f3a0958adea53e67757c6ac463ec.json +++ b/backend/.sqlx/query-16d438374b03a9c515f4c2d638366f38ffe2f3a0958adea53e67757c6ac463ec.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json index 30e071f8ce..43c68f8c5a 100644 --- a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json +++ b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json @@ -40,8 +40,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-22e0e8a1aa48f8b21763452bd36fbe7db4887c4ac5295052c796bd78a7edc50b.json b/backend/.sqlx/query-22e0e8a1aa48f8b21763452bd36fbe7db4887c4ac5295052c796bd78a7edc50b.json index 235f255dd1..66c9e6e96a 100644 --- a/backend/.sqlx/query-22e0e8a1aa48f8b21763452bd36fbe7db4887c4ac5295052c796bd78a7edc50b.json +++ b/backend/.sqlx/query-22e0e8a1aa48f8b21763452bd36fbe7db4887c4ac5295052c796bd78a7edc50b.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json b/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json index 7bd7367d8d..242b358ff5 100644 --- a/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json +++ b/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json @@ -40,8 +40,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json b/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json index 537ecbeacf..e6d71f386f 100644 --- a/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json +++ b/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-45024b932383199974616bba1fc2f7175cc6f2e02d9c565bb5159cae3e0b6835.json b/backend/.sqlx/query-45024b932383199974616bba1fc2f7175cc6f2e02d9c565bb5159cae3e0b6835.json index 30c5ff7a49..f215cf6147 100644 --- a/backend/.sqlx/query-45024b932383199974616bba1fc2f7175cc6f2e02d9c565bb5159cae3e0b6835.json +++ b/backend/.sqlx/query-45024b932383199974616bba1fc2f7175cc6f2e02d9c565bb5159cae3e0b6835.json @@ -30,8 +30,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-4d272cf4a77aab7007a5b35589e08532a1020cabaf5e22325a1e05f0491d785c.json b/backend/.sqlx/query-4d272cf4a77aab7007a5b35589e08532a1020cabaf5e22325a1e05f0491d785c.json index cfb975b838..896b21dc7f 100644 --- a/backend/.sqlx/query-4d272cf4a77aab7007a5b35589e08532a1020cabaf5e22325a1e05f0491d785c.json +++ b/backend/.sqlx/query-4d272cf4a77aab7007a5b35589e08532a1020cabaf5e22325a1e05f0491d785c.json @@ -37,8 +37,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json b/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json index 4e8ec56d12..789c0334d7 100644 --- a/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json +++ b/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json @@ -32,8 +32,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } @@ -71,8 +70,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json index 9422e3e8d0..6d3941ad69 100644 --- a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json +++ b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json @@ -245,8 +245,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-66a0e51cf149ba532463e29dd361a803e1bced2f8e1a12f8933b7598ee85a147.json b/backend/.sqlx/query-66a0e51cf149ba532463e29dd361a803e1bced2f8e1a12f8933b7598ee85a147.json index 5f9be1cba9..0d12be2448 100644 --- a/backend/.sqlx/query-66a0e51cf149ba532463e29dd361a803e1bced2f8e1a12f8933b7598ee85a147.json +++ b/backend/.sqlx/query-66a0e51cf149ba532463e29dd361a803e1bced2f8e1a12f8933b7598ee85a147.json @@ -35,8 +35,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-7065f23d04e26831664048f2cfc4f412c57af931f80621aee5012e9cb3535626.json b/backend/.sqlx/query-7065f23d04e26831664048f2cfc4f412c57af931f80621aee5012e9cb3535626.json index 48ea42f8a2..02823e8291 100644 --- a/backend/.sqlx/query-7065f23d04e26831664048f2cfc4f412c57af931f80621aee5012e9cb3535626.json +++ b/backend/.sqlx/query-7065f23d04e26831664048f2cfc4f412c57af931f80621aee5012e9cb3535626.json @@ -29,8 +29,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-73fdd01bad58b8be1a52f89faef8d92a983470adcd3cc850734960c905e61e83.json b/backend/.sqlx/query-73fdd01bad58b8be1a52f89faef8d92a983470adcd3cc850734960c905e61e83.json index 8aacd8a805..cba7ffdfef 100644 --- a/backend/.sqlx/query-73fdd01bad58b8be1a52f89faef8d92a983470adcd3cc850734960c905e61e83.json +++ b/backend/.sqlx/query-73fdd01bad58b8be1a52f89faef8d92a983470adcd3cc850734960c905e61e83.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json index 59b56ceda7..cd795e6fec 100644 --- a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json +++ b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json @@ -40,8 +40,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json b/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json index 8c380df861..393a920b7c 100644 --- a/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json +++ b/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json @@ -27,8 +27,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-836bac47d89113d90bd03a471446eb9016207975af1e37042d81df8cb6ae2c53.json b/backend/.sqlx/query-836bac47d89113d90bd03a471446eb9016207975af1e37042d81df8cb6ae2c53.json index 3012e9ef77..437d644eb2 100644 --- a/backend/.sqlx/query-836bac47d89113d90bd03a471446eb9016207975af1e37042d81df8cb6ae2c53.json +++ b/backend/.sqlx/query-836bac47d89113d90bd03a471446eb9016207975af1e37042d81df8cb6ae2c53.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json b/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json index 5a61f82be3..9350442134 100644 --- a/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json +++ b/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json @@ -35,8 +35,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-940b6d78bab940a37a42492f030d2393e297043e4e58555d872b5c4dd89c196a.json b/backend/.sqlx/query-940b6d78bab940a37a42492f030d2393e297043e4e58555d872b5c4dd89c196a.json index 09aadbda1e..47554ad43e 100644 --- a/backend/.sqlx/query-940b6d78bab940a37a42492f030d2393e297043e4e58555d872b5c4dd89c196a.json +++ b/backend/.sqlx/query-940b6d78bab940a37a42492f030d2393e297043e4e58555d872b5c4dd89c196a.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json b/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json index 66a487695f..9759bad4d4 100644 --- a/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json +++ b/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json @@ -32,8 +32,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json index b6fe1b7fa0..79c8f0b45d 100644 --- a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json +++ b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json @@ -30,8 +30,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json index 33636da608..a25f845f91 100644 --- a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json +++ b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json @@ -155,8 +155,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json index 03ee58ca3f..da6d213748 100644 --- a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json +++ b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json @@ -185,8 +185,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json index 4fa7da00e0..d08af6ffdd 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json @@ -160,8 +160,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json b/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json index 186822a000..54a4e3cd93 100644 --- a/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json +++ b/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json index 4118760af2..0688afebd3 100644 --- a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json +++ b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json @@ -105,8 +105,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-c3b1152b554812d65eb27f95b1fd434f860922fbc021185beffb9827647feb8e.json b/backend/.sqlx/query-c3b1152b554812d65eb27f95b1fd434f860922fbc021185beffb9827647feb8e.json index 07243717a0..0098e51ab2 100644 --- a/backend/.sqlx/query-c3b1152b554812d65eb27f95b1fd434f860922fbc021185beffb9827647feb8e.json +++ b/backend/.sqlx/query-c3b1152b554812d65eb27f95b1fd434f860922fbc021185beffb9827647feb8e.json @@ -31,8 +31,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-ccef7a1bde5cac6c362c5fedb6c13f1f882b695f896f94e5cf91d205633355a1.json b/backend/.sqlx/query-ccef7a1bde5cac6c362c5fedb6c13f1f882b695f896f94e5cf91d205633355a1.json index 2b39145512..5091b2fc69 100644 --- a/backend/.sqlx/query-ccef7a1bde5cac6c362c5fedb6c13f1f882b695f896f94e5cf91d205633355a1.json +++ b/backend/.sqlx/query-ccef7a1bde5cac6c362c5fedb6c13f1f882b695f896f94e5cf91d205633355a1.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json index 9d2f8d9a9f..11271e94d8 100644 --- a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json +++ b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json @@ -105,8 +105,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-d495c94b580fd34d5ae90615ef21a8a9cc35f362197c0766a5787436af141106.json b/backend/.sqlx/query-d495c94b580fd34d5ae90615ef21a8a9cc35f362197c0766a5787436af141106.json index 67a38d1904..9cef6500d3 100644 --- a/backend/.sqlx/query-d495c94b580fd34d5ae90615ef21a8a9cc35f362197c0766a5787436af141106.json +++ b/backend/.sqlx/query-d495c94b580fd34d5ae90615ef21a8a9cc35f362197c0766a5787436af141106.json @@ -25,8 +25,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json index 7309b03a02..5b07bcd9c9 100644 --- a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json +++ b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json @@ -185,8 +185,7 @@ "sqs", "gcp", "mqtt", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-e80177f3ffd4c1f52cdb4757483f03f72ef81db302d727e18e63a307ac902022.json b/backend/.sqlx/query-e80177f3ffd4c1f52cdb4757483f03f72ef81db302d727e18e63a307ac902022.json index dd65a586d1..1e38e57fb5 100644 --- a/backend/.sqlx/query-e80177f3ffd4c1f52cdb4757483f03f72ef81db302d727e18e63a307ac902022.json +++ b/backend/.sqlx/query-e80177f3ffd4c1f52cdb4757483f03f72ef81db302d727e18e63a307ac902022.json @@ -31,8 +31,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json b/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json index 7c45c44b13..57607ae052 100644 --- a/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json +++ b/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/.sqlx/query-ed8facbf29ebb670d05fe8aa34b50d6a6935420fbedc83aa3ad1e9be7465c8dd.json b/backend/.sqlx/query-ed8facbf29ebb670d05fe8aa34b50d6a6935420fbedc83aa3ad1e9be7465c8dd.json index 1876db927e..43fd90abe5 100644 --- a/backend/.sqlx/query-ed8facbf29ebb670d05fe8aa34b50d6a6935420fbedc83aa3ad1e9be7465c8dd.json +++ b/backend/.sqlx/query-ed8facbf29ebb670d05fe8aa34b50d6a6935420fbedc83aa3ad1e9be7465c8dd.json @@ -24,8 +24,7 @@ "mqtt", "gcp", "default_email", - "nextcloud", - "google" + "nextcloud" ] } } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7720be162d..a4449f8fe2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dc880625e4..5ae09ccef0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 14252c346d..1e12969ab0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -327cf1bff1c5a61f6ea2bd81f1476bee51d152c5 \ No newline at end of file +9f9dfab164c9a53bdf8750970f9ccf49693ca342 \ No newline at end of file diff --git a/backend/windmill-api-groups/Cargo.toml b/backend/windmill-api-groups/Cargo.toml new file mode 100644 index 0000000000..f785b9ebed --- /dev/null +++ b/backend/windmill-api-groups/Cargo.toml @@ -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 diff --git a/backend/windmill-api-groups/src/folder_history.rs b/backend/windmill-api-groups/src/folder_history.rs new file mode 100644 index 0000000000..b11f328e3d --- /dev/null +++ b/backend/windmill-api-groups/src/folder_history.rs @@ -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, + pub change_type: String, + pub affected: Option, +} + +async fn get_folder_permission_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Query(pagination): Query, +) -> JsonResult> { + // 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)) +} diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs new file mode 100644 index 0000000000..1a4d530462 --- /dev/null +++ b/backend/windmill-api-groups/src/folders.rs @@ -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, + pub extra_perms: serde_json::Value, + pub summary: Option, + pub created_by: Option, + pub edited_at: Option>, +} + +#[derive(Deserialize)] +pub struct NewFolder { + pub name: String, + pub summary: Option, + pub display_name: Option, + pub owners: Option>, + pub extra_perms: Option, +} + +#[derive(Deserialize)] +pub struct UpdateFolder { + pub summary: Option, + pub display_name: Option, + pub owners: Option>, + pub extra_perms: Option, +} + +#[derive(Deserialize)] +pub struct Owner { + pub owner: String, + pub write: Option, +} + +async fn list_folders( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Query(pagination): Query, +) -> JsonResult> { + 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, + Path(w_id): Path, + Query(pagination): Query, +) -> JsonResult> { + 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, + Extension(user_db): Extension, + Extension(webhook): Extension, + Extension(cache): Extension>, + Path(w_id): Path, + Json(ng): Json, +) -> Result { + 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 { + 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, + Extension(user_db): Extension, + Extension(webhook): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(mut ng): Json, +) -> Result { + 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::>() + .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> { + 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, + Path((w_id, name)): Path<(String, String)>, +) -> JsonResult { + 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, + Path((w_id, name)): Path<(String, String)>, +) -> JsonResult { + 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, + Path((w_id, name)): Path<(String, String)>, +) -> JsonResult { + 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, + Extension(user_db): Extension, + Extension(webhook): Extension, + Path((w_id, name)): Path<(String, String)>, +) -> Result { + 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, + Extension(webhook): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(Owner { owner, .. }): Json, +) -> Result { + 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, + Extension(webhook): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(Owner { owner, write }): Json, +) -> Result { + 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>(&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(()) +} diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs new file mode 100644 index 0000000000..048a4120f5 --- /dev/null +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -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, +} + +async fn add_granular_acl( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(GranularAcl { owner, write }): Json, +) -> Result { + 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, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(GranularAcl { owner, .. }): Json, +) -> Result { + 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, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + 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)) +} diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs new file mode 100644 index 0000000000..9a66531329 --- /dev/null +++ b/backend/windmill-api-groups/src/groups.rs @@ -0,0 +1,1092 @@ +/* + * 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 windmill_api_auth::{require_super_admin, ApiAuthed}; +use windmill_common::DB; + +use axum::{ + extract::{Extension, Path, Query}, + routing::{delete, get, post}, + Json, Router, +}; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::worker::CLOUD_HOSTED; +use windmill_common::{ + auth::get_groups_for_user, + error::{Error, JsonResult, Result}, + utils::{not_found_if_none, paginate, Pagination}, +}; +use windmill_common::{db::UserDB, users::username_to_permissioned_as}; + +use serde::{Deserialize, Serialize}; +use sqlx::{query_scalar, FromRow, Postgres, Transaction}; +use windmill_git_sync::handle_deployment_metadata; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/list", get(list_groups)) + .route("/listnames", get(list_group_names)) + .route("/create", post(create_group)) + .route("/get/:name", get(get_group)) + .route("/update/:name", post(update_group)) + .route("/delete/:name", delete(delete_group)) + .route("/adduser/:name", post(add_user)) + .route("/removeuser/:name", post(remove_user)) + .route("/is_owner", get(is_owner)) +} + +pub fn global_service() -> Router { + Router::new() + .route("/list", get(list_igroups)) + .route("/list_with_workspaces", get(list_igroups_with_workspaces)) + .route("/get/:name", get(get_igroup)) + .route("/create", post(create_igroup)) + .route("/update/:name", post(update_igroup)) + .route("/delete/:name", delete(delete_igroup)) + .route("/adduser/:name", post(add_user_igroup)) + .route("/removeuser/:name", post(remove_user_igroup)) + .route("/export", get(export_igroups)) + .route("/overwrite", post(overwrite_igroups)) +} + +/// Normalize group names: replace spaces with underscores and convert to lowercase +/// Used when manually creating groups and SCIM-managed groups +pub fn convert_name(name: &str) -> String { + name.replace(" ", "_").to_lowercase() +} + +#[derive(FromRow, Serialize, Deserialize)] +pub struct Group { + pub workspace_id: String, + pub name: String, + pub summary: Option, + pub extra_perms: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct NewGroup { + pub name: String, + pub summary: Option, +} + +#[derive(Serialize)] +pub struct GroupInfo { + pub workspace_id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub members: Vec, + pub extra_perms: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct EditGroup { + pub summary: Option, +} + +#[derive(Deserialize)] +pub struct Username { + pub username: String, +} + +#[derive(Deserialize)] +pub struct Email { + pub email: String, +} + +async fn list_groups( + Extension(db): Extension, + Path(w_id): Path, + Query(pagination): Query, +) -> JsonResult> { + let (per_page, offset) = paginate(pagination); + + let rows = sqlx::query_as!( + Group, + "SELECT * FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", + w_id, + per_page as i64, + offset as i64 + ) + .fetch_all(&db) + .await?; + + Ok(Json(rows)) +} + +#[derive(Deserialize)] +struct QueryListGroup { + pub only_member_of: Option, +} +async fn list_group_names( + ApiAuthed { username, email, .. }: ApiAuthed, + Extension(db): Extension, + Query(QueryListGroup { only_member_of }): Query, + Path(w_id): Path, +) -> JsonResult> { + let rows = if !only_member_of.unwrap_or(false) { + sqlx::query_scalar!( + "SELECT name FROM group_ WHERE workspace_id = $1 UNION SELECT name FROM instance_group ORDER BY name asc", + w_id + ) + .fetch_all(&db) + .await? + .into_iter() + .filter_map(|x| x) + .collect() + } else { + get_groups_for_user(&w_id, &username, &email, &db).await? + }; + + Ok(Json(rows)) +} + +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 group_ 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!( + "Group {} already exists", + name + ))); + } + return Ok(()); +} + +pub async fn is_owner( + ApiAuthed { username, is_admin, groups, .. }: ApiAuthed, + Extension(db): Extension, + Path((w_id, name)): Path<(String, String)>, +) -> JsonResult { + if is_admin { + Ok(Json(true)) + } else { + Ok(Json( + require_is_owner(&name, &username, &groups, &w_id, &db) + .await + .is_ok(), + )) + } +} + +pub async fn require_is_owner( + group_name: &str, + username: &str, + groups: &Vec, + w_id: &str, + db: &DB, +) -> Result<()> { + let is_owner = query_scalar!( + "SELECT EXISTS(SELECT 1 FROM group_ WHERE (group_.extra_perms ->> CONCAT('u/', $1::text))::boolean AND name = $2 AND workspace_id = $4) OR exists( + SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f + WHERE $2 = g.name AND $4 = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY($3::text[]) + AND value::boolean)", + username, + group_name, + groups, + w_id, + ).fetch_one(db) + .await? + .unwrap_or(false); + if !is_owner { + Err(Error::BadRequest(format!( + "{} is not an owner of {} and hence is not authorized to perform this operation", + username, group_name + ))) + } else { + Ok(()) + } +} + +async fn _check_nb_of_groups(db: &DB) -> Result<()> { + let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack'",) + .fetch_one(db) + .await?; + if nb_groups.unwrap_or(0) >= 3 { + return Err(Error::BadRequest( + "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack' and 'error_handler') without an enterprise license" + .to_string(), + )); + } + return Ok(()); +} + +async fn create_group( + authed: ApiAuthed, + Extension(_db): Extension, + Extension(user_db): Extension, + Path(w_id): Path, + Json(ng): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + + check_name_conflict(&mut tx, &w_id, &ng.name).await?; + + #[cfg(not(feature = "enterprise"))] + _check_nb_of_groups(&_db).await?; + + sqlx::query!( + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4)", + w_id, + ng.name, + ng.summary, + serde_json::json!({username_to_permissioned_as(&authed.username): true}) + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + &w_id, + &authed.username, + ng.name, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "group.create", + ActionKind::Create, + &w_id, + Some(&ng.name.to_string()), + None, + ) + .await?; + + log_group_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, + windmill_git_sync::DeployedObject::Group { name: ng.name.clone() }, + Some(format!("Created group '{}'", &ng.name)), + true, + None, + ) + .await?; + + Ok(format!("Created group {}", ng.name)) +} + +async fn create_igroup( + authed: ApiAuthed, + Extension(db): Extension, + Json(ng): Json, +) -> Result { + use uuid::Uuid; + + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + let normalized_name = convert_name(&ng.name); + + let id = Uuid::new_v4().to_string(); + sqlx::query!( + "INSERT INTO instance_group (name, summary, id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + normalized_name, + ng.summary, + id, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "igroup.create", + ActionKind::Create, + "global", + Some(&normalized_name), + None, + ) + .await?; + + tx.commit().await?; + Ok(format!("Created group {}", normalized_name)) +} + +#[derive(Deserialize)] +struct IGroupUpdate { + new_summary: String, +} + +async fn update_igroup( + authed: ApiAuthed, + Extension(db): Extension, + Path(name): Path, + Json(igroup_update): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1") + .bind(name.clone()) + .fetch_optional(&mut *tx) + .await?; + not_found_if_none(exists_opt, "instance_group", name.clone())?; + + sqlx::query("UPDATE instance_group SET summary = $1 WHERE name = $2") + .bind(igroup_update.new_summary) + .bind(&name) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "igroup.updated", + ActionKind::Delete, + "global", + Some(&name.to_string()), + None, + ) + .await?; + + tx.commit().await?; + Ok(format!("Deleted group {}", name)) +} + +async fn delete_igroup( + authed: ApiAuthed, + Extension(db): Extension, + Path(name): Path, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "igroup.delete", + ActionKind::Delete, + "global", + Some(&name.to_string()), + None, + ) + .await?; + + tx.commit().await?; + Ok(format!("Deleted group {}", name)) +} + +pub async fn get_group_opt<'c>( + db: &mut Transaction<'c, Postgres>, + w_id: &str, + name: &str, +) -> Result> { + let group_opt = sqlx::query_as!( + Group, + "SELECT * FROM group_ WHERE name = $1 AND workspace_id = $2", + name, + w_id + ) + .fetch_optional(&mut **db) + .await?; + Ok(group_opt) +} + +async fn get_group( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, +) -> JsonResult { + if *CLOUD_HOSTED && w_id == "demo" && name == "all" && !authed.is_admin { + return Ok(Json(GroupInfo { + workspace_id: w_id, + name: name, + summary: Some("The group that contains all users".to_string()), + members: vec!["redacted_in_demo_workspace".to_string()], + extra_perms: serde_json::json!({}), + })); + } + + let mut tx = user_db.begin(&authed).await?; + + let group = not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + + let members = sqlx::query_scalar!( + "SELECT usr.username + FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username AND usr_to_group.workspace_id = $2 + WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2", + name, + w_id + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + Ok(Json(GroupInfo { + workspace_id: group.workspace_id, + name: group.name, + summary: group.summary, + members, + extra_perms: group.extra_perms, + })) +} + +async fn delete_group( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + + if name == "all" { + return Err(Error::BadRequest( + "The group 'all' is a special group that contains all users and cannot be deleted" + .to_string(), + )); + } + + if !authed.is_admin { + require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; + } + not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + + sqlx::query!( + "DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2", + name, + w_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "DELETE FROM group_ WHERE name = $1 AND workspace_id = $2", + name, + w_id + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "group.delete", + ActionKind::Delete, + &w_id, + Some(&name.to_string()), + None, + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Group { name: name.clone() }, + Some(format!("Deleted group '{}'", &name)), + true, + None, + ) + .await?; + + Ok(format!("delete group at name {}", name)) +} + +async fn update_group( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(eg): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + if !authed.is_admin { + require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; + } + not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + + sqlx::query!( + "UPDATE group_ SET summary = $1 WHERE name = $2 AND workspace_id = $3", + eg.summary, + &name, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "group.edit", + ActionKind::Update, + &w_id, + Some(&name.to_string()), + None, + ) + .await?; + + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "update_summary", + None, + ) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Group { name: name.clone() }, + Some(format!("Updated group '{}'", &name)), + true, + None, + ) + .await?; + + Ok(format!("Edited group {}", name)) +} + +async fn add_user( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(Username { username: user_username }): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + if !authed.is_admin { + require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; + } + + not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + + let result = sqlx::query!( + "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + &w_id, + user_username, + name, + ) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Ok(format!( + "{} is already a member of group {}", + user_username, name + )); + } + + audit_log( + &mut *tx, + &authed, + "group.adduser", + ActionKind::Update, + &w_id, + Some(&name.to_string()), + Some([("user", user_username.as_str())].into()), + ) + .await?; + + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "add_member", + Some(&user_username), + ) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Group { name: name.clone() }, + Some(format!("Added user to group '{}'", &name)), + true, + None, + ) + .await?; + + Ok(format!("Added {} to group {}", user_username, name)) +} + +async fn add_user_igroup( + authed: ApiAuthed, + Extension(db): Extension, + Path(name): Path, + Json(Email { email }): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name) + .fetch_optional(&mut *tx) + .await?; + + not_found_if_none(group_opt, "IGroup", &name)?; + + sqlx::query!( + "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2) ON CONFLICT DO NOTHING", + email, + name, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "igroup.adduser", + ActionKind::Update, + "global", + Some(&name.to_string()), + Some([("email", email.as_str())].into()), + ) + .await?; + + // Sync user to workspaces configured with this instance group + #[cfg(all(feature = "private", feature = "enterprise"))] + { + use windmill_api_workspaces::workspaces_ee::auto_add_user; + let workspaces = sqlx::query!( + r#" + SELECT workspace_id, auto_invite->'instance_groups_roles' as instance_groups_roles + FROM workspace_settings + WHERE auto_invite->'instance_groups' ? $1 + "#, + &name + ) + .fetch_all(&mut *tx) + .await?; + for ws in workspaces { + let role = ws + .instance_groups_roles + .and_then(|r| r.get(&name).and_then(|v| v.as_str().map(String::from))) + .unwrap_or_else(|| "developer".to_string()); + let (is_admin, is_operator) = match role.as_str() { + "admin" => (true, false), + "operator" => (false, true), + _ => (false, false), + }; + auto_add_user( + &email, + &ws.workspace_id, + &is_operator, + &mut tx, + &authed, + Some(serde_json::json!({"source": "instance_group", "group": &name})), + ) + .await?; + if is_admin { + sqlx::query!( + "UPDATE usr SET is_admin = true WHERE workspace_id = $1 AND email = $2", + &ws.workspace_id, + &email + ) + .execute(&mut *tx) + .await?; + } + } + } + + tx.commit().await?; + Ok(format!("Added {} to igroup {}", email, name)) +} + +#[derive(Serialize)] +struct IGroup { + name: String, + summary: Option, + emails: Option>, +} + +#[derive(Serialize)] +struct IGroupWithWorkspaces { + name: String, + summary: Option, + emails: Option>, + workspaces: Vec, +} + +#[derive(Serialize, Clone)] +struct WorkspaceInfo { + workspace_id: String, + workspace_name: String, + role: String, +} +async fn list_igroups(Extension(db): Extension) -> JsonResult> { + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + let groups = sqlx::query_as!( + IGroup, + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + return Ok(Json(groups)); +} + +async fn list_igroups_with_workspaces( + Extension(db): Extension, +) -> JsonResult> { + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + // Get all instance groups with their emails first + let groups = sqlx::query_as!( + IGroup, + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary" + ) + .fetch_all(&mut *tx) + .await?; + + // Get all workspace mappings for instance groups in a single query + let workspace_mappings = sqlx::query!( + r#" + SELECT + ig.name as group_name, + ws.workspace_id, + w.name as workspace_name, + ws.auto_invite->'instance_groups_roles'->ig.name as role + FROM instance_group ig + INNER JOIN workspace_settings ws ON ws.auto_invite->'instance_groups' IS NOT NULL + AND ws.auto_invite->'instance_groups' ? ig.name + INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false + ORDER BY ig.name, ws.workspace_id + "# + ) + .fetch_all(&mut *tx) + .await?; + + // Create a map of group_name -> Vec + let mut workspaces_by_group: std::collections::HashMap> = + std::collections::HashMap::new(); + for mapping in workspace_mappings { + let role = mapping + .role + .and_then(|r| r.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "developer".to_string()); + + let workspace_info = WorkspaceInfo { + workspace_id: mapping.workspace_id.clone(), + workspace_name: mapping.workspace_name, + role, + }; + + workspaces_by_group + .entry(mapping.group_name) + .or_insert_with(Vec::new) + .push(workspace_info); + } + + let mut result = Vec::new(); + for group in groups { + let workspaces = workspaces_by_group + .get(&group.name) + .cloned() + .unwrap_or_default(); + + result.push(IGroupWithWorkspaces { + name: group.name, + summary: group.summary, + emails: group.emails, + workspaces, + }); + } + + tx.commit().await?; + return Ok(Json(result)); +} + +async fn get_igroup(Path(name): Path, Extension(db): Extension) -> JsonResult { + let group = sqlx::query_as!( + IGroup, + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + name + ) + .fetch_optional(&db) + .await?; + let group = not_found_if_none(group, "IGroup", &name)?; + return Ok(Json(group)); +} + +async fn remove_user_igroup( + authed: ApiAuthed, + Extension(db): Extension, + Path(name): Path, + Json(Email { email }): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name,) + .fetch_optional(&mut *tx) + .await?; + + not_found_if_none(group_opt, "IGroup", &name)?; + + sqlx::query!( + "DELETE FROM email_to_igroup WHERE email = $1 AND igroup = $2", + email, + name, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "igroup.removeuser", + ActionKind::Update, + "global", + Some(&name.to_string()), + Some([("email", email.as_str())].into()), + ) + .await?; + + // Remove user from workspaces where they were added via this instance group + #[cfg(all(feature = "private", feature = "enterprise"))] + { + use windmill_api_workspaces::workspaces_ee::remove_users_from_instance_group_workspaces; + remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?; + } + + tx.commit().await?; + Ok(format!("Removed {} from igroup {}", email, name)) +} + +async fn remove_user( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Json(Username { username: user_username }): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + if !authed.is_admin { + require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; + } + + not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + if &name == "all" { + return Err(Error::BadRequest(format!("Cannot delete users from all"))); + } + sqlx::query!( + "DELETE FROM usr_to_group WHERE usr = $1 AND group_ = $2 AND workspace_id = $3", + user_username, + name, + &w_id, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "group.removeuser", + ActionKind::Update, + &w_id, + Some(&name.to_string()), + Some([("user", user_username.as_str())].into()), + ) + .await?; + + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "remove_member", + Some(&user_username), + ) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Group { name: name.clone() }, + Some(format!("Removed user from group '{}'", &name)), + true, + None, + ) + .await?; + + Ok(format!("Removed {} to group {}", user_username, name)) +} + +#[cfg(feature = "enterprise")] +#[derive(Serialize, Deserialize)] +struct ExportedIGroup { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scim_display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + external_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + emails: Option>, +} + +#[cfg(feature = "enterprise")] +async fn export_igroups( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + let igroups = sqlx::query_as!( + ExportedIGroup, + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + ).fetch_all(&mut *tx).await?; + + audit_log( + &mut *tx, + &authed, + "igroups.export", + ActionKind::Execute, + "global", + None, + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(igroups)) +} + +#[cfg(not(feature = "enterprise"))] +async fn export_igroups() -> JsonResult { + Err(Error::BadRequest( + "This feature is only available in the enterprise version".to_string(), + )) +} + +#[cfg(feature = "enterprise")] +async fn overwrite_igroups( + authed: ApiAuthed, + Extension(db): Extension, + Json(igroups): Json>, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + sqlx::query!("DELETE FROM email_to_igroup") + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM instance_group") + .execute(&mut *tx) + .await?; + + for igroup in igroups.iter() { + sqlx::query!( + "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + igroup.name, + igroup.summary, + igroup.id, + igroup.scim_display_name, + igroup.external_id, + ) + .execute(&mut *tx) + .await?; + + if let Some(emails) = &igroup.emails { + for email in emails.iter() { + sqlx::query!( + "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2)", + email, + igroup.name, + ) + .execute(&mut *tx) + .await?; + } + } + } + + audit_log( + &mut *tx, + &authed, + "igroups.import", + ActionKind::Create, + "global", + None, + None, + ) + .await?; + + tx.commit().await?; + Ok("Imported igroups".to_string()) +} + +#[cfg(not(feature = "enterprise"))] +async fn overwrite_igroups() -> JsonResult { + Err(Error::BadRequest( + "This feature is only available in the enterprise version".to_string(), + )) +} + +pub async fn log_group_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + group_name: &str, + changed_by: &str, + change_type: &str, + member_affected: Option<&str>, +) -> Result<()> { + sqlx::query!( + "INSERT INTO group_permission_history + (workspace_id, group_name, changed_by, change_type, member_affected) + VALUES ($1, $2, $3, $4, $5)", + workspace_id, + group_name, + changed_by, + change_type, + member_affected + ) + .execute(db) + .await?; + Ok(()) +} diff --git a/backend/windmill-api-groups/src/lib.rs b/backend/windmill-api-groups/src/lib.rs new file mode 100644 index 0000000000..75b9349105 --- /dev/null +++ b/backend/windmill-api-groups/src/lib.rs @@ -0,0 +1,4 @@ +pub mod folder_history; +pub mod folders; +pub mod granular_acls; +pub mod groups; diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml new file mode 100644 index 0000000000..c167eff11a --- /dev/null +++ b/backend/windmill-api-scripts/Cargo.toml @@ -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 diff --git a/backend/windmill-api-scripts/src/flows.rs b/backend/windmill-api-scripts/src/flows.rs new file mode 100644 index 0000000000..adeb24d068 --- /dev/null +++ b/backend/windmill-api-scripts/src/flows.rs @@ -0,0 +1,1945 @@ +/* + * 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::collections::HashMap; + +use axum::response::IntoResponse; +use axum::{ + extract::{Extension, Path, Query}, + routing::{delete, get, post}, + Json, Router, +}; +use windmill_api_auth::{ + auth::{list_tokens_internal, TruncatedTokenWithEmail}, + check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, +}; +use windmill_common::{ + utils::{WithStarredInfoQuery, HTTP_CLIENT}, + webhook::{WebhookMessage, WebhookShared}, + DB, +}; +use windmill_queue::schedule::clear_schedule; + +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use sql_builder::prelude::*; +use sqlx::{FromRow, Postgres, Transaction}; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; +use windmill_common::min_version::{ + MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, +}; +use windmill_common::runnable_settings::RunnableSettingsTrait; +use windmill_common::utils::query_elems_from_hub; +use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; +use windmill_common::HUB_BASE_URL; +use windmill_common::{ + db::UserDB, + error::{self, to_anyhow, Error, JsonResult, Result}, + flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, + jobs::JobPayload, + schedule::Schedule, + scripts::Schema, + utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, +}; +use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; +use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; +use windmill_queue::WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT; +use windmill_queue::{push, schedule::push_scheduled_job, PushIsolationLevel}; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/list", get(list_flows)) + .route("/list_search", get(list_search_flows)) + .route("/create", post(create_flow)) + .route("/update/*path", post(update_flow)) + .route("/archive/*path", post(archive_flow_by_path)) + .route("/delete/*path", delete(delete_flow_by_path)) + .route("/list_tokens/*path", get(list_tokens)) + .route("/get/*path", get(get_flow_by_path)) + .route("/deployment_status/p/*path", get(get_deployment_status)) + .route("/get/draft/*path", get(get_flow_by_path_w_draft)) + .route("/exists/*path", get(exists_flow_by_path)) + .route("/list_paths", get(list_paths)) + .route("/history/p/*path", get(get_flow_history)) + .route("/get_latest_version/*path", get(get_latest_version)) + .route( + "/list_paths_from_workspace_runnable/:runnable_kind/*path", + get(list_paths_from_workspace_runnable), + ) + .route("/history_update/v/:version", post(update_flow_history)) + .route("/get/v/:version", get(get_flow_version_by_id)) + .route("/get/v/:version/p/*path", get(get_flow_version)) + .route( + "/toggle_workspace_error_handler/*path", + post(toggle_workspace_error_handler), + ) +} + +pub fn global_service() -> Router { + Router::new() + .route("/hub/list", get(list_hub_flows)) + .route("/hub/get/:id", get(get_hub_flow_by_id)) +} + +#[derive(Serialize, FromRow)] +pub struct SearchFlow { + path: String, + value: sqlx::types::Json>, +} +async fn list_search_flows( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult> { + #[cfg(feature = "enterprise")] + let n = 1000; + + #[cfg(not(feature = "enterprise"))] + let n = 3; + let mut tx = user_db.begin(&authed).await?; + + let rows = sqlx::query_as::<_, SearchFlow>( + "SELECT flow.path, flow_version.value + FROM flow + LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] + WHERE flow.workspace_id = $1 LIMIT $2", + ) + .bind(&w_id) + .bind(n) + .fetch_all(&mut *tx) + .await? + .into_iter() + .collect::>(); + tx.commit().await?; + Ok(Json(rows)) +} + +async fn list_flows( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Query(pagination): Query, + Query(lq): Query, +) -> JsonResult> { + let (per_page, offset) = paginate(pagination); + + let mut sqlb = SqlBuilder::select_from("flow as o") + .fields(&[ + "o.workspace_id", + "o.path", + "summary", + if !lq.without_description.unwrap_or(false) { + "description" + } else { + "NULL as description" + }, + "fv.created_by as edited_by", + "fv.created_at as edited_at", + "archived", + "extra_perms", + "favorite.path IS NOT NULL as starred", + "draft.path IS NOT NULL as has_draft", + "draft_only", + "ws_error_handler_muted" + ]) + .left() + .join("favorite") + .on( + "favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" + .bind(&authed.username), + ) + .left() + .join("draft") + .on( + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + ) + .left() + .join("flow_version fv") + .on( + "fv.id = o.versions[array_upper(o.versions, 1)]" + ) + .order_desc("favorite.path IS NOT NULL") + .order_by("fv.created_at", lq.order_desc.unwrap_or(true)) + .and_where("o.workspace_id = ?".bind(&w_id)) + .offset(offset) + .limit(per_page) + .clone(); + + sqlb.and_where_eq("archived", lq.show_archived.unwrap_or(false)); + + if let Some(ps) = &lq.path_start { + sqlb.and_where_like_left("o.path", ps); + } + if let Some(p) = &lq.path_exact { + sqlb.and_where_eq("o.path", "?".bind(p)); + } + if let Some(cb) = &lq.edited_by { + sqlb.and_where_eq("fv.created_by", "?".bind(cb)); + } + if lq.starred_only.unwrap_or(false) { + sqlb.and_where_is_not_null("favorite.path"); + } + + if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { + sqlb.and_where("o.draft_only IS NOT TRUE"); + } + if let Some(dw) = &lq.dedicated_worker { + sqlb.and_where_eq("dedicated_worker", dw); + } + + if lq.with_deployment_msg.unwrap_or(false) { + sqlb.join("deployment_metadata dm") + .left() + .on("dm.flow_version = o.versions[array_upper(o.versions, 1)]") + .fields(&["dm.deployment_msg"]); + } + + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_as::<_, ListableFlow>(&sql) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +async fn list_hub_flows(Extension(db): Extension) -> impl IntoResponse { + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!( + "{}/searchFlowData?approved=true", + *HUB_BASE_URL.read().await + ), + None, + &db, + ) + .await?; + Ok::<_, Error>((status_code, headers, response)) +} + +async fn list_paths( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let flows = sqlx::query_scalar!( + "SELECT distinct(path) FROM flow WHERE workspace_id = $1", + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(flows)) +} + +pub async fn get_hub_flow_by_id( + Path(id): Path, + Extension(db): Extension, +) -> JsonResult> { + let value = http_get_from_hub( + &HTTP_CLIENT, + &format!("{}/flows/{}/json", *HUB_BASE_URL.read().await, id), + false, + None, + Some(&db), + ) + .await? + .json() + .await + .map_err(to_anyhow)?; + Ok(Json(value)) +} + +#[derive(Deserialize)] +pub struct ToggleWorkspaceErrorHandler { + #[cfg(feature = "enterprise")] + pub muted: Option, +} + +#[cfg(not(feature = "enterprise"))] +async fn toggle_workspace_error_handler( + _authed: ApiAuthed, + Extension(_user_db): Extension, + Path((_w_id, _path)): Path<(String, StripPath)>, + Json(_req): Json, +) -> Result { + return Err(Error::BadRequest( + "Muting the error handler for certain flow is only available in enterprise version" + .to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn toggle_workspace_error_handler( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(req): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + + let error_handler_maybe: Option = sqlx::query_scalar!( + r#" + SELECT + error_handler->>'path' + FROM + workspace_settings + WHERE + workspace_id = $1 + "#, + w_id + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(None); + + let response = match error_handler_maybe { + Some(_) => { + sqlx::query_scalar!( + r#" + UPDATE + flow + SET + ws_error_handler_muted = $3 + WHERE + path = $1 AND + workspace_id = $2 + "#, + path.to_path(), + w_id, + req.muted, + ) + .execute(&mut *tx) + .await?; + Ok("".to_string()) + } + None => Err(Error::BadRequest( + "Workspace error handler needs to be defined".to_string(), + )), + }; + + tx.commit().await?; + + return response; +} + +async fn check_path_conflict<'c>( + tx: &mut Transaction<'c, Postgres>, + w_id: &str, + path: &str, +) -> Result<()> { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", + path, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(false); + if exists { + return Err(Error::BadRequest(format!("Flow {} already exists", path))); + } + return Ok(()); +} + +#[derive(Deserialize)] +struct ListPathsFromWorkspaceRunnableQuery { + match_path_start: Option, +} + +async fn list_paths_from_workspace_runnable( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, + Query(query): Query, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || { + format!("flows:read:{}", format!("{}/{}", runnable_kind, path)) + })?; + let mut tx = user_db.begin(&authed).await?; + + let runnables = if query.match_path_start.unwrap_or(false) { + sqlx::query_scalar!( + r#"SELECT DISTINCT f.path + FROM workspace_runnable_dependencies wru + JOIN flow f + ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id + WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, + path, + matches!(runnable_kind, RunnableKind::Flow), + w_id + ) + .fetch_all(&mut *tx) + .await? + } else { + sqlx::query_scalar!( + r#"SELECT f.path + FROM workspace_runnable_dependencies wru + JOIN flow f + ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id + WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, + path, + matches!(runnable_kind, RunnableKind::Flow), + w_id + ) + .fetch_all(&mut *tx) + .await? + }; + + tx.commit().await?; + Ok(Json(runnables)) +} + +async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { + #[cfg(not(feature = "enterprise"))] + if new_flow.ws_error_handler_muted.is_some_and(|val| val) { + return Err(Error::BadRequest( + "Muting the error handler for certain flow is only available in enterprise version" + .to_string(), + )); + } + + guard_flow_from_debounce_data(new_flow).await?; + + return Ok(()); +} + +async fn create_flow( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Extension(webhook): Extension, + Path(w_id): Path, + Json(nf): Json, +) -> Result<(StatusCode, String)> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot create flows for security reasons".to_string(), + )); + } + check_scopes(&authed, || format!("flows:write:{}", nf.path))?; + validate_flow(&nf).await?; + if *CLOUD_HOSTED { + let nb_flows = + sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) + .fetch_one(&db) + .await?; + if nb_flows.unwrap_or(0) >= 1000 { + return Err(Error::BadRequest( + "You have reached the maximum number of flows (1000) on cloud. Contact support@windmill.dev to increase the limit" + .to_string(), + )); + } + if nf.summary.len() > 300 { + return Err(Error::BadRequest( + "Summary must be less than 300 characters on cloud".to_string(), + )); + } + if nf + .description + .as_ref() + .is_some_and(|desc| desc.len() > 3000) + { + return Err(Error::BadRequest( + "Description must be less than 3000 characters on cloud".to_string(), + )); + } + } + + // cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; + let authed = maybe_refresh_folders(&nf.path, &w_id, authed, &db).await; + + let mut tx = user_db.clone().begin(&authed).await?; + + check_path_conflict(&mut tx, &w_id, &nf.path).await?; + check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; + + let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); + sqlx::query!( + r#"INSERT INTO flow ( + workspace_id, path, summary, description, + dependency_job, lock_error_logs, draft_only, tag, + dedicated_worker, visible_to_runner_only, on_behalf_of_email, + ws_error_handler_muted, + value, schema, edited_by, edited_at + ) VALUES ( + $1, $2, $3, $4, + NULL, '', $5, $6, + $7, $8, $9, + $10, + $11, $12::text::json, $13, now() + )"#, + w_id, + nf.path, + nf.summary, + nf.description.as_deref().unwrap_or(""), + nf.draft_only, + nf.tag, + nf.dedicated_worker, + nf.visible_to_runner_only.unwrap_or(false), + nf.on_behalf_of_email.and(Some(&authed.email)), + nf.ws_error_handler_muted.unwrap_or(false), + sqlx::types::Json(&nf.value) as _, + schema_str, + &authed.username, + ) + .execute(&mut *tx) + .await?; + + let version = sqlx::query_scalar!( + "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) + VALUES ($1, $2, $3, $4::text::json, $5) + RETURNING id", + w_id, + nf.path, + sqlx::types::Json(nf.value) as _, + schema_str, + &authed.username, + ) + .fetch_one(&mut *tx) + .await?; + + sqlx::query!( + "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", + version, + nf.path, + w_id + ).execute(&mut *tx).await?; + + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "flows.create", + ActionKind::Create, + &w_id, + Some(&nf.path.to_string()), + Some( + [Some(("flow", nf.path.as_str()))] + .into_iter() + .flatten() + .collect(), + ), + ) + .await?; + + let mut args: HashMap> = HashMap::new(); + if let Some(dm) = nf.deployment_message { + args.insert("deployment_message".to_string(), to_raw_value(&dm)); + } + + let tx = PushIsolationLevel::Transaction(tx); + let (dependency_job_uuid, mut new_tx) = push( + &db, + tx, + &w_id, + JobPayload::FlowDependencies { + path: nf.path.clone(), + dedicated_worker: nf.dedicated_worker, + version: version, + debouncing_settings: Default::default(), + }, + windmill_queue::PushArgs { args: &args, extra: None }, + &authed.username, + &authed.email, + windmill_common::users::username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + Some(&authed.clone().into()), + false, + None, + None, + None, + ) + .await?; + + sqlx::query!( + "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", + dependency_job_uuid, + nf.path, + w_id + ) + .execute(&mut *new_tx) + .await?; + + // Store the job_id in deployment_metadata for this flow deployment + sqlx::query!( + "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL + DO UPDATE SET job_id = EXCLUDED.job_id", + w_id, + nf.path, + version, + dependency_job_uuid + ) + .execute(&mut *new_tx) + .await?; + + new_tx.commit().await?; + webhook.send_message( + w_id.clone(), + WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() }, + ); + + Ok((StatusCode::CREATED, nf.path.to_string())) +} + +async fn check_schedule_conflict<'c>( + tx: &mut Transaction<'c, Postgres>, + w_id: &str, + path: &str, +) -> error::Result<()> { + let exists_flow = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2 AND path != \ + script_path)", + path, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(false); + if exists_flow { + return Err(error::Error::BadConfig(format!( + "A flow cannot have the same path as a schedule if the schedule does not trigger that \ + same flow: {path}", + ))); + }; + Ok(()) +} + +pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { + return windmill_api_auth::require_is_writer( + authed, + path, + w_id, + db, + "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", + "flow", + ) + .await; +} + +#[derive(Serialize)] +pub struct FlowVersion { + pub id: i64, + pub created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_msg: Option, +} + +async fn get_flow_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + let flows = sqlx::query_as!( + FlowVersion, + "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version + LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version + WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 + ORDER BY flow_version.created_at DESC", + path, + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(flows)) +} + +async fn get_latest_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + let version = sqlx::query_as!( + FlowVersion, + "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version + LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version + WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 + ORDER BY flow_version.created_at DESC", + path, + w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(version)) +} + +async fn get_flow_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, version, path)): Path<(String, i64, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + let flow = sqlx::query_as::<_, Flow>( + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + FROM flow + LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id + WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", + ) + .bind(path) + .bind(w_id) + .bind(version) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + + let flow = not_found_if_none(flow, "Flow version", version.to_string())?; + + Ok(Json(flow)) +} + +async fn get_flow_version_by_id( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, version)): Path<(String, i64)>, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + + // First, fetch the path to perform authorization check early + let path: Option = + sqlx::query_scalar("SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2") + .bind(version) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + let path = not_found_if_none( + path, + "Flow version", + format!("{} in workspace {}", version, w_id), + )?; + + // Perform authorization check before fetching full data + check_scopes(&authed, || format!("flows:read:{}", path))?; + + // Now fetch the full flow data with INNER JOIN to ensure flow exists + let flow = sqlx::query_as::<_, Flow>( + "SELECT + flow.workspace_id, + flow.path, + flow.summary, + flow.description, + flow.archived, + flow.extra_perms, + flow.draft_only, + flow.dedicated_worker, + flow.tag, + flow.ws_error_handler_muted, + flow.timeout, + flow.visible_to_runner_only, + flow.on_behalf_of_email, + flow_version.schema, + flow_version.value, + flow_version.created_at as edited_at, + flow_version.created_by as edited_by + FROM flow + INNER JOIN flow_version + ON flow_version.path = flow.path + AND flow_version.workspace_id = flow.workspace_id + WHERE flow_version.id = $1 AND flow.workspace_id = $2", + ) + .bind(version) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + + let flow = not_found_if_none( + flow, + "Flow", + format!("for version {} (flow may have been deleted)", version), + )?; + + Ok(Json(flow)) +} + +#[derive(Deserialize)] +pub struct FlowHistoryUpdate { + pub deployment_msg: String, +} + +async fn update_flow_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, version)): Path<(String, i64)>, + Json(history_update): Json, +) -> Result<()> { + let mut tx = user_db.begin(&authed).await?; + + // Fetch path and perform authorization check early + let path: Option = + sqlx::query_scalar("SELECT path FROM flow_version WHERE workspace_id = $1 AND id = $2") + .bind(&w_id) + .bind(version) + .fetch_optional(&mut *tx) + .await?; + + let path = not_found_if_none( + path, + "Flow version", + format!("{} in workspace {}", version, w_id), + )?; + + // Perform authorization check before any modifications + check_scopes(&authed, || format!("flows:write:{}", path))?; + + // Insert or update deployment metadata + sqlx::query!( + "INSERT INTO deployment_metadata (workspace_id, path, flow_version, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + &w_id, + path, + version, + history_update.deployment_msg, + ) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +async fn update_flow( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Extension(webhook): Extension, + Path((w_id, flow_path)): Path<(String, StripPath)>, + Json(nf): Json, +) -> Result { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot update flows for security reasons".to_string(), + )); + } + let flow_path = flow_path.to_path(); + check_scopes(&authed, || format!("flows:write:{}", flow_path))?; + validate_flow(&nf).await?; + + let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; + let mut tx = user_db.clone().begin(&authed).await?; + + check_schedule_conflict(&mut tx, &w_id, flow_path).await?; + + let schema = nf.schema.map(|x| x.0); + let old_dep_job = sqlx::query_scalar!( + "SELECT dependency_job FROM flow WHERE path = $1 AND workspace_id = $2", + flow_path, + w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; + let is_new_path = nf.path != flow_path; + let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); + + sqlx::query!( + " + UPDATE + flow + SET + path = $1, + summary = $2, + description = $3, + dependency_job = NULL, + lock_error_logs = '', + draft_only = NULL, + tag = $4, + dedicated_worker = $5, + visible_to_runner_only = $6, + on_behalf_of_email = $7, + ws_error_handler_muted = $8, + value = $9, + schema = $10::text::json, + edited_by = $11, + edited_at = now() + WHERE + path = $12 AND workspace_id = $13", + if is_new_path { flow_path } else { &nf.path }, + nf.summary, + nf.description.as_deref().unwrap_or(""), + nf.tag, + nf.dedicated_worker, + nf.visible_to_runner_only.unwrap_or(false), + nf.on_behalf_of_email.and(Some(&authed.email)), + nf.ws_error_handler_muted.unwrap_or(false), + sqlx::types::Json(&nf.value) as _, + schema_str, + authed.username, + flow_path, + w_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) + })?; + + if is_new_path { + // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint + sqlx::query!( + "INSERT INTO flow + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + FROM flow + WHERE path = $2 AND workspace_id = $3", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!("Error updating flow due to create new flow: {e:#}")) + })?; + + sqlx::query!( + "UPDATE flow_version SET path = $1 WHERE path = $2 AND workspace_id = $3", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating flow due to updating flow history path: {e:#}" + )) + })?; + + sqlx::query!( + "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", + flow_path, + w_id + ) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating flow due to deleting old flow: {e:#}" + )) + })?; + + sqlx::query!( + "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await?; + } + + // tracing::error!("Updating flow: {:?}", nf.value.get()); + + // This will lock anyone who is trying to iterate on flow_versions with given path and parameters. + let version = sqlx::query_scalar!( + "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id", + w_id, + nf.path, + sqlx::types::Json(nf.value) as _, + schema_str, + &authed.username, + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating flow due to flow history insert: {e:#}" + )) + })?; + + // TODO: This should happen only after we are done with dependency job. + sqlx::query!( + "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", + version, nf.path, w_id + ).execute(&mut *tx).await?; + + if is_new_path { + check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; + + if !authed.is_admin { + require_owner_of_path(&authed, flow_path)?; + } + } + + let mut schedulables: Vec = sqlx::query_as::<_, Schedule>( + "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") + .bind(&nf.path) + .bind(&flow_path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedules update: {e:#}")))?; + + let schedule = sqlx::query_as::<_, Schedule>( + "UPDATE schedule SET path = $1, script_path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") + .bind(&nf.path) + .bind(&flow_path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedule update: {e:#}")))?; + + if let Some(schedule) = schedule { + clear_schedule(&mut tx, &flow_path, &w_id).await?; + schedulables.push(schedule); + } + + for schedule in schedulables.into_iter() { + clear_schedule(&mut tx, &schedule.path, &w_id).await?; + + if schedule.enabled { + tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; + } + } + + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + flow_path, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "flows.update", + ActionKind::Create, + &w_id, + Some(&nf.path.to_string()), + Some( + [Some(("flow", nf.path.as_str()))] + .into_iter() + .flatten() + .collect(), + ), + ) + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::UpdateFlow { + workspace: w_id.clone(), + old_path: flow_path.to_owned(), + new_path: nf.path.clone(), + }, + ); + + let tx = PushIsolationLevel::Transaction(tx); + + let mut args: HashMap> = HashMap::new(); + if let Some(dm) = nf.deployment_message { + args.insert("deployment_message".to_string(), to_raw_value(&dm)); + } + args.insert("parent_path".to_string(), to_raw_value(&flow_path)); + + let (dependency_job_uuid, mut new_tx) = push( + &db, + tx, + &w_id, + JobPayload::FlowDependencies { + path: nf.path.clone(), + dedicated_worker: nf.dedicated_worker, + version, + debouncing_settings: Default::default(), + }, + windmill_queue::PushArgs { args: &args, extra: None }, + &authed.username, + &authed.email, + windmill_common::users::username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + Some(&authed.clone().into()), + false, + None, + None, + None, + ) + .await?; + + sqlx::query!( + "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", + dependency_job_uuid, + nf.path, + w_id + ) + .execute(&mut *new_tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating flow due to updating dependency job field: {e:#}" + )) + })?; + + // Store the job_id in deployment_metadata for this flow deployment + sqlx::query!( + "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL + DO UPDATE SET job_id = EXCLUDED.job_id", + w_id, + nf.path, + version, + dependency_job_uuid + ) + .execute(&mut *new_tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating deployment_metadata with job_id: {e:#}" + )) + })?; + + if let Some(old_dep_job) = old_dep_job { + sqlx::query!( + "UPDATE v2_job_queue SET + canceled_by = $2, + canceled_reason = 're-deployment' + WHERE id = $1", + old_dep_job, + &authed.username + ) + .execute(&mut *new_tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Error updating flow due to cancelling dependency job: {e:#}" + )) + })?; + } + + new_tx.commit().await?; + + Ok(nf.path.to_string()) +} + +async fn list_tokens( + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + list_tokens_internal(&db, &w_id, &path, true).await +} + +#[derive(Serialize)] +struct DeploymentStatus { + lock_error_logs: Option, + job_id: Option, +} +async fn get_deployment_status( + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + let mut tx = db.begin().await?; + let status_o = sqlx::query!( + "SELECT f.lock_error_logs, dm.job_id + FROM flow f + LEFT JOIN deployment_metadata dm ON f.versions[array_upper(f.versions, 1)] = dm.flow_version + AND f.workspace_id = dm.workspace_id AND f.path = dm.path + WHERE f.path = $1 AND f.workspace_id = $2", + path, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let status = not_found_if_none(status_o, "DeploymentStatus", path)?; + + let deployment_status = + DeploymentStatus { lock_error_logs: status.lock_error_logs, job_id: status.job_id }; + + tx.commit().await?; + Ok(Json(deployment_status)) +} + +async fn get_flow_by_path( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> JsonResult { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + let flow_o = if query.with_starred_info.unwrap_or(false) { + sqlx::query_as::<_, FlowWithStarred>( + r#" + SELECT + flow.workspace_id, + flow.path, + flow.lock_error_logs, + flow.summary, + flow.description, + flow.archived, + flow.extra_perms, + flow.draft_only, + flow.dedicated_worker, + flow.tag, + flow.ws_error_handler_muted, + flow.timeout, + flow.visible_to_runner_only, + flow.on_behalf_of_email, + flow_version.id AS version_id, + flow_version.schema, + flow_version.value, + flow_version.created_at AS edited_at, + flow_version.created_by AS edited_by, + favorite.path IS NOT NULL AS starred + FROM flow + LEFT JOIN favorite + ON favorite.favorite_kind = 'flow' + AND favorite.workspace_id = flow.workspace_id + AND favorite.path = flow.path + AND favorite.usr = $3 + LEFT JOIN flow_version + ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] + WHERE flow.path = $1 AND flow.workspace_id = $2 + "#, + ) + .bind(path) + .bind(w_id) + .bind(&authed.username) + .fetch_optional(&mut *tx) + .await? + } else { + sqlx::query_as::<_, FlowWithStarred>( + r#" + SELECT + flow.workspace_id, + flow.path, + flow.lock_error_logs, + flow.summary, + flow.description, + flow.archived, + flow.extra_perms, + flow.draft_only, + flow.dedicated_worker, + flow.tag, + flow.ws_error_handler_muted, + flow.timeout, + flow.visible_to_runner_only, + flow.on_behalf_of_email, + flow_version.id AS version_id, + flow_version.schema, + flow_version.value, + flow_version.created_at AS edited_at, + flow_version.created_by AS edited_by, + NULL AS starred + FROM flow + LEFT JOIN flow_version + ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] + WHERE flow.path = $1 AND flow.workspace_id = $2 + "#, + ) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await? + }; + + tx.commit().await?; + + let flow = not_found_if_none(flow_o, "Flow", path)?; + Ok(Json(flow)) +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct FlowWDraft { + pub path: String, + pub summary: String, + pub description: String, + pub schema: Option, + pub value: sqlx::types::Json>, + pub extra_perms: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ws_error_handler_muted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dedicated_worker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visible_to_runner_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_behalf_of_email: Option, +} + +async fn get_flow_by_path_w_draft( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + let flow_o = sqlx::query_as::<_, FlowWDraft>( + "SELECT + flow.path, + flow.summary, + flow.description, + flow_version.schema, + flow_version.value, + flow.extra_perms, + flow.draft_only, + flow.ws_error_handler_muted, + flow.dedicated_worker, + draft.value AS draft, + flow.tag, + flow.visible_to_runner_only, + flow.on_behalf_of_email + FROM flow + LEFT JOIN draft + ON flow.path = draft.path + AND draft.workspace_id = $2 + AND draft.typ = 'flow' + LEFT JOIN flow_version + ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] + WHERE flow.path = $1 + AND flow.workspace_id = $2", + ) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + + let flow = not_found_if_none(flow_o, "Flow", path)?; + Ok(Json(flow)) +} + +async fn exists_flow_by_path( + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", + path, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + Ok(Json(exists)) +} + +#[derive(Deserialize)] +struct Archived { + archived: Option, +} + +async fn archive_flow_by_path( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Extension(webhook): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(archived): Json, +) -> Result { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:write:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + sqlx::query!( + "UPDATE flow SET archived = $1 WHERE path = $2 AND workspace_id = $3", + archived.archived.unwrap_or(true), + path, + &w_id + ) + .execute(&mut *tx) + .await?; + + clear_static_asset_usage(&mut *tx, &w_id, path, AssetUsageKind::Flow).await?; + + audit_log( + &mut *tx, + &authed, + "flows.archive", + ActionKind::Delete, + &w_id, + Some(path), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + + ScopedDependencyMap::clear_map_for_item(path, &w_id, "flow", tx, &None) + .await + .commit() + .await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { + path: path.to_string(), + parent_path: Some(path.to_string()), + version: 0, // dummy version as it will not get inserted in db + }, + Some(format!( + "Flow '{}' {}", + path, + if archived.archived.unwrap_or(true) { + "archived" + } else { + "unarchived" + } + )), + true, + None, + ) + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::ArchiveFlow { workspace: w_id, path: path.to_owned() }, + ); + + Ok(format!("Flow {path} archived")) +} + +/// Validates that flow debouncing configuration is supported by all workers +/// Returns an error if debouncing is configured but workers are behind required version +async fn guard_flow_from_debounce_data(nf: &NewFlow) -> Result<()> { + if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await + && !nf.parse_flow_value()?.debouncing_settings.is_default() + { + tracing::warn!( + "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" + ); + Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into() }) + } else if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await + && !nf + .parse_flow_value()? + .debouncing_settings + .is_legacy_compatible() + && !*WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT + { + tracing::warn!( + "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" + ); + Err(Error::WorkersAreBehind { + feature: "V2 Debouncing".into(), + min_version: "1.597.0".into(), + }) + } else { + Ok(()) + } +} + +#[derive(Deserialize)] +struct DeleteFlowQuery { + keep_captures: Option, +} + +async fn delete_flow_by_path( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Extension(webhook): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> Result { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:write:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + + if !query.keep_captures.unwrap_or(false) { + sqlx::query!( + "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "flows.delete", + ActionKind::Delete, + &w_id, + Some(path), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { + path: path.to_string(), + parent_path: Some(path.to_string()), + version: 0, // dummy version as it will not get inserted in db + }, + Some(format!("Flow '{}' deleted", path)), + true, + None, + ) + .await?; + + sqlx::query!( + "DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND script_hash IS NULL and app_version IS NULL", + path, + w_id + ) + .execute(&db) + .await + .map_err(|e| { + Error::internal_err(format!( + "error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}" + )) + })?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteFlow { workspace: w_id, path: path.to_owned() }, + ); + + Ok(format!("Flow {path} deleted")) +} + +#[cfg(test)] +mod tests { + + use std::{collections::HashMap, time::Duration}; + + use windmill_common::{ + flows::{ + ConstantDelay, ExponentialDelay, FlowModule, FlowModuleValue, FlowValue, + InputTransform, Retry, StopAfterIf, + }, + runnable_settings::{ + ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, + }, + scripts, + }; + + const SECOND: Duration = Duration::from_secs(1); + + #[test] + fn flowmodule_serde() { + let fv = FlowValue { + modules: vec![ + FlowModule { + id: "a".to_string(), + value: windmill_common::worker::to_raw_value(&FlowModuleValue::Script { + path: "test".to_string(), + input_transforms: [( + "test".to_string(), + InputTransform::Static { + value: windmill_common::worker::to_raw_value(&"test2".to_string()), + }, + )] + .into(), + hash: None, + tag_override: None, + is_trigger: None, + pass_flow_input_directly: None, + }), + stop_after_if: None, + stop_after_all_iters_if: None, + summary: None, + suspend: Default::default(), + retry: None, + sleep: None, + cache_ttl: None, + cache_ignore_s3_path: None, + mock: None, + timeout: None, + priority: None, + delete_after_use: None, + continue_on_error: None, + skip_if: None, + apply_preprocessor: None, + pass_flow_input_directly: None, + }, + FlowModule { + id: "b".to_string(), + value: windmill_common::worker::to_raw_value(&FlowModuleValue::RawScript { + input_transforms: HashMap::new(), + content: "test".to_string(), + language: scripts::ScriptLang::Deno, + path: None, + lock: None, + tag: None, + is_trigger: None, + assets: None, + concurrency_settings: ConcurrencySettingsWithCustom::default(), + }), + stop_after_if: Some(StopAfterIf { + expr: "foo = 'bar'".to_string(), + ..Default::default() + }), + stop_after_all_iters_if: None, + summary: None, + suspend: Default::default(), + retry: None, + sleep: None, + cache_ttl: None, + cache_ignore_s3_path: None, + mock: None, + timeout: None, + priority: None, + delete_after_use: None, + continue_on_error: None, + skip_if: None, + apply_preprocessor: None, + pass_flow_input_directly: None, + }, + FlowModule { + id: "c".to_string(), + value: windmill_common::worker::to_raw_value(&FlowModuleValue::ForloopFlow { + iterator: InputTransform::Static { + value: windmill_common::worker::to_raw_value(&[1, 2, 3]), + }, + modules: vec![], + modules_node: None, + skip_failures: true, + parallel: false, + parallelism: None, + squash: None, + }), + stop_after_if: Some(StopAfterIf { + expr: "previous.isEmpty()".to_string(), + ..Default::default() + }), + stop_after_all_iters_if: None, + summary: None, + suspend: Default::default(), + retry: None, + sleep: None, + cache_ttl: None, + cache_ignore_s3_path: None, + mock: None, + timeout: None, + priority: None, + delete_after_use: None, + continue_on_error: None, + skip_if: None, + apply_preprocessor: None, + pass_flow_input_directly: None, + }, + ], + failure_module: Some(Box::new(FlowModule { + id: "d".to_string(), + value: FlowModuleValue::Script { + path: "test".to_string(), + input_transforms: HashMap::new(), + hash: None, + tag_override: None, + is_trigger: None, + pass_flow_input_directly: None, + } + .into(), + stop_after_if: Some(StopAfterIf { + expr: "previous.isEmpty()".to_string(), + ..Default::default() + }), + stop_after_all_iters_if: None, + summary: None, + suspend: Default::default(), + retry: None, + sleep: None, + cache_ttl: None, + cache_ignore_s3_path: None, + mock: None, + timeout: None, + priority: None, + delete_after_use: None, + continue_on_error: None, + skip_if: None, + apply_preprocessor: None, + pass_flow_input_directly: None, + })), + preprocessor_module: None, + same_worker: false, + skip_expr: None, + cache_ttl: None, + cache_ignore_s3_path: None, + priority: None, + early_return: None, + chat_input_enabled: None, + flow_env: None, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + }; + let expect = serde_json::json!({ + "modules": [ + { + "id": "a", + "value": { + "input_transforms": { + "test": { + "type": "static", + "value": "test2" + } + }, + "type": "script", + "path": "test", + }, + }, + { + "id": "b", + "value": { + "input_transforms": {}, + "type": "rawscript", + "content": "test", + "language": "deno" + }, + "stop_after_if": { + "expr": "foo = 'bar'", + "skip_if_stopped": false, + "error_message": null + } + }, + { + "id": "c", + "value": { + "type": "forloopflow", + "iterator": { + "type": "static", + "value": [ + 1, + 2, + 3 + ] + }, + "parallel": false, + "skip_failures": true, + "modules": [] + }, + "stop_after_if": { + "expr": "previous.isEmpty()", + "skip_if_stopped": false, + "error_message": null + } + } + ], + "failure_module": { + "id": "d", + "value": { + "input_transforms": {}, + "type": "script", + "path": "test", + }, + "stop_after_if": { + "expr": "previous.isEmpty()", + "skip_if_stopped": false, + "error_message": null + } + }, + }); + assert_eq!(dbg!(serde_json::json!(fv)), dbg!(expect)); + } + + #[test] + fn retry_serde() { + assert_eq!(Retry::default(), serde_json::from_str(r#"{}"#).unwrap()); + + assert_eq!( + Retry::default(), + serde_json::from_str( + r#" + { + "constant": { + "seconds": 0 + }, + "exponential": { + "multiplier": 1, + "seconds": 0 + }, + "retry_if": null + } + "# + ) + .unwrap() + ); + + assert_eq!( + Retry { + constant: Default::default(), + exponential: ExponentialDelay { + attempts: 0, + multiplier: 1, + seconds: 123, + random_factor: None + }, + retry_if: None + }, + serde_json::from_str( + r#" + { + "constant": {}, + "exponential": { "seconds": 123 }, + "retry_if" : null + } + "# + ) + .unwrap() + ); + } + + #[test] + fn retry_exponential() { + let retry = Retry { + constant: ConstantDelay::default(), + exponential: ExponentialDelay { + attempts: 3, + multiplier: 4, + seconds: 3, + random_factor: None, + }, + retry_if: None, + }; + assert_eq!( + vec![ + Some(12 * SECOND), + Some(36 * SECOND), + Some(108 * SECOND), + None + ], + (0..4) + .map(|previous_attempts| retry.interval(previous_attempts, false)) + .collect::>() + ); + + assert_eq!(Some(108 * SECOND), retry.max_interval()); + } + + #[test] + fn retry_both() { + let retry = Retry { + constant: ConstantDelay { attempts: 2, seconds: 4 }, + exponential: ExponentialDelay { + attempts: 2, + multiplier: 1, + seconds: 3, + random_factor: None, + }, + retry_if: None, + }; + assert_eq!( + vec![ + Some(4 * SECOND), + Some(4 * SECOND), + Some(27 * SECOND), + Some(81 * SECOND), + None, + ], + (0..5) + .map(|previous_attempts| retry.interval(previous_attempts, false)) + .collect::>() + ); + + assert_eq!(Some(81 * SECOND), retry.max_interval()); + } +} diff --git a/backend/windmill-api-scripts/src/lib.rs b/backend/windmill-api-scripts/src/lib.rs new file mode 100644 index 0000000000..aab68a4d52 --- /dev/null +++ b/backend/windmill-api-scripts/src/lib.rs @@ -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; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs new file mode 100644 index 0000000000..06aa1df1a1 --- /dev/null +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -0,0 +1,2318 @@ +/* + * 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::Multipart; +use windmill_api_auth::{ + auth::{list_tokens_internal, AuthCache, TruncatedTokenWithEmail}, + check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, +}; +use windmill_common::{ + utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, + webhook::{WebhookMessage, WebhookShared}, + DB, +}; +use windmill_queue::schedule::clear_schedule; + +use axum::{ + extract::{Extension, Path, Query}, + response::IntoResponse, + routing::{delete, get, post}, + Json, Router, +}; +use futures::future::try_join_all; +use http::header; +use hyper::StatusCode; +use itertools::Itertools; +use quick_cache::sync::Cache; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use serde_json::value::RawValue; +use sql_builder::prelude::*; +use sqlx::{FromRow, Postgres, Transaction}; +use std::{collections::HashMap, sync::Arc}; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_dep_map::process_relative_imports; +use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; + +use windmill_common::{ + assets::{ + clear_static_asset_usage, clear_static_asset_usage_by_script_hash, + insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType, + }, + error::{self, to_anyhow}, + min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, + runnable_settings::{ + min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait, + }, + s3_helpers::upload_artifact_to_store, + scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline}, + utils::{paginate_without_limits, WarnAfterExt}, + worker::CLOUD_HOSTED, +}; + +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult, Result}, + jobs::JobPayload, + schedule::Schedule, + schema::should_validate_schema, + scripts::{ + to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Schema, Script, ScriptHash, + ScriptHistory, ScriptHistoryUpdate, ScriptKind, ScriptLang, ScriptWithStarred, + }, + users::username_to_permissioned_as, + utils::{not_found_if_none, query_elems_from_hub, require_admin, Pagination, StripPath}, + worker::to_raw_value, + HUB_BASE_URL, +}; +use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; +use windmill_parser_ts::remove_pinned_imports; +use windmill_queue::{ + schedule::push_scheduled_job, PushIsolationLevel, WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT, +}; + +const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; + +#[derive(Serialize, sqlx::FromRow)] +pub struct ScriptWDraft { + pub hash: ScriptHash, + pub path: String, + pub summary: String, + pub description: String, + pub content: String, + pub language: ScriptLang, + pub kind: ScriptKind, + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option>>, + pub schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub envs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_ttl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_ignore_s3_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dedicated_worker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ws_error_handler_muted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restart_unless_cancelled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_after_use: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visible_to_runner_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_main_func: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_preprocessor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_behalf_of_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(json(nullable))] + pub assets: Option>, + #[serde(flatten)] + #[sqlx(flatten)] + pub runnable_settings: SR, +} + +impl ScriptWDraft { + pub async fn prefetch_cached<'a>( + self, + db: &DB, + ) -> error::Result> { + let (debouncing_settings, concurrency_settings) = + RunnableSettings::from_runnable_settings_handle( + self.runnable_settings.runnable_settings_handle, + db, + ) + .await? + .prefetch_cached(db) + .await?; + + Ok(ScriptWDraft { + runnable_settings: ScriptRunnableSettingsInline { + concurrency_settings: concurrency_settings.maybe_fallback( + self.runnable_settings.concurrency_key, + self.runnable_settings.concurrent_limit, + self.runnable_settings.concurrency_time_window_s, + ), + debouncing_settings: debouncing_settings.maybe_fallback( + self.runnable_settings.debounce_key, + self.runnable_settings.debounce_delay_s, + ), + }, + hash: self.hash, + path: self.path, + summary: self.summary, + description: self.description, + content: self.content, + language: self.language, + kind: self.kind, + tag: self.tag, + draft: self.draft, + schema: self.schema, + draft_only: self.draft_only, + envs: self.envs, + cache_ttl: self.cache_ttl, + cache_ignore_s3_path: self.cache_ignore_s3_path, + dedicated_worker: self.dedicated_worker, + ws_error_handler_muted: self.ws_error_handler_muted, + priority: self.priority, + restart_unless_cancelled: self.restart_unless_cancelled, + delete_after_use: self.delete_after_use, + timeout: self.timeout, + visible_to_runner_only: self.visible_to_runner_only, + no_main_func: self.no_main_func, + has_preprocessor: self.has_preprocessor, + on_behalf_of_email: self.on_behalf_of_email, + assets: self.assets, + }) + } +} + +pub fn global_service() -> Router { + Router::new() + .route("/hub/top", get(get_top_hub_scripts)) + .route("/hub/get/*path", get(get_hub_script_by_path)) + .route("/hub/get_full/*path", get(get_full_hub_script_by_path)) + .route("/hub/pick/*path", get(pick_hub_script_by_path)) +} + +pub fn global_unauthed_service() -> Router { + Router::new() + .route( + "/tokened_raw/:workspace/:token/*path", + get(get_tokened_raw_script_by_path), + ) + .route("/empty_ts/*path", get(get_empty_ts_script_by_path)) +} + +pub fn workspaced_service() -> Router { + Router::new() + .route("/list", get(list_scripts)) + .route("/list_search", get(list_search_scripts)) + .route("/create", post(create_script)) + .route("/create_snapshot", post(create_snapshot_script)) + .route("/archive/p/*path", post(archive_script_by_path)) + .route("/get/draft/*path", get(get_script_by_path_w_draft)) + .route("/get/p/*path", get(get_script_by_path)) + .route("/list_tokens/*path", get(list_tokens)) + .route("/raw/p/*path", get(raw_script_by_path)) + .route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned)) + .route("/exists/p/*path", get(exists_script_by_path)) + .route("/archive/h/:hash", post(archive_script_by_hash)) + .route("/delete/h/:hash", post(delete_script_by_hash)) + .route("/delete/p/*path", post(delete_script_by_path)) + .route("/delete_bulk", delete(delete_scripts_bulk)) + .route("/get/h/:hash", get(get_script_by_hash)) + .route("/raw/h/:hash", get(raw_script_by_hash)) + .route("/deployment_status/h/:hash", get(get_deployment_status)) + .route("/list_paths", get(list_paths)) + .route( + "/toggle_workspace_error_handler/p/*path", + post(toggle_workspace_error_handler), + ) + .route("/history/p/*path", get(get_script_history)) + .route("/get_latest_version/*path", get(get_latest_version)) + .route( + "/list_paths_from_workspace_runnable/*path", + get(list_paths_from_workspace_runnable), + ) + .route( + "/history_update/h/:hash/p/*path", + post(update_script_history), + ) +} + +#[derive(Serialize, FromRow)] +pub struct SearchScript { + path: String, + content: String, +} +async fn list_search_scripts( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + #[cfg(feature = "enterprise")] + let n = 10000; + + #[cfg(not(feature = "enterprise"))] + let n = 10; + + let rows = sqlx::query_as!( + SearchScript, + "SELECT path, content from script WHERE workspace_id = $1 AND archived = false LIMIT $2", + &w_id, + n + ) + .fetch_all(&mut *tx) + .await? + .into_iter() + .collect::>(); + tx.commit().await?; + Ok(Json(rows)) +} + +async fn list_scripts( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Query(pagination): Query, + Query(lq): Query, +) -> JsonResult> { + let (per_page, offset) = paginate_without_limits(pagination); + let mut sqlb = SqlBuilder::select_from("script as o") + .fields(&[ + "hash", + "o.path", + "summary", + "COALESCE(draft.created_at, o.created_at) as created_at", + "archived", + "extra_perms", + if !lq.without_description.unwrap_or(false) { + "description" + } else { + "NULL as description" + }, + "CASE WHEN lock_error_logs IS NOT NULL THEN true ELSE false END as has_deploy_errors", + "language", + "favorite.path IS NOT NULL as starred", + "tag", + "draft.path IS NOT NULL as has_draft", + "draft_only", + "ws_error_handler_muted", + "no_main_func", + "codebase IS NOT NULL as use_codebase", + "kind" + ]) + .left() + .join("favorite") + .on( + "favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" + .bind(&authed.username), + ) + .left() + .join("draft") + .on( + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + ) + .order_desc("favorite.path IS NOT NULL") + .order_by("created_at", lq.order_desc.unwrap_or(true)) + .and_where("o.workspace_id = ?".bind(&w_id)) + .offset(offset) + .limit(per_page) + .clone(); + + let lowercased_kinds: Option> = lq + .kinds + .map(|x| x.split(",").map(&str::to_lowercase).collect()); + + if (!lq.include_without_main.unwrap_or(false) + && lowercased_kinds + .as_ref() + .map(|x| !x.contains(&"preprocessor".to_string())) + .unwrap_or(true)) + || authed.is_operator + { + // only include scripts that have a main function + // do not hide scripts without main if preprocessor is in the kinds + sqlb.and_where("o.no_main_func IS NOT TRUE"); + } + + if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { + sqlb.and_where("draft_only IS NOT TRUE"); + } + + if lq.show_archived.unwrap_or(false) { + sqlb.and_where_eq( + "o.ctid", + "(SELECT ctid FROM script + WHERE path = o.path + AND workspace_id = ? + ORDER BY created_at DESC + LIMIT 1)" + .bind(&w_id), + ); + sqlb.and_where_eq("archived", true); + } else { + sqlb.and_where_eq("archived", false); + } + if let Some(ps) = &lq.path_start { + sqlb.and_where_like_left("o.path", ps); + } + if let Some(p) = &lq.path_exact { + sqlb.and_where_eq("o.path", "?".bind(p)); + } + if let Some(cb) = &lq.created_by { + sqlb.and_where_eq("created_by", "?".bind(cb)); + } + if let Some(ph) = &lq.first_parent_hash { + sqlb.and_where_eq("parent_hashes[1]", &ph.0); + } + if let Some(ph) = &lq.last_parent_hash { + sqlb.and_where_eq("parent_hashes[array_upper(parent_hashes, 1)]", &ph.0); + } + if let Some(ph) = &lq.parent_hash { + sqlb.and_where_eq("any(parent_hashes)", &ph.0); + } + if let Some(it) = &lq.is_template { + sqlb.and_where_eq("is_template", it); + } + if let Some(dw) = &lq.dedicated_worker { + sqlb.and_where_eq("dedicated_worker", dw); + } + if authed.is_operator { + sqlb.and_where_eq("kind", quote("script")); + } else if let Some(lowercased_kinds) = lowercased_kinds { + let safe_kinds = lowercased_kinds + .into_iter() + .map(sql_builder::quote) + .collect_vec(); + if safe_kinds.len() > 0 { + sqlb.and_where_in("kind", safe_kinds.as_slice()); + } + } + if lq.starred_only.unwrap_or(false) { + sqlb.and_where_is_not_null("favorite.path"); + } + + if lq.with_deployment_msg.unwrap_or(false) { + sqlb.join("deployment_metadata dm") + .left() + .on("dm.script_hash = o.hash") + .fields(&["dm.deployment_msg"]); + } + + if let Some(languages) = lq.languages { + sqlb.and_where_in( + "language", + &languages + .iter() + .map(|language| quote(language.as_str())) + .collect_vec(), + ); + } + + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_as::<_, ListableScript>(&sql) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +#[derive(Deserialize)] +struct TopHubScriptsQuery { + limit: Option, + app: Option, + kind: Option, +} + +async fn get_top_hub_scripts( + Query(query): Query, + Extension(db): Extension, +) -> impl IntoResponse { + let mut query_params = vec![]; + if let Some(query_limit) = query.limit { + query_params.push(("limit", query_limit.to_string().clone())); + } + if let Some(query_app) = query.app { + query_params.push(("app", query_app.to_string().clone())); + } + if let Some(query_kind) = query.kind { + query_params.push(("kind", query_kind.to_string().clone())); + } + + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!("{}/scripts/top", *HUB_BASE_URL.read().await), + Some(query_params), + &db, + ) + .await?; + Ok::<_, Error>((status_code, headers, response)) +} + +async fn create_snapshot_script( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(webhook): Extension, + Extension(db): Extension, + Path(w_id): Path, + mut multipart: Multipart, +) -> Result<(StatusCode, String)> { + // TODO: Check for debouncing here as well. + let mut script_hash = None; + let mut tx = None; + let mut uploaded = false; + let mut handle_deployment_metadata = None; + while let Some(field) = multipart.next_field().await.unwrap() { + let name = field.name().unwrap().to_string(); + let data = field.bytes().await.unwrap(); + if name == "script" { + let ns: NewScript = Some(serde_json::from_slice(&data).map_err(to_anyhow)?).unwrap(); + let is_tar = ns.codebase.as_ref().is_some_and(|x| x.ends_with(".tar")); + let use_esm = ns.codebase.as_ref().is_some_and(|x| x.contains(".esm")); + let (new_hash, ntx, hdm) = create_script_internal( + ns, + w_id.clone(), + authed.clone(), + db.clone(), + user_db.clone(), + webhook.clone(), + ) + .await?; + let mut nh = new_hash.to_string(); + if use_esm { + nh = format!("{nh}.esm"); + } + if is_tar { + nh = format!("{nh}.tar"); + } + script_hash = Some(nh); + tx = Some(ntx); + handle_deployment_metadata = hdm; + } + if name == "file" { + let hash = script_hash.as_ref().ok_or_else(|| { + Error::BadRequest( + "script need to be passed first in the multipart upload".to_string(), + ) + })?; + + uploaded = true; + + let path = windmill_common::s3_helpers::bundle(&w_id, &hash); + upload_artifact_to_store( + &path, + data, + &windmill_common::worker::ROOT_STANDALONE_BUNDLE_DIR, + ) + .await?; + } + // println!("Length of `{}` is {} bytes", name, data.len()); + } + if !uploaded { + return Err(Error::BadRequest("No file uploaded".to_string())); + } + if script_hash.is_none() { + return Err(Error::BadRequest( + "No script found in the uploaded file".to_string(), + )); + } + + tx.unwrap().commit().await?; + if let Some(hdm) = handle_deployment_metadata { + hdm.handle(&db).await?; + } + return Ok((StatusCode::CREATED, format!("{}", script_hash.unwrap()))); +} + +async fn list_paths_from_workspace_runnable( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let runnables = sqlx::query_scalar!( + r#"SELECT importer_path FROM dependency_map + WHERE workspace_id = $1 AND imported_path = $2"#, + w_id, + path.to_path(), + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(runnables)) +} + +async fn create_script( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(webhook): Extension, + Extension(db): Extension, + Path(w_id): Path, + Json(ns): Json, +) -> Result<(StatusCode, String)> { + let (hash, tx, hdm) = + create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?; + tx.commit().await?; + if let Some(hdm) = hdm { + hdm.handle(&db).await?; + } + Ok((StatusCode::CREATED, format!("{}", hash))) +} + +struct HandleDeploymentMetadata { + email: String, + created_by: String, + w_id: String, + obj: DeployedObject, + deployment_message: Option, + renamed_from: Option, +} + +impl HandleDeploymentMetadata { + async fn handle(self, db: &DB) -> Result<()> { + handle_deployment_metadata( + &self.email, + &self.created_by, + &db, + &self.w_id, + self.obj, + self.deployment_message, + false, + self.renamed_from.as_deref(), + ) + .await + } +} + +async fn create_script_internal<'c>( + ns: NewScript, + w_id: String, + authed: ApiAuthed, + db: sqlx::Pool, + user_db: UserDB, + webhook: WebhookShared, +) -> Result<( + ScriptHash, + Transaction<'c, Postgres>, + Option, +)> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot create scripts for security reasons".to_string(), + )); + } + check_scopes(&authed, || format!("scripts:write:{}", ns.path))?; + + guard_script_from_debounce_data(&ns).await?; + + let codebase = ns.codebase.as_ref(); + #[cfg(not(feature = "enterprise"))] + if ns.ws_error_handler_muted.is_some_and(|val| val) { + return Err(Error::BadRequest( + "Muting the error handler for certain script is only available in enterprise version" + .to_string(), + )); + } + if *CLOUD_HOSTED { + let nb_scripts = + sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id) + .fetch_one(&db) + .await?; + if nb_scripts.unwrap_or(0) >= 5000 { + return Err(Error::BadRequest( + "You have reached the maximum number of scripts (5000) on cloud. Contact support@windmill.dev to increase the limit" + .to_string(), + )); + } + + if ns.summary.len() > 300 { + return Err(Error::BadRequest( + "Summary must be less than 300 characters on cloud".to_string(), + )); + } + if ns.description.len() > 3000 { + return Err(Error::BadRequest( + "Description must be less than 3000 characters on cloud".to_string(), + )); + } + } + let script_path = ns.path.clone(); + let hash = ScriptHash(hash_script(&ns)); + let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; + let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; + if sqlx::query_scalar!( + "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2", + hash.0, + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .is_some() + { + return Err(Error::BadRequest( + "A script with same hash (hence same path, description, summary, content) already \ + exists!" + .to_owned(), + )); + }; + let clashing_script = sqlx::query_as::<_, Script>( + "SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", + ) + .bind(&ns.path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + struct ParentInfo { + p_hashes: Vec, + perms: serde_json::Value, + p_path: String, + } + let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { + (None, None) => Ok(None), + (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( + "Path conflict for {} with non-archived hash {}", + &ns.path, &s.hash + ))), + (None, Some(s)) => { + sqlx::query!( + "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", + s.hash.0, + &w_id + ) + .execute(&mut *tx) + .await?; + Ok(None) + } + (Some(p_hash), o) => { + // Lock the parent row to prevent concurrent updates with the same parent_hash + // This ensures linear lineage - only one script can have a given parent at a time + if sqlx::query_scalar!( + "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2 FOR UPDATE", + p_hash.0, + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .is_none() + { + return Err(Error::BadRequest( + "The parent hash does not seem to exist".to_owned(), + )); + }; + + let clashing_hash_o = sqlx::query_scalar!( + "SELECT hash FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2", + p_hash.0, + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(clashing_hash) = clashing_hash_o { + return Err(Error::BadRequest(format!( + "A script with hash {} with same parent_hash has been found. However, the \ + lineage must be linear: no 2 scripts can have the same parent", + ScriptHash(clashing_hash) + ))); + }; + + let ScriptWithStarred { script: ps, .. } = + get_script_by_hash_internal(&mut tx, &w_id, p_hash, None).await?; + + if ps.path != ns.path { + require_owner_of_path(&authed, &ps.path)?; + } + + let ph = { + let v = ps.parent_hashes.map(|x| x.0).unwrap_or_default(); + let mut v: Vec = v + .into_iter() + .take(MAX_HASH_HISTORY_LENGTH_STORED - 1) + .collect(); + v.insert(0, p_hash.0); + v + }; + let r: Result> = match o { + Some(clashing_script) + if clashing_script.path == ns.path && clashing_script.hash.0 != p_hash.0 => + { + Err(Error::BadRequest(format!( + "Path conflict for {} with non-archived hash {}", + &ns.path, &clashing_script.hash + ))) + } + Some(_) | None => Ok(Some(ParentInfo { + p_hashes: ph, + perms: ps.extra_perms, + p_path: ps.path, + })), + }; + sqlx::query!( + "UPDATE script SET archived = true WHERE hash = $1 AND workspace_id = $2", + p_hash.0, + &w_id + ) + .execute(&mut *tx) + .await?; + + clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; + + r + } + }?; + let p_hashes = parent_hashes_and_perms.as_ref().map(|v| &v.p_hashes[..]); + let extra_perms = parent_hashes_and_perms + .as_ref() + .map(|v| v.perms.clone()) + .unwrap_or(json!({})); + let lock = if ns.codebase.is_some() { + Some(String::new()) + } else if !( + ns.language == ScriptLang::Python3 + || ns.language == ScriptLang::Go + || ns.language == ScriptLang::Bun + || ns.language == ScriptLang::Bunnative + || ns.language == ScriptLang::Deno + || ns.language == ScriptLang::Rust + || ns.language == ScriptLang::Ansible + || ns.language == ScriptLang::CSharp + || ns.language == ScriptLang::Nu + || ns.language == ScriptLang::Php + || ns.language == ScriptLang::Java + || ns.language == ScriptLang::Ruby + // for related places search: ADD_NEW_LANG + ) { + Some(String::new()) + } else { + ns.lock.as_ref().and_then(|e| { + if e.is_empty() { + None + } else { + Some(e.to_string()) + } + }) + }; + + let needs_lock_gen = lock.is_none() && codebase.is_none(); + let envs = ns.envs.as_ref().map(|x| x.as_slice()); + let envs = if ns.envs.is_none() || ns.envs.as_ref().unwrap().is_empty() { + None + } else { + envs + }; + + let lang = if &ns.language == &ScriptLang::Bun || &ns.language == &ScriptLang::Bunnative { + let anns = windmill_common::worker::TypeScriptAnnotations::parse(&ns.content); + if anns.native { + ScriptLang::Bunnative + } else { + ScriptLang::Bun + } + } else { + ns.language.clone() + }; + + let validate_schema = should_validate_schema(&ns.content, &ns.language); + + let (no_main_func, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) { + (ns.no_main_func, ns.has_preprocessor) + } else { + match lang { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None); + match args { + Ok(args) => (args.no_main_func, args.has_preprocessor), + Err(e) => { + tracing::warn!( + "Error parsing deno signature when deploying script {}: {:?}", + ns.path, + e + ); + (None, None) + } + } + } + ScriptLang::Python3 => { + let args = windmill_parser_py::parse_python_signature(&ns.content, None, true); + match args { + Ok(args) => (args.no_main_func, args.has_preprocessor), + Err(e) => { + tracing::warn!( + "Error parsing python signature when deploying script {}: {:?}", + ns.path, + e + ); + (None, None) + } + } + } + _ => (ns.no_main_func, ns.has_preprocessor), + } + }; + + let runnable_settings_handle = RunnableSettings { + debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, + concurrency_settings: ns.concurrency_settings.insert_cached(&db).await?, + } + .insert_cached(&db) + .await?; + + let ( + guarded_concurrent_limit, + guarded_concurrency_time_window_s, + guarded_concurrency_key, + guarded_debounce_key, + guarded_debounce_delay_s, + ) = if min_version_supports_runnable_settings_v0().await { + Default::default() + } else { + ( + ns.concurrency_settings.concurrent_limit.clone(), + ns.concurrency_settings.concurrency_time_window_s.clone(), + ns.concurrency_settings.concurrency_key.clone(), + ns.debouncing_settings.debounce_key.clone(), + ns.debouncing_settings.debounce_delay_s.clone(), + ) + }; + + sqlx::query!( + "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ + content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ + draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ + dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ + delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)", + &w_id, + &hash.0, + ns.path, + p_hashes, + ns.summary, + ns.description, + &ns.content, + &authed.username, + ns.schema.and_then(|x| serde_json::to_string(&x.0).ok()), + ns.is_template.unwrap_or(false), + extra_perms, + lock, + lang as ScriptLang, + ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, + ns.tag, + ns.draft_only, + envs, + guarded_concurrent_limit, + guarded_concurrency_time_window_s, + ns.cache_ttl, + ns.dedicated_worker, + ns.ws_error_handler_muted.unwrap_or(false), + ns.priority, + ns.restart_unless_cancelled, + ns.delete_after_use, + ns.timeout, + guarded_concurrency_key, + ns.visible_to_runner_only, + no_main_func.filter(|x: &bool| *x), // should be Some(true) or None + codebase, + has_preprocessor.filter(|x: &bool| *x), // should be Some(true) or None + if ns.on_behalf_of_email.is_some() { + Some(&authed.email) + } else { + None + }, + validate_schema, + ns.assets.as_ref().and_then(|a| serde_json::to_value(a).ok()), + guarded_debounce_key, + guarded_debounce_delay_s, + ns.cache_ignore_s3_path, + runnable_settings_handle + ) + .execute(&mut *tx) + .await?; + + let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); + if let Some(ref p_path) = p_path_opt { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", + ns.path, + p_path, + w_id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", + ns.path, + p_path, + w_id + ) + .execute(&mut *tx) + .await?; + + let mut schedulables = sqlx::query_as::<_, Schedule>( + "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS false RETURNING *") + .bind(&ns.path) + .bind(&p_path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let schedule = sqlx::query_as::<_, Schedule>( + "UPDATE schedule SET path = $1, script_path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS false RETURNING *") + .bind(&ns.path) + .bind(&p_path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some(schedule) = schedule { + schedulables.push(schedule); + } + + // Update dynamic_skip references when script is renamed + sqlx::query!( + "UPDATE schedule SET dynamic_skip = $1 WHERE dynamic_skip = $2 AND workspace_id = $3", + &ns.path, + &p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + + for schedule in schedulables { + clear_schedule(&mut tx, &schedule.path, &w_id).await?; + + if schedule.enabled { + tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; + } + } + } else { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + ns.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } + if p_hashes.is_some() && !p_hashes.unwrap().is_empty() { + audit_log( + &mut *tx, + &authed, + "scripts.update", + ActionKind::Update, + &w_id, + Some(&ns.path), + Some([("hash", hash.to_string().as_str())].into()), + ) + .await?; + webhook.send_message( + w_id.clone(), + WebhookMessage::UpdateScript { + workspace: w_id.clone(), + path: ns.path.clone(), + hash: hash.to_string(), + }, + ); + } else { + audit_log( + &mut *tx, + &authed, + "scripts.create", + ActionKind::Create, + &w_id, + Some(&ns.path), + Some( + [ + ("workspace", w_id.as_str()), + ("hash", hash.to_string().as_str()), + ] + .into(), + ), + ) + .await?; + webhook.send_message( + w_id.clone(), + WebhookMessage::CreateScript { + workspace: w_id.clone(), + path: ns.path.clone(), + hash: hash.to_string(), + }, + ); + } + + clear_static_asset_usage(&mut *tx, &w_id, &script_path, AssetUsageKind::Script).await?; + for asset in ns.assets.as_ref().into_iter().flatten() { + insert_static_asset_usage(&mut *tx, &w_id, &asset, &ns.path, AssetUsageKind::Script) + .await?; + } + + let permissioned_as = username_to_permissioned_as(&authed.username); + if let Some(parent_hash) = ns.parent_hash { + tracing::info!( + "creating script {hash:?} at path {script_path} with parent {parent_hash} on workspace {w_id}", + ); + } else { + tracing::info!("creating script {hash:?} at path {script_path} on workspace {w_id}",); + } + if needs_lock_gen { + let tag = if ns.dedicated_worker.is_some_and(|x| x) { + Some(format!("{}:{}", &w_id, &ns.path,)) + } else if ns.tag.as_ref().is_some_and(|x| x.contains("$args[")) { + None + } else { + ns.tag + }; + + let mut args: HashMap> = HashMap::new(); + if let Some(dm) = ns.deployment_message { + args.insert("deployment_message".to_string(), to_raw_value(&dm)); + } + if let Some(ref p_path) = p_path_opt { + args.insert("parent_path".to_string(), to_raw_value(&p_path)); + } + + let tx = PushIsolationLevel::Transaction(tx); + let (job_id, mut new_tx) = windmill_queue::push( + &db, + tx, + &w_id, + JobPayload::Dependencies { + hash, + language: ns.language, + path: ns.path.clone(), + dedicated_worker: ns.dedicated_worker, + debouncing_settings: Default::default(), + }, + windmill_queue::PushArgs::from(&args), + &authed.username, + &authed.email, + permissioned_as, + authed.token_prefix.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + tag, + None, + None, + None, + Some(&authed.clone().into()), + false, + None, + None, + None, + ) + .await?; + + // Store the job_id in deployment_metadata for this script deployment + sqlx::query!( + "INSERT INTO deployment_metadata (workspace_id, path, script_hash, job_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL + DO UPDATE SET job_id = EXCLUDED.job_id", + w_id, + ns.path, + hash.0, + job_id + ) + .execute(&mut *new_tx) + .await?; + + Ok((hash, new_tx, None)) + } else { + if codebase.is_none() { + let db2 = db.clone(); + let w_id2 = w_id.clone(); + let authed2 = authed.clone(); + let permissioned_as2 = permissioned_as.clone(); + let script_path2 = script_path.clone(); + let parent_path = p_path_opt.clone(); + let deployment_message = ns.deployment_message.clone(); + let content = ns.content.clone(); + let language = ns.language.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + if let Err(e) = process_relative_imports( + &db2, + None, + None, + &w_id2, + &script_path2, + parent_path, + deployment_message, + &content, + &Some(language), + &authed2.email, + &authed2.username, + &permissioned_as2, + ) + .await + { + tracing::error!(%e, "error processing relative imports"); + } + }); + } + + // handle_deployment_metadata( + // &authed.email, + // &authed.username, + // &db, + // &w_id, + // DeployedObject::Script { + // hash: hash.clone(), + // path: script_path.clone(), + // parent_path: p_path_opt, + // }, + // ns.deployment_message, + // false, + // ) + // .await?; + + Ok(( + hash, + tx, + Some(HandleDeploymentMetadata { + email: authed.email, + created_by: authed.username, + w_id, + obj: DeployedObject::Script { + hash: hash.clone(), + path: script_path.clone(), + parent_path: p_path_opt.clone(), + }, + deployment_message: ns.deployment_message, + renamed_from: p_path_opt, + }), + )) + } +} + +pub async fn get_hub_script_by_path( + Path(path): Path, + Extension(db): Extension, +) -> Result { + windmill_common::scripts::get_hub_script_by_path(path, &HTTP_CLIENT, &db).await +} + +pub async fn get_full_hub_script_by_path( + Path(path): Path, + Extension(db): Extension, +) -> JsonResult { + Ok(Json( + windmill_common::scripts::get_full_hub_script_by_path(path, &HTTP_CLIENT, Some(&db)) + .await?, + )) +} + +pub async fn pick_hub_script_by_path( + Path(path): Path, + Extension(db): Extension, +) -> impl IntoResponse { + let path_str = path.to_path(); + + // Extract version_id from path (format: {hub}/{version_id}/{summary}) + let version_id = path_str.split('/').nth(1).unwrap_or(""); + + let hub_base_url = HUB_BASE_URL.read().await.clone(); + + // Determine which hub to use based on version_id + // If version_id < PRIVATE_HUB_MIN_VERSION, use default hub + let target_hub_url = if version_id + .parse::() + .is_ok_and(|v| v < windmill_common::PRIVATE_HUB_MIN_VERSION) + { + windmill_common::DEFAULT_HUB_BASE_URL + } else { + &hub_base_url + }; + + // Call the hub's pick endpoint: /scripts/{version_id}/pick + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!("{}/scripts/{}/pick", target_hub_url, version_id), + None, + &db, + ) + .await?; + + Ok::<_, Error>((status_code, headers, response)) +} + +#[axum::debug_handler] +async fn get_script_by_path( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + let script_o = if query.with_starred_info.unwrap_or(false) { + sqlx::query_as::<_, ScriptWithStarred>( + "SELECT s.*, favorite.path IS NOT NULL as starred + FROM script s + LEFT JOIN favorite + ON favorite.favorite_kind = 'script' + AND favorite.workspace_id = s.workspace_id + AND favorite.path = s.path + AND favorite.usr = $3 + WHERE s.path = $1 + AND s.workspace_id = $2 + ORDER BY s.created_at DESC LIMIT 1", + ) + .bind(path) + .bind(w_id) + .bind(&authed.username) + .fetch_optional(&mut *tx) + .await? + } else { + sqlx::query_as::<_, ScriptWithStarred>( + "SELECT *, NULL as starred FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + ) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await? + }; + tx.commit().await?; + + let script = not_found_if_none(script_o, "Script", path)? + .prefetch_cached(&db) + .await?; + + Ok(Json(script)) +} + +async fn list_tokens( + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + list_tokens_internal(&db, &w_id, &path, false).await +} + +async fn get_script_by_path_w_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + let script_o = sqlx::query_as::<_, ScriptWDraft>( + "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, has_preprocessor, on_behalf_of_email, assets, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON + script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' + WHERE script.path = $1 AND script.workspace_id = $2 + ORDER BY script.created_at DESC LIMIT 1", + ) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + let script = not_found_if_none(script_o, "Script", path)?; + Ok(Json(script.prefetch_cached(&db).await?)) +} + +async fn get_script_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + let query_result = sqlx::query!( + "SELECT s.hash as hash, dm.deployment_msg as deployment_msg + FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash + WHERE s.workspace_id = $1 AND s.path = $2 + ORDER by s.created_at DESC", + w_id, + path, + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + let result: Vec = query_result + .into_iter() + .map(|row| ScriptHistory { + script_hash: ScriptHash(row.hash), + deployment_msg: row.deployment_msg, + }) + .collect(); + return Ok(Json(result)); +} + +async fn get_latest_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:read:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + let row_o = sqlx::query!( + "SELECT s.hash as hash, dm.deployment_msg as deployment_msg + FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash + WHERE s.workspace_id = $1 AND s.path = $2 + ORDER by s.created_at DESC LIMIT 1", + w_id, + path, + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + if let Some(row) = row_o { + let result = ScriptHistory { + script_hash: ScriptHash(row.hash), + deployment_msg: row.deployment_msg, // + }; + return Ok(Json(Some(result))); + } else { + return Ok(Json(None)); + } +} + +async fn update_script_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, script_hash, script_path)): Path<(String, ScriptHash, StripPath)>, + Json(script_history_update): Json, +) -> Result<()> { + let script_path = script_path.to_path(); + check_scopes(&authed, || format!("scripts:write:{}", script_path))?; + + let mut tx = user_db.begin(&authed).await?; + sqlx::query!( + "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL + DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + w_id, + script_path, + script_hash.0, + script_history_update.deployment_msg, + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + return Ok(()); +} + +async fn list_paths( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let scripts = sqlx::query_scalar!( + "SELECT distinct(path) FROM script WHERE workspace_id = $1", + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(scripts)) +} + +#[derive(Deserialize)] +pub struct ToggleWorkspaceErrorHandler { + #[cfg(feature = "enterprise")] + pub muted: Option, +} + +#[cfg(not(feature = "enterprise"))] +async fn toggle_workspace_error_handler( + _authed: ApiAuthed, + Extension(_user_db): Extension, + Path((_w_id, _path)): Path<(String, StripPath)>, + Json(_req): Json, +) -> Result { + return Err(Error::BadRequest( + "Muting the error handler for certain script is only available in enterprise version" + .to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn toggle_workspace_error_handler( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(req): Json, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + + let error_handler_maybe: Option = sqlx::query_scalar!( + "SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(None); + + match error_handler_maybe { + Some(_) => { + sqlx::query_scalar!( + "UPDATE script + SET ws_error_handler_muted = $3 + WHERE ctid = ( + SELECT ctid FROM script + WHERE path = $1 AND workspace_id = $2 + ORDER BY created_at DESC + LIMIT 1 + ) +", + path.to_path(), + w_id, + req.muted, + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok("".to_string()) + } + None => { + tx.commit().await?; + Err(Error::ExecutionErr( + "Workspace error handler needs to be defined".to_string(), + )) + } + } +} + +async fn get_tokened_raw_script_by_path( + Extension(user_db): Extension, + Extension(db): Extension, + Extension(cache): Extension>, + Path((w_id, token, path)): Path<(String, String, StripPath)>, + Query(query): Query, +) -> Result { + let authed = cache + .get_authed(Some(w_id.clone()), &token) + .await + .ok_or_else(|| Error::NotAuthorized("Invalid token".to_string()))?; + return raw_script_by_path( + authed, + Extension(user_db), + Extension(db), + Path((w_id, path)), + Query(query), + ) + .await; +} + +async fn get_empty_ts_script_by_path() -> String { + return String::new(); +} + +#[derive(Deserialize)] +struct RawScriptByPathQuery { + // used to make cache immutable with respect to importer + cache_key: Option, + // used specifically for python to cache folders on import success to avoid extra db calls on package fetch + cache_folders: Option, +} + +struct StringWithLength(String); + +impl IntoResponse for StringWithLength { + fn into_response(self) -> axum::response::Response { + let len = self.0.len(); + ([(header::CONTENT_LENGTH, len.to_string())], self.0).into_response() + } +} + +async fn raw_script_by_path( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> Result { + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {}", path.to_path()); + } + let r = raw_script_by_path_internal(path, user_db, db, authed, w_id, false, query).await?; + Ok(StringWithLength(r)) +} + +async fn raw_script_by_path_unpinned( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> Result { + let r = raw_script_by_path_internal(path, user_db, db, authed, w_id, true, query).await?; + Ok(StringWithLength(r)) +} + +lazy_static::lazy_static! { + static ref DEBUG_RAW_SCRIPT_ENDPOINTS: bool = + std::env::var("DEBUG_RAW_SCRIPT_ENDPOINTS").is_ok(); +} + +lazy_static::lazy_static! { + pub static ref RAW_SCRIPT_CACHE: Cache = Cache::new(1000); + pub static ref CACHE_FOLDERS_PATH: Cache = Cache::new(1000); + +} + +async fn raw_script_by_path_internal( + path: StripPath, + user_db: UserDB, + db: DB, + authed: ApiAuthed, + w_id: String, + unpin: bool, + query: RawScriptByPathQuery, +) -> Result { + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:read:{}", path))?; + let cache_path = query + .cache_key + .map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" })); + if let Some(cache_path) = cache_path.clone() { + let cached_content = RAW_SCRIPT_CACHE.get(&cache_path); + if let Some(cached_content) = cached_content { + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {} (cached)", path); + } + return Ok(cached_content); + } + } + + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {} (not cached)", path); + } + + if !path.ends_with(".py") + && !path.ends_with(".ts") + && !path.ends_with(".go") + && !path.ends_with(".sh") + { + return Err(Error::BadRequest(format!( + "Path must ends with a .py, .ts, .go. or .sh extension: {}", + path + ))); + } + let path = path + .trim_end_matches(".py") + .trim_end_matches(".bun.ts") + .trim_end_matches(".deno.ts") + .trim_end_matches(".ts") + .trim_end_matches(".go") + .trim_end_matches(".sh"); + + // folder cache is only useful for python given it needs to recuse over all intermediate folders to find the package. + // When a script exists in a folder, we can cache the fact that the folder exists to avoid extra db calls. + let mut split_path = path.split("/").collect::>(); + let folder_path = if query.cache_folders.is_some() && split_path.len() > 2 { + Some(format!("{w_id}:{path}/")) + } else { + None + }; + + let has_folder_cache = folder_path.is_some(); + if let Some(cache_folders) = folder_path { + let cached_content = CACHE_FOLDERS_PATH.get(&cache_folders); + if let Some(cached_ts) = cached_content { + if cached_ts >= chrono::Utc::now().timestamp() - 300 { + // 5 minutes + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {} (cached folders)", path); + } + return Ok("WINDMILL_IS_FOLDER".to_string()); + } else { + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!( + "Raw script by path request: {} (cached folders expired)", + path + ); + } + } + } + } + + let mut tx = user_db.begin(&authed).await?; + + let content_o = sqlx::query_scalar!( + "SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", + path, + w_id + ) + .fetch_optional(&mut *tx) + .warn_after_seconds(5) + .await?; + tx.commit().await?; + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!( + "Raw script by path request: {} (content: {:?})", + path, + content_o + ); + } + + if content_o.is_none() { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)", + path, + w_id + ) + .fetch_one(&db) + .warn_after_seconds(5) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::NotFound(format!( + "Script {path} exists but {} does not have permissions to access it", + authed.username + ))); + } else { + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + let other_script_o = sqlx::query_scalar!( + "SELECT path FROM script WHERE workspace_id = $1 AND archived = false", + w_id + ) + .fetch_all(&db) + .await?; + let other_script_archived = sqlx::query_scalar!( + "SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true", + w_id + ) + .fetch_all(&db) + .await?; + tracing::warn!( + "Script {path} does not exist in workspace {w_id} but these paths do, non-archived: {:?} | archived: {:?}", + other_script_o.join(", "), + other_script_archived.join(", ") + ) + } + } + } + + let content = not_found_if_none(content_o, "Script", path)?; + + let content = if unpin { + remove_pinned_imports(&content)? + } else { + content + }; + + if has_folder_cache { + while split_path.len() >= 2 { + split_path.pop(); + let npath = split_path.join("/"); + CACHE_FOLDERS_PATH.insert(format!("{w_id}:{npath}/"), chrono::Utc::now().timestamp()); + } + } + + if let Some(cache_path) = cache_path { + RAW_SCRIPT_CACHE.insert(cache_path, content.clone()); + } + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {} (content response)", path); + } + Ok(content) +} + +async fn exists_script_by_path( + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", + path, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + Ok(Json(exists)) +} + +async fn get_script_by_hash_internal<'c>( + db: &mut Transaction<'c, Postgres>, + workspace_id: &str, + hash: &ScriptHash, + with_starred_info_for_username: Option<&str>, +) -> Result> { + let script_o = if let Some(username) = with_starred_info_for_username { + sqlx::query_as::<_, ScriptWithStarred>( + "SELECT s.*, favorite.path IS NOT NULL as starred + FROM script s + LEFT JOIN favorite + ON favorite.favorite_kind = 'script' + AND favorite.workspace_id = s.workspace_id + AND favorite.path = s.path + AND favorite.usr = $1 + WHERE s.hash = $2 AND s.workspace_id = $3", + ) + .bind(&username) + .bind(hash) + .bind(workspace_id) + .fetch_optional(&mut **db) + .await? + } else { + sqlx::query_as::<_, ScriptWithStarred>( + "SELECT *, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", + ) + .bind(hash) + .bind(workspace_id) + .fetch_optional(&mut **db) + .await? + }; + + let script = not_found_if_none(script_o, "Script", hash.to_string())?; + Ok(script) +} + +#[derive(Deserialize)] +struct GetScriptByHashQuery { + authed: Option, +} +async fn get_script_by_hash( + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, hash)): Path<(String, ScriptHash)>, + Query(query): Query, + Query(query_auth): Query, + Extension(authed): Extension, +) -> JsonResult> { + let mut tx = if query_auth.authed.is_some_and(|x| x) { + user_db.begin(&authed).await? + } else { + db.begin().await? + }; + let r = get_script_by_hash_internal( + &mut tx, + &w_id, + &hash, + query.with_starred_info.and_then(|x| { + if x { + Some(authed.username.as_str()) + } else { + None + } + }), + ) + .await?; + + check_scopes(&authed, || format!("scripts:read:{}", &r.script.path))?; + + tx.commit().await?; + + Ok(Json(r.prefetch_cached(&db).await?)) +} + +async fn raw_script_by_hash( + Extension(db): Extension, + Path((w_id, hash_str)): Path<(String, String)>, +) -> Result { + let mut tx = db.begin().await?; + let hash = ScriptHash(to_i64(hash_str.strip_suffix(".ts").ok_or_else(|| { + Error::BadRequest("Raw script path must end with .ts".to_string()) + })?)?); + let r = get_script_by_hash_internal(&mut tx, &w_id, &hash, None).await?; + tx.commit().await?; + + Ok(r.script.content) +} + +#[derive(Serialize)] +struct DeploymentStatus { + lock: Option, + lock_error_logs: Option, + job_id: Option, +} +async fn get_deployment_status( + Extension(db): Extension, + Path((w_id, hash)): Path<(String, ScriptHash)>, +) -> JsonResult { + let mut tx = db.begin().await?; + let status_o = sqlx::query!( + "SELECT s.lock, s.lock_error_logs, dm.job_id + FROM script s + LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id + WHERE s.hash = $1 AND s.workspace_id = $2", + hash.0, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let status = not_found_if_none(status_o, "DeploymentStatus", hash.to_string())?; + + let deployment_status = DeploymentStatus { + lock: status.lock, + lock_error_logs: status.lock_error_logs, + job_id: status.job_id, + }; + + tx.commit().await?; + Ok(Json(deployment_status)) +} + +pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { + return windmill_api_auth::require_is_writer( + authed, + path, + w_id, + db, + "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "script", + ) + .await; +} + +async fn archive_script_by_path( + authed: ApiAuthed, + Extension(webhook): Extension, + Extension(user_db): Extension, + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> Result<()> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot archive scripts for security reasons".to_string(), + )); + } + let path = path.to_path(); + check_scopes(&authed, || format!("scripts:write:{}", path))?; + let mut tx = user_db.begin(&authed).await?; + + require_owner_of_path(&authed, path)?; + + let hash: i64 = sqlx::query_scalar!( + "UPDATE script SET archived = true WHERE path = $1 AND workspace_id = $2 RETURNING hash", + path, + &w_id + ) + .fetch_one(&db) + .await + .map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?; + + clear_static_asset_usage(&mut *tx, &w_id, path, AssetUsageKind::Script).await?; + + audit_log( + &mut *tx, + &authed, + "scripts.archive", + ActionKind::Delete, + &w_id, + Some(&ScriptHash(hash).to_string()), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + + ScopedDependencyMap::clear_map_for_item(path, &w_id, "script", tx, &None) + .await + .commit() + .await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + hash: ScriptHash(0), // dummy hash as it will not get inserted in db + path: path.to_string(), + parent_path: Some(path.to_string()), + }, + Some(format!("Script '{}' archived", path)), + true, + None, + ) + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, + ); + + Ok(()) +} + +async fn archive_script_by_hash( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Extension(webhook): Extension, + Path((w_id, hash)): Path<(String, ScriptHash)>, +) -> JsonResult> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot archive scripts for security reasons".to_string(), + )); + } + let mut tx = user_db.begin(&authed).await?; + + let script = sqlx::query_as::<_, Script>( + "UPDATE script SET archived = true WHERE hash = $1 AND workspace_id = $2 RETURNING *", + ) + .bind(&hash.0) + .bind(&w_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?; + + check_scopes(&authed, || format!("scripts:write:{}", &script.path))?; + clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; + + audit_log( + &mut *tx, + &authed, + "scripts.archive", + ActionKind::Delete, + &w_id, + Some(&hash.to_string()), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + + ScopedDependencyMap::clear_map_for_item(&script.path, &w_id, "script", tx, &None) + .await + .commit() + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, + ); + + Ok(Json(script.prefetch_cached(&db).await?)) +} + +async fn delete_script_by_hash( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(webhook): Extension, + Extension(db): Extension, + Path((w_id, hash)): Path<(String, ScriptHash)>, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + require_admin(authed.is_admin, &authed.username)?; + let script = sqlx::query_as::<_, Script>( + "UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \ + workspace_id = $2 RETURNING *", + ) + .bind(&hash.0) + .bind(&w_id) + .fetch_one(&db) + .await + .map_err(|e| Error::internal_err(format!("deleting script by hash {w_id}: {e:#}")))?; + + check_scopes(&authed, || format!("scripts:write:{}", &script.path))?; + + clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; + + audit_log( + &mut *tx, + &authed, + "scripts.delete", + ActionKind::Delete, + &w_id, + Some(&hash.to_string()), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + tx.commit().await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, + ); + + Ok(Json(script.prefetch_cached(&db).await?)) +} + +#[derive(Deserialize)] +struct DeleteScriptQuery { + keep_captures: Option, +} + +async fn delete_script_by_path( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(webhook): Extension, + Extension(db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(query): Query, +) -> JsonResult { + let path = path.to_path(); + + check_scopes(&authed, || format!("scripts:write:{}", path))?; + + if path == "u/admin/hub_sync" && w_id == "admins" { + return Err(Error::BadRequest( + "Cannot delete the global setup app".to_string(), + )); + } + + let mut tx = user_db.begin(&authed).await?; + + let draft_only = sqlx::query_scalar!( + "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", + path, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + let script = if !draft_only { + require_admin(authed.is_admin, &authed.username)?; + sqlx::query_scalar!( + "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + path, + w_id + ) + .fetch_one(&db) + .await + .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? + } else { + sqlx::query_scalar!( + "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + path, + w_id + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? + }; + + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + path, + w_id + ) + .execute(&db) + .await?; + + if !query.keep_captures.unwrap_or(false) { + sqlx::query!( + "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE", + path, + w_id + ) + .execute(&db) + .await?; + + sqlx::query!( + "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE", + path, + w_id + ) + .execute(&db) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "scripts.delete", + ActionKind::Delete, + &w_id, + Some(&path), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + hash: ScriptHash(0), // Temporary value as it will get removed right after + path: path.to_string(), + parent_path: Some(path.to_string()), + }, + Some(format!("Script '{}' deleted", path)), + true, + None, + ) + .await?; + + sqlx::query!( + "DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND script_hash IS NOT NULL", + path, + w_id + ) + .execute(&db) + .await + .map_err(|e| { + Error::internal_err(format!( + "error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}" + )) + })?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteScriptPath { workspace: w_id, path: path.to_owned() }, + ); + + Ok(Json(script)) +} + +async fn delete_scripts_bulk( + authed: ApiAuthed, + Extension(webhook): Extension, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> JsonResult> { + for path in &request.paths { + check_scopes(&authed, || format!("scripts:write:{}", path))?; + } + + require_admin(authed.is_admin, &authed.username)?; + + if request.paths.contains(&"u/admin/hub_sync".to_string()) && w_id == "admins" { + return Err(Error::BadRequest( + "Cannot delete the global setup app".to_string(), + )); + } + + let mut tx = db.begin().await?; + + let mut deleted_paths = sqlx::query_scalar!( + "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path", + w_id, + &request.paths + ) + .fetch_all(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))?; + + // remove duplicates from deleted_paths + deleted_paths.sort(); + deleted_paths.dedup(); + + sqlx::query!( + "DELETE FROM draft WHERE workspace_id = $1 AND path = ANY($2) AND typ = 'script'", + w_id, + &deleted_paths + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM capture_config WHERE workspace_id = $1 AND path = ANY($2) AND is_flow IS FALSE", + w_id, + &deleted_paths + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM capture WHERE workspace_id = $1 AND path = ANY($2) AND is_flow IS FALSE", + w_id, + &deleted_paths + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "scripts.delete_bulk", + ActionKind::Delete, + &w_id, + Some(&deleted_paths.join(", ")), + Some([("workspace", w_id.as_str())].into()), + ) + .await?; + + tx.commit().await?; + + try_join_all(deleted_paths.iter().map(|path| { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { hash: ScriptHash(0), path: path.clone(), parent_path: None }, + Some(format!("Script '{}' deleted", path)), + true, + None, + ) + })) + .await?; + + sqlx::query!( + "DELETE FROM deployment_metadata WHERE workspace_id = $1 AND path = ANY($2) AND script_hash IS NOT NULL", + w_id, + &deleted_paths + ) + .execute(&db) + .await + .map_err(|e| { + Error::internal_err(format!( + "error deleting deployment metadata for scripts with paths {} in workspace {w_id}: {e:#}", deleted_paths.join(", ") + )) + })?; + + for path in &deleted_paths { + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteScriptPath { workspace: w_id.clone(), path: path.to_owned() }, + ); + } + + Ok(Json(deleted_paths)) +} + +/// Validates that script debouncing configuration is supported by all workers +/// Returns an error if debouncing is configured but workers are behind required version +async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> { + if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await && !ns.debouncing_settings.is_default() { + tracing::warn!( + "Script debouncing configuration rejected: workers are behind minimum required version for debouncing feature" + ); + Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into() }) + } else if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await + && !ns.debouncing_settings.is_legacy_compatible() + && !*WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT + { + tracing::warn!( + "Script debouncing configuration rejected: workers are behind minimum required version for debouncing feature" + ); + Err(Error::WorkersAreBehind { + feature: "V2 Debouncing".into(), + min_version: "1.597.0".into(), + }) + } else { + Ok(()) + } +} diff --git a/backend/windmill-api-users/Cargo.toml b/backend/windmill-api-users/Cargo.toml new file mode 100644 index 0000000000..e720b37eb7 --- /dev/null +++ b/backend/windmill-api-users/Cargo.toml @@ -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 diff --git a/backend/windmill-api-users/src/lib.rs b/backend/windmill-api-users/src/lib.rs new file mode 100644 index 0000000000..97aab59b2a --- /dev/null +++ b/backend/windmill-api-users/src/lib.rs @@ -0,0 +1,2 @@ +pub mod users; +pub mod users_oss; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs new file mode 100644 index 0000000000..014ad44931 --- /dev/null +++ b/backend/windmill-api-users/src/users.rs @@ -0,0 +1,2351 @@ +/* + * 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. + */ + +#![allow(non_snake_case)] + +use sqlx::{Postgres, Transaction}; + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use windmill_api_auth::ApiAuthed; + +pub use windmill_api_auth::Tokened; + +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{ + extract::{Extension, Path, Query}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use hyper::{header::LOCATION, StatusCode}; +use windmill_api_auth::require_super_admin; +use windmill_common::usernames::{ + generate_instance_wide_unique_username, get_instance_username_or_create_pending, +}; +use windmill_common::utils::{COOKIE_DOMAIN, IS_SECURE}; +use windmill_common::webhook::WebhookShared; +use windmill_common::DB; + +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; +use tower_cookies::{Cookie, Cookies}; +use tracing::Instrument; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::audit::AuditAuthor; +use windmill_common::auth::TOKEN_PREFIX_LEN; +use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; +use windmill_common::oauth2::InstanceEvent; +use windmill_common::users::truncate_token; +use windmill_common::users::COOKIE_NAME; +use windmill_common::utils::paginate; +use windmill_common::worker::CLOUD_HOSTED; +use windmill_common::BASE_URL; +use windmill_common::{ + auth::{get_folders_for_user, get_groups_for_user}, + db::UserDB, + error::{self, Error, JsonResult, Result}, + utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath}, +}; +use windmill_git_sync::handle_deployment_metadata; + +const COOKIE_PATH: &str = "/"; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/list", get(list_users)) + .route("/list_usage", get(list_user_usage)) + .route("/list_usernames", get(list_usernames)) + .route("/exists", post(exists_username)) + .route("/get/:user", get(get_workspace_user)) + .route("/update/:user", post(update_workspace_user)) + .route("/delete/:user", delete(delete_workspace_user)) + .route("/convert_to_group/:user", post(convert_user_to_group)) + .route("/is_owner/*path", get(is_owner_of_path)) + .route("/whois/:username", get(whois)) + .route("/whoami", get(whoami)) + .route("/leave", post(leave_workspace)) + .route("/username_to_email/:username", get(username_to_email)) +} + +pub fn global_service() -> Router { + Router::new() + .route("/exists/:email", get(exists_email)) + .route("/email", get(get_email)) + .route("/whoami", get(global_whoami)) + .route("/list_invites", get(list_invites)) + .route("/decline_invite", post(decline_invite)) + .route("/accept_invite", post(accept_invite)) + .route("/list_as_super_admin", get(list_users_as_super_admin)) + .route("/set_login_type/:user", post(set_login_type)) + .route("/update/:user", post(update_user)) + .route("/delete/:user", delete(delete_user)) + .route("/username_info/:user", get(get_instance_username_info)) + .route("/tokens/create", post(create_token)) + .route("/tokens/delete/:token_prefix", delete(delete_token)) + .route("/tokens/list", get(list_tokens)) + .route("/tokens/impersonate", post(impersonate)) + .route("/usage", get(get_usage)) + .route("/all_runnables", get(get_all_runnables)) + .route("/refresh_token", get(refresh_token)) + .route( + "/tutorial_progress", + post(update_tutorial_progress).get(get_tutorial_progress), + ) + .route("/leave_instance", post(leave_instance)) + .route("/export", get(export_global_users)) + .route("/overwrite", post(overwrite_global_users)) +} + +pub fn make_unauthed_service() -> Router { + Router::new() + .route("/login", post(login)) + .route("/logout", post(logout).get(logout)) + .route("/is_first_time_setup", get(is_first_time_setup)) + .route("/request_password_reset", post(request_password_reset)) + .route("/is_smtp_configured", get(is_smtp_configured)) +} + +pub use windmill_api_auth::{ + create_token_internal, fetch_api_authed, get_scope_tags, maybe_refresh_folders, + require_is_writer, require_path_read_access_for_preview, NewToken, OptAuthed, +}; + +#[cfg(feature = "parquet")] +pub use windmill_api_auth::fetch_api_authed_from_permissioned_as; + +#[derive(FromRow, Serialize)] +pub struct User { + pub workspace_id: String, + pub email: String, + pub username: String, + pub is_admin: bool, + pub created_at: chrono::DateTime, + pub operator: bool, + pub disabled: bool, + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub added_via: Option, +} + +#[derive(Serialize)] +pub struct UserWithUsage { + pub email: String, + pub executions: Option, +} + +#[derive(FromRow, Serialize, Debug)] +pub struct GlobalUserInfo { + email: String, + login_type: Option, + super_admin: bool, + devops: bool, + verified: bool, + name: Option, + company: Option, + username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operator_only: Option, + first_time_user: bool, +} + +#[derive(Serialize, Debug)] +pub struct UserInfo { + pub workspace_id: String, + pub email: String, + pub username: String, + pub is_admin: bool, + pub is_super_admin: bool, + pub created_at: chrono::DateTime, + pub groups: Vec, + pub operator: bool, + pub disabled: bool, + pub role: Option, + pub folders_read: Vec, + pub folders: Vec, + pub folders_owners: Vec, + pub name: Option, +} + +#[derive(FromRow, Serialize)] +pub struct WorkspaceInvite { + pub workspace_id: String, + pub email: String, + pub is_admin: bool, + pub operator: bool, + pub parent_workspace_id: Option, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct NewUser { + pub email: String, + pub password: String, + pub super_admin: bool, + pub name: Option, + pub company: Option, + pub skip_email: Option, +} + +#[derive(Deserialize)] +pub struct AcceptInvite { + pub workspace_id: String, + pub username: Option, +} + +#[derive(Deserialize)] +pub struct DeclineInvite { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct EditUser { + pub is_super_admin: Option, + pub is_devops: Option, + pub name: Option, +} + +#[derive(Deserialize)] +pub struct EditWorkspaceUser { + pub is_admin: Option, + pub operator: Option, + pub disabled: Option, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct EditPassword { + pub password: String, +} + +#[derive(Deserialize)] +pub struct EditLoginType { + pub login_type: String, +} + +#[derive(FromRow, Serialize)] +pub struct TruncatedToken { + pub label: Option, + pub token_prefix: Option, + pub expiration: Option>, + pub created_at: chrono::DateTime, + pub last_used_at: chrono::DateTime, + pub scopes: Option>, +} + +// NewToken is re-exported from windmill-api-auth above + +#[derive(Deserialize)] +pub struct Login { + pub email: String, + pub password: String, +} + +lazy_static::lazy_static! { + static ref FIRST_TIME_SETUP: Arc = Arc::new(AtomicBool::new(true)); +} + +pub async fn is_first_time_setup(Extension(db): Extension) -> JsonResult { + if !FIRST_TIME_SETUP.load(std::sync::atomic::Ordering::Relaxed) { + return Ok(Json(false)); + } + let single_user = sqlx::query_scalar!("SELECT 1 FROM password LIMIT 2") + .fetch_all(&db) + .await + .ok() + .unwrap_or_default() + .len() + == 1; + if single_user { + let user_is_admin_and_password_changeme = sqlx::query_scalar!( + "SELECT 1 FROM password WHERE email = 'admin@windmill.dev' AND password_hash = '$argon2id$v=19$m=4096,t=3,p=1$oLJo/lPn/gezXCuFOEyaNw$i0T2tCkw3xUFsrBIKZwr8jVNHlIfoxQe+HfDnLtd12I'" + ).fetch_all(&db) + .await + .ok() + .unwrap_or_default() + .len() == 1; + if user_is_admin_and_password_changeme { + let base_url_is_not_set = + sqlx::query_scalar!("SELECT COUNT(*) FROM global_settings WHERE name = 'base_url'") + .fetch_optional(&db) + .await + .ok() + .flatten() + .flatten() + .unwrap_or(0) + == 0; + if base_url_is_not_set { + return Ok(Json(true)); + } + } + } + FIRST_TIME_SETUP.store(false, std::sync::atomic::Ordering::Relaxed); + Ok(Json(false)) +} + +#[derive(Deserialize)] +struct WorkspaceUsername { + pub username: String, +} + +async fn exists_username( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Json(WorkspaceUsername { username }): Json, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)", + &w_id, + &username + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + tx.commit().await?; + Ok(Json(exists)) +} + +async fn list_users( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + if *CLOUD_HOSTED && w_id == "demo" { + require_admin(authed.is_admin, &authed.username)?; + } + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_as!( + User, + " + SELECT * + FROM usr + WHERE workspace_id = $1 + ", + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +async fn list_user_usage( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + if *CLOUD_HOSTED && w_id == "demo" { + require_admin(authed.is_admin, &authed.username)?; + } + let mut tx = user_db.begin(&authed).await?; + let rows = tokio::time::timeout( + Duration::from_secs(300), + sqlx::query_as!( + UserWithUsage, + " + SELECT usr.email, usage.executions + FROM usr, LATERAL ( + SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions + FROM v2_job_completed c JOIN v2_job j USING (id) + WHERE j.workspace_id = $1 + AND j.kind NOT IN ('flow', 'flowpreview', 'flownode') + AND j.permissioned_as_email = usr.email + AND now() - '1 week'::interval < j.created_at + ) usage + WHERE workspace_id = $1 + ", + w_id + ) + .fetch_all(&mut *tx), + ) + .await + .map_err(|e| Error::internal_err(format!("Timed out while fetching user usage: {e:#}")))??; + tx.commit().await?; + Ok(Json(rows)) +} + +#[derive(Deserialize)] +struct ActiveUsersOnly { + active_only: Option, +} + +async fn list_users_as_super_admin( + authed: ApiAuthed, + Extension(db): Extension, + Query(pagination): Query, + Query(ActiveUsersOnly { active_only }): Query, +) -> JsonResult> { + require_super_admin(&db, &authed.email).await?; + let per_page = pagination.per_page.unwrap_or(10000).max(1); + let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page; + + let rows = if active_only.is_some_and(|x| x) { + sqlx::query_as!( + GlobalUserInfo, + "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), + authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) + SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user + FROM password + WHERE email IN (SELECT email FROM active_users) + ORDER BY super_admin DESC, devops DESC + LIMIT $1 OFFSET $2", + per_page as i32, + offset as i32 + ) + .fetch_all(&db) + .await? + } else { + sqlx::query_as!( + GlobalUserInfo, + "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ + $1 OFFSET $2", + per_page as i32, + offset as i32 + ) + .fetch_all(&db) + .await? + }; + + Ok(Json(rows)) +} + +#[derive(Serialize, Deserialize)] +struct Progress { + progress: u64, + skipped_all: bool, +} +async fn get_tutorial_progress( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult { + let row = sqlx::query!( + "SELECT progress::bigint as progress, skipped_all FROM tutorial_progress WHERE email = $1", + authed.email + ) + .fetch_optional(&db) + .await?; + + if let Some(row) = row { + Ok(Json(Progress { + progress: row.progress.unwrap_or_default() as u64, + skipped_all: row.skipped_all, + })) + } else { + Ok(Json(Progress { progress: 0, skipped_all: false })) + } +} + +async fn update_tutorial_progress( + authed: ApiAuthed, + Extension(db): Extension, + Json(progress): Json, +) -> Result { + sqlx::query!( + "INSERT INTO tutorial_progress (email, progress, skipped_all) VALUES ($2, $1::bigint::bit(64), $3) ON CONFLICT (email) DO UPDATE SET progress = EXCLUDED.progress, skipped_all = EXCLUDED.skipped_all", + progress.progress as i64, + authed.email, + progress.skipped_all + ) + .execute(&db) + .await?; + Ok("tutorial progress updated".to_string()) +} + +async fn list_usernames( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + if *CLOUD_HOSTED && w_id == "demo" { + return Ok(Json(vec![ + authed.username, + "other_usernames_redacted_in_demo_workspace".to_string(), + ])); + } + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +async fn list_invites( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + let mut tx = db.begin().await?; + let rows = sqlx::query_as!( + WorkspaceInvite, + "SELECT + workspace_invite.workspace_id, + workspace_invite.email, + workspace_invite.is_admin, + workspace_invite.operator, + workspace.parent_workspace_id + FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", + authed.email + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +lazy_static::lazy_static! { + static ref INVALIDATE_ALL_SESSIONS_ON_LOGOUT: bool = std::env::var("INVALIDATE_ALL_SESSIONS_ON_LOGOUT") + .unwrap_or("false".to_string()) + .parse::() + .unwrap_or(false); +} + +#[derive(Deserialize)] +struct LogoutQuery { + rd: Option, +} +async fn logout( + Tokened { token }: Tokened, + cookies: Cookies, + Extension(db): Extension, + Query(LogoutQuery { rd }): Query, +) -> Result { + let mut cookie = Cookie::new(COOKIE_NAME, ""); + cookie.set_path(COOKIE_PATH); + if COOKIE_DOMAIN.is_some() { + cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); + } + cookies.remove(cookie); + let mut tx = db.begin().await?; + + let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { + sqlx::query_scalar!( + "WITH email_lookup AS ( + SELECT email FROM token WHERE token = $1 + ) + DELETE FROM token + WHERE email = (SELECT email FROM email_lookup) AND label = 'session' + RETURNING email", + token + ) + .fetch_optional(&mut *tx) + .await? + } else { + sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token) + .fetch_optional(&mut *tx) + .await? + }; + + if let Some(email) = email { + let email = email.unwrap_or("noemail".to_string()); + let audit_message = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { + "users.logout_all" + } else { + "users.logout" + }; + audit_log( + &mut *tx, + &AuditAuthor { + email: email.clone(), + username: email, + username_override: None, + token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + }, + audit_message, + ActionKind::Delete, + "global", + Some(&truncate_token(&token)), + None, + ) + .await?; + } + tx.commit().await?; + if let Some(rd) = rd { + Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) + } else { + Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) + } +} + +async fn whoami( + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed, +) -> JsonResult { + let user = get_user(&w_id, &username, &db).await?; + if let Some(user) = user { + Ok(Json(user)) + } else { + Ok(Json(UserInfo { + workspace_id: w_id, + email: email.clone(), + username: email, + name: None, + is_admin, + is_super_admin: is_admin, + created_at: chrono::Utc::now(), + groups: groups, + operator: false, + disabled: false, + role: Some("superadmin".to_string()), + folders_read: folders.clone().into_iter().map(|x| x.0).collect(), + folders: folders + .clone() + .into_iter() + .filter_map(|x| if x.1 { Some(x.0) } else { None }) + .collect(), + folders_owners: folders + .into_iter() + .filter_map(|x| if x.2 { Some(x.0) } else { None }) + .collect(), + })) + } +} + +async fn global_whoami( + Extension(db): Extension, + ApiAuthed { email, .. }: ApiAuthed, + Tokened { token }: Tokened, +) -> JsonResult { + let user = sqlx::query_as!( + GlobalUserInfo, + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE \ + email = $1", + email + ) + .fetch_one(&db) + .await + .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}"))); + + if let Ok(user) = user { + Ok(Json(user)) + } else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) { + Ok(Json(GlobalUserInfo { + email: email.clone(), + login_type: Some("superadmin_secret".to_string()), + super_admin: true, + devops: false, + verified: true, + name: None, + company: None, + username: None, + operator_only: None, + first_time_user: false, + })) + } else { + Err(user.unwrap_err()) + } +} + +async fn exists_email(Extension(db): Extension, Path(email): Path) -> JsonResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)", + email + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + Ok(Json(exists)) +} + +async fn get_email(ApiAuthed { email, .. }: ApiAuthed) -> Result { + Ok(email) +} + +async fn get_usage( + Extension(db): Extension, + ApiAuthed { email, .. }: ApiAuthed, +) -> Result { + let usage = sqlx::query_scalar!( + " + SELECT usage.usage FROM usage + WHERE is_workspace = false + AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) + AND id = $1", + email + ) + .fetch_optional(&db) + .await? + .unwrap_or(0); + Ok(usage.to_string()) +} + +#[derive(FromRow, Serialize)] +pub struct User2 { + pub workspace_id: String, + pub email: String, + pub username: String, + pub is_admin: bool, + pub created_at: chrono::DateTime, + pub operator: bool, + pub disabled: bool, + pub role: Option, + pub super_admin: bool, + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub added_via: Option, +} + +async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { + let user = sqlx::query_as!( + User2, + "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 + ", + username, + w_id + ) + .fetch_optional(db) + .await?; + let groups = get_groups_for_user( + &w_id, + username, + &user + .as_ref() + .map(|x| x.email.to_string()) + .unwrap_or_else(|| "".to_string()), + db, + ) + .await?; + let folders = get_folders_for_user(&w_id, username, &groups, db).await?; + + Ok(user.map(|usr| UserInfo { + groups, + workspace_id: usr.workspace_id, + email: usr.email, + username: usr.username, + name: usr.name, + is_admin: usr.is_admin, + is_super_admin: usr.super_admin, + created_at: usr.created_at, + operator: usr.operator, + disabled: usr.disabled, + role: usr.role, + folders_read: folders.clone().into_iter().map(|x| x.0).collect(), + folders: folders + .clone() + .into_iter() + .filter_map(|x| if x.1 { Some(x.0) } else { None }) + .collect(), + folders_owners: folders + .into_iter() + .filter_map(|x| if x.2 { Some(x.0) } else { None }) + .collect(), + })) +} + +pub async fn is_owner_of_path( + authed: ApiAuthed, + Path((_w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + if authed.is_admin { + Ok(Json(true)) + } else { + Ok(Json(require_owner_of_path(&authed, path).is_ok())) + } +} + +pub use windmill_api_auth::require_owner_of_path; + +// get_perm_in_extra_perms_for_authed and require_is_writer are re-exported from windmill-api-auth above +async fn whois( + Extension(db): Extension, + Path((w_id, username)): Path<(String, String)>, +) -> JsonResult { + let user_o = get_user(&w_id, &username, &db).await?; + let user = not_found_if_none(user_o, "User", username)?; + Ok(Json(user)) +} + +// async fn create_invite_code( +// ApiAuthed { email, .. }: ApiAuthed, +// Extension(db): Extension, +// Json(nu): Json, +// ) -> Result<(StatusCode, String)> { + +// let mut tx = db.begin().await?; +// require_super_admin(&mut *tx, email).await?; + +// sqlx::query!( +// "INSERT INTO invite_code +// (code, seats_left) +// VALUES ($1, $2)", +// nu.code, +// nu.seats +// ) +// .execute(&mut *tx) +// .await?; + +// tx.commit().await?; + +// Ok(( +// StatusCode::CREATED, +// format!("new invite code {}", nu.code), +// )) +// } + +async fn decline_invite( + authed: ApiAuthed, + Extension(db): Extension, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + let mut tx = db.begin().await?; + + let is_admin = sqlx::query_scalar!( + "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin", + nu.workspace_id, + authed.email, + ) + .fetch_optional(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.decline_invite", + ActionKind::Delete, + &nu.workspace_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + if is_admin.is_some() { + Ok(( + StatusCode::OK, + format!( + "user {} declined invite to workspace {}", + &authed.email, nu.workspace_id + ), + )) + } else { + Err(Error::NotFound(format!( + "invite for {} not found", + authed.email + ))) + } +} + +pub use windmill_common::usernames::VALID_USERNAME; + +async fn accept_invite( + authed: ApiAuthed, + Extension(webhook): Extension, + Extension(db): Extension, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + let mut tx = db.begin().await?; + + let r = sqlx::query!( + "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator", + nu.workspace_id, + authed.email, + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(r) = r { + let already_in_workspace = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + &nu.workspace_id, + &authed.email, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_in_workspace { + tx.commit().await?; + return Ok(( + StatusCode::CREATED, + format!( + "user {} accepted invite to workspace {}", + &authed.email, nu.workspace_id + ), + )); + } + let username; + (tx, username) = join_workspace( + &nu.workspace_id, + &authed, + nu.username, + r.is_admin, + r.operator, + tx, + ) + .await?; + + audit_log( + &mut *tx, + &ApiAuthed { username: username.clone(), ..authed.clone() }, + "users.accept_invite", + ActionKind::Create, + &nu.workspace_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &username, + &db, + &nu.workspace_id, + windmill_git_sync::DeployedObject::User { email: authed.email.clone() }, + Some(format!("User '{}' accepted invite", &authed.email)), + true, + None, + ) + .await?; + webhook.send_instance_event(InstanceEvent::UserJoinedWorkspace { + email: authed.email.clone(), + workspace: nu.workspace_id.clone(), + username: username, + }); + Ok(( + StatusCode::CREATED, + format!( + "user {} accepted invite to workspace {}", + &authed.email, nu.workspace_id + ), + )) + } else { + Err(Error::NotFound(format!( + "invite for {} not found", + authed.email + ))) + } +} + +async fn join_workspace<'c>( + w_id: &str, + authed: &ApiAuthed, + username: Option, + is_admin: bool, + operator: bool, + mut tx: sqlx::Transaction<'c, sqlx::Postgres>, +) -> error::Result<(sqlx::Transaction<'c, sqlx::Postgres>, String)> { + let automate_username_creation = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + AUTOMATE_USERNAME_CREATION_SETTING, + ) + .fetch_optional(&mut *tx) + .await? + .map(|v| v.as_bool()) + .flatten() + .unwrap_or(false); + + let username = if automate_username_creation { + if username.is_some() && username.unwrap().len() > 0 { + return Err(Error::BadRequest( + "username is not allowed when username creation is automated".to_string(), + )); + } + get_instance_username_or_create_pending(&mut tx, &authed.email).await? + } else { + let username = username.ok_or(Error::BadRequest("username is required".to_string()))?; + let already_exists_username = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)", + &w_id, + username, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_exists_username { + return Err(Error::BadRequest(format!( + "user with username {} already exists in workspace {}", + username, w_id + ))); + } + + if !VALID_USERNAME.is_match(&username) { + return Err(windmill_common::error::Error::BadRequest(format!( + "Usermame can only contain alphanumeric characters and underscores and must start with a letter" + ))); + } + username.to_string() + }; + + let already_exists_email = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + &w_id, + authed.email, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_exists_email { + return Err(Error::BadRequest(format!( + "user with email {} already exists in workspace {}", + authed.email, w_id + ))); + } + + sqlx::query!( + "INSERT INTO usr + (workspace_id, email, username, is_admin, operator) + VALUES ($1, $2, $3, $4, $5)", + &w_id, + authed.email, + username, + is_admin, + operator + ) + .execute(&mut *tx) + .await?; + sqlx::query_as!( + Group, + "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + &w_id, + username, + "all", + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &AuditAuthor { username: username.clone(), ..authed.into() }, + "users.add_to_workspace", + ActionKind::Create, + &w_id, + Some(&authed.email), + None, + ) + .await?; + Ok((tx, username)) +} + +async fn leave_instance(Extension(db): Extension, authed: ApiAuthed) -> Result { + let mut tx = db.begin().await?; + sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.leave", + ActionKind::Delete, + "global", + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Left instance",)) +} + +async fn get_workspace_user( + ApiAuthed { username, is_admin, .. }: ApiAuthed, + Extension(db): Extension, + Path((w_id, username_to_update)): Path<(String, String)>, +) -> Result> { + require_admin(is_admin, &username)?; + + let user = sqlx::query_as!( + User, + "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2", + &username_to_update, + &w_id + ) + .fetch_optional(&db) + .await?; + + let user = not_found_if_none(user, "User", username_to_update)?; + + Ok(Json(user)) +} + +async fn update_workspace_user( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, username_to_update)): Path<(String, String)>, + Json(eu): Json, +) -> Result { + let mut tx = db.begin().await?; + + require_admin(authed.is_admin, &authed.username)?; + + update_workspace_user_internal( + &w_id, + &username_to_update, + eu.is_admin, + eu.operator, + eu.disabled, + &mut tx, + Some(&authed), + ) + .await?; + + let user_email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + &username_to_update, + &w_id + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::User { email: user_email.clone() }, + Some(format!("Updated user '{}'", &user_email)), + true, + None, + ) + .await?; + + Ok(format!("user {} updated", user_email)) +} + +async fn convert_user_to_group( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, username_to_convert)): Path<(String, String)>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let mut tx = db.begin().await?; + + // Get user email and current status + let user_info = sqlx::query!( + "SELECT email, is_admin, operator, added_via FROM usr WHERE username = $1 AND workspace_id = $2", + username_to_convert, + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let user_info = not_found_if_none(user_info, "User", &username_to_convert)?; + + // Check if user is already a group user + if let Some(added_via) = &user_info.added_via { + if added_via.get("source").and_then(|v| v.as_str()) == Some("instance_group") { + return Err(Error::BadRequest( + "User is already a group user".to_string(), + )); + } + } + + // Find which instance groups this user belongs to that are configured for auto-add in this workspace + let eligible_groups = sqlx::query!( + r#" + SELECT + eig.igroup as group_name, + ws.auto_invite->'instance_groups_roles' as instance_groups_roles + FROM email_to_igroup eig + INNER JOIN workspace_settings ws ON ws.workspace_id = $1 + WHERE eig.email = $2 + AND ws.auto_invite->'instance_groups' ? eig.igroup + "#, + &w_id, + &user_info.email + ) + .fetch_all(&mut *tx) + .await?; + + if eligible_groups.is_empty() { + return Err(Error::BadRequest( + "User is not a member of any instance groups configured for auto-add in this workspace" + .to_string(), + )); + } + + // Determine the group with highest precedence (same logic as process_instance_group_auto_adds) + let roles: std::collections::HashMap = + if let Some(roles_json) = &eligible_groups[0].instance_groups_roles { + serde_json::from_value(roles_json.clone()).unwrap_or_default() + } else { + std::collections::HashMap::new() + }; + + let mut best_group = &eligible_groups[0].group_name; + let mut best_precedence = 0u8; + + for group in &eligible_groups { + let default_role = "developer".to_string(); + let role = roles.get(&group.group_name).unwrap_or(&default_role); + + let precedence = match role.as_str() { + "admin" => 3, + "developer" => 2, + "operator" => 1, + _ => 2, + }; + + if precedence > best_precedence { + best_precedence = precedence; + best_group = &group.group_name; + } + } + + let primary_group_name = best_group; + + // Determine role from group configuration using the selected primary group + let default_role = "developer".to_string(); + let role = roles + .get(primary_group_name) + .unwrap_or(&default_role) + .as_str(); + + let (is_admin, is_operator) = match role { + "admin" => (true, false), + "operator" => (false, true), + _ => (false, false), + }; + + // Update user with instance group information + let instance_group_source = serde_json::json!({ + "source": "instance_group", + "group": primary_group_name + }); + + sqlx::query!( + "UPDATE usr SET added_via = $1, is_admin = $2, operator = $3 WHERE username = $4 AND workspace_id = $5", + instance_group_source, + is_admin, + is_operator, + username_to_convert, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.convert_to_group", + ActionKind::Update, + &w_id, + Some(&username_to_convert), + Some([("group", primary_group_name.as_str()), ("role", role)].into()), + ) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::User { email: user_info.email.clone() }, + Some(format!( + "Converted user '{}' to group user (group: {}, role: {})", + &user_info.email, primary_group_name, role + )), + true, + None, + ) + .await?; + + Ok(format!( + "User {} converted to group user (group: {}, role: {})", + username_to_convert, primary_group_name, role + )) +} + +async fn update_user( + authed: ApiAuthed, + Path(email_to_update): Path, + Extension(db): Extension, + Json(eu): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + let mut new_super_admin: Option = None; + if let Some(sa) = eu.is_super_admin { + sqlx::query_scalar!( + "UPDATE password SET super_admin = $1 WHERE email = $2", + sa, + &email_to_update + ) + .execute(&mut *tx) + .await?; + new_super_admin = Some(sa); + } + + if let Some(dv) = eu.is_devops { + sqlx::query_scalar!( + "UPDATE password SET devops = $1 WHERE email = $2", + dv, + &email_to_update + ) + .execute(&mut *tx) + .await?; + // If super_admin wasn't explicitly set, we still need to refresh tokens + if new_super_admin.is_none() { + new_super_admin = sqlx::query_scalar!( + "SELECT super_admin FROM password WHERE email = $1", + &email_to_update + ) + .fetch_optional(&mut *tx) + .await?; + } + } + + if let Some(sa) = new_super_admin { + // Delete session tokens to force re-login with new privileges + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email_to_update + ) + .execute(&mut *tx) + .await?; + // Update super_admin flag on non-session tokens (webhooks, API tokens, etc.) + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + sa, + &email_to_update + ) + .execute(&mut *tx) + .await?; + } + + if let Some(n) = eu.name { + sqlx::query_scalar!( + "UPDATE password SET name = $1 WHERE email = $2", + n, + &email_to_update + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "users.update", + ActionKind::Update, + "global", + Some(&email_to_update), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("email {} updated", &email_to_update)) +} + +async fn delete_user( + authed: ApiAuthed, + Path(email_to_delete): Path, + Extension(db): Extension, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) + .execute(&mut *tx) + .await?; + + let usernames = sqlx::query_scalar!( + "DELETE FROM usr WHERE email = $1 RETURNING username", + &email_to_delete + ) + .fetch_all(&mut *tx) + .await?; + + for username in usernames { + sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM usr_to_group WHERE usr = $1", &username) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM workspace_invite WHERE email = $1", + &email_to_delete + ) + .execute(&mut *tx) + .await?; + } + + // Remove user from all instance groups email_to_igroup + sqlx::query!( + "DELETE FROM email_to_igroup WHERE email = $1", + &email_to_delete + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.delete", + ActionKind::Delete, + "global", + Some(&email_to_delete), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("email {} deleted", &email_to_delete)) +} + +lazy_static::lazy_static! { + pub static ref NEW_USER_WEBHOOK: Option = std::env::var("NEW_USER_WEBHOOK").ok(); + +} + +/// Internal helper for updating workspace user permissions - used by both API and system operations +pub async fn update_workspace_user_internal( + w_id: &str, + username_to_update: &str, + is_admin: Option, + operator: Option, + disabled: Option, + tx: &mut Transaction<'_, Postgres>, + authed: Option<&ApiAuthed>, // None for system operations +) -> Result<()> { + if let Some(a) = is_admin { + sqlx::query_scalar!( + "UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3", + a, + username_to_update, + w_id + ) + .execute(&mut **tx) + .await?; + } + if let Some(a) = operator { + sqlx::query_scalar!( + "UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3", + a, + username_to_update, + w_id + ) + .execute(&mut **tx) + .await?; + } + if let Some(a) = disabled { + sqlx::query_scalar!( + "UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3", + a, + username_to_update, + w_id + ) + .execute(&mut **tx) + .await?; + } + + // Only audit if we have an authenticated user (API calls) + if let Some(auth) = authed { + audit_log( + &mut **tx, + auth, + "users.update", + ActionKind::Update, + w_id, + Some(username_to_update), + None, + ) + .await?; + } + + Ok(()) +} + +/// Internal helper for deleting workspace users - used by both API and system operations +pub async fn delete_workspace_user_internal( + w_id: &str, + username_to_delete: &str, + email_to_delete: &str, + tx: &mut Transaction<'_, Postgres>, + authed: Option<&ApiAuthed>, // None for system operations +) -> Result<()> { + sqlx::query_scalar!( + "DELETE FROM usr WHERE email = $1 AND workspace_id = $2", + email_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2", + username_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + // Only audit if we have an authenticated user (API calls) + if let Some(auth) = authed { + audit_log( + &mut **tx, + auth, + "users.delete", + ActionKind::Delete, + w_id, + Some(username_to_delete), + None, + ) + .await?; + } + + Ok(()) +} + +async fn delete_workspace_user( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, username_to_delete)): Path<(String, String)>, +) -> Result { + let mut tx = db.begin().await?; + + require_admin(authed.is_admin, &authed.username)?; + + let email_to_delete_o = sqlx::query_scalar!( + "SELECT email FROM usr where username = $1 AND workspace_id = $2", + username_to_delete, + &w_id, + ) + .fetch_optional(&db) + .await?; + + let email_to_delete = not_found_if_none(email_to_delete_o, "User", &username_to_delete)?; + + delete_workspace_user_internal( + &w_id, + &username_to_delete, + &email_to_delete, + &mut tx, + Some(&authed), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::User { email: email_to_delete.clone() }, + Some(format!( + "Removed user '{}' from workspace", + &email_to_delete + )), + true, + None, + ) + .await?; + + Ok(format!("username {} deleted", username_to_delete)) +} + +async fn set_login_type( + Extension(db): Extension, + Path(email): Path, + authed: ApiAuthed, + Json(et): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + + sqlx::query!( + "UPDATE password SET login_type = $1 WHERE email = $2", + et.login_type, + email + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.set_login_type", + ActionKind::Update, + "global", + Some(&email), + None, + ) + .await?; + + tx.commit().await?; + Ok(format!( + "login type of {} updated to {}", + email, et.login_type + )) +} + +#[allow(unreachable_code, unused_variables)] +async fn login( + cookies: Cookies, + Extension(db): Extension, + Extension(argon2): Extension>>, + Json(Login { email, password }): Json, +) -> Result { + #[cfg(feature = "no_auth")] + { + return Ok("no_auth".to_string()); + } + + let mut tx = db.begin().await?; + let email = email.to_lowercase(); + let audit_author = AuditAuthor { + email: email.clone(), + username: email.clone(), + username_override: None, + token_prefix: None, + }; + let email_w_h: Option<(String, String, bool)> = sqlx::query_as( + "SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \ + 'password'", + ) + .bind(&email) + .fetch_optional(&mut *tx) + .await?; + + if let Some((email, hash, super_admin)) = email_w_h { + let parsed_hash = + PasswordHash::new(&hash).map_err(|e| Error::internal_err(e.to_string()))?; + if argon2 + .verify_password(password.as_bytes(), &parsed_hash) + .is_err() + { + audit_log( + &mut *tx, + &audit_author, + "users.login_failure", + ActionKind::Create, + "global", + None, + None, + ) + .await?; + Err(Error::BadRequest("Invalid login".to_string())) + } else { + let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; + + let audit_author = AuditAuthor { + email: email.clone(), + username: email.clone(), + username_override: None, + token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + }; + + audit_log( + &mut *tx, + &audit_author, + "users.login", + ActionKind::Create, + "global", + Some(&truncate_token(&token)), + None, + ) + .await?; + + tx.commit().await?; + Ok(token) + } + } else { + audit_log( + &mut *tx, + &audit_author, + "users.login_failure", + ActionKind::Create, + "global", + None, + None, + ) + .await?; + Err(Error::BadRequest("Invalid login".to_string())) + } +} + +#[derive(Deserialize)] +struct RefreshTokenQuery { + if_expiring_in_less_than_s: Option, +} +async fn refresh_token( + Extension(db): Extension, + Query(query): Query, + Tokened { token }: Tokened, + authed: ApiAuthed, + cookies: Cookies, +) -> Result { + let mut tx = db.begin().await?; + + if let Some(thresh_s) = query.if_expiring_in_less_than_s { + let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &token, thresh_s) + .fetch_optional(&db) + .await? + .flatten() + .unwrap_or(false); + if not_expired { + return Ok("token expiry is far enough".to_string()); + } + } + + let super_admin = sqlx::query_scalar!( + "SELECT super_admin FROM password WHERE email = $1", + &authed.email + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false); + + let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; + + audit_log( + &mut *tx, + &AuditAuthor { + email: authed.email.to_string(), + username: authed.email.to_string(), + username_override: None, + token_prefix: authed.token_prefix, + }, + "users.token.refresh", + ActionKind::Create, + &"global", + Some(&truncate_token(&new_token)), + None, + ) + .await?; + + tx.commit().await?; + Ok("token refreshed".to_string()) +} + +lazy_static::lazy_static! { + static ref MAX_SESSION_VALIDITY_SECONDS: i64 = std::env::var("MAX_SESSION_VALIDITY_SECONDS").ok().unwrap_or_else(|| String::new()).parse::().unwrap_or(3 * 24 * 60 * 60); + static ref INVALIDATE_OLD_SESSIONS: bool = std::env::var("INVALIDATE_OLD_SESSIONS").ok().unwrap_or_else(|| String::new()).parse::().unwrap_or(false); +} + +pub async fn create_session_token<'c>( + email: &str, + super_admin: bool, + tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, + cookies: Cookies, +) -> Result { + let token = rd_string(32); + + if *INVALIDATE_OLD_SESSIONS { + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + email + ) + .execute(&mut **tx) + .await?; + + audit_log( + &mut **tx, + &AuditAuthor { + email: email.to_string(), + username: email.to_string(), + username_override: None, + token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + }, + "users.token.invalidate_old_sessions", + ActionKind::Delete, + &"global", + None, + None, + ) + .instrument(tracing::info_span!("token", email)) + .await?; + } + + sqlx::query!( + "INSERT INTO token + (token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", + token, + email, + "session", + &MAX_SESSION_VALIDITY_SECONDS.to_string(), + super_admin + ) + .execute(&mut **tx) + .await?; + + let mut cookie = Cookie::new(COOKIE_NAME, token.clone()); + cookie.set_secure(IS_SECURE.read().await.clone()); + cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax)); + cookie.set_http_only(true); + cookie.set_path(COOKIE_PATH); + if COOKIE_DOMAIN.is_some() { + cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); + } + + let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc(); + expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS); + cookie.set_expires(expire); + cookies.add(cookie); + Ok(token) +} + +// create_token_internal is re-exported from windmill-api-auth above + +async fn create_token( + Extension(db): Extension, + authed: ApiAuthed, + Json(token_config): Json, +) -> Result<(StatusCode, String)> { + let mut tx = db.begin().await?; + + let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; + + tx.commit().await?; + Ok((StatusCode::CREATED, token)) +} + +async fn impersonate( + Extension(db): Extension, + authed: ApiAuthed, + Json(new_token): Json, +) -> Result<(StatusCode, String)> { + let token = rd_string(32); + require_super_admin(&db, &authed.email).await?; + + if new_token.impersonate_email.is_none() { + return Err(Error::BadRequest( + "impersonate_username is required".to_string(), + )); + } + + let impersonated = new_token.impersonate_email.unwrap(); + + let is_super_admin = sqlx::query_scalar!( + "SELECT super_admin FROM password WHERE email = $1", + impersonated + ) + .fetch_optional(&db) + .await? + .unwrap_or(false); + let mut tx = db.begin().await?; + + sqlx::query!( + "INSERT INTO token + (token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5)", + token, + impersonated, + new_token.label, + new_token.expiration, + is_super_admin + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.impersonate", + ActionKind::Delete, + &"global", + Some(&token[0..10]), + Some([("impersonated", &format!("{impersonated}")[..])].into()), + ) + .instrument(tracing::info_span!("token", email = &impersonated)) + .await?; + tx.commit().await?; + Ok((StatusCode::CREATED, token)) +} + +#[derive(Deserialize)] +struct ListTokenQuery { + exclude_ephemeral: Option, +} + +async fn list_tokens( + Extension(db): Extension, + ApiAuthed { email, .. }: ApiAuthed, + Query(query): Query, + Query(pagination): Query, +) -> JsonResult> { + let (per_page, offset) = paginate(pagination); + let rows = if query.exclude_ephemeral.unwrap_or(false) { + sqlx::query_as!( + TruncatedToken, + "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + email, + per_page as i64, + offset as i64, + ) + .fetch_all(&db) + .await? + } else { + sqlx::query_as!( + TruncatedToken, + "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + last_used_at, scopes FROM token WHERE email = $1 + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + email, + per_page as i64, + offset as i64, + ) + .fetch_all(&db) + .await? + }; + Ok(Json(rows)) +} + +async fn delete_token( + Extension(db): Extension, + authed: ApiAuthed, + Path(token_prefix): Path, +) -> Result { + let mut tx = db.begin().await?; + + let tokens_deleted: Vec = sqlx::query_scalar( + "DELETE FROM token + WHERE email = $1 + AND token LIKE concat($2::text, '%') + RETURNING concat(substring(token for 10), '*****')", + ) + .bind(&authed.email) + .bind(&token_prefix) + .fetch_all(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.token.delete", + ActionKind::Delete, + &"global", + Some(&token_prefix), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!( + "deleted {} tokens {:?} with prefix {}", + tokens_deleted.len(), + tokens_deleted, + token_prefix + )) +} + +async fn leave_workspace( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, +) -> Result { + let mut tx = db.begin().await?; + sqlx::query!( + "DELETE FROM usr WHERE workspace_id = $1 AND username = $2", + &w_id, + authed.username + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.leave_workspace", + ActionKind::Delete, + &w_id, + None, + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("left workspace {w_id}")) +} + +#[derive(Serialize)] +struct Runnable { + workspace: String, + endpoint_async: String, + endpoint_sync: String, + summary: String, + description: String, + schema: Option, + kind: String, + path: String, +} + +async fn get_all_runnables( + Extension(db): Extension, + authed: ApiAuthed, + Tokened { token }: Tokened, + Extension(cache): Extension>, +) -> JsonResult> { + let mut tx = db.clone().begin(&authed).await?; + let mut runnables = Vec::new(); + let workspaces = sqlx::query_scalar!( + "SELECT workspace.id as id FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \ + usr.email = $1 AND deleted = false", + authed.email + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + for workspace in workspaces { + let nauthed = cache + .get_authed(Some(workspace.clone()), &token) + .await + .ok_or_else(|| { + Error::BadRequest(format!("not authorized to access workspace: {workspace}")) + })?; + let mut tx = db.clone().begin(&nauthed).await?; + let flows = sqlx::query!( + "SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema + FROM flow + LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] + WHERE flow.workspace_id = $1", + workspace + ) + .fetch_all(&mut *tx) + .await?; + runnables.extend( + flows + .into_iter() + .map(|f| Runnable { + workspace: f.workspace.clone(), + endpoint_async: format!("/w/{}/jobs/run/f/{}", &f.workspace, &f.path), + endpoint_sync: format!( + "/w/{}/jobs/run_wait_result/f/{}", + &f.workspace, &f.path + ), + summary: f.summary, + description: f.description, + schema: f.schema, + kind: "flow".to_string(), + path: f.path, + }) + .collect::>(), + ); + let scripts = sqlx::query!( + "SELECT workspace_id as workspace, path, summary, description, schema FROM script as o + WHERE created_at = (select max(created_at) from script where o.path = path and workspace_id = $1 AND archived = false) + AND workspace_id = $1 and archived = false", workspace + ) + .fetch_all(&mut *tx) + .await?; + runnables.extend( + scripts + .into_iter() + .map(|s| Runnable { + workspace: s.workspace.clone(), + endpoint_async: format!("/w/{}/jobs/run/p/{}", &s.workspace, &s.path), + endpoint_sync: format!( + "/w/{}/jobs/run_wait_result/p/{}", + &s.workspace, &s.path + ), + summary: s.summary, + description: s.description, + schema: s.schema, + kind: "script".to_string(), + path: s.path, + }) + .collect::>(), + ); + tx.commit().await?; + } + Ok(Json(runnables)) +} + +//used by oauth +#[allow(dead_code)] +#[derive(Deserialize, Debug, Clone)] +pub struct LoginUserInfo { + pub email: Option, + pub name: Option, + pub company: Option, + pub preferred_username: Option, + pub displayName: Option, +} + +#[derive(Serialize)] +struct InstanceUsernameInfo { + username: String, + workspace_usernames: Vec, +} + +#[derive(Serialize)] +struct WorkspaceUsernameInfo { + workspace_id: String, + username: String, +} +async fn get_instance_username_info( + ApiAuthed { email, .. }: ApiAuthed, + Path(user_email): Path, + Extension(db): Extension, +) -> JsonResult { + require_super_admin(&db, &email).await?; + let mut tx = db.begin().await?; + let instance_username = match sqlx::query_scalar!( + "SELECT username FROM password WHERE email = $1", + &user_email + ) + .fetch_one(&mut *tx) + .await? + { + Some(username) => username, + None => generate_instance_wide_unique_username(&mut tx, &user_email).await?, + }; + + let workspace_usernames = sqlx::query_as!( + WorkspaceUsernameInfo, + "SELECT workspace_id, username FROM usr WHERE email = $1", + &user_email + ) + .fetch_all(&mut *tx) + .await?; + + Ok(Json(InstanceUsernameInfo { + username: instance_username, + workspace_usernames: workspace_usernames, + })) +} + +async fn username_to_email( + Path((w_id, username)): Path<(String, String)>, + Extension(db): Extension, +) -> Result { + let email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + &username, + &w_id + ) + .fetch_optional(&db) + .await?; + + let email = not_found_if_none(email, "user", username)?; + + Ok(email) +} + +#[cfg(feature = "enterprise")] +#[derive(Serialize, Deserialize)] +struct ExportedGlobalUser { + email: String, + password_hash: Option, + login_type: String, + super_admin: bool, + verified: bool, + name: Option, + company: Option, + first_time_user: bool, + username: Option, +} + +#[cfg(feature = "enterprise")] +async fn export_global_users( + Extension(db): Extension, + authed: ApiAuthed, +) -> JsonResult> { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + let users = sqlx::query_as!( + ExportedGlobalUser, + "SELECT email, password_hash, login_type, super_admin, verified, name, company, first_time_user, username FROM password" + ) + .fetch_all(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.export_export", + ActionKind::Execute, + "global", + None, + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(users)) +} + +#[cfg(not(feature = "enterprise"))] +async fn export_global_users() -> JsonResult { + Err(Error::BadRequest( + "This feature is only available in the enterprise version".to_string(), + )) +} + +#[cfg(feature = "enterprise")] +async fn overwrite_global_users( + Extension(db): Extension, + authed: ApiAuthed, + Json(users): Json>, +) -> Result { + require_super_admin(&db, &authed.email).await?; + let mut tx = db.begin().await?; + sqlx::query!("DELETE FROM password") + .execute(&mut *tx) + .await?; + for user in users { + sqlx::query!( + "INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, company, first_time_user, username) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + user.email, + user.password_hash, + user.login_type, + user.super_admin, + user.verified, + user.name, + user.company, + user.first_time_user, + user.username + ) + .execute(&mut *tx) + .await?; + } + audit_log( + &mut *tx, + &authed, + "users.import_global", + ActionKind::Create, + "global", + None, + None, + ) + .await?; + tx.commit().await?; + Ok("loaded global users".to_string()) +} + +#[cfg(not(feature = "enterprise"))] +async fn overwrite_global_users() -> JsonResult { + Err(Error::BadRequest( + "This feature is only available in the enterprise version".to_string(), + )) +} + +// NOTE: rename_user, update_username_in_workpsace, and RenameUser +// are in windmill-api (depend on EE secret_backend_ext) + +#[derive(Deserialize)] +pub struct RequestPasswordReset { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPassword { + pub token: String, + pub new_password: String, +} + +#[derive(Serialize)] +pub struct PasswordResetResponse { + pub message: String, +} + +// Password Reset Functions + +/// Check if SMTP is configured +async fn is_smtp_configured(Extension(db): Extension) -> JsonResult { + let smtp = windmill_common::server::load_smtp_config(&db).await?; + Ok(Json(smtp.is_some())) +} + +/// Request a password reset email +async fn request_password_reset( + Extension(db): Extension, + Json(req): Json, +) -> Result> { + let email = req.email.to_lowercase(); + + // Check if SMTP is configured + let smtp = windmill_common::server::load_smtp_config(&db).await?; + let smtp = smtp.ok_or_else(|| { + Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string()) + })?; + + // Check if user exists with password login type + let user_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')", + &email + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + // Always return success to prevent email enumeration + // But only send email if user exists + if user_exists { + // Generate a secure token + let token = rd_string(32); + + // Delete any existing tokens for this email + sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email) + .execute(&db) + .await?; + + // Insert new token with 1 hour expiration + sqlx::query!( + "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')", + &email, + &token + ) + .execute(&db) + .await?; + + // Get the base URL for the reset link + let base_url = BASE_URL.read().await.clone(); + let base_url = if base_url.is_empty() { + std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string()) + } else { + base_url + }; + + let reset_link = format!("{}/user/reset-password?token={}", base_url, token); + + // Send the email + let subject = "Windmill Password Reset"; + let content = format!( + "You have requested a password reset for your Windmill account.\n\n\ + Click the link below to reset your password:\n\ + {}\n\n\ + This link will expire in 1 hour.\n\n\ + If you did not request this password reset, you can safely ignore this email.", + reset_link + ); + + // Send the email - don't fail the request if email fails + if let Err(e) = windmill_common::email_oss::send_email_plain_text( + subject, + &content, + vec![email.clone()], + smtp, + Some(Duration::from_secs(10)), + ) + .await + { + tracing::error!("Failed to send password reset email to {}: {:?}", email, e); + } + } + + // Always return success to prevent email enumeration + Ok(Json(PasswordResetResponse { + message: "If an account with that email exists, a password reset link has been sent." + .to_string(), + })) +} + +// NOTE: reset_password is in windmill-api (depends on users_oss::hash_password EE dispatch) diff --git a/backend/windmill-api-users/src/users_oss.rs b/backend/windmill-api-users/src/users_oss.rs new file mode 100644 index 0000000000..afbb729b79 --- /dev/null +++ b/backend/windmill-api-users/src/users_oss.rs @@ -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>, + 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>, + _authed: ApiAuthed, + _user_email: &str, + _ep: EditPassword, +) -> Result { + Err(Error::internal_err( + "Not implemented in Windmill's Open Source repository".to_string(), + )) +} + +pub fn hash_password(_argon2: Arc>, _password: String) -> Result { + 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, + Json(_data): Json, +) -> Result { + Err(Error::internal_err( + "Not implemented in Windmill's Open Source repository".to_string(), + )) +} diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml new file mode 100644 index 0000000000..fd07e893be --- /dev/null +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -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 diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs new file mode 100644 index 0000000000..7c12ebd3a0 --- /dev/null +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -0,0 +1,6 @@ +pub mod workspaces; +pub mod workspaces_extra; +pub mod workspaces_oss; + +#[cfg(feature = "private")] +pub mod workspaces_ee; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs new file mode 100644 index 0000000000..b7fb588b5c --- /dev/null +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -0,0 +1,5040 @@ +/* + * 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 windmill_api_auth::{require_super_admin, ApiAuthed}; +use windmill_api_users::users::WorkspaceInvite; +use windmill_common::email_oss::send_email_if_possible; +use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; +use windmill_common::webhook::WebhookShared; +use windmill_common::{BASE_URL, DB}; + +use axum::{ + extract::{Extension, Path, Query}, + routing::{delete, get, post}, + Json, Router, +}; +use chrono::Utc; + +use regex::Regex; + +use hex; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::db::UserDB; +use windmill_common::s3_helpers::LargeFileStorage; +use windmill_common::users::username_to_permissioned_as; +use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE}; +use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; +#[cfg(feature = "enterprise")] +use windmill_common::workspaces::GitRepositorySettings; +#[cfg(feature = "enterprise")] +use windmill_common::workspaces::WorkspaceDeploymentUISettings; +use windmill_common::workspaces::{ + get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, + WorkspaceGitSyncSettings, +}; +use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; +use windmill_common::PgDatabase; +use windmill_common::{ + error::{Error, JsonResult, Result}, + global_settings::AUTOMATE_USERNAME_CREATION_SETTING, + oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, + utils::{paginate, rd_string, require_admin, Pagination}, +}; +use windmill_dep_map::scoped_dependency_map::{ + DependencyDependent, DependencyMap, ScopedDependencyMap, +}; +use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject}; + +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Postgres, Transaction}; +use windmill_common::oauth2::InstanceEvent; +use windmill_common::utils::not_found_if_none; + +lazy_static::lazy_static! { + static ref WORKSPACE_KEY_REGEXP: Regex = Regex::new("^[a-zA-Z0-9]{64}$").unwrap(); +} + +pub fn workspaced_service() -> Router { + Router::new() + .route("/get_as_superadmin", get(get_workspace_as_superadmin)) + .route("/list_pending_invites", get(list_pending_invites)) + .route("/update", post(edit_workspace)) + .route("/archive", post(archive_workspace)) + .route("/invite_user", post(invite_user)) + .route("/add_user", post(add_user)) + .route("/delete_invite", post(delete_invite)) + .route("/rebuild_dependency_map", post(rebuild_dependency_map)) + .route("/get_dependency_map", get(get_dependency_map)) + .route("/get_dependents/*imported_path", get(get_dependents)) + .route("/get_dependents_amounts", post(get_dependents_amounts)) + .route("/get_settings", get(get_settings)) + .route("/get_deploy_to", get(get_deploy_to)) + .route("/edit_slack_command", post(edit_slack_command)) + .route( + "/run_slack_message_test_job", + post(run_slack_message_test_job), + ) + .route("/slack_oauth_config", get(get_slack_oauth_config)) + .route("/slack_oauth_config", post(set_slack_oauth_config)) + .route("/slack_oauth_config", delete(delete_slack_oauth_config)) + .route("/edit_webhook", post(edit_webhook)) + .route("/edit_auto_invite", post(edit_auto_invite)) + .route("/edit_instance_groups", post(edit_instance_groups)) + .route("/edit_deploy_to", post(edit_deploy_to)) + .route( + "/get_secondary_storage_names", + get(get_secondary_storage_names), + ) + .route("/is_premium", get(is_premium)) + .route("/edit_error_handler", post(edit_error_handler)) + .route("/edit_success_handler", post(edit_success_handler)) + .route( + "/edit_large_file_storage_config", + post(edit_large_file_storage_config), + ) + .route("/edit_ducklake_config", post(edit_ducklake_config)) + .route("/list_ducklakes", get(list_ducklakes)) + .route("/list_datatables", get(list_datatables)) + .route("/list_datatable_schemas", get(list_datatable_schemas)) + .route("/edit_datatable_config", post(edit_datatable_config)) + .route("/edit_git_sync_config", post(edit_git_sync_config)) + .route("/edit_git_sync_repository", post(edit_git_sync_repository)) + .route( + "/delete_git_sync_repository", + delete(delete_git_sync_repository), + ) + .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) + .route("/edit_default_app", post(edit_default_app)) + .route("/default_app", get(get_default_app)) + .route( + "/default_scripts", + post(edit_default_scripts).get(get_default_scripts), + ) + .route("/set_environment_variable", post(set_environment_variable)) + .route( + "/encryption_key", + get(get_encryption_key).post(set_encryption_key), + ) + .route("/leave", post(leave_workspace)) + .route("/get_workspace_name", get(get_workspace_name)) + .route("/create_fork", post(create_workspace_fork)) + .route("/change_workspace_name", post(change_workspace_name)) + .route("/change_workspace_color", post(change_workspace_color)) + .route( + "/change_workspace_id", + post(crate::workspaces_extra::change_workspace_id), + ) + .route("/usage", get(get_usage)) + .route("/used_triggers", get(get_used_triggers)) + .route("/public_app_rate_limit", post(edit_public_app_rate_limit)) + .route("/operator_settings", post(update_operator_settings)) + .route( + "/create_workspace_fork_branch", + post(create_workspace_fork_branch), + ) + .route( + "/reset_diff_tally/:fork_workspace_id", + post(reset_workspace_diffs), + ) + .route("/compare/:target_workspace_id", get(compare_workspaces)) +} +pub fn global_service() -> Router { + Router::new() + .route("/list_as_superadmin", get(list_workspaces_as_super_admin)) + .route("/list", get(list_workspaces)) + .route("/users", get(user_workspaces)) + .route("/create", post(create_workspace)) + .route("/create_fork", post(deprecated_create_workspace_fork)) + .route("/exists", post(exists_workspace)) + .route("/exists_username", post(exists_username)) + .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) + .route("/unarchive/:workspace", post(unarchive_workspace)) + .route( + "/delete/:workspace", + delete(crate::workspaces_extra::delete_workspace), + ) + .route( + "/create_workspace_require_superadmin", + get(create_workspace_require_superadmin), + ) +} + +#[derive(FromRow, Serialize)] +struct Workspace { + id: String, + name: String, + owner: String, + deleted: bool, + premium: bool, + color: Option, + parent_workspace_id: Option, +} + +#[derive(FromRow, Serialize, Debug)] +pub struct WorkspaceSettings { + pub workspace_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slack_team_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub teams_team_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub teams_team_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub teams_team_guid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slack_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slack_command_script: Option, + pub teams_command_script: Option, + pub slack_email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slack_oauth_client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slack_oauth_client_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub large_file_storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ducklake: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub datatable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_sync: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_ui: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_scripts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mute_critical_alerts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operator_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_app_installations: Option, + // Grouped config fields + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_invite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub success_handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_app_execution_limit_per_minute: Option, +} + +/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] +// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] +// pub enum WorkspaceKeyKind { +// Cloud, +// } + +#[derive(Deserialize)] +struct EditCommandScript { + slack_command_script: Option, +} + +#[derive(Deserialize)] +struct RunSlackMessageTestJobRequest { + hub_script_path: String, + channel: String, + test_msg: String, +} + +#[derive(Serialize)] +struct RunSlackMessageTestJobResponse { + job_uuid: String, +} + +#[cfg(feature = "enterprise")] +#[derive(Deserialize)] +struct EditDeployTo { + deploy_to: Option, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct EditAutoInvite { + pub operator: Option, + pub invite_all: Option, + pub auto_add: Option, +} + +#[derive(Deserialize)] +struct EditWebhook { + webhook: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +struct LargeFileStorageWithSecondary { + #[serde(flatten)] + large_file_storage: LargeFileStorage, + #[serde(default)] + secondary_storage: HashMap, +} +#[derive(Deserialize, Debug)] +struct EditLargeFileStorageConfig { + large_file_storage: Option, +} + +#[derive(Deserialize, Debug)] +struct EditDucklakeConfig { + settings: DucklakeSettings, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct DucklakeSettings { + pub ducklakes: HashMap, +} + +#[derive(Deserialize, Debug)] +struct EditDataTableConfig { + settings: DataTableSettings, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct DataTableSettings { + pub datatables: HashMap, +} + +#[derive(Deserialize)] +struct CreateWorkspace { + id: String, + name: String, + username: Option, + color: Option, +} + +#[derive(Deserialize)] +struct CreateWorkspaceFork { + id: String, + name: String, + color: Option, +} + +#[derive(Deserialize)] +struct EditWorkspace { + name: String, + owner: String, +} + +#[derive(Serialize)] +struct WorkspaceList { + pub email: String, + pub workspaces: Vec, +} + +#[derive(Serialize)] +struct UserWorkspace { + pub id: String, + pub name: String, + pub username: String, + pub color: Option, + pub operator_settings: Option>, + pub parent_workspace_id: Option, + pub disabled: bool, +} + +#[derive(Deserialize)] +struct WorkspaceId { + pub id: String, +} + +#[derive(Deserialize)] +struct ValidateUsername { + pub id: String, + pub username: String, +} + +#[derive(Deserialize)] +pub struct NewWorkspaceInvite { + pub email: String, + pub is_admin: bool, + pub operator: bool, +} + +#[derive(Deserialize)] +pub struct NewWorkspaceUser { + pub email: String, + pub username: Option, + pub is_admin: bool, + pub operator: bool, +} + +// New format for error handler (grouped) +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EditErrorHandlerNew { + pub path: Option, + pub extra_args: Option, + #[serde(default)] + pub muted_on_cancel: bool, + #[serde(default)] + pub muted_on_user_path: bool, +} + +// Legacy format for error handler (flat fields from old CLI) +#[derive(Deserialize)] +pub struct EditErrorHandlerLegacy { + pub error_handler: Option, + pub error_handler_extra_args: Option, + #[serde(default)] + pub error_handler_muted_on_cancel: bool, +} + +// Accepts both old and new formats +#[derive(Deserialize)] +#[serde(untagged)] +pub enum EditErrorHandler { + New(EditErrorHandlerNew), + Legacy(EditErrorHandlerLegacy), +} + +impl EditErrorHandler { + pub fn into_normalized(self) -> EditErrorHandlerNew { + match self { + EditErrorHandler::New(new) => new, + EditErrorHandler::Legacy(legacy) => EditErrorHandlerNew { + path: legacy.error_handler, + extra_args: legacy.error_handler_extra_args, + muted_on_cancel: legacy.error_handler_muted_on_cancel, + muted_on_user_path: false, // Old format doesn't have this field + }, + } + } +} + +// New format for success handler (grouped) +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EditSuccessHandlerNew { + pub path: Option, + pub extra_args: Option, +} + +// Legacy format for success handler (flat fields from old CLI) +#[derive(Deserialize)] +pub struct EditSuccessHandlerLegacy { + pub success_handler: Option, + pub success_handler_extra_args: Option, +} + +// Accepts both old and new formats +#[derive(Deserialize)] +#[serde(untagged)] +pub enum EditSuccessHandler { + New(EditSuccessHandlerNew), + Legacy(EditSuccessHandlerLegacy), +} + +impl EditSuccessHandler { + pub fn into_normalized(self) -> EditSuccessHandlerNew { + match self { + EditSuccessHandler::New(new) => new, + EditSuccessHandler::Legacy(legacy) => EditSuccessHandlerNew { + path: legacy.success_handler, + extra_args: legacy.success_handler_extra_args, + }, + } + } +} + +lazy_static::lazy_static! { + pub static ref EMAIL_REGEXP: Regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); +} + +async fn list_pending_invites( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult> { + require_admin(authed.is_admin, &authed.username)?; + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_as!( + WorkspaceInvite, + "SELECT + workspace_invite.workspace_id, + workspace_invite.email, + workspace_invite.is_admin, + workspace_invite.operator, + workspace.parent_workspace_id + FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id + WHERE workspace_id = $1", + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +async fn is_premium( + authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + #[cfg(feature = "cloud")] + let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id) + .await? + .premium; + #[cfg(not(feature = "cloud"))] + let premium = false; + Ok(Json(premium)) +} + +async fn exists_workspace( + authed: ApiAuthed, + Extension(user_db): Extension, + Json(WorkspaceId { id }): Json, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)", + id + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + tx.commit().await?; + Ok(Json(exists)) +} + +async fn list_workspaces( + authed: ApiAuthed, + Extension(user_db): Extension, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let workspaces = sqlx::query_as!( + Workspace, + "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id + FROM workspace + LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id + JOIN usr ON usr.workspace_id = workspace.id + WHERE usr.email = $1 AND workspace.deleted = false", + authed.email + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(workspaces)) +} + +async fn get_settings( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let settings = sqlx::query_as!( + WorkspaceSettings, + r#" + SELECT + workspace_id, + slack_team_id, + teams_team_id, + teams_team_name, + teams_team_guid, + slack_name, + slack_command_script, + teams_command_script, + slack_email, + slack_oauth_client_id, + slack_oauth_client_secret, + customer_id, + plan, + webhook, + deploy_to, + ai_config, + large_file_storage, + datatable, + ducklake, + git_sync, + deploy_ui, + default_app, + default_scripts, + mute_critical_alerts, + color, + operator_settings, + git_app_installations, + auto_invite, + error_handler, + success_handler, + public_app_execution_limit_per_minute + FROM + workspace_settings + WHERE + workspace_id = $1 + "#, + &w_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?; + + tx.commit().await?; + + let settings = not_found_if_none(settings, "workspace settings", &w_id)?; + Ok(Json(settings)) +} + +#[derive(Serialize)] +struct DeployTo { + deploy_to: Option, +} +async fn get_deploy_to( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let settings = sqlx::query_as!( + DeployTo, + "SELECT deploy_to FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting deploy_to: {e:#}")))?; + + tx.commit().await?; + Ok(Json(settings)) +} + +async fn edit_slack_command( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(es): Json, +) -> Result { + require_admin(is_admin, &username)?; + let mut tx = db.begin().await?; + + if es.slack_command_script.is_some() { + let exists_slack_command_with_team_id = sqlx::query_scalar!( + r#" + SELECT EXISTS (SELECT 1 + FROM workspace_settings + WHERE workspace_id <> $1 + AND slack_command_script IS NOT NULL + AND slack_team_id IS NOT NULL + AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1)) + "#, + &w_id + ) + .fetch_one(&mut *tx) + .await?.unwrap_or(false); + + if exists_slack_command_with_team_id { + return Err(Error::BadRequest( + "A workspace connected to the same slack team already has a command script. Please remove it first." + .to_string(), + )); + } + } + + sqlx::query!( + "UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2", + es.slack_command_script, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_command_script", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some( + [( + "script", + es.slack_command_script + .unwrap_or("NO_SCRIPT".to_string()) + .as_str(), + )] + .into(), + ), + ) + .await?; + tx.commit().await?; + + Ok(format!("Edit command script {}", &w_id)) +} + +async fn run_slack_message_test_job( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> JsonResult { + let mut fake_result = HashMap::new(); + fake_result.insert("error".to_string(), to_raw_value(&req.test_msg)); + fake_result.insert("success_result".to_string(), to_raw_value(&req.test_msg)); + + let mut extra_args = HashMap::new(); + extra_args.insert("channel".to_string(), to_raw_value(&req.channel)); + extra_args.insert( + "slack".to_string(), + to_raw_value(&format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}")), + ); + + let uuid = windmill_queue::push_error_handler( + &db, + Uuid::parse_str("00000000-0000-0000-0000-000000000000")?, + None, + Some("slack_message_test".to_string()), + false, + w_id.as_str(), + &format!("script/{}", req.hub_script_path.as_str()), + sqlx::types::Json(&fake_result), + None, + Some(Utc::now()), + Some(sqlx::types::Json(to_raw_value(&extra_args))), + authed.email.as_str(), + false, + false, + None, // Note: we could mark it as high priority to return result quickly to the user + ) + .await?; + + Ok(Json(RunSlackMessageTestJobResponse { + job_uuid: uuid.to_string(), + })) +} + +#[derive(Deserialize)] +struct SetSlackOAuthConfigRequest { + slack_oauth_client_id: String, + slack_oauth_client_secret: String, +} + +#[derive(Serialize)] +struct GetSlackOAuthConfigResponse { + slack_oauth_client_id: Option, + slack_oauth_client_secret: Option, +} + +async fn get_slack_oauth_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let settings = sqlx::query!( + "SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&db) + .await?; + + // Mask the secret if it exists + let masked_secret = settings + .slack_oauth_client_secret + .map(|_| "***".to_string()); + + Ok(Json(GetSlackOAuthConfigResponse { + slack_oauth_client_id: settings.slack_oauth_client_id, + slack_oauth_client_secret: masked_secret, + })) +} + +async fn set_slack_oauth_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + if req.slack_oauth_client_id.is_empty() || req.slack_oauth_client_secret.is_empty() { + return Err(Error::BadRequest( + "Both client ID and client secret are required".to_string(), + )); + } + + let mut tx = db.begin().await?; + + sqlx::query!( + "UPDATE workspace_settings + SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2 + WHERE workspace_id = $3", + &req.slack_oauth_client_id, + &req.slack_oauth_client_secret, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.set_slack_oauth_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("client_id", req.slack_oauth_client_id.as_str())].into()), + ) + .await?; + + tx.commit().await?; + + Ok(format!("Slack OAuth config set for workspace {}", &w_id)) +} + +async fn delete_slack_oauth_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + sqlx::query!( + "UPDATE workspace_settings + SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL + WHERE workspace_id = $1", + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.delete_slack_oauth_config", + ActionKind::Delete, + &w_id, + Some(&authed.email), + None, + ) + .await?; + + tx.commit().await?; + + Ok(format!( + "Slack OAuth config deleted for workspace {}", + &w_id + )) +} + +#[derive(Deserialize)] +struct GetSecondaryStorageNamesQuery { + #[serde(default)] + include_default: bool, +} + +async fn get_secondary_storage_names( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Query(query): Query, +) -> JsonResult> { + let mut result: Vec = sqlx::query_scalar!( + "SELECT jsonb_object_keys(large_file_storage->'secondary_storage') AS \"secondary_storage_name!: _\" + FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_all(&db) + .await?; + + // If include_default is true, check if primary storage is set and add "_default_" + if query.include_default { + let has_primary_storage: Option = sqlx::query_scalar!( + "SELECT (large_file_storage IS NOT NULL + AND large_file_storage != 'null'::jsonb + AND jsonb_typeof(large_file_storage) = 'object') AS \"has_primary!\" + FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&db) + .await?; + + if has_primary_storage.unwrap_or(false) { + result.insert(0, "_default_".to_string()); + } + } + + Ok(Json(result)) +} + +#[cfg(feature = "enterprise")] +async fn edit_deploy_to( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(es): Json, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2", + es.deploy_to, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_deploy_to", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some( + [( + "script", + es.deploy_to.unwrap_or("NO_DEPLOY_TO".to_string()).as_str(), + )] + .into(), + ), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "deploy_to".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!("Edit deploy to for {}", &w_id)) +} + +#[cfg(not(feature = "enterprise"))] +async fn edit_deploy_to() -> Result { + return Err(Error::BadRequest( + "Deploy to is only available on enterprise".to_string(), + )); +} + +pub const BANNED_DOMAINS: &str = include_str!("../../windmill-api/banned_domains.txt"); +pub const WM_FORK_PREFIX: &str = "wm-fork-"; +pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000; + +async fn is_allowed_auto_domain(ApiAuthed { email, .. }: ApiAuthed) -> JsonResult { + let domain = email.split('@').last().unwrap(); + return Ok(Json(!BANNED_DOMAINS.contains(domain))); +} + +async fn edit_auto_invite( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(ea): Json, +) -> Result { + crate::workspaces_oss::edit_auto_invite(authed, db, w_id, ea).await +} + +#[cfg(feature = "private")] +async fn edit_instance_groups( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(config): Json, +) -> Result { + crate::workspaces_ee::edit_instance_groups(authed, db, w_id, config).await +} + +#[cfg(not(feature = "private"))] +async fn edit_instance_groups( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_config): Json, +) -> Result { + Err(Error::BadRequest( + "Instance groups are only available on Windmill Enterprise Edition".to_string(), + )) +} + +async fn edit_webhook( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(ew): Json, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + if let Some(webhook) = &ew.webhook { + sqlx::query!( + "UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2", + webhook, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + audit_log( + &mut *tx, + &authed, + "workspaces.edit_webhook", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "webhook".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!("Edit webhook for workspace {}", &w_id)) +} + +async fn edit_large_file_storage_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + let args_for_audit = format!("{:?}", new_config.large_file_storage); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_large_file_storage_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("large_file_storage", args_for_audit.as_str())].into()), + ) + .await?; + + if let Some(lfs_config) = new_config.large_file_storage { + let serialized_lfs_config = + serde_json::to_value::(lfs_config) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + serialized_lfs_config, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + // Trigger git sync for large file storage changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: "large_file_storage".to_string(), + }, + Some("Large file storage configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!( + "Edit large file storage config for workspace {}", + &w_id + )) +} + +async fn list_ducklakes( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let ducklakes = sqlx::query_scalar!( + r#" + SELECT jsonb_object_keys(ws.ducklake->'ducklakes') AS ducklake_name + FROM workspace_settings ws + WHERE ws.workspace_id = $1 + "#, + &w_id + ) + .fetch_all(&db) + .await? + .into_iter() + .filter_map(|s| s) + .collect(); + + Ok(Json(ducklakes)) +} + +async fn list_datatables( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let datatables = sqlx::query_scalar!( + r#" + SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name + FROM workspace_settings ws + WHERE ws.workspace_id = $1 + "#, + &w_id + ) + .fetch_all(&db) + .await? + .into_iter() + .filter_map(|s| s) + .collect(); + + Ok(Json(datatables)) +} + +/// Compact column representation: "type" or "type?" for nullable, with "=default" suffix if has default +type CompactColumn = String; + +/// Columns mapped by name to their compact type +type ColumnMap = HashMap; + +/// Tables mapped by name to their columns +type TableMap = HashMap; + +/// Schemas mapped by name to their tables +type SchemaMap = HashMap; + +#[derive(Serialize, Debug)] +struct DataTableSchema { + datatable_name: String, + /// Hierarchical schema: schema_name -> table_name -> column_name -> "type[?][=default]" + schemas: SchemaMap, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +async fn list_datatable_schemas( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + // Get all datatable names for this workspace + let datatable_names: Vec = sqlx::query_scalar!( + r#" + SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name + FROM workspace_settings ws + WHERE ws.workspace_id = $1 + "#, + &w_id + ) + .fetch_all(&db) + .await? + .into_iter() + .filter_map(|s| s) + .collect(); + + let mut results = Vec::new(); + + for datatable_name in datatable_names { + let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await { + Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None }, + Err(e) => DataTableSchema { + datatable_name, + schemas: HashMap::new(), + error: Some(e.to_string()), + }, + }; + results.push(schema); + } + + Ok(Json(results)) +} + +async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result { + // Get the datatable resource (connection credentials) + let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; + + // Parse the resource as PgDatabase + let pg_db: PgDatabase = serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + + // Connect to the datatable database + let (client, connection) = pg_db.connect().await?; + + // Spawn the connection handler + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + + // First, get all non-system schemas (including empty ones) + let schema_rows = client + .query( + r#" + SELECT nspname::text AS schema_name + FROM pg_namespace + WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname + "#, + &[], + ) + .await + .map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?; + + // Build hierarchical structure: schema -> table -> column -> compact_type + let mut schema_map: SchemaMap = HashMap::new(); + + // Collect schema names and initialize map + let schema_names: Vec = schema_rows + .iter() + .map(|row| { + let name: String = row.get(0); + schema_map.entry(name.clone()).or_default(); + name + }) + .collect(); + + // Query column information only for the schemas we found + let rows = client + .query( + r#" + SELECT + table_schema::text, + table_name::text, + column_name::text, + udt_name::text, + is_nullable::text, + column_default::text + FROM information_schema.columns + WHERE table_schema = ANY($1) + AND table_name IS NOT NULL + ORDER BY table_schema, table_name, ordinal_position + "#, + &[&schema_names], + ) + .await + .map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?; + + for row in rows { + let table_schema: String = row.get(0); + let table_name: String = row.get(1); + let column_name: String = row.get(2); + let udt_name: String = row.get(3); + let is_nullable: String = row.get(4); + let column_default: Option = row.get(5); + + // Build compact type representation: "type[?][=default]" + let mut compact = udt_name; + if is_nullable == "YES" { + compact.push('?'); + } + if let Some(default) = column_default { + // Truncate long defaults for compactness + let short_default = if default.len() > 30 { + format!("{}...", &default[..27]) + } else { + default + }; + compact.push('='); + compact.push_str(&short_default); + } + + schema_map + .entry(table_schema) + .or_default() + .entry(table_name) + .or_default() + .insert(column_name, compact); + } + + Ok(schema_map) +} + +async fn edit_ducklake_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, email, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + let is_superadmin = require_super_admin(&db, &email).await.is_ok(); + + let mut tx = db.begin().await?; + + let args_for_audit = format!("{:?}", new_config.settings); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_ducklake_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("ducklake", args_for_audit.as_str())].into()), + ) + .await?; + + // Check that non-superadmins are not abusing Instance databases + if !is_superadmin { + let old_ducklakes = sqlx::query_scalar!( + r#" + SELECT ws.ducklake->'ducklakes' AS ducklake_name + FROM workspace_settings ws + WHERE ws.workspace_id = $1 + "#, + &w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(serde_json::Value::Null); + let old_ducklakes: HashMap = + serde_json::from_value(old_ducklakes).unwrap_or_default(); + for (name, dl) in new_config.settings.ducklakes.iter() { + if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance { + let old_dl = old_ducklakes.get(name); + if old_dl.is_none() + || old_dl.unwrap().catalog.resource_type + != DucklakeCatalogResourceType::Instance + || old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path + { + return Err(Error::BadRequest( + "Only superadmins can create or modify ducklakes with Instance databases" + .to_string(), + )); + } + } + } + } + + let config: serde_json::Value = serde_json::to_value(new_config.settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET ducklake = $1 WHERE workspace_id = $2", + config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(format!("Edit ducklake config for workspace {}", &w_id)) +} + +async fn edit_datatable_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, email, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + let is_superadmin = require_super_admin(&db, &email).await.is_ok(); + + let mut tx = db.begin().await?; + + let args_for_audit = format!("{:?}", new_config.settings); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_datatable_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("datatable", args_for_audit.as_str())].into()), + ) + .await?; + + // Check that non-superadmins are not abusing Instance databases + if !is_superadmin { + let old_datatables = sqlx::query_scalar!( + r#" + SELECT ws.datatable->'datatables' AS datatable_name + FROM workspace_settings ws + WHERE ws.workspace_id = $1 + "#, + &w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(serde_json::Value::Null); + let old_datatables: HashMap = + serde_json::from_value(old_datatables).unwrap_or_default(); + for (name, dt) in new_config.settings.datatables.iter() { + if dt.database.resource_type == DataTableCatalogResourceType::Instance { + let old_dt = old_datatables.get(name); + if old_dt.is_none() + || old_dt.unwrap().database.resource_type + != DataTableCatalogResourceType::Instance + || old_dt.unwrap().database.resource_path != dt.database.resource_path + { + return Err(Error::BadRequest( + "Only superadmins can create or modify data tables with Instance databases" + .to_string(), + )); + } + } + } + } + + let config: serde_json::Value = serde_json::to_value(new_config.settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2", + config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(format!("Edit datatable config for workspace {}", &w_id)) +} + +#[derive(Deserialize)] +pub struct EditGitSyncConfig { + #[cfg(feature = "enterprise")] + pub git_sync_settings: Option, +} + +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct EditGitSyncRepository { + pub git_repo_resource_path: String, + pub repository: GitRepositorySettings, +} + +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct DeleteGitSyncRepositoryRequest { + pub git_repo_resource_path: String, +} + +#[cfg(feature = "enterprise")] +fn validate_git_repo_resource_path(path: &str) -> Result<()> { + // Resource paths should follow the pattern: $res:f// or $res:u// + if path.is_empty() { + return Err(Error::BadRequest( + "Resource path cannot be empty".to_string(), + )); + } + + // Must start with $res: prefix + if !path.starts_with("$res:") { + return Err(Error::BadRequest( + "Resource path must start with '$res:'".to_string(), + )); + } + + // Extract the actual path after $res: + let actual_path = &path[5..]; // Remove "$res:" prefix + + // Basic validation: must start with f/ or u/ and contain at least one slash + if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") { + return Err(Error::BadRequest( + "Resource path must start with '$res:f/' or '$res:u/'".to_string(), + )); + } + + // Must have at least 3 parts (type, folder/user, name) + let parts: Vec<&str> = actual_path.split('/').collect(); + if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) { + return Err(Error::BadRequest( + "Invalid resource path format".to_string(), + )); + } + + // Resource name validation (last part) + let resource_name = parts.last().unwrap(); + if !resource_name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return Err(Error::BadRequest( + "Resource name can only contain alphanumeric characters, underscores, and hyphens" + .to_string(), + )); + } + + Ok(()) +} + +#[cfg(feature = "enterprise")] +fn cleanup_legacy_git_sync_settings_in_memory( + git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, + workspace_id: &str, +) { + // Check if all repositories are in new format (have settings field) + let all_repos_migrated = git_sync_settings + .repositories + .iter() + .all(|repo| repo.settings.is_some()); + + // If all repos are migrated and we still have legacy workspace-level settings + if all_repos_migrated + && (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some()) + { + tracing::info!( + workspace_id = workspace_id, + "All git sync repositories migrated to new format, cleaning up legacy workspace-level settings" + ); + + // Remove workspace-level legacy fields + git_sync_settings.include_path = None; + git_sync_settings.include_type = None; + } +} + +#[cfg(not(feature = "enterprise"))] +async fn edit_git_sync_config( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_new_config): Json, +) -> Result { + return Err(Error::BadRequest( + "Git sync is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn edit_git_sync_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + let args_for_audit = format!("{:?}", new_config.git_sync_settings); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_git_sync_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("git_sync_settings", args_for_audit.as_str())].into()), + ) + .await?; + + if let Some(mut git_sync_settings) = new_config.git_sync_settings { + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET git_sync = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + // Trigger git sync for git sync settings changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some("Git sync configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!("Edit git sync config for workspace {}", &w_id)) +} + +#[cfg(not(feature = "enterprise"))] +async fn edit_git_sync_repository( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_new_config): Json, +) -> Result { + return Err(Error::BadRequest( + "Git sync is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn edit_git_sync_repository( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + + // Validate the resource path format + validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; + + let mut tx = db.begin().await?; + + // First, get the current git sync settings + let current_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // Audit log before we move the repository + audit_log( + &mut *tx, + &authed, + "workspaces.edit_git_sync_repository", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some( + [ + ( + "repository_path", + new_config.git_repo_resource_path.as_str(), + ), + ("repository_data", &format!("{:?}", new_config.repository)), + ] + .into(), + ), + ) + .await?; + + // Check if repository exists before modifying + let repo_exists = git_sync_settings + .repositories + .iter() + .any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); + + // Find and update the specific repository, or add it if it doesn't exist + let repo_found = git_sync_settings + .repositories + .iter_mut() + .find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); + + if let Some(existing_repo) = repo_found { + // Update existing repository + *existing_repo = new_config.repository; + } else { + // Repository doesn't exist, add it as a new repository + git_sync_settings.repositories.push(new_config.repository); + } + + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + + // Save the updated configuration + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Trigger git sync for individual repository update/add + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some(format!( + "Git sync repository '{}' {}", + new_config.git_repo_resource_path, + if repo_exists { "updated" } else { "added" } + )), + false, + None, + ) + .await?; + + Ok(format!( + "{} git sync repository '{}' for workspace {}", + if repo_exists { "Updated" } else { "Added" }, + new_config.git_repo_resource_path, + &w_id + )) +} + +#[cfg(not(feature = "enterprise"))] +async fn delete_git_sync_repository( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_request): Json, +) -> Result { + return Err(Error::BadRequest( + "Git sync is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn delete_git_sync_repository( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(request): Json, +) -> Result { + require_admin(is_admin, &username)?; + + // For deletion, only validate that path is not empty to allow cleanup of malformed entries + if request.git_repo_resource_path.is_empty() { + return Err(Error::BadRequest( + "Resource path cannot be empty".to_string(), + )); + } + + let mut tx = db.begin().await?; + + // First, get the current git sync settings + let current_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // Check if repository exists and remove it + let original_count = git_sync_settings.repositories.len(); + git_sync_settings + .repositories + .retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path); + + if git_sync_settings.repositories.len() == original_count { + return Err(Error::BadRequest(format!( + "Repository with path '{}' not found in git sync configuration", + request.git_repo_resource_path + ))); + } + + // Audit log + audit_log( + &mut *tx, + &authed, + "workspaces.delete_git_sync_repository", + ActionKind::Delete, + &w_id, + Some(&authed.email), + Some([("repository_path", request.git_repo_resource_path.as_str())].into()), + ) + .await?; + + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + + // Save the updated configuration + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Trigger git sync for repository deletion + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some(format!( + "Git sync repository '{}' deleted", + request.git_repo_resource_path + )), + false, + None, + ) + .await?; + + Ok(format!( + "Deleted git sync repository '{}' from workspace {}", + request.git_repo_resource_path, &w_id + )) +} + +#[cfg(feature = "enterprise")] +#[derive(Debug, Deserialize)] +struct EditDeployUIConfig { + deploy_ui_settings: Option, +} + +#[cfg(not(feature = "enterprise"))] +async fn edit_deploy_ui_config( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, +) -> Result { + return Err(Error::BadRequest( + "Deployment UI is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn edit_deploy_ui_config( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + let args_for_audit = format!("{:?}", new_config.deploy_ui_settings); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_deploy_ui_config", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("deployment_ui_settings", args_for_audit.as_str())].into()), + ) + .await?; + + if let Some(deploy_ui_settings) = new_config.deploy_ui_settings { + let serialized_config = + serde_json::to_value::(deploy_ui_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET deploy_ui = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET deploy_ui = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + Ok(format!("Edit deployment UI config for workspace {}", &w_id)) +} + +#[derive(Deserialize)] +pub struct EditDefaultApp { + #[cfg(feature = "enterprise")] + pub default_app_path: Option, +} + +#[cfg(not(feature = "enterprise"))] +async fn edit_default_app( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_new_config): Json, +) -> Result { + return Err(Error::BadRequest( + "Setting a workspace default app is only available on Windmill Enterprise Edition" + .to_string(), + )); +} + +async fn edit_default_scripts( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json>, +) -> Result { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_default_scripts", + ActionKind::Update, + &w_id, + Some(&authed.email), + None, + ) + .await?; + + if let Some(config) = new_config { + sqlx::query!( + "UPDATE workspace_settings SET default_scripts = $1 WHERE workspace_id = $2", + config, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET default_scripts = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + // Trigger git sync for default scripts changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "default_scripts".to_string() }, + Some("Default scripts configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!("Edit default scripts for workspace {}", &w_id)) +} + +async fn get_default_scripts( + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let mut tx = db.begin().await?; + let default_scripts = sqlx::query_scalar!( + "SELECT default_scripts FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; + tx.commit().await?; + + Ok(Json(default_scripts.flatten())) +} + +#[cfg(feature = "enterprise")] +async fn edit_default_app( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + #[cfg(not(feature = "enterprise"))] + { + return Err(Error::BadRequest( + "Setting a workspace default app is only available on Windmill Enterprise Edition" + .to_string(), + )); + } + + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + let args_for_audit = format!("{:?}", new_config.default_app_path); + audit_log( + &mut *tx, + &authed, + "workspaces.edit_default_app", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("default_app", args_for_audit.as_str())].into()), + ) + .await?; + + if let Some(default_app_path) = new_config.default_app_path { + sqlx::query!( + "UPDATE workspace_settings SET default_app = $1 WHERE workspace_id = $2", + default_app_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET default_app = NULL WHERE workspace_id = $1", + &w_id, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + // Trigger git sync for default app changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "default_app".to_string() }, + Some("Default app configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!("Edit default app for workspace {}", &w_id)) +} + +#[derive(Serialize)] +struct WorkspaceDefaultApp { + pub default_app_path: Option, +} +async fn get_default_app( + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + let mut tx = db.begin().await?; + let default_app_path = sqlx::query_scalar!( + "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&mut *tx) + .await + .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; + tx.commit().await?; + + Ok(Json(WorkspaceDefaultApp { default_app_path })) +} + +async fn edit_error_handler( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(ee): Json, +) -> Result { + require_admin(is_admin, &username)?; + + // Normalize to new format (handles both old CLI and new CLI requests) + let ee = ee.into_normalized(); + + let mut tx = db.begin().await?; + + sqlx::query_as!( + Group, + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + w_id, + "error_handler", + "The group the error handler acts on behalf of", + serde_json::json!({username_to_permissioned_as(&authed.username): true}) + ) + .execute(&mut *tx) + .await?; + + if let Some(path) = &ee.path { + match ee.extra_args.as_ref() { + Some(extra_args) if extra_args.is_object() => { + let Ok(email_recipients) = serde_json::from_value::>>( + extra_args["email_recipients"].to_owned(), + ) else { + return Err(Error::BadRequest( + "Field `email_recipients` expected to be JSON array".to_string(), + )); + }; + + if let Some(email_recipients) = email_recipients { + for email in email_recipients { + if !EMAIL_REGEXP.is_match(&email) { + return Err(Error::BadRequest(format!( + "Invalid email format: {}", + email + ))); + } + } + } + } + None => {} + _ => { + return Err(Error::BadRequest( + "Field `extra_args` expected to be JSON object".to_string(), + )) + } + } + + let mut error_handler = serde_json::json!({ + "path": path, + }); + if let Some(extra_args) = &ee.extra_args { + error_handler["extra_args"] = extra_args.clone(); + } + if ee.muted_on_cancel { + error_handler["muted_on_cancel"] = serde_json::json!(true); + } + if ee.muted_on_user_path { + error_handler["muted_on_user_path"] = serde_json::json!(true); + } + + sqlx::query!( + "UPDATE workspace_settings SET error_handler = $1 WHERE workspace_id = $2", + error_handler, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET error_handler = NULL WHERE workspace_id = $1", + &w_id + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_error_handler", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("error_handler", &format!("{:?}", ee.path)[..])].into()), + ) + .await?; + tx.commit().await?; + + // Trigger git sync for error handler changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "error_handler".to_string() }, + Some("Error handler configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!("Edit error_handler for workspace {}", &w_id)) +} + +async fn edit_success_handler( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(es): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + // Normalize to new format (handles both old CLI and new CLI requests) + let es = es.into_normalized(); + + let mut tx = db.begin().await?; + + sqlx::query_as!( + Group, + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + w_id, + "success_handler", + "The group the success handler acts on behalf of", + serde_json::json!({username_to_permissioned_as(&authed.username): true}) + ) + .execute(&mut *tx) + .await?; + + if let Some(path) = &es.path { + let mut success_handler = serde_json::json!({ + "path": path, + }); + if let Some(extra_args) = &es.extra_args { + success_handler["extra_args"] = extra_args.clone(); + } + + sqlx::query!( + "UPDATE workspace_settings SET success_handler = $1 WHERE workspace_id = $2", + success_handler, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET success_handler = NULL WHERE workspace_id = $1", + &w_id + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_success_handler", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("success_handler", &format!("{:?}", es.path)[..])].into()), + ) + .await?; + tx.commit().await?; + + // Trigger git sync for success handler changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "success_handler".to_string() }, + Some("Success handler configuration updated".to_string()), + false, + None, + ) + .await?; + + Ok(format!("Edit success_handler for workspace {}", &w_id)) +} + +#[derive(Deserialize)] +struct NewEnvironmentVariable { + name: String, + value: Option, +} + +async fn set_environment_variable( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(NewEnvironmentVariable { value, name }): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + match value { + Some(value) => { + sqlx::query!( + "INSERT INTO workspace_env (workspace_id, name, value) VALUES ($1, $2, $3) ON CONFLICT (workspace_id, name) DO UPDATE SET value = EXCLUDED.value", + &w_id, + name, + value + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspace.set_environment_variable", + ActionKind::Create, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Set environment variable {}", name)) + } + None => { + sqlx::query!( + "DELETE FROM workspace_env WHERE workspace_id = $1 AND name = $2", + &w_id, + name + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspace.delete_environment_variable", + ActionKind::Delete, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Deleted environment variable {}", name)) + } + } +} + +#[derive(Serialize)] +pub struct GetEncryptionKeyResponse { + key: String, +} + +async fn get_encryption_key( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let encryption_key_opt = sqlx::query_scalar!( + "SELECT key FROM workspace_key WHERE workspace_id = $1", + w_id + ) + .fetch_optional(&db) + .await?; + + let encryption_key = not_found_if_none(encryption_key_opt, "workspace_encryption_key", w_id)?; + return Ok(Json(GetEncryptionKeyResponse { key: encryption_key })); +} + +#[derive(Deserialize)] +struct SetEncryptionKeyRequest { + new_key: String, + skip_reencrypt: Option, +} + +async fn set_encryption_key( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> Result<()> { + require_super_admin(&db, &authed.email).await?; + + if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) { + return Err(Error::BadRequest( + "Encryption key should be an alphanumeric string of 64 characters".to_string(), + )); + } + + let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; + + sqlx::query!( + "UPDATE workspace_key SET key = $1 WHERE workspace_id = $2", + request.new_key.clone(), + w_id + ) + .execute(&db) + .await?; + + WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); + + if !request.skip_reencrypt.unwrap_or(false) { + let new_encryption_key = build_crypt(&db, w_id.as_str()).await?; + + let mut truncated_new_key = request.new_key.clone(); + truncated_new_key.truncate(8); + tracing::warn!( + "Re-encrypting all secrets for workspace {}. New key is {}***", + w_id, + truncated_new_key + ); + + let all_variables = sqlx::query!( + "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", + w_id + ) + .fetch_all(&db) + .await?; + + for variable in all_variables { + if !variable.is_secret { + continue; + } + let decrypted_value = + decrypt(&previous_encryption_key, variable.value).map_err(|e| { + Error::internal_err(format!( + "Error decrypting variable {}: {}", + variable.path, e + )) + })?; + let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str()); + sqlx::query!( + "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3", + new_encrypted_value, + w_id, + variable.path + ) + .execute(&db) + .await?; + } + } + + // Trigger git sync for encryption key changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() }, + Some("Encryption key updated".to_string()), + false, + None, + ) + .await?; + + return Ok(()); +} + +#[derive(Serialize)] +struct UsedTriggers { + pub websocket_used: bool, + pub http_routes_used: bool, + pub kafka_used: bool, + pub nats_used: bool, + pub postgres_used: bool, + pub mqtt_used: bool, + pub sqs_used: bool, + pub gcp_used: bool, + pub email_used: bool, + pub nextcloud_used: bool, +} + +async fn get_used_triggers( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let triggers_used = sqlx::query_as!( + UsedTriggers, + r#" + SELECT + EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!", + EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!", + EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!", + EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!", + EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!", + EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!", + EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!", + EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!", + EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!", + EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!" + "#, + w_id + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(triggers_used)) +} + +async fn get_workspace_as_superadmin( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + require_super_admin(&db, &authed.email).await?; + let workspace = sqlx::query_as!( + Workspace, + "SELECT + workspace.id AS \"id!\", + workspace.name AS \"name!\", + workspace.owner AS \"owner!\", + workspace.deleted AS \"deleted!\", + workspace.premium AS \"premium!\", + workspace_settings.color AS \"color\", + workspace.parent_workspace_id AS \"parent_workspace_id\" + FROM workspace + LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id + WHERE workspace.id = $1", + w_id + ) + .fetch_optional(&db) + .await?; + + let workspace = not_found_if_none(workspace, "workspace", w_id)?; + + Ok(Json(workspace)) +} + +async fn list_workspaces_as_super_admin( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Query(pagination): Query, + ApiAuthed { email, .. }: ApiAuthed, +) -> JsonResult> { + require_super_admin(&db, &email).await?; + let (per_page, offset) = paginate(pagination); + + let mut tx = user_db.begin(&authed).await?; + let workspaces = sqlx::query_as!( + Workspace, + "SELECT + workspace.id AS \"id!\", + workspace.name AS \"name!\", + workspace.owner AS \"owner!\", + workspace.deleted AS \"deleted!\", + workspace.premium AS \"premium!\", + workspace_settings.color AS \"color\", + workspace.parent_workspace_id AS \"parent_workspace_id\" + FROM workspace + LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id + LIMIT $1 OFFSET $2", + per_page as i32, + offset as i32 + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(workspaces)) +} + +async fn user_workspaces( + Extension(db): Extension, + ApiAuthed { email, .. }: ApiAuthed, +) -> JsonResult { + let mut tx = db.begin().await?; + let workspaces = sqlx::query_as!( + UserWorkspace, + "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, + CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, + usr.disabled + FROM workspace + JOIN usr ON usr.workspace_id = workspace.id + JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id + WHERE usr.email = $1 AND workspace.deleted = false", + email + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(WorkspaceList { email, workspaces })) +} + +pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> { + if w_id == "global" { + return Err(windmill_common::error::Error::BadRequest( + "'global' is not allowed as a workspace ID".to_string(), + )); + } + let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", w_id) + .fetch_one(&mut **tx) + .await? + .unwrap_or(false); + if exists { + return Err(windmill_common::error::Error::BadRequest(format!( + "Workspace {} already exists", + w_id + ))); + } + return Ok(()); +} + +lazy_static::lazy_static! { + + pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = { + match std::env::var("CREATE_WORKSPACE_REQUIRE_SUPERADMIN") { + Ok(val) => val == "true", + Err(_) => true, + } + }; + + pub static ref DISABLE_WORKSPACE_FORK: bool = { + match std::env::var("DISABLE_WORKSPACE_FORK") { + Ok(val) => val == "true", + Err(_) => false, + } + }; + +} + +async fn create_workspace_require_superadmin() -> String { + format!("{}", *CREATE_WORKSPACE_REQUIRE_SUPERADMIN) +} + +async fn _check_nb_of_workspaces(db: &DB) -> Result<()> { + let nb_workspaces = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = false", + ) + .fetch_one(db) + .await?; + if nb_workspaces.unwrap_or(0) >= 2 { + return Err(Error::BadRequest( + "You have reached the maximum number of workspaces (2 outside of default workspace 'admins') without an enterprise license. Archive/delete another workspace to create a new one" + .to_string(), + )); + } + return Ok(()); +} + +async fn create_workspace( + authed: ApiAuthed, + Extension(db): Extension, + Json(nw): Json, +) -> Result { + if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { + require_super_admin(&db, &authed.email).await?; + } + + #[cfg(not(feature = "enterprise"))] + _check_nb_of_workspaces(&db).await?; + + if *CLOUD_HOSTED { + let nb_workspaces = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace WHERE owner = $1", + authed.email + ) + .fetch_one(&db) + .await?; + if nb_workspaces.unwrap_or(0) >= 10 { + return Err(Error::BadRequest( + "You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit" + .to_string(), + )); + } + } + + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + check_w_id_conflict(&mut tx, &nw.id).await?; + sqlx::query!( + "INSERT INTO workspace + (id, name, owner) + VALUES ($1, $2, $3)", + nw.id, + nw.name, + authed.email, + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO workspace_settings + (workspace_id, color) + VALUES ($1, $2)", + nw.id, + nw.color, + ) + .execute(&mut *tx) + .await?; + let key = rd_string(64); + sqlx::query!( + "INSERT INTO workspace_key + (workspace_id, kind, key) + VALUES ($1, 'cloud', $2)", + nw.id, + &key + ) + .execute(&mut *tx) + .await?; + + // let mc = magic_crypt::new_magic_crypt!(key, 256); + // sqlx::query!( + // "INSERT INTO variable + // (workspace_id, path, value, is_secret, description) + // VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'), + // ($3, 'g/all/not_secret', $4, false, 'This item is not secret')", + // nw.id, + // crate::variables::encrypt(&mc, "pretty secret value"), + // nw.id, + // "finland does not actually exist", + // ) + // .execute(&mut *tx) + // .await?; + + let automate_username_creation = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + AUTOMATE_USERNAME_CREATION_SETTING, + ) + .fetch_optional(&mut *tx) + .await? + .map(|v| v.as_bool()) + .flatten() + .unwrap_or(false); + + let username = if automate_username_creation { + if nw.username.is_some() && nw.username.unwrap().len() > 0 { + return Err(Error::BadRequest( + "username is not allowed when username creation is automated".to_string(), + )); + } + get_instance_username_or_create_pending(&mut tx, &authed.email).await? + } else { + nw.username + .ok_or(Error::BadRequest("username is required".to_string()))? + }; + + sqlx::query!( + "INSERT INTO usr + (workspace_id, email, username, is_admin) + VALUES ($1, $2, $3, true)", + nw.id, + authed.email, + username, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO group_ + VALUES ($1, 'all', 'The group that always contains all users of this workspace')", + nw.id + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO usr_to_group + VALUES ($1, 'all', $2)", + nw.id, + username + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.create", + ActionKind::Create, + &nw.id, + Some(nw.name.as_str()), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("Created workspace {}", &nw.id)) +} + +async fn clone_workspace_data( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + // Clone workspace settings (merge with existing basic settings) + update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; + + // Clone workspace environment variables + clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?; + + // Clone folders + clone_folders(tx, source_workspace_id, target_workspace_id).await?; + + // Clone groups + clone_groups(tx, source_workspace_id, target_workspace_id).await?; + + // Clone resource types + clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; + + // Clone resources + clone_resources(tx, source_workspace_id, target_workspace_id).await?; + + // Clone variables with re-encryption + clone_variables(tx, source_workspace_id, target_workspace_id).await?; + + // Clone scripts with new hashes + clone_scripts(tx, source_workspace_id, target_workspace_id).await?; + + // Clone flows with new versions + clone_flows(tx, source_workspace_id, target_workspace_id).await?; + + // Clone flow nodes + clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; + + // Clone apps with new IDs and app scripts + let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; + + // Clone raw apps + clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + + // Clone workspace runnable dependencies and dependency map + clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; + + // TODO: Enable when git sync is implemented for workspace dependencies. + // // Clone workspace dependencies + // clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?; + Ok(()) +} + +async fn update_workspace_settings( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO workspace_key (workspace_id, kind, key) + SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#" + UPDATE workspace_settings + SET + deploy_to = $1, + ai_config = source_ws.ai_config, + large_file_storage = source_ws.large_file_storage, + ducklake = source_ws.ducklake, + datatable = source_ws.datatable, + git_app_installations = source_ws.git_app_installations + FROM workspace_settings source_ws + WHERE source_ws.workspace_id = $1 + AND workspace_settings.workspace_id = $2 + "#, + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + let current_git_sync_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_optional(&mut **tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_git_sync_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // We only keep the first git sync repo that is sync mode (use_individual_branch = false), since it is considered the main one + // Context: see WIN-1559 + git_sync_settings.repositories = git_sync_settings + .repositories + .into_iter() + .filter(|r| !r.use_individual_branch.unwrap_or(false)) + .take(1) + .collect(); + + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + target_workspace_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_workspace_env( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO workspace_env (workspace_id, name, value) + SELECT $2, name, value + FROM workspace_env + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_folders( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by) + SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by + FROM folder + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_groups( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) + SELECT $2, name, summary, extra_perms + FROM group_ + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "INSERT INTO usr_to_group (workspace_id, group_, usr) + SELECT $2, group_, usr + FROM usr_to_group + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_resource_types( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension) + SELECT $2, name, schema, description, edited_at, created_by, format_extension + FROM resource_type + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_resources( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by) + SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by + FROM resource + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_variables( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at) + SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at + FROM variable + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_scripts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + // Clone all scripts directly with a single query + sqlx::query!( + r#"INSERT INTO script ( + workspace_id, hash, path, parent_hashes, summary, description, content, + created_by, created_at, archived, schema, deleted, is_template, + extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, + dedicated_worker, ws_error_handler_muted, priority, timeout, + delete_after_use, restart_unless_cancelled, concurrency_key, + visible_to_runner_only, no_main_func, codebase, has_preprocessor, + on_behalf_of_email, assets + ) + SELECT + $1, hash, path, parent_hashes, summary, description, content, + created_by, created_at, archived, schema, deleted, is_template, + extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, + dedicated_worker, ws_error_handler_muted, priority, timeout, + delete_after_use, restart_unless_cancelled, concurrency_key, + visible_to_runner_only, no_main_func, codebase, has_preprocessor, + on_behalf_of_email, assets + FROM script + WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_flows( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + // First, clone flows without versions + sqlx::query!( + "INSERT INTO flow ( + workspace_id, path, summary, description, value, edited_by, edited_at, + archived, schema, extra_perms, dependency_job, draft_only, tag, + ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, + concurrency_key, versions, on_behalf_of_email, lock_error_logs + ) + SELECT $2, path, summary, description, value, edited_by, edited_at, + archived, schema, extra_perms, NULL, draft_only, tag, + ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, + concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs + FROM flow + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + // Then clone flow versions + let flow_versions = sqlx::query!( + "SELECT id, workspace_id, path, value, schema, created_by, created_at + FROM flow_version + WHERE workspace_id = $1 + ORDER BY path, created_at", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for version in flow_versions { + let new_version_id = sqlx::query_scalar!( + "INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id", + target_workspace_id, + version.path, + version.value, + version.schema, + version.created_by, + version.created_at, + ) + .fetch_one(&mut **tx) + .await?; + + // Update flow to include this version + sqlx::query!( + "UPDATE flow + SET versions = array_append(versions, $1) + WHERE workspace_id = $2 AND path = $3", + new_version_id, + target_workspace_id, + version.path, + ) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn clone_flow_nodes( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2) + SELECT $2, + (SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash, + source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2 + FROM flow_node source_fn + WHERE source_fn.workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_apps( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + // Get all apps from source workspace + let apps = sqlx::query!( + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + FROM app + WHERE workspace_id = $1", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + let mut app_id_mapping: HashMap = HashMap::new(); + + // Clone apps with new IDs + for app in apps { + let new_app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id", + target_workspace_id, + app.path, + app.summary, + app.policy, + &Vec::::new(), // Start with empty versions array + app.extra_perms, + app.draft_only, + app.custom_path, + ) + .fetch_one(&mut **tx) + .await?; + + app_id_mapping.insert(app.id, new_app_id); + } + + { + // Clone app versions + let app_versions = sqlx::query!( + "SELECT app_id, value, created_by, created_at, raw_app + FROM app_version + WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1) + ORDER BY app_id, created_at", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for version in app_versions { + if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) { + sqlx::query!( + "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) + VALUES ($1, $2, $3, $4, $5)", + new_app_id, + version.value, + version.created_by, + version.created_at, + version.raw_app, + ) + .execute(&mut **tx) + .await?; + } + } + } + + // Update app versions arrays + sqlx::query!( + "UPDATE app SET versions = ( + SELECT array_agg(av.id ORDER BY av.created_at) + FROM app_version av + WHERE av.app_id = app.id + ) WHERE workspace_id = $1", + target_workspace_id + ) + .execute(&mut **tx) + .await?; + + // Clone app scripts with recomputed hashes + let app_scripts = sqlx::query!( + "SELECT app, hash, lock, code, code_sha256 + FROM app_script + WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", + source_workspace_id + ) + .fetch_all(&mut **tx) + .await?; + + for app_script in app_scripts { + if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { + // Recompute hash using app_id, code_sha256, and lock + let mut hasher = Sha256::new(); + hasher.update(new_app_id.to_be_bytes()); + hasher.update(hex::decode(&app_script.code_sha256)?); + if let Some(lock) = &app_script.lock { + hasher.update(lock.as_bytes()); + } + let new_hash = hex::encode(hasher.finalize()); + + sqlx::query!( + "INSERT INTO app_script (app, hash, lock, code, code_sha256) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + new_app_id, + new_hash, + app_script.lock, + app_script.code, + app_script.code_sha256, + ) + .execute(&mut **tx) + .await?; + } + } + + Ok(app_id_mapping) +} + +async fn clone_raw_apps( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms) + SELECT path, version, $2, summary, edited_at, data, extra_perms + FROM raw_app + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn clone_workspace_runnable_dependencies( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + // Clone workspace_runnable_dependencies + sqlx::query!( + "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path) + SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path + FROM workspace_runnable_dependencies + WHERE workspace_id = $2", + target_workspace_id, + source_workspace_id + ) + .execute(&mut **tx) + .await?; + + // Clone dependency_map to preserve import relationships + sqlx::query!( + "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) + SELECT $1, importer_path, importer_kind, imported_path, importer_node_id + FROM dependency_map + WHERE workspace_id = $2", + target_workspace_id, + source_workspace_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[allow(dead_code)] +async fn clone_workspace_dependencies( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + // Clone workspace_runnable_dependencies + sqlx::query!( + "INSERT INTO workspace_dependencies (workspace_id, language, name, description, content, archived, created_at) + SELECT $1, language, name, description, content, archived, created_at + FROM workspace_dependencies + WHERE workspace_id = $2", + target_workspace_id, + source_workspace_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result { + return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string())); +} + +/// Return the uuids of the git sync jobs to create the branch before creating the fork +async fn create_workspace_fork_branch( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(nw): Json, +) -> JsonResult> { + if *CLOUD_HOSTED { + return Err(Error::BadRequest(format!( + "Forking workspaces is not available on app.windmill.dev" + ))); + } + + if *DISABLE_WORKSPACE_FORK { + require_super_admin(&db, &authed.email).await?; + } + + Ok(Json( + handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, + )) +} + +async fn create_workspace_fork( + authed: ApiAuthed, + Extension(db): Extension, + Path(parent_workspace_id): Path, + Json(nw): Json, +) -> Result { + if *CLOUD_HOSTED { + return Err(Error::BadRequest(format!( + "Forking workspaces is not available on app.windmill.dev" + ))); + } + + if *DISABLE_WORKSPACE_FORK { + require_super_admin(&db, &authed.email).await?; + } + + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + // Generate unique forked workspace ID with wm-fork prefix + if !nw.id.starts_with(WM_FORK_PREFIX) { + return Err(Error::BadRequest(format!( + "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", + nw.id, WM_FORK_PREFIX + ))); + } + + let forked_id = nw.id; + + sqlx::query!( + "INSERT INTO workspace + (id, name, owner, parent_workspace_id) + VALUES ($1, $2, $3, $4)", + forked_id, + nw.name, + authed.email, + parent_workspace_id, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO workspace_settings + (workspace_id, color) + VALUES ($1, $2)", + forked_id, + nw.color, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO usr + (workspace_id, email, username, is_admin) + SELECT $1, email, username, is_admin FROM usr + WHERE workspace_id = $3 AND email = $2 + ", + forked_id, + authed.email, + parent_workspace_id, + ) + .execute(&mut *tx) + .await?; + + // Clone all data from the parent workspace using Rust implementation + clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + + sqlx::query!( + "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) + SELECT $1, email, is_admin, operator + FROM usr + WHERE workspace_id = $2", + &forked_id, + &parent_workspace_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.create_fork", + ActionKind::Create, + &forked_id, + Some(nw.name.as_str()), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("Created forked workspace {}", &forked_id)) +} + +async fn edit_workspace( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(ew): Json, +) -> Result { + require_admin(is_admin, &username)?; + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace SET name = $1, owner = $2 WHERE id = $3", + ew.name, + ew.owner, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.update", + ActionKind::Update, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Updated workspace {}", &w_id)) +} + +/// Archive a workspace: disable schedules, cancel jobs, and mark as deleted. +/// Returns (schedules_disabled_count, jobs_canceled_count). +pub(crate) async fn archive_workspace_impl( + db: &DB, + w_id: &str, + username: &str, +) -> Result<(usize, usize)> { + // Step 1: Disable all schedules and clear their queued jobs + let mut tx = db.begin().await?; + let disabled_schedules = sqlx::query_scalar!( + "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path", + w_id + ) + .fetch_all(&mut *tx) + .await?; + + let schedules_count = disabled_schedules.len(); + tracing::info!( + "Disabled {} schedules in workspace {}", + schedules_count, + w_id + ); + + // Clear all schedule-related jobs using the existing clear_schedule function + for schedule_path in &disabled_schedules { + windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?; + } + + // Mark workspace as archived + sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs) + let jobs_to_cancel = + sqlx::query_scalar!("SELECT id FROM v2_job_queue WHERE workspace_id = $1", w_id) + .fetch_all(db) + .await?; + + let jobs_count = jobs_to_cancel.len(); + tracing::info!( + "Found {} remaining jobs to cancel in workspace {}", + jobs_count, + w_id + ); + + // Step 3: Cancel all remaining jobs using the existing cancel_jobs function + let canceled_count = if !jobs_to_cancel.is_empty() { + let axum::Json(canceled_jobs) = windmill_api_jobs::cancel_jobs( + jobs_to_cancel, + db, + username, + w_id, + false, // force_cancel + ) + .await?; + + let count = canceled_jobs.len(); + tracing::info!("Canceled {} jobs in workspace {}", count, w_id); + count + } else { + 0 + }; + + Ok((schedules_count, canceled_count)) +} + +async fn archive_workspace( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let (schedules_count, canceled_count) = + archive_workspace_impl(&db, &w_id, &authed.username).await?; + + // Audit log + let mut tx = db.begin().await?; + let mut audit_params = HashMap::new(); + audit_params.insert("disabled_schedules", schedules_count.to_string()); + audit_params.insert("canceled_jobs", canceled_count.to_string()); + let audit_params_refs: HashMap<&str, &str> = + audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + audit_log( + &mut *tx, + &authed, + "workspaces.archive", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some(audit_params_refs), + ) + .await?; + tx.commit().await?; + + Ok(format!( + "Archived workspace {}, disabled {} schedules and canceled {} jobs", + &w_id, schedules_count, canceled_count + )) +} + +async fn leave_workspace( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, +) -> Result { + let mut tx = db.begin().await?; + sqlx::query!( + "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", + &w_id, + &authed.email + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.leave", + ActionKind::Delete, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Left workspace {}", &w_id)) +} + +async fn unarchive_workspace( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let mut tx = db.begin().await?; + sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.unarchive", + ActionKind::Update, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Unarchived workspace {}", &w_id)) +} + +async fn invite_user( + ApiAuthed { username, is_admin, .. }: ApiAuthed, + Extension(db): Extension, + Extension(webhook): Extension, + Path(w_id): Path, + Json(mut nu): Json, +) -> Result<(StatusCode, String)> { + require_admin(is_admin, &username)?; + + nu.email = nu.email.to_lowercase(); + + let mut tx = db.begin().await?; + + let already_in_workspace = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + &w_id, + nu.email + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_in_workspace { + return Err(Error::BadRequest(format!( + "user with email {} already exists in workspace {}", + nu.email, w_id + ))); + } + + sqlx::query!( + "INSERT INTO workspace_invite + (workspace_id, email, is_admin, operator) + VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email) + DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator", + &w_id, + nu.email, + nu.is_admin, + nu.operator + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + send_email_if_possible( + &format!("Invited to Windmill's workspace: {w_id}"), + &format!( + "You have been granted access to Windmill's workspace {w_id} + +If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", + BASE_URL.read().await.clone() + ), + &nu.email, + ); + + webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace { + email: nu.email.clone(), + workspace: w_id, + }); + + Ok(( + StatusCode::CREATED, + format!("user with email {} invited", nu.email), + )) +} + +async fn add_user( + authed: ApiAuthed, + Extension(db): Extension, + Extension(webhook): Extension, + Path(w_id): Path, + Json(mut nu): Json, +) -> Result<(StatusCode, String)> { + require_admin(authed.is_admin, &authed.username)?; + nu.email = nu.email.to_lowercase(); + + let mut tx = db.begin().await?; + + let already_exists_email = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + &w_id, + nu.email, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_exists_email { + return Err(Error::BadRequest(format!( + "user with email {} already exists in workspace {}", + nu.email, w_id + ))); + } + + let automate_username_creation = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + AUTOMATE_USERNAME_CREATION_SETTING, + ) + .fetch_optional(&mut *tx) + .await? + .map(|v| v.as_bool()) + .flatten() + .unwrap_or(false); + + let username = if automate_username_creation { + if nu.username.is_some() && nu.username.unwrap().len() > 0 { + return Err(Error::BadRequest( + "username is not allowed when username creation is automated".to_string(), + )); + } + get_instance_username_or_create_pending(&mut tx, &nu.email).await? + } else { + let username = nu + .username + .ok_or(Error::BadRequest("username is required".to_string()))?; + + if !VALID_USERNAME.is_match(&username) { + return Err(windmill_common::error::Error::BadRequest(format!( + "Usermame can only contain alphanumeric characters and underscores and must start with a letter" + ))); + } + + username + }; + + sqlx::query!( + "INSERT INTO usr + (workspace_id, email, username, is_admin, operator) + VALUES ($1, $2, $3, $4, $5)", + &w_id, + nu.email, + username, + nu.is_admin, + nu.operator + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2", + &w_id, + nu.email + ) + .execute(&mut *tx) + .await?; + + sqlx::query_as!( + Group, + "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + &w_id, + username, + "all", + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.add_to_workspace", + ActionKind::Create, + &w_id, + Some(&nu.email), + None, + ) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::User { email: nu.email.clone() }, + Some(format!("Added user '{}' to workspace", &nu.email)), + true, + None, + ) + .await?; + + send_email_if_possible( + &format!("Added to Windmill's workspace: {w_id}"), + &format!( + "You have been granted access to Windmill's workspace {w_id} by {} + +If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", + authed.email, + BASE_URL.read().await.clone() + ), + &nu.email, + ); + + webhook.send_instance_event(InstanceEvent::UserAddedWorkspace { + workspace: w_id.clone(), + email: nu.email.clone(), + }); + + Ok(( + StatusCode::CREATED, + format!("user with email {} added", nu.email), + )) +} + +async fn delete_invite( + ApiAuthed { username, is_admin, .. }: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + require_admin(is_admin, &username)?; + + let mut tx = db.begin().await?; + + sqlx::query!( + "DELETE FROM workspace_invite WHERE + workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4", + &w_id, + nu.email, + nu.is_admin, + nu.operator + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(( + StatusCode::CREATED, + format!("invite to email {} deleted", nu.email), + )) +} + +async fn exists_username( + Extension(db): Extension, + Json(vu): Json, +) -> Result { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", + vu.username, + vu.id + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + + if exists { + return Err(Error::BadRequest("username already taken".to_string())); + } + + Ok("valid username".to_string()) +} + +async fn get_workspace_name( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + let workspace = sqlx::query_scalar!("SELECT name FROM workspace WHERE id = $1", &w_id) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(workspace) +} + +async fn get_dependency_map( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult> { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = user_db.begin(&authed).await?; + let dmap = sqlx::query_as!( + DependencyMap, + " + SELECT workspace_id, importer_path, importer_kind::text, imported_path, importer_node_id + FROM dependency_map WHERE workspace_id = $1", + &w_id + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Json(dmap)) +} + +#[axum::debug_handler] +async fn rebuild_dependency_map( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + if *CLOUD_HOSTED { + return Err(Error::BadRequest("Disabled on Cloud".into())); + } + ScopedDependencyMap::rebuild_map(&w_id, &db).await +} + +#[axum::debug_handler] +async fn get_dependents( + Extension(db): Extension, + Path((w_id, imported_path)): Path<(String, String)>, + _authed: ApiAuthed, +) -> JsonResult> { + tracing::debug!( + workspace_id = %w_id, + imported_path = %imported_path, + "API: Getting dependents for imported path" + ); + + let dependents = ScopedDependencyMap::get_dependents(&imported_path, &w_id, &db).await?; + + tracing::debug!( + workspace_id = %w_id, + imported_path = %imported_path, + dependents_count = dependents.len(), + "API: Found dependents: {:?}", + dependents + ); + + Ok(Json(dependents)) +} + +#[derive(Serialize, Debug)] +struct DependentsAmount { + imported_path: String, + count: i64, +} + +#[axum::debug_handler] +async fn get_dependents_amounts( + Extension(db): Extension, + Path(w_id): Path, + Json(imported_paths): Json>, +) -> JsonResult> { + tracing::debug!( + workspace_id = %w_id, + imported_paths = ?imported_paths, + "API: Getting dependents amounts for imported paths" + ); + + let results = sqlx::query_as!( + DependentsAmount, + r#" + SELECT + imported_path, + COUNT(DISTINCT importer_path) as "count!" + FROM dependency_map + WHERE workspace_id = $1 AND imported_path = ANY($2) + GROUP BY imported_path + "#, + w_id, + &imported_paths + ) + .fetch_all(&db) + .await?; + + tracing::debug!( + workspace_id = %w_id, + results_count = results.len(), + "API: Found dependents amounts: {:?}", + results + ); + + Ok(Json(results)) +} + +#[derive(Deserialize)] +struct ChangeWorkspaceName { + new_name: String, +} + +#[derive(Deserialize)] +struct ChangeWorkspaceColor { + color: Option, +} + +async fn change_workspace_name( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(rw): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + sqlx::query!( + "UPDATE workspace SET name = $1 WHERE id = $2", + &rw.new_name, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspace.change_workspace_name", + ActionKind::Update, + &w_id, + Some(&authed.email), + None, + ) + .await?; + + tx.commit().await?; + + // Trigger git sync for workspace name changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "workspace_name".to_string() }, + Some(format!("Workspace name updated to {}", &rw.new_name)), + false, + None, + ) + .await?; + + Ok(format!("updated workspace name to {}", &rw.new_name)) +} + +async fn change_workspace_color( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(rw): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + sqlx::query!( + "UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2", + rw.color, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "workspace_color".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!( + "updated workspace color to {}", + rw.color.as_deref().unwrap_or("no color") + )) +} + +async fn get_usage(Extension(db): Extension, Path(w_id): Path) -> Result { + let usage = sqlx::query_scalar!( + " + SELECT usage.usage FROM usage + WHERE is_workspace = true + AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) + AND id = $1", + w_id + ) + .fetch_optional(&db) + .await? + .unwrap_or(0); + Ok(usage.to_string()) +} + +#[derive(Deserialize)] +pub struct EditPublicAppRateLimitRequest { + pub public_app_execution_limit_per_minute: Option, +} + +async fn edit_public_app_rate_limit( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + sqlx::query!( + "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2", + req.public_app_execution_limit_per_minute, + &w_id + ) + .execute(&db) + .await?; + + // Cache is invalidated via DB trigger -> notify_event -> polling in main.rs + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!( + "Updated public app rate limit for workspace: {}", + &w_id + )) +} + +// 5 minutes fallback TTL (in addition to event-based invalidation) +const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300; + +pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result> { + use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE; + + let now = Utc::now().timestamp(); + + if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) { + if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS { + return Ok(rate_limit); + } + } + + let result: Option> = sqlx::query_scalar( + "SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(w_id) + .fetch_optional(db) + .await?; + let rate_limit = result.flatten(); + PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now)); + Ok(rate_limit) +} + +#[derive(Deserialize, Serialize)] +struct ChangeOperatorSettings { + #[serde(default)] + runs: bool, + #[serde(default)] + schedules: bool, + #[serde(default)] + resources: bool, + #[serde(default)] + variables: bool, + #[serde(default)] + assets: bool, + #[serde(default)] + triggers: bool, + #[serde(default)] + audit_logs: bool, + #[serde(default)] + groups: bool, + #[serde(default)] + folders: bool, + #[serde(default)] + workers: bool, +} + +async fn update_operator_settings( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(settings): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + let settings_json = serde_json::json!(settings); + + sqlx::query!( + "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", + settings_json, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Trigger git sync for operator settings changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: "operator_settings".to_string(), + }, + Some("Operator settings updated".to_string()), + false, + None, + ) + .await?; + + Ok("Operator settings updated successfully".to_string()) +} + +#[derive(Serialize)] +pub struct WorkspaceComparison { + pub all_ahead_items_visible: bool, + pub all_behind_items_visible: bool, + pub skipped_comparison: bool, + pub diffs: Vec, + pub summary: CompareSummary, +} + +#[derive(Serialize, Default)] +pub struct CompareSummary { + pub total_diffs: usize, + pub total_ahead: usize, + pub total_behind: usize, + pub scripts_changed: usize, + pub flows_changed: usize, + pub apps_changed: usize, + pub resources_changed: usize, + pub variables_changed: usize, + pub resource_types_changed: usize, + pub folders_changed: usize, + pub conflicts: usize, // Items that are both ahead and behind +} + +async fn reset_workspace_diffs( + authed: ApiAuthed, + Path((w_id, target_workspace_id)): Path<(String, String)>, + Extension(db): Extension, + Extension(user_db): Extension, +) -> JsonResult<()> { + // Needed to compute the has_changes: Option. Otherwise it will be None, and the query will not hit the items + let _ = compare_workspaces( + authed, + Path((w_id.clone(), target_workspace_id.clone())), + Extension(db.clone()), + Extension(user_db), + ) + .await?; + + sqlx::query!( + "DELETE FROM workspace_diff WHERE has_changes = false AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + target_workspace_id, + w_id, + ) + .execute(&db) + .await?; + + Ok(Json(())) +} + +#[derive(Serialize, Debug, Clone, Default)] +pub struct WorkspaceDiffRow { + kind: String, + path: String, + ahead: i32, + behind: i32, + has_changes: Option, + exists_in_source: Option, + exists_in_fork: Option, +} + +async fn compare_workspaces( + authed: ApiAuthed, + Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>, + Extension(db): Extension, + Extension(user_db): Extension, +) -> JsonResult { + // require_admin(authed.is_admin, &authed.username)?; + + let skipped_comparison: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM skip_workspace_diff_tally + WHERE workspace_id = $1 + )", + ) + .bind(&fork_workspace_id) + .fetch_one(&db) + .await?; + + if skipped_comparison { + return Ok(Json(WorkspaceComparison { + all_ahead_items_visible: true, + all_behind_items_visible: true, + skipped_comparison, + diffs: vec![], + summary: Default::default(), + })); + } + + let diff_items = sqlx::query_as!( + WorkspaceDiffRow, + "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff + WHERE source_workspace_id = $1 AND fork_workspace_id = $2", + source_workspace_id, + fork_workspace_id, + ) + .fetch_all(&db) + .await?; + + let mut confirmed_diffs = vec![]; + for item in diff_items { + if let Some(has_changes) = item.has_changes { + if has_changes { + confirmed_diffs.push(item); + } + continue; + } + + let item_comparison = match item.kind.as_str() { + "script" => Some( + compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "flow" => Some( + compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "app" => Some( + compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?, + ), + "resource" => Some( + compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "variable" => Some( + compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "resource_type" => Some( + compare_two_resource_types( + &db, + &source_workspace_id, + &fork_workspace_id, + &item.path, + ) + .await?, + ), + "folder" => Some( + compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + k => { + tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path); + None + // Some(ItemComparison { + // has_changes: true, + // exists_in_source: true, + // exists_in_fork: true, + // }) + } + }; + + if let Some(item_comparison) = item_comparison { + if item_comparison.has_changes { + sqlx::query!( + "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6 + WHERE path = $3 AND kind = $4 AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + source_workspace_id, + fork_workspace_id, + item.path, + item.kind, + item_comparison.exists_in_source, + item_comparison.exists_in_fork, + ) + .execute(&db) + .await?; + confirmed_diffs.push(WorkspaceDiffRow { + has_changes: Some(item_comparison.has_changes), + exists_in_source: Some(item_comparison.exists_in_source), + exists_in_fork: Some(item_comparison.exists_in_fork), + ..item + }); + } else { + sqlx::query!( + "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + source_workspace_id, + fork_workspace_id, + item.path, + item.kind, + ) + .execute(&db) + .await?; + } + } + } + + let visible_diffs = filter_visible_diffs( + &confirmed_diffs, + &source_workspace_id, + &fork_workspace_id, + user_db.begin(&authed).await?, + ) + .await?; + + let summary = CompareSummary { + total_diffs: visible_diffs.len(), + total_ahead: visible_diffs + .iter() + .map(|s| s.ahead) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), + total_behind: visible_diffs + .iter() + .map(|s| s.behind) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), + scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(), + flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(), + apps_changed: visible_diffs.iter().filter(|s| s.kind == "app").count(), + resources_changed: visible_diffs + .iter() + .filter(|s| s.kind == "resource") + .count(), + variables_changed: visible_diffs + .iter() + .filter(|s| s.kind == "variable") + .count(), + resource_types_changed: visible_diffs + .iter() + .filter(|s| s.kind == "resource_type") + .count(), + folders_changed: visible_diffs.iter().filter(|s| s.kind == "folder").count(), + conflicts: visible_diffs + .iter() + .filter(|s| s.ahead > 0 && s.behind > 0) + .count(), + }; + + let all_ahead_items_visible = summary.total_ahead + == confirmed_diffs + .iter() + .map(|s| s.ahead) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); + let all_behind_items_visible = summary.total_behind + == confirmed_diffs + .iter() + .map(|s| s.behind) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); + + return Ok(Json(WorkspaceComparison { + all_ahead_items_visible, + all_behind_items_visible, + skipped_comparison: false, + diffs: visible_diffs, + summary, + })); +} + +async fn filter_visible_diffs( + confirmed_diffs: &[WorkspaceDiffRow], + source_workspace_id: &str, + fork_workspace_id: &str, + mut tx: Transaction<'static, Postgres>, +) -> Result> { + // Step 1: Group paths by (workspace, kind) + let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new(); + + for diff in confirmed_diffs { + if diff.exists_in_source.unwrap_or(false) { + source_items.entry(&diff.kind).or_default().push(&diff.path); + } + if diff.exists_in_fork.unwrap_or(false) { + fork_items.entry(&diff.kind).or_default().push(&diff.path); + } + } + + // Step 2: Batch query for each (workspace, kind) combination + let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; + let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; + + // Step 3: Filter diffs based on visibility + let visible_diffs: Vec = confirmed_diffs + .iter() + .filter(|diff| { + let v = (diff.kind.to_string(), diff.path.to_string()); + let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v); + let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v); + source_ok && fork_ok + }) + .cloned() + .collect(); + + Ok(visible_diffs) +} + +async fn query_visible_items<'c>( + tx: &mut Transaction<'c, Postgres>, + workspace_id: &str, + items_by_kind: &HashMap<&str, Vec<&str>>, +) -> Result> { + let mut visible = HashSet::new(); + + for (kind, paths) in items_by_kind { + let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); + + let results = match *kind { + "script" => { + sqlx::query_scalar!( + "SELECT path FROM script + WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "flow" => { + sqlx::query_scalar!( + "SELECT path FROM flow + WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "app" => { + sqlx::query_scalar!( + "SELECT path FROM app + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "resource" => { + sqlx::query_scalar!( + "SELECT path FROM resource + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "variable" => { + sqlx::query_scalar!( + "SELECT path FROM variable + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "folder" => { + let a: Vec = paths_vec + .iter() + .map(|p| p.strip_prefix("f/").unwrap_or(p.as_str()).to_string()) + .collect(); + sqlx::query_scalar!( + "SELECT name FROM folder + WHERE workspace_id = $1 AND name = ANY($2)", + workspace_id, + &a, + ) + .fetch_all(&mut **tx) + .await? + .into_iter() + .map(|p| format!("f/{p}")) + .collect() + } + "resource_type" => { + sqlx::query_scalar!( + "SELECT name FROM resource_type + WHERE workspace_id = $1 AND name = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + _ => vec![], // Unknown kind + }; + + for path in results { + visible.insert((kind.to_string(), path)); + } + } + + Ok(visible) +} + +#[derive(Debug)] +struct ItemComparison { + has_changes: bool, + exists_in_source: bool, + exists_in_fork: bool, +} + +async fn compare_two_scripts( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get latest script from each workspace + let source_script = sqlx::query!( + "SELECT hash, created_at, content, summary, description, lock, schema + FROM script + WHERE workspace_id = $1 AND path = $2 AND archived = false + ORDER BY created_at DESC + LIMIT 1", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_script = sqlx::query!( + "SELECT hash, created_at, content, summary, description, lock, schema + FROM script + WHERE workspace_id = $1 AND path = $2 AND archived = false + ORDER BY created_at DESC + LIMIT 1", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_script, &target_script) { + if source.content != target.content + || source.summary != target.summary + || source.description != target.description + || source.lock != target.lock + || source.schema != target.schema + { + has_changes = true; + } + } else if source_script.is_some() || target_script.is_some() { + // The script exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_script.is_some(), + exists_in_fork: target_script.is_some(), + }); +} + +async fn compare_two_flows( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get latest flow from each workspace + let source_flow = sqlx::query!( + "SELECT value, summary, description, schema + FROM flow + WHERE workspace_id = $1 AND path = $2 AND archived = false", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_flow = sqlx::query!( + "SELECT value, summary, description, schema + FROM flow + WHERE workspace_id = $1 AND path = $2 AND archived = false", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_flow, &target_flow) { + if source.value != target.value + || source.summary != target.summary + || source.description != target.description + || source.schema != target.schema + { + has_changes = true; + } + } else if source_flow.is_some() || target_flow.is_some() { + // The flow exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_flow.is_some(), + exists_in_fork: target_flow.is_some(), + }); +} + +async fn compare_two_apps( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get app with its latest version data from source workspace + let source_app = sqlx::query!( + "SELECT app.summary, app.policy, app_version.value + FROM app + JOIN app_version + ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_app = sqlx::query!( + "SELECT app.summary, app.policy, app_version.value + FROM app + JOIN app_version + ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata and content differences + if let (Some(source), Some(target)) = (&source_app, &target_app) { + if source.summary != target.summary + || source.policy != target.policy + || source.value != target.value + { + has_changes = true; + } + } else if source_app.is_some() || target_app.is_some() { + // The app exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_app.is_some(), + exists_in_fork: target_app.is_some(), + }); +} + +async fn compare_two_resources( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get resource from each workspace + let source_resource = sqlx::query!( + "SELECT value, description, resource_type + FROM resource + WHERE workspace_id = $1 AND path = $2", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_resource = sqlx::query!( + "SELECT value, description, resource_type + FROM resource + WHERE workspace_id = $1 AND path = $2", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_resource, &target_resource) { + if source.value != target.value + || source.description != target.description + || source.resource_type != target.resource_type + { + has_changes = true; + } + } else if source_resource.is_some() || target_resource.is_some() { + // The resource exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_resource.is_some(), + exists_in_fork: target_resource.is_some(), + }); +} + +async fn compare_two_variables( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get variable from each workspace + let source_variable = sqlx::query!( + "SELECT value, is_secret, description + FROM variable + WHERE workspace_id = $1 AND path = $2", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_variable = sqlx::query!( + "SELECT value, is_secret, description + FROM variable + WHERE workspace_id = $1 AND path = $2", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_variable, &target_variable) { + if source.is_secret != target.is_secret + || source.value != target.value + || source.description != target.description + { + has_changes = true; + } + } else if source_variable.is_some() || target_variable.is_some() { + // The variable exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_variable.is_some(), + exists_in_fork: target_variable.is_some(), + }); +} + +async fn compare_two_resource_types( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + name: &str, +) -> Result { + // Get resource type from each workspace + let source_resource_type = sqlx::query!( + "SELECT schema, description, format_extension + FROM resource_type + WHERE workspace_id = $1 AND name = $2", + source_workspace_id, + name + ) + .fetch_optional(db) + .await?; + + let target_resource_type = sqlx::query!( + "SELECT schema, description, format_extension + FROM resource_type + WHERE workspace_id = $1 AND name = $2", + fork_workspace_id, + name + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_resource_type, &target_resource_type) { + if source.schema != target.schema + || source.description != target.description + || source.format_extension != target.format_extension + { + has_changes = true; + } + } else if source_resource_type.is_some() || target_resource_type.is_some() { + // The resource type exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_resource_type.is_some(), + exists_in_fork: target_resource_type.is_some(), + }); +} + +async fn compare_two_folders( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + name: &str, +) -> Result { + // Get folder from each workspace + let source_folder = sqlx::query!( + "SELECT display_name, owners, extra_perms, summary + FROM folder + WHERE workspace_id = $1 AND name = $2", + source_workspace_id, + name.strip_prefix("f/"), + ) + .fetch_optional(db) + .await?; + + let target_folder = sqlx::query!( + "SELECT display_name, owners, extra_perms, summary + FROM folder + WHERE workspace_id = $1 AND name = $2", + fork_workspace_id, + name.strip_prefix("f/"), + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_folder, &target_folder) { + if source.display_name != target.display_name + || source.owners != target.owners + || source.extra_perms != target.extra_perms + || source.summary != target.summary + { + has_changes = true; + } + } else if source_folder.is_some() || target_folder.is_some() { + // The folder exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_folder.is_some(), + exists_in_fork: target_folder.is_some(), + }); +} diff --git a/backend/windmill-api/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs similarity index 99% rename from backend/windmill-api/src/workspaces_extra.rs rename to backend/windmill-api-workspaces/src/workspaces_extra.rs index 79b9554ebf..b6b1cceae9 100644 --- a/backend/windmill-api/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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 diff --git a/backend/windmill-api/src/workspaces_oss.rs b/backend/windmill-api-workspaces/src/workspaces_oss.rs similarity index 71% rename from backend/windmill-api/src/workspaces_oss.rs rename to backend/windmill-api-workspaces/src/workspaces_oss.rs index 4f55539da1..da46622c26 100644 --- a/backend/windmill-api/src/workspaces_oss.rs +++ b/backend/windmill-api-workspaces/src/workspaces_oss.rs @@ -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( diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 08c6164d29..72f3580dbf 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -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 diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index 5473a93d0d..cc1c3e97c0 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -6,1185 +6,22 @@ * LICENSE-AGPL for a copy of the license. */ -use std::collections::HashMap; +// Re-export everything from windmill-api-scripts +pub use windmill_api_scripts::flows::*; -use crate::utils::WithStarredInfoQuery; -use crate::{ - auth::{list_tokens_internal, TruncatedTokenWithEmail}, - db::{ApiAuthed, DB}, - schedule::clear_schedule, - triggers::{get_triggers_count_internal, TriggersCount}, - users::{maybe_refresh_folders, require_owner_of_path}, - utils::check_scopes, - webhook_util::{WebhookMessage, WebhookShared}, - HTTP_CLIENT, -}; -use axum::response::IntoResponse; +use crate::triggers::{get_triggers_count_internal, TriggersCount}; use axum::{ - extract::{Extension, Path, Query}, - routing::{delete, get, post}, - Json, Router, + extract::{Extension, Path}, + routing::get, + Router, }; +use windmill_common::{error::JsonResult, utils::StripPath, DB}; -use hyper::StatusCode; -use serde::{Deserialize, Serialize}; -use sql_builder::prelude::*; -use sqlx::{FromRow, Postgres, Transaction}; -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; -use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; -use windmill_common::min_version::{ - MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, -}; -use windmill_common::runnable_settings::RunnableSettingsTrait; -use windmill_common::utils::query_elems_from_hub; -use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; -use windmill_common::HUB_BASE_URL; -use windmill_common::{ - db::UserDB, - error::{self, to_anyhow, Error, JsonResult, Result}, - flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, - jobs::JobPayload, - schedule::Schedule, - scripts::Schema, - utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, -}; -use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; -use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; -use windmill_queue::WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT; -use windmill_queue::{push, schedule::push_scheduled_job, PushIsolationLevel}; - +/// Wraps the subcrate's workspaced_service with the trigger count route +/// that depends on windmill-api internals. pub fn workspaced_service() -> Router { - Router::new() - .route("/list", get(list_flows)) - .route("/list_search", get(list_search_flows)) - .route("/create", post(create_flow)) - .route("/update/*path", post(update_flow)) - .route("/archive/*path", post(archive_flow_by_path)) - .route("/delete/*path", delete(delete_flow_by_path)) + windmill_api_scripts::flows::workspaced_service() .route("/get_triggers_count/*path", get(get_triggers_count)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/get/*path", get(get_flow_by_path)) - .route("/deployment_status/p/*path", get(get_deployment_status)) - .route("/get/draft/*path", get(get_flow_by_path_w_draft)) - .route("/exists/*path", get(exists_flow_by_path)) - .route("/list_paths", get(list_paths)) - .route("/history/p/*path", get(get_flow_history)) - .route("/get_latest_version/*path", get(get_latest_version)) - .route( - "/list_paths_from_workspace_runnable/:runnable_kind/*path", - get(list_paths_from_workspace_runnable), - ) - .route("/history_update/v/:version", post(update_flow_history)) - .route("/get/v/:version", get(get_flow_version_by_id)) - .route("/get/v/:version/p/*path", get(get_flow_version)) - .route( - "/toggle_workspace_error_handler/*path", - post(toggle_workspace_error_handler), - ) -} - -pub fn global_service() -> Router { - Router::new() - .route("/hub/list", get(list_hub_flows)) - .route("/hub/get/:id", get(get_hub_flow_by_id)) -} - -#[derive(Serialize, FromRow)] -pub struct SearchFlow { - path: String, - value: sqlx::types::Json>, -} -async fn list_search_flows( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> JsonResult> { - #[cfg(feature = "enterprise")] - let n = 1000; - - #[cfg(not(feature = "enterprise"))] - let n = 3; - let mut tx = user_db.begin(&authed).await?; - - let rows = sqlx::query_as::<_, SearchFlow>( - "SELECT flow.path, flow_version.value - FROM flow - LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1 LIMIT $2", - ) - .bind(&w_id) - .bind(n) - .fetch_all(&mut *tx) - .await? - .into_iter() - .collect::>(); - tx.commit().await?; - Ok(Json(rows)) -} - -async fn list_flows( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, - Query(pagination): Query, - Query(lq): Query, -) -> JsonResult> { - let (per_page, offset) = paginate(pagination); - - let mut sqlb = SqlBuilder::select_from("flow as o") - .fields(&[ - "o.workspace_id", - "o.path", - "summary", - if !lq.without_description.unwrap_or(false) { - "description" - } else { - "NULL as description" - }, - "fv.created_by as edited_by", - "fv.created_at as edited_at", - "archived", - "extra_perms", - "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", - "ws_error_handler_muted" - ]) - .left() - .join("favorite") - .on( - "favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" - .bind(&authed.username), - ) - .left() - .join("draft") - .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" - ) - .left() - .join("flow_version fv") - .on( - "fv.id = o.versions[array_upper(o.versions, 1)]" - ) - .order_desc("favorite.path IS NOT NULL") - .order_by("fv.created_at", lq.order_desc.unwrap_or(true)) - .and_where("o.workspace_id = ?".bind(&w_id)) - .offset(offset) - .limit(per_page) - .clone(); - - sqlb.and_where_eq("archived", lq.show_archived.unwrap_or(false)); - - if let Some(ps) = &lq.path_start { - sqlb.and_where_like_left("o.path", ps); - } - if let Some(p) = &lq.path_exact { - sqlb.and_where_eq("o.path", "?".bind(p)); - } - if let Some(cb) = &lq.edited_by { - sqlb.and_where_eq("fv.created_by", "?".bind(cb)); - } - if lq.starred_only.unwrap_or(false) { - sqlb.and_where_is_not_null("favorite.path"); - } - - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("o.draft_only IS NOT TRUE"); - } - if let Some(dw) = &lq.dedicated_worker { - sqlb.and_where_eq("dedicated_worker", dw); - } - - if lq.with_deployment_msg.unwrap_or(false) { - sqlb.join("deployment_metadata dm") - .left() - .on("dm.flow_version = o.versions[array_upper(o.versions, 1)]") - .fields(&["dm.deployment_msg"]); - } - - let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableFlow>(&sql) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -async fn list_hub_flows(Extension(db): Extension) -> impl IntoResponse { - let (status_code, headers, response) = query_elems_from_hub( - &HTTP_CLIENT, - &format!( - "{}/searchFlowData?approved=true", - *HUB_BASE_URL.read().await - ), - None, - &db, - ) - .await?; - Ok::<_, Error>((status_code, headers, response)) -} - -async fn list_paths( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - - let flows = sqlx::query_scalar!( - "SELECT distinct(path) FROM flow WHERE workspace_id = $1", - w_id - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - - Ok(Json(flows)) -} - -pub async fn get_hub_flow_by_id( - Path(id): Path, - Extension(db): Extension, -) -> JsonResult> { - let value = http_get_from_hub( - &HTTP_CLIENT, - &format!("{}/flows/{}/json", *HUB_BASE_URL.read().await, id), - false, - None, - Some(&db), - ) - .await? - .json() - .await - .map_err(to_anyhow)?; - Ok(Json(value)) -} - -#[derive(Deserialize)] -pub struct ToggleWorkspaceErrorHandler { - #[cfg(feature = "enterprise")] - pub muted: Option, -} - -#[cfg(not(feature = "enterprise"))] -async fn toggle_workspace_error_handler( - _authed: ApiAuthed, - Extension(_user_db): Extension, - Path((_w_id, _path)): Path<(String, StripPath)>, - Json(_req): Json, -) -> Result { - return Err(Error::BadRequest( - "Muting the error handler for certain flow is only available in enterprise version" - .to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn toggle_workspace_error_handler( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Json(req): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - let error_handler_maybe: Option = sqlx::query_scalar!( - r#" - SELECT - error_handler->>'path' - FROM - workspace_settings - WHERE - workspace_id = $1 - "#, - w_id - ) - .fetch_optional(&mut *tx) - .await? - .unwrap_or(None); - - let response = match error_handler_maybe { - Some(_) => { - sqlx::query_scalar!( - r#" - UPDATE - flow - SET - ws_error_handler_muted = $3 - WHERE - path = $1 AND - workspace_id = $2 - "#, - path.to_path(), - w_id, - req.muted, - ) - .execute(&mut *tx) - .await?; - Ok("".to_string()) - } - None => Err(Error::BadRequest( - "Workspace error handler needs to be defined".to_string(), - )), - }; - - tx.commit().await?; - - return response; -} - -async fn check_path_conflict<'c>( - tx: &mut Transaction<'c, Postgres>, - w_id: &str, - path: &str, -) -> Result<()> { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", - path, - w_id - ) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - if exists { - return Err(Error::BadRequest(format!("Flow {} already exists", path))); - } - return Ok(()); -} - -#[derive(Deserialize)] -struct ListPathsFromWorkspaceRunnableQuery { - match_path_start: Option, -} - -async fn list_paths_from_workspace_runnable( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, - Query(query): Query, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || { - format!("flows:read:{}", format!("{}/{}", runnable_kind, path)) - })?; - let mut tx = user_db.begin(&authed).await?; - - let runnables = if query.match_path_start.unwrap_or(false) { - sqlx::query_scalar!( - r#"SELECT DISTINCT f.path - FROM workspace_runnable_dependencies wru - JOIN flow f - ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id - WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, - path, - matches!(runnable_kind, RunnableKind::Flow), - w_id - ) - .fetch_all(&mut *tx) - .await? - } else { - sqlx::query_scalar!( - r#"SELECT f.path - FROM workspace_runnable_dependencies wru - JOIN flow f - ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id - WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, - path, - matches!(runnable_kind, RunnableKind::Flow), - w_id - ) - .fetch_all(&mut *tx) - .await? - }; - - tx.commit().await?; - Ok(Json(runnables)) -} - -async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { - #[cfg(not(feature = "enterprise"))] - if new_flow.ws_error_handler_muted.is_some_and(|val| val) { - return Err(Error::BadRequest( - "Muting the error handler for certain flow is only available in enterprise version" - .to_string(), - )); - } - - guard_flow_from_debounce_data(new_flow).await?; - - return Ok(()); -} - -async fn create_flow( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Extension(webhook): Extension, - Path(w_id): Path, - Json(nf): Json, -) -> Result<(StatusCode, String)> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create flows for security reasons".to_string(), - )); - } - check_scopes(&authed, || format!("flows:write:{}", nf.path))?; - validate_flow(&nf).await?; - if *CLOUD_HOSTED { - let nb_flows = - sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) - .fetch_one(&db) - .await?; - if nb_flows.unwrap_or(0) >= 1000 { - return Err(Error::BadRequest( - "You have reached the maximum number of flows (1000) on cloud. Contact support@windmill.dev to increase the limit" - .to_string(), - )); - } - if nf.summary.len() > 300 { - return Err(Error::BadRequest( - "Summary must be less than 300 characters on cloud".to_string(), - )); - } - if nf - .description - .as_ref() - .is_some_and(|desc| desc.len() > 3000) - { - return Err(Error::BadRequest( - "Description must be less than 3000 characters on cloud".to_string(), - )); - } - } - - // cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; - let authed = maybe_refresh_folders(&nf.path, &w_id, authed, &db).await; - - let mut tx = user_db.clone().begin(&authed).await?; - - check_path_conflict(&mut tx, &w_id, &nf.path).await?; - check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; - - let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); - sqlx::query!( - r#"INSERT INTO flow ( - workspace_id, path, summary, description, - dependency_job, lock_error_logs, draft_only, tag, - dedicated_worker, visible_to_runner_only, on_behalf_of_email, - ws_error_handler_muted, - value, schema, edited_by, edited_at - ) VALUES ( - $1, $2, $3, $4, - NULL, '', $5, $6, - $7, $8, $9, - $10, - $11, $12::text::json, $13, now() - )"#, - w_id, - nf.path, - nf.summary, - nf.description.as_deref().unwrap_or(""), - nf.draft_only, - nf.tag, - nf.dedicated_worker, - nf.visible_to_runner_only.unwrap_or(false), - nf.on_behalf_of_email.and(Some(&authed.email)), - nf.ws_error_handler_muted.unwrap_or(false), - sqlx::types::Json(&nf.value) as _, - schema_str, - &authed.username, - ) - .execute(&mut *tx) - .await?; - - let version = sqlx::query_scalar!( - "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) - VALUES ($1, $2, $3, $4::text::json, $5) - RETURNING id", - w_id, - nf.path, - sqlx::types::Json(nf.value) as _, - schema_str, - &authed.username, - ) - .fetch_one(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", - version, - nf.path, - w_id - ).execute(&mut *tx).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "flows.create", - ActionKind::Create, - &w_id, - Some(&nf.path.to_string()), - Some( - [Some(("flow", nf.path.as_str()))] - .into_iter() - .flatten() - .collect(), - ), - ) - .await?; - - let mut args: HashMap> = HashMap::new(); - if let Some(dm) = nf.deployment_message { - args.insert("deployment_message".to_string(), to_raw_value(&dm)); - } - - let tx = PushIsolationLevel::Transaction(tx); - let (dependency_job_uuid, mut new_tx) = push( - &db, - tx, - &w_id, - JobPayload::FlowDependencies { - path: nf.path.clone(), - dedicated_worker: nf.dedicated_worker, - version: version, - debouncing_settings: Default::default(), - }, - windmill_queue::PushArgs { args: &args, extra: None }, - &authed.username, - &authed.email, - windmill_common::users::username_to_permissioned_as(&authed.username), - authed.token_prefix.as_deref(), - None, - None, - None, - None, - None, - None, - false, - false, - None, - true, - None, - None, - None, - None, - Some(&authed.clone().into()), - false, - None, - None, - None, - ) - .await?; - - sqlx::query!( - "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", - dependency_job_uuid, - nf.path, - w_id - ) - .execute(&mut *new_tx) - .await?; - - // Store the job_id in deployment_metadata for this flow deployment - sqlx::query!( - "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL - DO UPDATE SET job_id = EXCLUDED.job_id", - w_id, - nf.path, - version, - dependency_job_uuid - ) - .execute(&mut *new_tx) - .await?; - - new_tx.commit().await?; - webhook.send_message( - w_id.clone(), - WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() }, - ); - - Ok((StatusCode::CREATED, nf.path.to_string())) -} - -async fn check_schedule_conflict<'c>( - tx: &mut Transaction<'c, Postgres>, - w_id: &str, - path: &str, -) -> error::Result<()> { - let exists_flow = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2 AND path != \ - script_path)", - path, - w_id - ) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - if exists_flow { - return Err(error::Error::BadConfig(format!( - "A flow cannot have the same path as a schedule if the schedule does not trigger that \ - same flow: {path}", - ))); - }; - Ok(()) -} - -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", - "flow", - ) - .await; -} - -#[derive(Serialize)] -pub struct FlowVersion { - pub id: i64, - pub created_at: chrono::DateTime, - #[serde(skip_serializing_if = "Option::is_none")] - pub deployment_msg: Option, -} - -async fn get_flow_history( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let flows = sqlx::query_as!( - FlowVersion, - "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version - LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version - WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 - ORDER BY flow_version.created_at DESC", - path, - w_id - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - - Ok(Json(flows)) -} - -async fn get_latest_version( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let version = sqlx::query_as!( - FlowVersion, - "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version - LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version - WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 - ORDER BY flow_version.created_at DESC", - path, - w_id - ) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - Ok(Json(version)) -} - -async fn get_flow_version( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, version, path)): Path<(String, i64, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by - FROM flow - LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id - WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", - ) - .bind(path) - .bind(w_id) - .bind(version) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow, "Flow version", version.to_string())?; - - Ok(Json(flow)) -} - -async fn get_flow_version_by_id( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, version)): Path<(String, i64)>, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - - // First, fetch the path to perform authorization check early - let path: Option = - sqlx::query_scalar("SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2") - .bind(version) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - let path = not_found_if_none( - path, - "Flow version", - format!("{} in workspace {}", version, w_id), - )?; - - // Perform authorization check before fetching full data - check_scopes(&authed, || format!("flows:read:{}", path))?; - - // Now fetch the full flow data with INNER JOIN to ensure flow exists - let flow = sqlx::query_as::<_, Flow>( - "SELECT - flow.workspace_id, - flow.path, - flow.summary, - flow.description, - flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, - flow.tag, - flow.ws_error_handler_muted, - flow.timeout, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow_version.schema, - flow_version.value, - flow_version.created_at as edited_at, - flow_version.created_by as edited_by - FROM flow - INNER JOIN flow_version - ON flow_version.path = flow.path - AND flow_version.workspace_id = flow.workspace_id - WHERE flow_version.id = $1 AND flow.workspace_id = $2", - ) - .bind(version) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let flow = not_found_if_none( - flow, - "Flow", - format!("for version {} (flow may have been deleted)", version), - )?; - - Ok(Json(flow)) -} - -#[derive(Deserialize)] -pub struct FlowHistoryUpdate { - pub deployment_msg: String, -} - -async fn update_flow_history( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, version)): Path<(String, i64)>, - Json(history_update): Json, -) -> Result<()> { - let mut tx = user_db.begin(&authed).await?; - - // Fetch path and perform authorization check early - let path: Option = - sqlx::query_scalar("SELECT path FROM flow_version WHERE workspace_id = $1 AND id = $2") - .bind(&w_id) - .bind(version) - .fetch_optional(&mut *tx) - .await?; - - let path = not_found_if_none( - path, - "Flow version", - format!("{} in workspace {}", version, w_id), - )?; - - // Perform authorization check before any modifications - check_scopes(&authed, || format!("flows:write:{}", path))?; - - // Insert or update deployment metadata - sqlx::query!( - "INSERT INTO deployment_metadata (workspace_id, path, flow_version, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - &w_id, - path, - version, - history_update.deployment_msg, - ) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - Ok(()) -} - -async fn update_flow( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(db): Extension, - Extension(webhook): Extension, - Path((w_id, flow_path)): Path<(String, StripPath)>, - Json(nf): Json, -) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot update flows for security reasons".to_string(), - )); - } - let flow_path = flow_path.to_path(); - check_scopes(&authed, || format!("flows:write:{}", flow_path))?; - validate_flow(&nf).await?; - - let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; - let mut tx = user_db.clone().begin(&authed).await?; - - check_schedule_conflict(&mut tx, &w_id, flow_path).await?; - - let schema = nf.schema.map(|x| x.0); - let old_dep_job = sqlx::query_scalar!( - "SELECT dependency_job FROM flow WHERE path = $1 AND workspace_id = $2", - flow_path, - w_id - ) - .fetch_optional(&mut *tx) - .await?; - - let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; - let is_new_path = nf.path != flow_path; - let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); - - sqlx::query!( - " - UPDATE - flow - SET - path = $1, - summary = $2, - description = $3, - dependency_job = NULL, - lock_error_logs = '', - draft_only = NULL, - tag = $4, - dedicated_worker = $5, - visible_to_runner_only = $6, - on_behalf_of_email = $7, - ws_error_handler_muted = $8, - value = $9, - schema = $10::text::json, - edited_by = $11, - edited_at = now() - WHERE - path = $12 AND workspace_id = $13", - if is_new_path { flow_path } else { &nf.path }, - nf.summary, - nf.description.as_deref().unwrap_or(""), - nf.tag, - nf.dedicated_worker, - nf.visible_to_runner_only.unwrap_or(false), - nf.on_behalf_of_email.and(Some(&authed.email)), - nf.ws_error_handler_muted.unwrap_or(false), - sqlx::types::Json(&nf.value) as _, - schema_str, - authed.username, - flow_path, - w_id, - ) - .execute(&mut *tx) - .await - .map_err(|e| { - error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) - })?; - - if is_new_path { - // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint - sqlx::query!( - "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at - FROM flow - WHERE path = $2 AND workspace_id = $3", - nf.path, - flow_path, - w_id - ) - .execute(&mut *tx) - .await - .map_err(|e| { - error::Error::internal_err(format!("Error updating flow due to create new flow: {e:#}")) - })?; - - sqlx::query!( - "UPDATE flow_version SET path = $1 WHERE path = $2 AND workspace_id = $3", - nf.path, - flow_path, - w_id - ) - .execute(&mut *tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating flow due to updating flow history path: {e:#}" - )) - })?; - - sqlx::query!( - "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", - flow_path, - w_id - ) - .execute(&mut *tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating flow due to deleting old flow: {e:#}" - )) - })?; - - sqlx::query!( - "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", - nf.path, - flow_path, - w_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", - nf.path, - flow_path, - w_id - ) - .execute(&mut *tx) - .await?; - } - - // tracing::error!("Updating flow: {:?}", nf.value.get()); - - // This will lock anyone who is trying to iterate on flow_versions with given path and parameters. - let version = sqlx::query_scalar!( - "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id", - w_id, - nf.path, - sqlx::types::Json(nf.value) as _, - schema_str, - &authed.username, - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating flow due to flow history insert: {e:#}" - )) - })?; - - // TODO: This should happen only after we are done with dependency job. - sqlx::query!( - "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", - version, nf.path, w_id - ).execute(&mut *tx).await?; - - if is_new_path { - check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; - - if !authed.is_admin { - require_owner_of_path(&authed, flow_path)?; - } - } - - let mut schedulables: Vec = sqlx::query_as::<_, Schedule>( - "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") - .bind(&nf.path) - .bind(&flow_path) - .bind(&w_id) - .fetch_all(&mut *tx) - .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedules update: {e:#}")))?; - - let schedule = sqlx::query_as::<_, Schedule>( - "UPDATE schedule SET path = $1, script_path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") - .bind(&nf.path) - .bind(&flow_path) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedule update: {e:#}")))?; - - if let Some(schedule) = schedule { - clear_schedule(&mut tx, &flow_path, &w_id).await?; - schedulables.push(schedule); - } - - for schedule in schedulables.into_iter() { - clear_schedule(&mut tx, &schedule.path, &w_id).await?; - - if schedule.enabled { - tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; - } - } - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - flow_path, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "flows.update", - ActionKind::Create, - &w_id, - Some(&nf.path.to_string()), - Some( - [Some(("flow", nf.path.as_str()))] - .into_iter() - .flatten() - .collect(), - ), - ) - .await?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::UpdateFlow { - workspace: w_id.clone(), - old_path: flow_path.to_owned(), - new_path: nf.path.clone(), - }, - ); - - let tx = PushIsolationLevel::Transaction(tx); - - let mut args: HashMap> = HashMap::new(); - if let Some(dm) = nf.deployment_message { - args.insert("deployment_message".to_string(), to_raw_value(&dm)); - } - args.insert("parent_path".to_string(), to_raw_value(&flow_path)); - - let (dependency_job_uuid, mut new_tx) = push( - &db, - tx, - &w_id, - JobPayload::FlowDependencies { - path: nf.path.clone(), - dedicated_worker: nf.dedicated_worker, - version, - debouncing_settings: Default::default(), - }, - windmill_queue::PushArgs { args: &args, extra: None }, - &authed.username, - &authed.email, - windmill_common::users::username_to_permissioned_as(&authed.username), - authed.token_prefix.as_deref(), - None, - None, - None, - None, - None, - None, - false, - false, - None, - true, - None, - None, - None, - None, - Some(&authed.clone().into()), - false, - None, - None, - None, - ) - .await?; - - sqlx::query!( - "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", - dependency_job_uuid, - nf.path, - w_id - ) - .execute(&mut *new_tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating flow due to updating dependency job field: {e:#}" - )) - })?; - - // Store the job_id in deployment_metadata for this flow deployment - sqlx::query!( - "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL - DO UPDATE SET job_id = EXCLUDED.job_id", - w_id, - nf.path, - version, - dependency_job_uuid - ) - .execute(&mut *new_tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating deployment_metadata with job_id: {e:#}" - )) - })?; - - if let Some(old_dep_job) = old_dep_job { - sqlx::query!( - "UPDATE v2_job_queue SET - canceled_by = $2, - canceled_reason = 're-deployment' - WHERE id = $1", - old_dep_job, - &authed.username - ) - .execute(&mut *new_tx) - .await - .map_err(|e| { - error::Error::internal_err(format!( - "Error updating flow due to cancelling dependency job: {e:#}" - )) - })?; - } - - new_tx.commit().await?; - - Ok(nf.path.to_string()) } async fn get_triggers_count( @@ -1194,762 +31,3 @@ async fn get_triggers_count( let path = path.to_path(); get_triggers_count_internal(&db, &w_id, &path, true).await } - -async fn list_tokens( - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - list_tokens_internal(&db, &w_id, &path, true).await -} - -#[derive(Serialize)] -struct DeploymentStatus { - lock_error_logs: Option, - job_id: Option, -} -async fn get_deployment_status( - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - let mut tx = db.begin().await?; - let status_o = sqlx::query!( - "SELECT f.lock_error_logs, dm.job_id - FROM flow f - LEFT JOIN deployment_metadata dm ON f.versions[array_upper(f.versions, 1)] = dm.flow_version - AND f.workspace_id = dm.workspace_id AND f.path = dm.path - WHERE f.path = $1 AND f.workspace_id = $2", - path, - w_id, - ) - .fetch_optional(&mut *tx) - .await?; - - let status = not_found_if_none(status_o, "DeploymentStatus", path)?; - - let deployment_status = - DeploymentStatus { lock_error_logs: status.lock_error_logs, job_id: status.job_id }; - - tx.commit().await?; - Ok(Json(deployment_status)) -} - -async fn get_flow_by_path( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = if query.with_starred_info.unwrap_or(false) { - sqlx::query_as::<_, FlowWithStarred>( - r#" - SELECT - flow.workspace_id, - flow.path, - flow.lock_error_logs, - flow.summary, - flow.description, - flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, - flow.tag, - flow.ws_error_handler_muted, - flow.timeout, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow_version.id AS version_id, - flow_version.schema, - flow_version.value, - flow_version.created_at AS edited_at, - flow_version.created_by AS edited_by, - favorite.path IS NOT NULL AS starred - FROM flow - LEFT JOIN favorite - ON favorite.favorite_kind = 'flow' - AND favorite.workspace_id = flow.workspace_id - AND favorite.path = flow.path - AND favorite.usr = $3 - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 AND flow.workspace_id = $2 - "#, - ) - .bind(path) - .bind(w_id) - .bind(&authed.username) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_as::<_, FlowWithStarred>( - r#" - SELECT - flow.workspace_id, - flow.path, - flow.lock_error_logs, - flow.summary, - flow.description, - flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, - flow.tag, - flow.ws_error_handler_muted, - flow.timeout, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow_version.id AS version_id, - flow_version.schema, - flow_version.value, - flow_version.created_at AS edited_at, - flow_version.created_by AS edited_by, - NULL AS starred - FROM flow - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 AND flow.workspace_id = $2 - "#, - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await? - }; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct FlowWDraft { - pub path: String, - pub summary: String, - pub description: String, - pub schema: Option, - pub value: sqlx::types::Json>, - pub extra_perms: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, -} - -async fn get_flow_by_path_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = sqlx::query_as::<_, FlowWDraft>( - "SELECT - flow.path, - flow.summary, - flow.description, - flow_version.schema, - flow_version.value, - flow.extra_perms, - flow.draft_only, - flow.ws_error_handler_muted, - flow.dedicated_worker, - draft.value AS draft, - flow.tag, - flow.visible_to_runner_only, - flow.on_behalf_of_email - FROM flow - LEFT JOIN draft - ON flow.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'flow' - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 - AND flow.workspace_id = $2", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -async fn exists_flow_by_path( - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - Ok(Json(exists)) -} - -#[derive(Deserialize)] -struct Archived { - archived: Option, -} - -async fn archive_flow_by_path( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Extension(webhook): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Json(archived): Json, -) -> Result { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:write:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "UPDATE flow SET archived = $1 WHERE path = $2 AND workspace_id = $3", - archived.archived.unwrap_or(true), - path, - &w_id - ) - .execute(&mut *tx) - .await?; - - clear_static_asset_usage(&mut *tx, &w_id, path, AssetUsageKind::Flow).await?; - - audit_log( - &mut *tx, - &authed, - "flows.archive", - ActionKind::Delete, - &w_id, - Some(path), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - - ScopedDependencyMap::clear_map_for_item(path, &w_id, "flow", tx, &None) - .await - .commit() - .await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Flow { - path: path.to_string(), - parent_path: Some(path.to_string()), - version: 0, // dummy version as it will not get inserted in db - }, - Some(format!( - "Flow '{}' {}", - path, - if archived.archived.unwrap_or(true) { - "archived" - } else { - "unarchived" - } - )), - true, - None, - ) - .await?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::ArchiveFlow { workspace: w_id, path: path.to_owned() }, - ); - - Ok(format!("Flow {path} archived")) -} - -/// Validates that flow debouncing configuration is supported by all workers -/// Returns an error if debouncing is configured but workers are behind required version -async fn guard_flow_from_debounce_data(nf: &NewFlow) -> Result<()> { - if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await - && !nf.parse_flow_value()?.debouncing_settings.is_default() - { - tracing::warn!( - "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" - ); - Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into() }) - } else if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await - && !nf - .parse_flow_value()? - .debouncing_settings - .is_legacy_compatible() - && !*WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT - { - tracing::warn!( - "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" - ); - Err(Error::WorkersAreBehind { - feature: "V2 Debouncing".into(), - min_version: "1.597.0".into(), - }) - } else { - Ok(()) - } -} - -#[derive(Deserialize)] -struct DeleteFlowQuery { - keep_captures: Option, -} - -async fn delete_flow_by_path( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Extension(webhook): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> Result { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:write:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - path, - &w_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", - path, - &w_id - ) - .execute(&mut *tx) - .await?; - - if !query.keep_captures.unwrap_or(false) { - sqlx::query!( - "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", - path, - &w_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", - path, - &w_id - ) - .execute(&mut *tx) - .await?; - } - - audit_log( - &mut *tx, - &authed, - "flows.delete", - ActionKind::Delete, - &w_id, - Some(path), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Flow { - path: path.to_string(), - parent_path: Some(path.to_string()), - version: 0, // dummy version as it will not get inserted in db - }, - Some(format!("Flow '{}' deleted", path)), - true, - None, - ) - .await?; - - sqlx::query!( - "DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND script_hash IS NULL and app_version IS NULL", - path, - w_id - ) - .execute(&db) - .await - .map_err(|e| { - Error::internal_err(format!( - "error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}" - )) - })?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteFlow { workspace: w_id, path: path.to_owned() }, - ); - - Ok(format!("Flow {path} deleted")) -} - -#[cfg(test)] -mod tests { - - use std::{collections::HashMap, time::Duration}; - - use windmill_common::{ - flows::{ - ConstantDelay, ExponentialDelay, FlowModule, FlowModuleValue, FlowValue, - InputTransform, Retry, StopAfterIf, - }, - runnable_settings::{ - ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, - }, - scripts, - }; - - const SECOND: Duration = Duration::from_secs(1); - - #[test] - fn flowmodule_serde() { - let fv = FlowValue { - modules: vec![ - FlowModule { - id: "a".to_string(), - value: windmill_common::worker::to_raw_value(&FlowModuleValue::Script { - path: "test".to_string(), - input_transforms: [( - "test".to_string(), - InputTransform::Static { - value: windmill_common::worker::to_raw_value(&"test2".to_string()), - }, - )] - .into(), - hash: None, - tag_override: None, - is_trigger: None, - pass_flow_input_directly: None, - }), - stop_after_if: None, - stop_after_all_iters_if: None, - summary: None, - suspend: Default::default(), - retry: None, - sleep: None, - cache_ttl: None, - cache_ignore_s3_path: None, - mock: None, - timeout: None, - priority: None, - delete_after_use: None, - continue_on_error: None, - skip_if: None, - apply_preprocessor: None, - pass_flow_input_directly: None, - }, - FlowModule { - id: "b".to_string(), - value: windmill_common::worker::to_raw_value(&FlowModuleValue::RawScript { - input_transforms: HashMap::new(), - content: "test".to_string(), - language: scripts::ScriptLang::Deno, - path: None, - lock: None, - tag: None, - is_trigger: None, - assets: None, - concurrency_settings: ConcurrencySettingsWithCustom::default(), - }), - stop_after_if: Some(StopAfterIf { - expr: "foo = 'bar'".to_string(), - ..Default::default() - }), - stop_after_all_iters_if: None, - summary: None, - suspend: Default::default(), - retry: None, - sleep: None, - cache_ttl: None, - cache_ignore_s3_path: None, - mock: None, - timeout: None, - priority: None, - delete_after_use: None, - continue_on_error: None, - skip_if: None, - apply_preprocessor: None, - pass_flow_input_directly: None, - }, - FlowModule { - id: "c".to_string(), - value: windmill_common::worker::to_raw_value(&FlowModuleValue::ForloopFlow { - iterator: InputTransform::Static { - value: windmill_common::worker::to_raw_value(&[1, 2, 3]), - }, - modules: vec![], - modules_node: None, - skip_failures: true, - parallel: false, - parallelism: None, - squash: None, - }), - stop_after_if: Some(StopAfterIf { - expr: "previous.isEmpty()".to_string(), - ..Default::default() - }), - stop_after_all_iters_if: None, - summary: None, - suspend: Default::default(), - retry: None, - sleep: None, - cache_ttl: None, - cache_ignore_s3_path: None, - mock: None, - timeout: None, - priority: None, - delete_after_use: None, - continue_on_error: None, - skip_if: None, - apply_preprocessor: None, - pass_flow_input_directly: None, - }, - ], - failure_module: Some(Box::new(FlowModule { - id: "d".to_string(), - value: FlowModuleValue::Script { - path: "test".to_string(), - input_transforms: HashMap::new(), - hash: None, - tag_override: None, - is_trigger: None, - pass_flow_input_directly: None, - } - .into(), - stop_after_if: Some(StopAfterIf { - expr: "previous.isEmpty()".to_string(), - ..Default::default() - }), - stop_after_all_iters_if: None, - summary: None, - suspend: Default::default(), - retry: None, - sleep: None, - cache_ttl: None, - cache_ignore_s3_path: None, - mock: None, - timeout: None, - priority: None, - delete_after_use: None, - continue_on_error: None, - skip_if: None, - apply_preprocessor: None, - pass_flow_input_directly: None, - })), - preprocessor_module: None, - same_worker: false, - skip_expr: None, - cache_ttl: None, - cache_ignore_s3_path: None, - priority: None, - early_return: None, - chat_input_enabled: None, - flow_env: None, - concurrency_settings: ConcurrencySettings::default(), - debouncing_settings: DebouncingSettings::default(), - }; - let expect = serde_json::json!({ - "modules": [ - { - "id": "a", - "value": { - "input_transforms": { - "test": { - "type": "static", - "value": "test2" - } - }, - "type": "script", - "path": "test", - }, - }, - { - "id": "b", - "value": { - "input_transforms": {}, - "type": "rawscript", - "content": "test", - "language": "deno" - }, - "stop_after_if": { - "expr": "foo = 'bar'", - "skip_if_stopped": false, - "error_message": null - } - }, - { - "id": "c", - "value": { - "type": "forloopflow", - "iterator": { - "type": "static", - "value": [ - 1, - 2, - 3 - ] - }, - "parallel": false, - "skip_failures": true, - "modules": [] - }, - "stop_after_if": { - "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null - } - } - ], - "failure_module": { - "id": "d", - "value": { - "input_transforms": {}, - "type": "script", - "path": "test", - }, - "stop_after_if": { - "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null - } - }, - }); - assert_eq!(dbg!(serde_json::json!(fv)), dbg!(expect)); - } - - #[test] - fn retry_serde() { - assert_eq!(Retry::default(), serde_json::from_str(r#"{}"#).unwrap()); - - assert_eq!( - Retry::default(), - serde_json::from_str( - r#" - { - "constant": { - "seconds": 0 - }, - "exponential": { - "multiplier": 1, - "seconds": 0 - }, - "retry_if": null - } - "# - ) - .unwrap() - ); - - assert_eq!( - Retry { - constant: Default::default(), - exponential: ExponentialDelay { - attempts: 0, - multiplier: 1, - seconds: 123, - random_factor: None - }, - retry_if: None - }, - serde_json::from_str( - r#" - { - "constant": {}, - "exponential": { "seconds": 123 }, - "retry_if" : null - } - "# - ) - .unwrap() - ); - } - - #[test] - fn retry_exponential() { - let retry = Retry { - constant: ConstantDelay::default(), - exponential: ExponentialDelay { - attempts: 3, - multiplier: 4, - seconds: 3, - random_factor: None, - }, - retry_if: None, - }; - assert_eq!( - vec![ - Some(12 * SECOND), - Some(36 * SECOND), - Some(108 * SECOND), - None - ], - (0..4) - .map(|previous_attempts| retry.interval(previous_attempts, false)) - .collect::>() - ); - - assert_eq!(Some(108 * SECOND), retry.max_interval()); - } - - #[test] - fn retry_both() { - let retry = Retry { - constant: ConstantDelay { attempts: 2, seconds: 4 }, - exponential: ExponentialDelay { - attempts: 2, - multiplier: 1, - seconds: 3, - random_factor: None, - }, - retry_if: None, - }; - assert_eq!( - vec![ - Some(4 * SECOND), - Some(4 * SECOND), - Some(27 * SECOND), - Some(81 * SECOND), - None, - ], - (0..5) - .map(|previous_attempts| retry.interval(previous_attempts, false)) - .collect::>() - ); - - assert_eq!(Some(81 * SECOND), retry.max_interval()); - } -} diff --git a/backend/windmill-api/src/folder_history.rs b/backend/windmill-api/src/folder_history.rs index 581b568eb1..d5cf1ab3c8 100644 --- a/backend/windmill-api/src/folder_history.rs +++ b/backend/windmill-api/src/folder_history.rs @@ -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, - pub change_type: String, - pub affected: Option, -} - -async fn get_folder_permission_history( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, - Query(pagination): Query, -) -> JsonResult> { - // 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::*; diff --git a/backend/windmill-api/src/folders.rs b/backend/windmill-api/src/folders.rs index 80bb2540ca..ca8504a824 100644 --- a/backend/windmill-api/src/folders.rs +++ b/backend/windmill-api/src/folders.rs @@ -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, - pub extra_perms: serde_json::Value, - pub summary: Option, - pub created_by: Option, - pub edited_at: Option>, -} - -#[derive(Deserialize)] -pub struct NewFolder { - pub name: String, - pub summary: Option, - pub display_name: Option, - pub owners: Option>, - pub extra_perms: Option, -} - -#[derive(Deserialize)] -pub struct UpdateFolder { - pub summary: Option, - pub display_name: Option, - pub owners: Option>, - pub extra_perms: Option, -} - -#[derive(Deserialize)] -pub struct Owner { - pub owner: String, - pub write: Option, -} - -async fn list_folders( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, - Query(pagination): Query, -) -> JsonResult> { - 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, - Path(w_id): Path, - Query(pagination): Query, -) -> JsonResult> { - 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, - Extension(user_db): Extension, - Extension(webhook): Extension, - Extension(cache): Extension>, - Path(w_id): Path, - Json(ng): Json, -) -> Result { - 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 { - 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, - Extension(user_db): Extension, - Extension(webhook): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(mut ng): Json, -) -> Result { - 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::>() - .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> { - 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, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - 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, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - 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, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - 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, - Extension(user_db): Extension, - Extension(webhook): Extension, - Path((w_id, name)): Path<(String, String)>, -) -> Result { - 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, - Extension(webhook): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(Owner { owner, .. }): Json, -) -> Result { - 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, - Extension(webhook): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(Owner { owner, write }): Json, -) -> Result { - 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>(&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::*; diff --git a/backend/windmill-api/src/granular_acls.rs b/backend/windmill-api/src/granular_acls.rs index 8a0354906a..98f373b8fa 100644 --- a/backend/windmill-api/src/granular_acls.rs +++ b/backend/windmill-api/src/granular_acls.rs @@ -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, -} - -async fn add_granular_acl( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Json(GranularAcl { owner, write }): Json, -) -> Result { - 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, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Json(GranularAcl { owner, .. }): Json, -) -> Result { - 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, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - 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::*; diff --git a/backend/windmill-api/src/groups.rs b/backend/windmill-api/src/groups.rs index d69f45d88f..84f735dc77 100644 --- a/backend/windmill-api/src/groups.rs +++ b/backend/windmill-api/src/groups.rs @@ -1,1092 +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 crate::{db::DB, utils::require_super_admin}; - -use axum::{ - extract::{Extension, Path, Query}, - routing::{delete, get, post}, - Json, Router, -}; -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; -use windmill_common::worker::CLOUD_HOSTED; -use windmill_common::{ - auth::get_groups_for_user, - error::{Error, JsonResult, Result}, - utils::{not_found_if_none, paginate, Pagination}, -}; -use windmill_common::{db::UserDB, users::username_to_permissioned_as}; - -use serde::{Deserialize, Serialize}; -use sqlx::{query_scalar, FromRow, Postgres, Transaction}; -use windmill_git_sync::handle_deployment_metadata; - -pub fn workspaced_service() -> Router { - Router::new() - .route("/list", get(list_groups)) - .route("/listnames", get(list_group_names)) - .route("/create", post(create_group)) - .route("/get/:name", get(get_group)) - .route("/update/:name", post(update_group)) - .route("/delete/:name", delete(delete_group)) - .route("/adduser/:name", post(add_user)) - .route("/removeuser/:name", post(remove_user)) - .route("/is_owner", get(is_owner)) -} - -pub fn global_service() -> Router { - Router::new() - .route("/list", get(list_igroups)) - .route("/list_with_workspaces", get(list_igroups_with_workspaces)) - .route("/get/:name", get(get_igroup)) - .route("/create", post(create_igroup)) - .route("/update/:name", post(update_igroup)) - .route("/delete/:name", delete(delete_igroup)) - .route("/adduser/:name", post(add_user_igroup)) - .route("/removeuser/:name", post(remove_user_igroup)) - .route("/export", get(export_igroups)) - .route("/overwrite", post(overwrite_igroups)) -} - -/// Normalize group names: replace spaces with underscores and convert to lowercase -/// Used when manually creating groups and SCIM-managed groups -pub fn convert_name(name: &str) -> String { - name.replace(" ", "_").to_lowercase() -} - -#[derive(FromRow, Serialize, Deserialize)] -pub struct Group { - pub workspace_id: String, - pub name: String, - pub summary: Option, - pub extra_perms: serde_json::Value, -} - -#[derive(Deserialize)] -pub struct NewGroup { - pub name: String, - pub summary: Option, -} - -#[derive(Serialize)] -pub struct GroupInfo { - pub workspace_id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - pub members: Vec, - pub extra_perms: serde_json::Value, -} - -#[derive(Deserialize)] -pub struct EditGroup { - pub summary: Option, -} - -#[derive(Deserialize)] -pub struct Username { - pub username: String, -} - -#[derive(Deserialize)] -pub struct Email { - pub email: String, -} - -async fn list_groups( - Extension(db): Extension, - Path(w_id): Path, - Query(pagination): Query, -) -> JsonResult> { - let (per_page, offset) = paginate(pagination); - - let rows = sqlx::query_as!( - Group, - "SELECT * FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", - w_id, - per_page as i64, - offset as i64 - ) - .fetch_all(&db) - .await?; - - Ok(Json(rows)) -} - -#[derive(Deserialize)] -struct QueryListGroup { - pub only_member_of: Option, -} -async fn list_group_names( - ApiAuthed { username, email, .. }: ApiAuthed, - Extension(db): Extension, - Query(QueryListGroup { only_member_of }): Query, - Path(w_id): Path, -) -> JsonResult> { - let rows = if !only_member_of.unwrap_or(false) { - sqlx::query_scalar!( - "SELECT name FROM group_ WHERE workspace_id = $1 UNION SELECT name FROM instance_group ORDER BY name asc", - w_id - ) - .fetch_all(&db) - .await? - .into_iter() - .filter_map(|x| x) - .collect() - } else { - get_groups_for_user(&w_id, &username, &email, &db).await? - }; - - Ok(Json(rows)) -} - -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 group_ 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!( - "Group {} already exists", - name - ))); - } - return Ok(()); -} - -pub async fn is_owner( - ApiAuthed { username, is_admin, groups, .. }: ApiAuthed, - Extension(db): Extension, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - if is_admin { - Ok(Json(true)) - } else { - Ok(Json( - require_is_owner(&name, &username, &groups, &w_id, &db) - .await - .is_ok(), - )) - } -} - -pub async fn require_is_owner( - group_name: &str, - username: &str, - groups: &Vec, - w_id: &str, - db: &DB, -) -> Result<()> { - let is_owner = query_scalar!( - "SELECT EXISTS(SELECT 1 FROM group_ WHERE (group_.extra_perms ->> CONCAT('u/', $1::text))::boolean AND name = $2 AND workspace_id = $4) OR exists( - SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f - WHERE $2 = g.name AND $4 = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY($3::text[]) - AND value::boolean)", - username, - group_name, - groups, - w_id, - ).fetch_one(db) - .await? - .unwrap_or(false); - if !is_owner { - Err(Error::BadRequest(format!( - "{} is not an owner of {} and hence is not authorized to perform this operation", - username, group_name - ))) - } else { - Ok(()) - } -} - -async fn _check_nb_of_groups(db: &DB) -> Result<()> { - let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack'",) - .fetch_one(db) - .await?; - if nb_groups.unwrap_or(0) >= 3 { - return Err(Error::BadRequest( - "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack' and 'error_handler') without an enterprise license" - .to_string(), - )); - } - return Ok(()); -} - -async fn create_group( - authed: ApiAuthed, - Extension(_db): Extension, - Extension(user_db): Extension, - Path(w_id): Path, - Json(ng): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - check_name_conflict(&mut tx, &w_id, &ng.name).await?; - - #[cfg(not(feature = "enterprise"))] - _check_nb_of_groups(&_db).await?; - - sqlx::query!( - "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4)", - w_id, - ng.name, - ng.summary, - serde_json::json!({username_to_permissioned_as(&authed.username): true}) - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - &w_id, - &authed.username, - ng.name, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "group.create", - ActionKind::Create, - &w_id, - Some(&ng.name.to_string()), - None, - ) - .await?; - - log_group_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, - windmill_git_sync::DeployedObject::Group { name: ng.name.clone() }, - Some(format!("Created group '{}'", &ng.name)), - true, - None, - ) - .await?; - - Ok(format!("Created group {}", ng.name)) -} - -async fn create_igroup( - authed: ApiAuthed, - Extension(db): Extension, - Json(ng): Json, -) -> Result { - use uuid::Uuid; - - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - let normalized_name = convert_name(&ng.name); - - let id = Uuid::new_v4().to_string(); - sqlx::query!( - "INSERT INTO instance_group (name, summary, id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - normalized_name, - ng.summary, - id, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "igroup.create", - ActionKind::Create, - "global", - Some(&normalized_name), - None, - ) - .await?; - - tx.commit().await?; - Ok(format!("Created group {}", normalized_name)) -} - -#[derive(Deserialize)] -struct IGroupUpdate { - new_summary: String, -} - -async fn update_igroup( - authed: ApiAuthed, - Extension(db): Extension, - Path(name): Path, - Json(igroup_update): Json, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1") - .bind(name.clone()) - .fetch_optional(&mut *tx) - .await?; - not_found_if_none(exists_opt, "instance_group", name.clone())?; - - sqlx::query("UPDATE instance_group SET summary = $1 WHERE name = $2") - .bind(igroup_update.new_summary) - .bind(&name) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "igroup.updated", - ActionKind::Delete, - "global", - Some(&name.to_string()), - None, - ) - .await?; - - tx.commit().await?; - Ok(format!("Deleted group {}", name)) -} - -async fn delete_igroup( - authed: ApiAuthed, - Extension(db): Extension, - Path(name): Path, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) - .execute(&mut *tx) - .await?; - - sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "igroup.delete", - ActionKind::Delete, - "global", - Some(&name.to_string()), - None, - ) - .await?; - - tx.commit().await?; - Ok(format!("Deleted group {}", name)) -} - -pub async fn get_group_opt<'c>( - db: &mut Transaction<'c, Postgres>, - w_id: &str, - name: &str, -) -> Result> { - let group_opt = sqlx::query_as!( - Group, - "SELECT * FROM group_ WHERE name = $1 AND workspace_id = $2", - name, - w_id - ) - .fetch_optional(&mut **db) - .await?; - Ok(group_opt) -} - -async fn get_group( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - if *CLOUD_HOSTED && w_id == "demo" && name == "all" && !authed.is_admin { - return Ok(Json(GroupInfo { - workspace_id: w_id, - name: name, - summary: Some("The group that contains all users".to_string()), - members: vec!["redacted_in_demo_workspace".to_string()], - extra_perms: serde_json::json!({}), - })); - } - - let mut tx = user_db.begin(&authed).await?; - - let group = not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - - let members = sqlx::query_scalar!( - "SELECT usr.username - FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username AND usr_to_group.workspace_id = $2 - WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2", - name, - w_id - ) - .fetch_all(&mut *tx) - .await?; - - tx.commit().await?; - Ok(Json(GroupInfo { - workspace_id: group.workspace_id, - name: group.name, - summary: group.summary, - members, - extra_perms: group.extra_perms, - })) -} - -async fn delete_group( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - if name == "all" { - return Err(Error::BadRequest( - "The group 'all' is a special group that contains all users and cannot be deleted" - .to_string(), - )); - } - - if !authed.is_admin { - require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; - } - not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - - sqlx::query!( - "DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2", - name, - w_id - ) - .execute(&mut *tx) - .await?; - sqlx::query!( - "DELETE FROM group_ WHERE name = $1 AND workspace_id = $2", - name, - w_id - ) - .execute(&mut *tx) - .await?; - audit_log( - &mut *tx, - &authed, - "group.delete", - ActionKind::Delete, - &w_id, - Some(&name.to_string()), - None, - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Group { name: name.clone() }, - Some(format!("Deleted group '{}'", &name)), - true, - None, - ) - .await?; - - Ok(format!("delete group at name {}", name)) -} - -async fn update_group( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(eg): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - if !authed.is_admin { - require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; - } - not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - - sqlx::query!( - "UPDATE group_ SET summary = $1 WHERE name = $2 AND workspace_id = $3", - eg.summary, - &name, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "group.edit", - ActionKind::Update, - &w_id, - Some(&name.to_string()), - None, - ) - .await?; - - log_group_permission_change( - &mut *tx, - &w_id, - &name, - &authed.username, - "update_summary", - None, - ) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Group { name: name.clone() }, - Some(format!("Updated group '{}'", &name)), - true, - None, - ) - .await?; - - Ok(format!("Edited group {}", name)) -} - -async fn add_user( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(Username { username: user_username }): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - if !authed.is_admin { - require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; - } - - not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - - let result = sqlx::query!( - "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - &w_id, - user_username, - name, - ) - .execute(&mut *tx) - .await?; - - if result.rows_affected() == 0 { - return Ok(format!( - "{} is already a member of group {}", - user_username, name - )); - } - - audit_log( - &mut *tx, - &authed, - "group.adduser", - ActionKind::Update, - &w_id, - Some(&name.to_string()), - Some([("user", user_username.as_str())].into()), - ) - .await?; - - log_group_permission_change( - &mut *tx, - &w_id, - &name, - &authed.username, - "add_member", - Some(&user_username), - ) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Group { name: name.clone() }, - Some(format!("Added user to group '{}'", &name)), - true, - None, - ) - .await?; - - Ok(format!("Added {} to group {}", user_username, name)) -} - -async fn add_user_igroup( - authed: ApiAuthed, - Extension(db): Extension, - Path(name): Path, - Json(Email { email }): Json, -) -> Result { - require_super_admin(&db, &authed.email).await?; - - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name) - .fetch_optional(&mut *tx) - .await?; - - not_found_if_none(group_opt, "IGroup", &name)?; - - sqlx::query!( - "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2) ON CONFLICT DO NOTHING", - email, - name, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "igroup.adduser", - ActionKind::Update, - "global", - Some(&name.to_string()), - Some([("email", email.as_str())].into()), - ) - .await?; - - // Sync user to workspaces configured with this instance group - #[cfg(all(feature = "private", feature = "enterprise"))] - { - use crate::workspaces_ee::auto_add_user; - let workspaces = sqlx::query!( - r#" - SELECT workspace_id, auto_invite->'instance_groups_roles' as instance_groups_roles - FROM workspace_settings - WHERE auto_invite->'instance_groups' ? $1 - "#, - &name - ) - .fetch_all(&mut *tx) - .await?; - for ws in workspaces { - let role = ws - .instance_groups_roles - .and_then(|r| r.get(&name).and_then(|v| v.as_str().map(String::from))) - .unwrap_or_else(|| "developer".to_string()); - let (is_admin, is_operator) = match role.as_str() { - "admin" => (true, false), - "operator" => (false, true), - _ => (false, false), - }; - auto_add_user( - &email, - &ws.workspace_id, - &is_operator, - &mut tx, - &authed, - Some(serde_json::json!({"source": "instance_group", "group": &name})), - ) - .await?; - if is_admin { - sqlx::query!( - "UPDATE usr SET is_admin = true WHERE workspace_id = $1 AND email = $2", - &ws.workspace_id, - &email - ) - .execute(&mut *tx) - .await?; - } - } - } - - tx.commit().await?; - Ok(format!("Added {} to igroup {}", email, name)) -} - -#[derive(Serialize)] -struct IGroup { - name: String, - summary: Option, - emails: Option>, -} - -#[derive(Serialize)] -struct IGroupWithWorkspaces { - name: String, - summary: Option, - emails: Option>, - workspaces: Vec, -} - -#[derive(Serialize, Clone)] -struct WorkspaceInfo { - workspace_id: String, - workspace_name: String, - role: String, -} -async fn list_igroups(Extension(db): Extension) -> JsonResult> { - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - let groups = sqlx::query_as!( - IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" - ) - .fetch_all(&mut *tx) - .await?; - - tx.commit().await?; - return Ok(Json(groups)); -} - -async fn list_igroups_with_workspaces( - Extension(db): Extension, -) -> JsonResult> { - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - // Get all instance groups with their emails first - let groups = sqlx::query_as!( - IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary" - ) - .fetch_all(&mut *tx) - .await?; - - // Get all workspace mappings for instance groups in a single query - let workspace_mappings = sqlx::query!( - r#" - SELECT - ig.name as group_name, - ws.workspace_id, - w.name as workspace_name, - ws.auto_invite->'instance_groups_roles'->ig.name as role - FROM instance_group ig - INNER JOIN workspace_settings ws ON ws.auto_invite->'instance_groups' IS NOT NULL - AND ws.auto_invite->'instance_groups' ? ig.name - INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false - ORDER BY ig.name, ws.workspace_id - "# - ) - .fetch_all(&mut *tx) - .await?; - - // Create a map of group_name -> Vec - let mut workspaces_by_group: std::collections::HashMap> = - std::collections::HashMap::new(); - for mapping in workspace_mappings { - let role = mapping - .role - .and_then(|r| r.as_str().map(|s| s.to_string())) - .unwrap_or_else(|| "developer".to_string()); - - let workspace_info = WorkspaceInfo { - workspace_id: mapping.workspace_id.clone(), - workspace_name: mapping.workspace_name, - role, - }; - - workspaces_by_group - .entry(mapping.group_name) - .or_insert_with(Vec::new) - .push(workspace_info); - } - - let mut result = Vec::new(); - for group in groups { - let workspaces = workspaces_by_group - .get(&group.name) - .cloned() - .unwrap_or_default(); - - result.push(IGroupWithWorkspaces { - name: group.name, - summary: group.summary, - emails: group.emails, - workspaces, - }); - } - - tx.commit().await?; - return Ok(Json(result)); -} - -async fn get_igroup(Path(name): Path, Extension(db): Extension) -> JsonResult { - let group = sqlx::query_as!( - IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", - name - ) - .fetch_optional(&db) - .await?; - let group = not_found_if_none(group, "IGroup", &name)?; - return Ok(Json(group)); -} - -async fn remove_user_igroup( - authed: ApiAuthed, - Extension(db): Extension, - Path(name): Path, - Json(Email { email }): Json, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name,) - .fetch_optional(&mut *tx) - .await?; - - not_found_if_none(group_opt, "IGroup", &name)?; - - sqlx::query!( - "DELETE FROM email_to_igroup WHERE email = $1 AND igroup = $2", - email, - name, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "igroup.removeuser", - ActionKind::Update, - "global", - Some(&name.to_string()), - Some([("email", email.as_str())].into()), - ) - .await?; - - // Remove user from workspaces where they were added via this instance group - #[cfg(all(feature = "private", feature = "enterprise"))] - { - use crate::workspaces_ee::remove_users_from_instance_group_workspaces; - remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?; - } - - tx.commit().await?; - Ok(format!("Removed {} from igroup {}", email, name)) -} - -async fn remove_user( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, name)): Path<(String, String)>, - Json(Username { username: user_username }): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - if !authed.is_admin { - require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?; - } - - not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - if &name == "all" { - return Err(Error::BadRequest(format!("Cannot delete users from all"))); - } - sqlx::query!( - "DELETE FROM usr_to_group WHERE usr = $1 AND group_ = $2 AND workspace_id = $3", - user_username, - name, - &w_id, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "group.removeuser", - ActionKind::Update, - &w_id, - Some(&name.to_string()), - Some([("user", user_username.as_str())].into()), - ) - .await?; - - log_group_permission_change( - &mut *tx, - &w_id, - &name, - &authed.username, - "remove_member", - Some(&user_username), - ) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Group { name: name.clone() }, - Some(format!("Removed user from group '{}'", &name)), - true, - None, - ) - .await?; - - Ok(format!("Removed {} to group {}", user_username, name)) -} - -#[cfg(feature = "enterprise")] -#[derive(Serialize, Deserialize)] -struct ExportedIGroup { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scim_display_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - external_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - emails: Option>, -} - -#[cfg(feature = "enterprise")] -async fn export_igroups( - authed: ApiAuthed, - Extension(db): Extension, -) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - let igroups = sqlx::query_as!( - ExportedIGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", - ).fetch_all(&mut *tx).await?; - - audit_log( - &mut *tx, - &authed, - "igroups.export", - ActionKind::Execute, - "global", - None, - None, - ) - .await?; - - tx.commit().await?; - - Ok(Json(igroups)) -} - -#[cfg(not(feature = "enterprise"))] -async fn export_igroups() -> JsonResult { - Err(Error::BadRequest( - "This feature is only available in the enterprise version".to_string(), - )) -} - -#[cfg(feature = "enterprise")] -async fn overwrite_igroups( - authed: ApiAuthed, - Extension(db): Extension, - Json(igroups): Json>, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - sqlx::query!("DELETE FROM email_to_igroup") - .execute(&mut *tx) - .await?; - - sqlx::query!("DELETE FROM instance_group") - .execute(&mut *tx) - .await?; - - for igroup in igroups.iter() { - sqlx::query!( - "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", - igroup.name, - igroup.summary, - igroup.id, - igroup.scim_display_name, - igroup.external_id, - ) - .execute(&mut *tx) - .await?; - - if let Some(emails) = &igroup.emails { - for email in emails.iter() { - sqlx::query!( - "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2)", - email, - igroup.name, - ) - .execute(&mut *tx) - .await?; - } - } - } - - audit_log( - &mut *tx, - &authed, - "igroups.import", - ActionKind::Create, - "global", - None, - None, - ) - .await?; - - tx.commit().await?; - Ok("Imported igroups".to_string()) -} - -#[cfg(not(feature = "enterprise"))] -async fn overwrite_igroups() -> JsonResult { - Err(Error::BadRequest( - "This feature is only available in the enterprise version".to_string(), - )) -} - -pub async fn log_group_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - workspace_id: &str, - group_name: &str, - changed_by: &str, - change_type: &str, - member_affected: Option<&str>, -) -> Result<()> { - sqlx::query!( - "INSERT INTO group_permission_history - (workspace_id, group_name, changed_by, change_type, member_affected) - VALUES ($1, $2, $3, $4, $5)", - workspace_id, - group_name, - changed_by, - change_type, - member_affected - ) - .execute(db) - .await?; - Ok(()) -} +pub use windmill_api_groups::groups::*; diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index e82763197c..369d64e0e7 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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>> = Arc::new(RwLock::new(None)); - pub static ref COOKIE_DOMAIN: Option = std::env::var("COOKIE_DOMAIN").ok(); - - pub static ref IS_SECURE: Arc> = 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; diff --git a/backend/windmill-api/src/schedule.rs b/backend/windmill-api/src/schedule.rs index a3200ca95a..0ea5f9e054 100644 --- a/backend/windmill-api/src/schedule.rs +++ b/backend/windmill-api/src/schedule.rs @@ -1060,34 +1060,7 @@ pub struct EditSchedule { pub dynamic_skip: Option, } -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 { diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 7345c02e3c..abd0b4201b 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -6,1308 +6,22 @@ * LICENSE-AGPL for a copy of the license. */ -use crate::{ - auth::AuthCache, - auth::{list_tokens_internal, TruncatedTokenWithEmail}, - db::{ApiAuthed, DB}, - schedule::clear_schedule, - triggers::{get_triggers_count_internal, TriggersCount}, - users::{maybe_refresh_folders, require_owner_of_path}, - utils::{check_scopes, BulkDeleteRequest, WithStarredInfoQuery}, - webhook_util::{WebhookMessage, WebhookShared}, - HTTP_CLIENT, -}; -use axum::extract::Multipart; +// Re-export everything from windmill-api-scripts +pub use windmill_api_scripts::scripts::*; +use crate::triggers::{get_triggers_count_internal, TriggersCount}; use axum::{ - extract::{Extension, Path, Query}, - response::IntoResponse, - routing::{delete, get, post}, - Json, Router, + extract::{Extension, Path}, + routing::get, + Router, }; -use futures::future::try_join_all; -use http::header; -use hyper::StatusCode; -use itertools::Itertools; -use quick_cache::sync::Cache; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use serde_json::value::RawValue; -use sql_builder::prelude::*; -use sqlx::{FromRow, Postgres, Transaction}; -use std::{collections::HashMap, sync::Arc}; -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; -use windmill_dep_map::process_relative_imports; -use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; - -use windmill_common::{ - assets::{ - clear_static_asset_usage, clear_static_asset_usage_by_script_hash, - insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType, - }, - error::{self, to_anyhow}, - min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, - runnable_settings::{ - min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait, - }, - s3_helpers::upload_artifact_to_store, - scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline}, - utils::{paginate_without_limits, WarnAfterExt}, - worker::CLOUD_HOSTED, -}; - -use windmill_common::{ - db::UserDB, - error::{Error, JsonResult, Result}, - jobs::JobPayload, - schedule::Schedule, - schema::should_validate_schema, - scripts::{ - to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Schema, Script, ScriptHash, - ScriptHistory, ScriptHistoryUpdate, ScriptKind, ScriptLang, ScriptWithStarred, - }, - users::username_to_permissioned_as, - utils::{not_found_if_none, query_elems_from_hub, require_admin, Pagination, StripPath}, - worker::to_raw_value, - HUB_BASE_URL, -}; -use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; -use windmill_parser_ts::remove_pinned_imports; -use windmill_queue::{ - schedule::push_scheduled_job, PushIsolationLevel, WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT, -}; - -const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; - -#[derive(Serialize, sqlx::FromRow)] -pub struct ScriptWDraft { - pub hash: ScriptHash, - pub path: String, - pub summary: String, - pub description: String, - pub content: String, - pub language: ScriptLang, - pub kind: ScriptKind, - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub envs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ttl: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ignore_s3_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_unless_cancelled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_use: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_preprocessor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub assets: Option>, - #[serde(flatten)] - #[sqlx(flatten)] - pub runnable_settings: SR, -} - -impl ScriptWDraft { - pub async fn prefetch_cached<'a>( - self, - db: &DB, - ) -> error::Result> { - let (debouncing_settings, concurrency_settings) = - RunnableSettings::from_runnable_settings_handle( - self.runnable_settings.runnable_settings_handle, - db, - ) - .await? - .prefetch_cached(db) - .await?; - - Ok(ScriptWDraft { - runnable_settings: ScriptRunnableSettingsInline { - concurrency_settings: concurrency_settings.maybe_fallback( - self.runnable_settings.concurrency_key, - self.runnable_settings.concurrent_limit, - self.runnable_settings.concurrency_time_window_s, - ), - debouncing_settings: debouncing_settings.maybe_fallback( - self.runnable_settings.debounce_key, - self.runnable_settings.debounce_delay_s, - ), - }, - hash: self.hash, - path: self.path, - summary: self.summary, - description: self.description, - content: self.content, - language: self.language, - kind: self.kind, - tag: self.tag, - draft: self.draft, - schema: self.schema, - draft_only: self.draft_only, - envs: self.envs, - cache_ttl: self.cache_ttl, - cache_ignore_s3_path: self.cache_ignore_s3_path, - dedicated_worker: self.dedicated_worker, - ws_error_handler_muted: self.ws_error_handler_muted, - priority: self.priority, - restart_unless_cancelled: self.restart_unless_cancelled, - delete_after_use: self.delete_after_use, - timeout: self.timeout, - visible_to_runner_only: self.visible_to_runner_only, - no_main_func: self.no_main_func, - has_preprocessor: self.has_preprocessor, - on_behalf_of_email: self.on_behalf_of_email, - assets: self.assets, - }) - } -} - -pub fn global_service() -> Router { - Router::new() - .route("/hub/top", get(get_top_hub_scripts)) - .route("/hub/get/*path", get(get_hub_script_by_path)) - .route("/hub/get_full/*path", get(get_full_hub_script_by_path)) - .route("/hub/pick/*path", get(pick_hub_script_by_path)) -} - -pub fn global_unauthed_service() -> Router { - Router::new() - .route( - "/tokened_raw/:workspace/:token/*path", - get(get_tokened_raw_script_by_path), - ) - .route("/empty_ts/*path", get(get_empty_ts_script_by_path)) -} +use windmill_common::{error::JsonResult, utils::StripPath, DB}; +/// Wraps the subcrate's workspaced_service with the trigger count route +/// that depends on windmill-api internals. pub fn workspaced_service() -> Router { - Router::new() - .route("/list", get(list_scripts)) - .route("/list_search", get(list_search_scripts)) - .route("/create", post(create_script)) - .route("/create_snapshot", post(create_snapshot_script)) - .route("/archive/p/*path", post(archive_script_by_path)) - .route("/get/draft/*path", get(get_script_by_path_w_draft)) - .route("/get/p/*path", get(get_script_by_path)) + windmill_api_scripts::scripts::workspaced_service() .route("/get_triggers_count/*path", get(get_triggers_count)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/raw/p/*path", get(raw_script_by_path)) - .route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned)) - .route("/exists/p/*path", get(exists_script_by_path)) - .route("/archive/h/:hash", post(archive_script_by_hash)) - .route("/delete/h/:hash", post(delete_script_by_hash)) - .route("/delete/p/*path", post(delete_script_by_path)) - .route("/delete_bulk", delete(delete_scripts_bulk)) - .route("/get/h/:hash", get(get_script_by_hash)) - .route("/raw/h/:hash", get(raw_script_by_hash)) - .route("/deployment_status/h/:hash", get(get_deployment_status)) - .route("/list_paths", get(list_paths)) - .route( - "/toggle_workspace_error_handler/p/*path", - post(toggle_workspace_error_handler), - ) - .route("/history/p/*path", get(get_script_history)) - .route("/get_latest_version/*path", get(get_latest_version)) - .route( - "/list_paths_from_workspace_runnable/*path", - get(list_paths_from_workspace_runnable), - ) - .route( - "/history_update/h/:hash/p/*path", - post(update_script_history), - ) -} - -#[derive(Serialize, FromRow)] -pub struct SearchScript { - path: String, - content: String, -} -async fn list_search_scripts( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - #[cfg(feature = "enterprise")] - let n = 10000; - - #[cfg(not(feature = "enterprise"))] - let n = 10; - - let rows = sqlx::query_as!( - SearchScript, - "SELECT path, content from script WHERE workspace_id = $1 AND archived = false LIMIT $2", - &w_id, - n - ) - .fetch_all(&mut *tx) - .await? - .into_iter() - .collect::>(); - tx.commit().await?; - Ok(Json(rows)) -} - -async fn list_scripts( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, - Query(pagination): Query, - Query(lq): Query, -) -> JsonResult> { - let (per_page, offset) = paginate_without_limits(pagination); - let mut sqlb = SqlBuilder::select_from("script as o") - .fields(&[ - "hash", - "o.path", - "summary", - "COALESCE(draft.created_at, o.created_at) as created_at", - "archived", - "extra_perms", - if !lq.without_description.unwrap_or(false) { - "description" - } else { - "NULL as description" - }, - "CASE WHEN lock_error_logs IS NOT NULL THEN true ELSE false END as has_deploy_errors", - "language", - "favorite.path IS NOT NULL as starred", - "tag", - "draft.path IS NOT NULL as has_draft", - "draft_only", - "ws_error_handler_muted", - "no_main_func", - "codebase IS NOT NULL as use_codebase", - "kind" - ]) - .left() - .join("favorite") - .on( - "favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" - .bind(&authed.username), - ) - .left() - .join("draft") - .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" - ) - .order_desc("favorite.path IS NOT NULL") - .order_by("created_at", lq.order_desc.unwrap_or(true)) - .and_where("o.workspace_id = ?".bind(&w_id)) - .offset(offset) - .limit(per_page) - .clone(); - - let lowercased_kinds: Option> = lq - .kinds - .map(|x| x.split(",").map(&str::to_lowercase).collect()); - - if (!lq.include_without_main.unwrap_or(false) - && lowercased_kinds - .as_ref() - .map(|x| !x.contains(&"preprocessor".to_string())) - .unwrap_or(true)) - || authed.is_operator - { - // only include scripts that have a main function - // do not hide scripts without main if preprocessor is in the kinds - sqlb.and_where("o.no_main_func IS NOT TRUE"); - } - - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("draft_only IS NOT TRUE"); - } - - if lq.show_archived.unwrap_or(false) { - sqlb.and_where_eq( - "o.ctid", - "(SELECT ctid FROM script - WHERE path = o.path - AND workspace_id = ? - ORDER BY created_at DESC - LIMIT 1)" - .bind(&w_id), - ); - sqlb.and_where_eq("archived", true); - } else { - sqlb.and_where_eq("archived", false); - } - if let Some(ps) = &lq.path_start { - sqlb.and_where_like_left("o.path", ps); - } - if let Some(p) = &lq.path_exact { - sqlb.and_where_eq("o.path", "?".bind(p)); - } - if let Some(cb) = &lq.created_by { - sqlb.and_where_eq("created_by", "?".bind(cb)); - } - if let Some(ph) = &lq.first_parent_hash { - sqlb.and_where_eq("parent_hashes[1]", &ph.0); - } - if let Some(ph) = &lq.last_parent_hash { - sqlb.and_where_eq("parent_hashes[array_upper(parent_hashes, 1)]", &ph.0); - } - if let Some(ph) = &lq.parent_hash { - sqlb.and_where_eq("any(parent_hashes)", &ph.0); - } - if let Some(it) = &lq.is_template { - sqlb.and_where_eq("is_template", it); - } - if let Some(dw) = &lq.dedicated_worker { - sqlb.and_where_eq("dedicated_worker", dw); - } - if authed.is_operator { - sqlb.and_where_eq("kind", quote("script")); - } else if let Some(lowercased_kinds) = lowercased_kinds { - let safe_kinds = lowercased_kinds - .into_iter() - .map(sql_builder::quote) - .collect_vec(); - if safe_kinds.len() > 0 { - sqlb.and_where_in("kind", safe_kinds.as_slice()); - } - } - if lq.starred_only.unwrap_or(false) { - sqlb.and_where_is_not_null("favorite.path"); - } - - if lq.with_deployment_msg.unwrap_or(false) { - sqlb.join("deployment_metadata dm") - .left() - .on("dm.script_hash = o.hash") - .fields(&["dm.deployment_msg"]); - } - - if let Some(languages) = lq.languages { - sqlb.and_where_in( - "language", - &languages - .iter() - .map(|language| quote(language.as_str())) - .collect_vec(), - ); - } - - let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableScript>(&sql) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -#[derive(Deserialize)] -struct TopHubScriptsQuery { - limit: Option, - app: Option, - kind: Option, -} - -async fn get_top_hub_scripts( - Query(query): Query, - Extension(db): Extension, -) -> impl IntoResponse { - let mut query_params = vec![]; - if let Some(query_limit) = query.limit { - query_params.push(("limit", query_limit.to_string().clone())); - } - if let Some(query_app) = query.app { - query_params.push(("app", query_app.to_string().clone())); - } - if let Some(query_kind) = query.kind { - query_params.push(("kind", query_kind.to_string().clone())); - } - - let (status_code, headers, response) = query_elems_from_hub( - &HTTP_CLIENT, - &format!("{}/scripts/top", *HUB_BASE_URL.read().await), - Some(query_params), - &db, - ) - .await?; - Ok::<_, Error>((status_code, headers, response)) -} - -async fn create_snapshot_script( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(webhook): Extension, - Extension(db): Extension, - Path(w_id): Path, - mut multipart: Multipart, -) -> Result<(StatusCode, String)> { - // TODO: Check for debouncing here as well. - let mut script_hash = None; - let mut tx = None; - let mut uploaded = false; - let mut handle_deployment_metadata = None; - while let Some(field) = multipart.next_field().await.unwrap() { - let name = field.name().unwrap().to_string(); - let data = field.bytes().await.unwrap(); - if name == "script" { - let ns: NewScript = Some(serde_json::from_slice(&data).map_err(to_anyhow)?).unwrap(); - let is_tar = ns.codebase.as_ref().is_some_and(|x| x.ends_with(".tar")); - let use_esm = ns.codebase.as_ref().is_some_and(|x| x.contains(".esm")); - let (new_hash, ntx, hdm) = create_script_internal( - ns, - w_id.clone(), - authed.clone(), - db.clone(), - user_db.clone(), - webhook.clone(), - ) - .await?; - let mut nh = new_hash.to_string(); - if use_esm { - nh = format!("{nh}.esm"); - } - if is_tar { - nh = format!("{nh}.tar"); - } - script_hash = Some(nh); - tx = Some(ntx); - handle_deployment_metadata = hdm; - } - if name == "file" { - let hash = script_hash.as_ref().ok_or_else(|| { - Error::BadRequest( - "script need to be passed first in the multipart upload".to_string(), - ) - })?; - - uploaded = true; - - let path = windmill_common::s3_helpers::bundle(&w_id, &hash); - upload_artifact_to_store( - &path, - data, - &windmill_common::worker::ROOT_STANDALONE_BUNDLE_DIR, - ) - .await?; - } - // println!("Length of `{}` is {} bytes", name, data.len()); - } - if !uploaded { - return Err(Error::BadRequest("No file uploaded".to_string())); - } - if script_hash.is_none() { - return Err(Error::BadRequest( - "No script found in the uploaded file".to_string(), - )); - } - - tx.unwrap().commit().await?; - if let Some(hdm) = handle_deployment_metadata { - hdm.handle(&db).await?; - } - return Ok((StatusCode::CREATED, format!("{}", script_hash.unwrap()))); -} - -async fn list_paths_from_workspace_runnable( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - let runnables = sqlx::query_scalar!( - r#"SELECT importer_path FROM dependency_map - WHERE workspace_id = $1 AND imported_path = $2"#, - w_id, - path.to_path(), - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(runnables)) -} - -async fn create_script( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(webhook): Extension, - Extension(db): Extension, - Path(w_id): Path, - Json(ns): Json, -) -> Result<(StatusCode, String)> { - let (hash, tx, hdm) = - create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?; - tx.commit().await?; - if let Some(hdm) = hdm { - hdm.handle(&db).await?; - } - Ok((StatusCode::CREATED, format!("{}", hash))) -} - -struct HandleDeploymentMetadata { - email: String, - created_by: String, - w_id: String, - obj: DeployedObject, - deployment_message: Option, - renamed_from: Option, -} - -impl HandleDeploymentMetadata { - async fn handle(self, db: &DB) -> Result<()> { - handle_deployment_metadata( - &self.email, - &self.created_by, - &db, - &self.w_id, - self.obj, - self.deployment_message, - false, - self.renamed_from.as_deref(), - ) - .await - } -} - -async fn create_script_internal<'c>( - ns: NewScript, - w_id: String, - authed: ApiAuthed, - db: sqlx::Pool, - user_db: UserDB, - webhook: WebhookShared, -) -> Result<( - ScriptHash, - Transaction<'c, Postgres>, - Option, -)> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create scripts for security reasons".to_string(), - )); - } - check_scopes(&authed, || format!("scripts:write:{}", ns.path))?; - - guard_script_from_debounce_data(&ns).await?; - - let codebase = ns.codebase.as_ref(); - #[cfg(not(feature = "enterprise"))] - if ns.ws_error_handler_muted.is_some_and(|val| val) { - return Err(Error::BadRequest( - "Muting the error handler for certain script is only available in enterprise version" - .to_string(), - )); - } - if *CLOUD_HOSTED { - let nb_scripts = - sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id) - .fetch_one(&db) - .await?; - if nb_scripts.unwrap_or(0) >= 5000 { - return Err(Error::BadRequest( - "You have reached the maximum number of scripts (5000) on cloud. Contact support@windmill.dev to increase the limit" - .to_string(), - )); - } - - if ns.summary.len() > 300 { - return Err(Error::BadRequest( - "Summary must be less than 300 characters on cloud".to_string(), - )); - } - if ns.description.len() > 3000 { - return Err(Error::BadRequest( - "Description must be less than 3000 characters on cloud".to_string(), - )); - } - } - let script_path = ns.path.clone(); - let hash = ScriptHash(hash_script(&ns)); - let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; - let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; - if sqlx::query_scalar!( - "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2", - hash.0, - &w_id - ) - .fetch_optional(&mut *tx) - .await? - .is_some() - { - return Err(Error::BadRequest( - "A script with same hash (hence same path, description, summary, content) already \ - exists!" - .to_owned(), - )); - }; - let clashing_script = sqlx::query_as::<_, Script>( - "SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", - ) - .bind(&ns.path) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - struct ParentInfo { - p_hashes: Vec, - perms: serde_json::Value, - p_path: String, - } - let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { - (None, None) => Ok(None), - (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( - "Path conflict for {} with non-archived hash {}", - &ns.path, &s.hash - ))), - (None, Some(s)) => { - sqlx::query!( - "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - s.hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - Ok(None) - } - (Some(p_hash), o) => { - // Lock the parent row to prevent concurrent updates with the same parent_hash - // This ensures linear lineage - only one script can have a given parent at a time - if sqlx::query_scalar!( - "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2 FOR UPDATE", - p_hash.0, - &w_id - ) - .fetch_optional(&mut *tx) - .await? - .is_none() - { - return Err(Error::BadRequest( - "The parent hash does not seem to exist".to_owned(), - )); - }; - - let clashing_hash_o = sqlx::query_scalar!( - "SELECT hash FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2", - p_hash.0, - &w_id - ) - .fetch_optional(&mut *tx) - .await?; - - if let Some(clashing_hash) = clashing_hash_o { - return Err(Error::BadRequest(format!( - "A script with hash {} with same parent_hash has been found. However, the \ - lineage must be linear: no 2 scripts can have the same parent", - ScriptHash(clashing_hash) - ))); - }; - - let ScriptWithStarred { script: ps, .. } = - get_script_by_hash_internal(&mut tx, &w_id, p_hash, None).await?; - - if ps.path != ns.path { - require_owner_of_path(&authed, &ps.path)?; - } - - let ph = { - let v = ps.parent_hashes.map(|x| x.0).unwrap_or_default(); - let mut v: Vec = v - .into_iter() - .take(MAX_HASH_HISTORY_LENGTH_STORED - 1) - .collect(); - v.insert(0, p_hash.0); - v - }; - let r: Result> = match o { - Some(clashing_script) - if clashing_script.path == ns.path && clashing_script.hash.0 != p_hash.0 => - { - Err(Error::BadRequest(format!( - "Path conflict for {} with non-archived hash {}", - &ns.path, &clashing_script.hash - ))) - } - Some(_) | None => Ok(Some(ParentInfo { - p_hashes: ph, - perms: ps.extra_perms, - p_path: ps.path, - })), - }; - sqlx::query!( - "UPDATE script SET archived = true WHERE hash = $1 AND workspace_id = $2", - p_hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - - clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; - - r - } - }?; - let p_hashes = parent_hashes_and_perms.as_ref().map(|v| &v.p_hashes[..]); - let extra_perms = parent_hashes_and_perms - .as_ref() - .map(|v| v.perms.clone()) - .unwrap_or(json!({})); - let lock = if ns.codebase.is_some() { - Some(String::new()) - } else if !( - ns.language == ScriptLang::Python3 - || ns.language == ScriptLang::Go - || ns.language == ScriptLang::Bun - || ns.language == ScriptLang::Bunnative - || ns.language == ScriptLang::Deno - || ns.language == ScriptLang::Rust - || ns.language == ScriptLang::Ansible - || ns.language == ScriptLang::CSharp - || ns.language == ScriptLang::Nu - || ns.language == ScriptLang::Php - || ns.language == ScriptLang::Java - || ns.language == ScriptLang::Ruby - // for related places search: ADD_NEW_LANG - ) { - Some(String::new()) - } else { - ns.lock.as_ref().and_then(|e| { - if e.is_empty() { - None - } else { - Some(e.to_string()) - } - }) - }; - - let needs_lock_gen = lock.is_none() && codebase.is_none(); - let envs = ns.envs.as_ref().map(|x| x.as_slice()); - let envs = if ns.envs.is_none() || ns.envs.as_ref().unwrap().is_empty() { - None - } else { - envs - }; - - let lang = if &ns.language == &ScriptLang::Bun || &ns.language == &ScriptLang::Bunnative { - let anns = windmill_common::worker::TypeScriptAnnotations::parse(&ns.content); - if anns.native { - ScriptLang::Bunnative - } else { - ScriptLang::Bun - } - } else { - ns.language.clone() - }; - - let validate_schema = should_validate_schema(&ns.content, &ns.language); - - let (no_main_func, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) { - (ns.no_main_func, ns.has_preprocessor) - } else { - match lang { - ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { - let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None); - match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), - Err(e) => { - tracing::warn!( - "Error parsing deno signature when deploying script {}: {:?}", - ns.path, - e - ); - (None, None) - } - } - } - ScriptLang::Python3 => { - let args = windmill_parser_py::parse_python_signature(&ns.content, None, true); - match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), - Err(e) => { - tracing::warn!( - "Error parsing python signature when deploying script {}: {:?}", - ns.path, - e - ); - (None, None) - } - } - } - _ => (ns.no_main_func, ns.has_preprocessor), - } - }; - - let runnable_settings_handle = RunnableSettings { - debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, - concurrency_settings: ns.concurrency_settings.insert_cached(&db).await?, - } - .insert_cached(&db) - .await?; - - let ( - guarded_concurrent_limit, - guarded_concurrency_time_window_s, - guarded_concurrency_key, - guarded_debounce_key, - guarded_debounce_delay_s, - ) = if min_version_supports_runnable_settings_v0().await { - Default::default() - } else { - ( - ns.concurrency_settings.concurrent_limit.clone(), - ns.concurrency_settings.concurrency_time_window_s.clone(), - ns.concurrency_settings.concurrency_key.clone(), - ns.debouncing_settings.debounce_key.clone(), - ns.debouncing_settings.debounce_delay_s.clone(), - ) - }; - - sqlx::query!( - "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ - content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ - dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)", - &w_id, - &hash.0, - ns.path, - p_hashes, - ns.summary, - ns.description, - &ns.content, - &authed.username, - ns.schema.and_then(|x| serde_json::to_string(&x.0).ok()), - ns.is_template.unwrap_or(false), - extra_perms, - lock, - lang as ScriptLang, - ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, - ns.tag, - ns.draft_only, - envs, - guarded_concurrent_limit, - guarded_concurrency_time_window_s, - ns.cache_ttl, - ns.dedicated_worker, - ns.ws_error_handler_muted.unwrap_or(false), - ns.priority, - ns.restart_unless_cancelled, - ns.delete_after_use, - ns.timeout, - guarded_concurrency_key, - ns.visible_to_runner_only, - no_main_func.filter(|x| *x), // should be Some(true) or None - codebase, - has_preprocessor.filter(|x| *x), // should be Some(true) or None - if ns.on_behalf_of_email.is_some() { - Some(&authed.email) - } else { - None - }, - validate_schema, - ns.assets.as_ref().and_then(|a| serde_json::to_value(a).ok()), - guarded_debounce_key, - guarded_debounce_delay_s, - ns.cache_ignore_s3_path, - runnable_settings_handle - ) - .execute(&mut *tx) - .await?; - - let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); - if let Some(ref p_path) = p_path_opt { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - p_path, - &w_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", - ns.path, - p_path, - w_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", - ns.path, - p_path, - w_id - ) - .execute(&mut *tx) - .await?; - - let mut schedulables = sqlx::query_as::<_, Schedule>( - "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS false RETURNING *") - .bind(&ns.path) - .bind(&p_path) - .bind(&w_id) - .fetch_all(&mut *tx) - .await?; - - let schedule = sqlx::query_as::<_, Schedule>( - "UPDATE schedule SET path = $1, script_path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS false RETURNING *") - .bind(&ns.path) - .bind(&p_path) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - if let Some(schedule) = schedule { - schedulables.push(schedule); - } - - // Update dynamic_skip references when script is renamed - sqlx::query!( - "UPDATE schedule SET dynamic_skip = $1 WHERE dynamic_skip = $2 AND workspace_id = $3", - &ns.path, - &p_path, - &w_id - ) - .execute(&mut *tx) - .await?; - - for schedule in schedulables { - clear_schedule(&mut tx, &schedule.path, &w_id).await?; - - if schedule.enabled { - tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; - } - } - } else { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - ns.path, - &w_id - ) - .execute(&mut *tx) - .await?; - } - if p_hashes.is_some() && !p_hashes.unwrap().is_empty() { - audit_log( - &mut *tx, - &authed, - "scripts.update", - ActionKind::Update, - &w_id, - Some(&ns.path), - Some([("hash", hash.to_string().as_str())].into()), - ) - .await?; - webhook.send_message( - w_id.clone(), - WebhookMessage::UpdateScript { - workspace: w_id.clone(), - path: ns.path.clone(), - hash: hash.to_string(), - }, - ); - } else { - audit_log( - &mut *tx, - &authed, - "scripts.create", - ActionKind::Create, - &w_id, - Some(&ns.path), - Some( - [ - ("workspace", w_id.as_str()), - ("hash", hash.to_string().as_str()), - ] - .into(), - ), - ) - .await?; - webhook.send_message( - w_id.clone(), - WebhookMessage::CreateScript { - workspace: w_id.clone(), - path: ns.path.clone(), - hash: hash.to_string(), - }, - ); - } - - clear_static_asset_usage(&mut *tx, &w_id, &script_path, AssetUsageKind::Script).await?; - for asset in ns.assets.as_ref().into_iter().flatten() { - insert_static_asset_usage(&mut *tx, &w_id, &asset, &ns.path, AssetUsageKind::Script) - .await?; - } - - let permissioned_as = username_to_permissioned_as(&authed.username); - if let Some(parent_hash) = ns.parent_hash { - tracing::info!( - "creating script {hash:?} at path {script_path} with parent {parent_hash} on workspace {w_id}", - ); - } else { - tracing::info!("creating script {hash:?} at path {script_path} on workspace {w_id}",); - } - if needs_lock_gen { - let tag = if ns.dedicated_worker.is_some_and(|x| x) { - Some(format!("{}:{}", &w_id, &ns.path,)) - } else if ns.tag.as_ref().is_some_and(|x| x.contains("$args[")) { - None - } else { - ns.tag - }; - - let mut args: HashMap> = HashMap::new(); - if let Some(dm) = ns.deployment_message { - args.insert("deployment_message".to_string(), to_raw_value(&dm)); - } - if let Some(ref p_path) = p_path_opt { - args.insert("parent_path".to_string(), to_raw_value(&p_path)); - } - - let tx = PushIsolationLevel::Transaction(tx); - let (job_id, mut new_tx) = windmill_queue::push( - &db, - tx, - &w_id, - JobPayload::Dependencies { - hash, - language: ns.language, - path: ns.path.clone(), - dedicated_worker: ns.dedicated_worker, - debouncing_settings: Default::default(), - }, - windmill_queue::PushArgs::from(&args), - &authed.username, - &authed.email, - permissioned_as, - authed.token_prefix.as_deref(), - None, - None, - None, - None, - None, - None, - false, - false, - None, - true, - tag, - None, - None, - None, - Some(&authed.clone().into()), - false, - None, - None, - None, - ) - .await?; - - // Store the job_id in deployment_metadata for this script deployment - sqlx::query!( - "INSERT INTO deployment_metadata (workspace_id, path, script_hash, job_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL - DO UPDATE SET job_id = EXCLUDED.job_id", - w_id, - ns.path, - hash.0, - job_id - ) - .execute(&mut *new_tx) - .await?; - - Ok((hash, new_tx, None)) - } else { - if codebase.is_none() { - let db2 = db.clone(); - let w_id2 = w_id.clone(); - let authed2 = authed.clone(); - let permissioned_as2 = permissioned_as.clone(); - let script_path2 = script_path.clone(); - let parent_path = p_path_opt.clone(); - let deployment_message = ns.deployment_message.clone(); - let content = ns.content.clone(); - let language = ns.language.clone(); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - if let Err(e) = process_relative_imports( - &db2, - None, - None, - &w_id2, - &script_path2, - parent_path, - deployment_message, - &content, - &Some(language), - &authed2.email, - &authed2.username, - &permissioned_as2, - ) - .await - { - tracing::error!(%e, "error processing relative imports"); - } - }); - } - - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // hash: hash.clone(), - // path: script_path.clone(), - // parent_path: p_path_opt, - // }, - // ns.deployment_message, - // false, - // ) - // .await?; - - Ok(( - hash, - tx, - Some(HandleDeploymentMetadata { - email: authed.email, - created_by: authed.username, - w_id, - obj: DeployedObject::Script { - hash: hash.clone(), - path: script_path.clone(), - parent_path: p_path_opt.clone(), - }, - deployment_message: ns.deployment_message, - renamed_from: p_path_opt, - }), - )) - } -} - -pub async fn get_hub_script_by_path( - Path(path): Path, - Extension(db): Extension, -) -> Result { - windmill_common::scripts::get_hub_script_by_path(path, &HTTP_CLIENT, &db).await -} - -pub async fn get_full_hub_script_by_path( - Path(path): Path, - Extension(db): Extension, -) -> JsonResult { - Ok(Json( - windmill_common::scripts::get_full_hub_script_by_path(path, &HTTP_CLIENT, Some(&db)) - .await?, - )) -} - -pub async fn pick_hub_script_by_path( - Path(path): Path, - Extension(db): Extension, -) -> impl IntoResponse { - let path_str = path.to_path(); - - // Extract version_id from path (format: {hub}/{version_id}/{summary}) - let version_id = path_str.split('/').nth(1).unwrap_or(""); - - let hub_base_url = HUB_BASE_URL.read().await.clone(); - - // Determine which hub to use based on version_id - // If version_id < PRIVATE_HUB_MIN_VERSION, use default hub - let target_hub_url = if version_id - .parse::() - .is_ok_and(|v| v < windmill_common::PRIVATE_HUB_MIN_VERSION) - { - windmill_common::DEFAULT_HUB_BASE_URL - } else { - &hub_base_url - }; - - // Call the hub's pick endpoint: /scripts/{version_id}/pick - let (status_code, headers, response) = query_elems_from_hub( - &HTTP_CLIENT, - &format!("{}/scripts/{}/pick", target_hub_url, version_id), - None, - &db, - ) - .await?; - - Ok::<_, Error>((status_code, headers, response)) -} - -#[axum::debug_handler] -async fn get_script_by_path( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = if query.with_starred_info.unwrap_or(false) { - sqlx::query_as::<_, ScriptWithStarred>( - "SELECT s.*, favorite.path IS NOT NULL as starred - FROM script s - LEFT JOIN favorite - ON favorite.favorite_kind = 'script' - AND favorite.workspace_id = s.workspace_id - AND favorite.path = s.path - AND favorite.usr = $3 - WHERE s.path = $1 - AND s.workspace_id = $2 - ORDER BY s.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .bind(&authed.username) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_as::<_, ScriptWithStarred>( - "SELECT *, NULL as starred FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await? - }; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)? - .prefetch_cached(&db) - .await?; - - Ok(Json(script)) -} - -async fn list_tokens( - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - list_tokens_internal(&db, &w_id, &path, false).await } async fn get_triggers_count( @@ -1317,1012 +31,3 @@ async fn get_triggers_count( let path = path.to_path(); get_triggers_count_internal(&db, &w_id, &path, false).await } - -async fn get_script_by_path_w_draft( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, has_preprocessor, on_behalf_of_email, assets, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' - WHERE script.path = $1 AND script.workspace_id = $2 - ORDER BY script.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)?; - Ok(Json(script.prefetch_cached(&db).await?)) -} - -async fn get_script_history( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let query_result = sqlx::query!( - "SELECT s.hash as hash, dm.deployment_msg as deployment_msg - FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash - WHERE s.workspace_id = $1 AND s.path = $2 - ORDER by s.created_at DESC", - w_id, - path, - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - - let result: Vec = query_result - .into_iter() - .map(|row| ScriptHistory { - script_hash: ScriptHash(row.hash), - deployment_msg: row.deployment_msg, - }) - .collect(); - return Ok(Json(result)); -} - -async fn get_latest_version( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let row_o = sqlx::query!( - "SELECT s.hash as hash, dm.deployment_msg as deployment_msg - FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash - WHERE s.workspace_id = $1 AND s.path = $2 - ORDER by s.created_at DESC LIMIT 1", - w_id, - path, - ) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - if let Some(row) = row_o { - let result = ScriptHistory { - script_hash: ScriptHash(row.hash), - deployment_msg: row.deployment_msg, // - }; - return Ok(Json(Some(result))); - } else { - return Ok(Json(None)); - } -} - -async fn update_script_history( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, script_hash, script_path)): Path<(String, ScriptHash, StripPath)>, - Json(script_history_update): Json, -) -> Result<()> { - let script_path = script_path.to_path(); - check_scopes(&authed, || format!("scripts:write:{}", script_path))?; - - let mut tx = user_db.begin(&authed).await?; - sqlx::query!( - "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL - DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - w_id, - script_path, - script_hash.0, - script_history_update.deployment_msg, - ) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - return Ok(()); -} - -async fn list_paths( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - - let scripts = sqlx::query_scalar!( - "SELECT distinct(path) FROM script WHERE workspace_id = $1", - w_id - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - - Ok(Json(scripts)) -} - -#[derive(Deserialize)] -pub struct ToggleWorkspaceErrorHandler { - #[cfg(feature = "enterprise")] - pub muted: Option, -} - -#[cfg(not(feature = "enterprise"))] -async fn toggle_workspace_error_handler( - _authed: ApiAuthed, - Extension(_user_db): Extension, - Path((_w_id, _path)): Path<(String, StripPath)>, - Json(_req): Json, -) -> Result { - return Err(Error::BadRequest( - "Muting the error handler for certain script is only available in enterprise version" - .to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn toggle_workspace_error_handler( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Json(req): Json, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - let error_handler_maybe: Option = sqlx::query_scalar!( - "SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = $1", - w_id - ) - .fetch_optional(&mut *tx) - .await? - .unwrap_or(None); - - match error_handler_maybe { - Some(_) => { - sqlx::query_scalar!( - "UPDATE script - SET ws_error_handler_muted = $3 - WHERE ctid = ( - SELECT ctid FROM script - WHERE path = $1 AND workspace_id = $2 - ORDER BY created_at DESC - LIMIT 1 - ) -", - path.to_path(), - w_id, - req.muted, - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok("".to_string()) - } - None => { - tx.commit().await?; - Err(Error::ExecutionErr( - "Workspace error handler needs to be defined".to_string(), - )) - } - } -} - -async fn get_tokened_raw_script_by_path( - Extension(user_db): Extension, - Extension(db): Extension, - Extension(cache): Extension>, - Path((w_id, token, path)): Path<(String, String, StripPath)>, - Query(query): Query, -) -> Result { - let authed = cache - .get_authed(Some(w_id.clone()), &token) - .await - .ok_or_else(|| Error::NotAuthorized("Invalid token".to_string()))?; - return raw_script_by_path( - authed, - Extension(user_db), - Extension(db), - Path((w_id, path)), - Query(query), - ) - .await; -} - -async fn get_empty_ts_script_by_path() -> String { - return String::new(); -} - -#[derive(Deserialize)] -struct RawScriptByPathQuery { - // used to make cache immutable with respect to importer - cache_key: Option, - // used specifically for python to cache folders on import success to avoid extra db calls on package fetch - cache_folders: Option, -} - -struct StringWithLength(String); - -impl IntoResponse for StringWithLength { - fn into_response(self) -> axum::response::Response { - let len = self.0.len(); - ([(header::CONTENT_LENGTH, len.to_string())], self.0).into_response() - } -} - -async fn raw_script_by_path( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> Result { - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {}", path.to_path()); - } - let r = raw_script_by_path_internal(path, user_db, db, authed, w_id, false, query).await?; - Ok(StringWithLength(r)) -} - -async fn raw_script_by_path_unpinned( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> Result { - let r = raw_script_by_path_internal(path, user_db, db, authed, w_id, true, query).await?; - Ok(StringWithLength(r)) -} - -lazy_static::lazy_static! { - static ref DEBUG_RAW_SCRIPT_ENDPOINTS: bool = - std::env::var("DEBUG_RAW_SCRIPT_ENDPOINTS").is_ok(); -} - -lazy_static::lazy_static! { - pub static ref RAW_SCRIPT_CACHE: Cache = Cache::new(1000); - pub static ref CACHE_FOLDERS_PATH: Cache = Cache::new(1000); - -} - -async fn raw_script_by_path_internal( - path: StripPath, - user_db: UserDB, - db: DB, - authed: ApiAuthed, - w_id: String, - unpin: bool, - query: RawScriptByPathQuery, -) -> Result { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let cache_path = query - .cache_key - .map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" })); - if let Some(cache_path) = cache_path.clone() { - let cached_content = RAW_SCRIPT_CACHE.get(&cache_path); - if let Some(cached_content) = cached_content { - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (cached)", path); - } - return Ok(cached_content); - } - } - - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (not cached)", path); - } - - if !path.ends_with(".py") - && !path.ends_with(".ts") - && !path.ends_with(".go") - && !path.ends_with(".sh") - { - return Err(Error::BadRequest(format!( - "Path must ends with a .py, .ts, .go. or .sh extension: {}", - path - ))); - } - let path = path - .trim_end_matches(".py") - .trim_end_matches(".bun.ts") - .trim_end_matches(".deno.ts") - .trim_end_matches(".ts") - .trim_end_matches(".go") - .trim_end_matches(".sh"); - - // folder cache is only useful for python given it needs to recuse over all intermediate folders to find the package. - // When a script exists in a folder, we can cache the fact that the folder exists to avoid extra db calls. - let mut split_path = path.split("/").collect::>(); - let folder_path = if query.cache_folders.is_some() && split_path.len() > 2 { - Some(format!("{w_id}:{path}/")) - } else { - None - }; - - let has_folder_cache = folder_path.is_some(); - if let Some(cache_folders) = folder_path { - let cached_content = CACHE_FOLDERS_PATH.get(&cache_folders); - if let Some(cached_ts) = cached_content { - if cached_ts >= chrono::Utc::now().timestamp() - 300 { - // 5 minutes - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (cached folders)", path); - } - return Ok("WINDMILL_IS_FOLDER".to_string()); - } else { - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!( - "Raw script by path request: {} (cached folders expired)", - path - ); - } - } - } - } - - let mut tx = user_db.begin(&authed).await?; - - let content_o = sqlx::query_scalar!( - "SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", - path, - w_id - ) - .fetch_optional(&mut *tx) - .warn_after_seconds(5) - .await?; - tx.commit().await?; - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!( - "Raw script by path request: {} (content: {:?})", - path, - content_o - ); - } - - if content_o.is_none() { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)", - path, - w_id - ) - .fetch_one(&db) - .warn_after_seconds(5) - .await? - .unwrap_or(false); - - if exists { - return Err(Error::NotFound(format!( - "Script {path} exists but {} does not have permissions to access it", - authed.username - ))); - } else { - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - let other_script_o = sqlx::query_scalar!( - "SELECT path FROM script WHERE workspace_id = $1 AND archived = false", - w_id - ) - .fetch_all(&db) - .await?; - let other_script_archived = sqlx::query_scalar!( - "SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true", - w_id - ) - .fetch_all(&db) - .await?; - tracing::warn!( - "Script {path} does not exist in workspace {w_id} but these paths do, non-archived: {:?} | archived: {:?}", - other_script_o.join(", "), - other_script_archived.join(", ") - ) - } - } - } - - let content = not_found_if_none(content_o, "Script", path)?; - - let content = if unpin { - remove_pinned_imports(&content)? - } else { - content - }; - - if has_folder_cache { - while split_path.len() >= 2 { - split_path.pop(); - let npath = split_path.join("/"); - CACHE_FOLDERS_PATH.insert(format!("{w_id}:{npath}/"), chrono::Utc::now().timestamp()); - } - } - - if let Some(cache_path) = cache_path { - RAW_SCRIPT_CACHE.insert(cache_path, content.clone()); - } - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (content response)", path); - } - Ok(content) -} - -async fn exists_script_by_path( - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - Ok(Json(exists)) -} - -async fn get_script_by_hash_internal<'c>( - db: &mut Transaction<'c, Postgres>, - workspace_id: &str, - hash: &ScriptHash, - with_starred_info_for_username: Option<&str>, -) -> Result> { - let script_o = if let Some(username) = with_starred_info_for_username { - sqlx::query_as::<_, ScriptWithStarred>( - "SELECT s.*, favorite.path IS NOT NULL as starred - FROM script s - LEFT JOIN favorite - ON favorite.favorite_kind = 'script' - AND favorite.workspace_id = s.workspace_id - AND favorite.path = s.path - AND favorite.usr = $1 - WHERE s.hash = $2 AND s.workspace_id = $3", - ) - .bind(&username) - .bind(hash) - .bind(workspace_id) - .fetch_optional(&mut **db) - .await? - } else { - sqlx::query_as::<_, ScriptWithStarred>( - "SELECT *, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", - ) - .bind(hash) - .bind(workspace_id) - .fetch_optional(&mut **db) - .await? - }; - - let script = not_found_if_none(script_o, "Script", hash.to_string())?; - Ok(script) -} - -#[derive(Deserialize)] -struct GetScriptByHashQuery { - authed: Option, -} -async fn get_script_by_hash( - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, hash)): Path<(String, ScriptHash)>, - Query(query): Query, - Query(query_auth): Query, - Extension(authed): Extension, -) -> JsonResult> { - let mut tx = if query_auth.authed.is_some_and(|x| x) { - user_db.begin(&authed).await? - } else { - db.begin().await? - }; - let r = get_script_by_hash_internal( - &mut tx, - &w_id, - &hash, - query.with_starred_info.and_then(|x| { - if x { - Some(authed.username.as_str()) - } else { - None - } - }), - ) - .await?; - - check_scopes(&authed, || format!("scripts:read:{}", &r.script.path))?; - - tx.commit().await?; - - Ok(Json(r.prefetch_cached(&db).await?)) -} - -async fn raw_script_by_hash( - Extension(db): Extension, - Path((w_id, hash_str)): Path<(String, String)>, -) -> Result { - let mut tx = db.begin().await?; - let hash = ScriptHash(to_i64(hash_str.strip_suffix(".ts").ok_or_else(|| { - Error::BadRequest("Raw script path must end with .ts".to_string()) - })?)?); - let r = get_script_by_hash_internal(&mut tx, &w_id, &hash, None).await?; - tx.commit().await?; - - Ok(r.script.content) -} - -#[derive(Serialize)] -struct DeploymentStatus { - lock: Option, - lock_error_logs: Option, - job_id: Option, -} -async fn get_deployment_status( - Extension(db): Extension, - Path((w_id, hash)): Path<(String, ScriptHash)>, -) -> JsonResult { - let mut tx = db.begin().await?; - let status_o = sqlx::query!( - "SELECT s.lock, s.lock_error_logs, dm.job_id - FROM script s - LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id - WHERE s.hash = $1 AND s.workspace_id = $2", - hash.0, - w_id, - ) - .fetch_optional(&mut *tx) - .await?; - - let status = not_found_if_none(status_o, "DeploymentStatus", hash.to_string())?; - - let deployment_status = DeploymentStatus { - lock: status.lock, - lock_error_logs: status.lock_error_logs, - job_id: status.job_id, - }; - - tx.commit().await?; - Ok(Json(deployment_status)) -} - -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - "script", - ) - .await; -} - -async fn archive_script_by_path( - authed: ApiAuthed, - Extension(webhook): Extension, - Extension(user_db): Extension, - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> Result<()> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive scripts for security reasons".to_string(), - )); - } - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:write:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - require_owner_of_path(&authed, path)?; - - let hash: i64 = sqlx::query_scalar!( - "UPDATE script SET archived = true WHERE path = $1 AND workspace_id = $2 RETURNING hash", - path, - &w_id - ) - .fetch_one(&db) - .await - .map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?; - - clear_static_asset_usage(&mut *tx, &w_id, path, AssetUsageKind::Script).await?; - - audit_log( - &mut *tx, - &authed, - "scripts.archive", - ActionKind::Delete, - &w_id, - Some(&ScriptHash(hash).to_string()), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - - ScopedDependencyMap::clear_map_for_item(path, &w_id, "script", tx, &None) - .await - .commit() - .await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Script { - hash: ScriptHash(0), // dummy hash as it will not get inserted in db - path: path.to_string(), - parent_path: Some(path.to_string()), - }, - Some(format!("Script '{}' archived", path)), - true, - None, - ) - .await?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, - ); - - Ok(()) -} - -async fn archive_script_by_hash( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(db): Extension, - Extension(webhook): Extension, - Path((w_id, hash)): Path<(String, ScriptHash)>, -) -> JsonResult> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive scripts for security reasons".to_string(), - )); - } - let mut tx = user_db.begin(&authed).await?; - - let script = sqlx::query_as::<_, Script>( - "UPDATE script SET archived = true WHERE hash = $1 AND workspace_id = $2 RETURNING *", - ) - .bind(&hash.0) - .bind(&w_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?; - - check_scopes(&authed, || format!("scripts:write:{}", &script.path))?; - clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; - - audit_log( - &mut *tx, - &authed, - "scripts.archive", - ActionKind::Delete, - &w_id, - Some(&hash.to_string()), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - - ScopedDependencyMap::clear_map_for_item(&script.path, &w_id, "script", tx, &None) - .await - .commit() - .await?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, - ); - - Ok(Json(script.prefetch_cached(&db).await?)) -} - -async fn delete_script_by_hash( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(webhook): Extension, - Extension(db): Extension, - Path((w_id, hash)): Path<(String, ScriptHash)>, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - - require_admin(authed.is_admin, &authed.username)?; - let script = sqlx::query_as::<_, Script>( - "UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \ - workspace_id = $2 RETURNING *", - ) - .bind(&hash.0) - .bind(&w_id) - .fetch_one(&db) - .await - .map_err(|e| Error::internal_err(format!("deleting script by hash {w_id}: {e:#}")))?; - - check_scopes(&authed, || format!("scripts:write:{}", &script.path))?; - - clear_static_asset_usage_by_script_hash(&mut *tx, &w_id, hash).await?; - - audit_log( - &mut *tx, - &authed, - "scripts.delete", - ActionKind::Delete, - &w_id, - Some(&hash.to_string()), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - tx.commit().await?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() }, - ); - - Ok(Json(script.prefetch_cached(&db).await?)) -} - -#[derive(Deserialize)] -struct DeleteScriptQuery { - keep_captures: Option, -} - -async fn delete_script_by_path( - authed: ApiAuthed, - Extension(user_db): Extension, - Extension(webhook): Extension, - Extension(db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { - let path = path.to_path(); - - check_scopes(&authed, || format!("scripts:write:{}", path))?; - - if path == "u/admin/hub_sync" && w_id == "admins" { - return Err(Error::BadRequest( - "Cannot delete the global setup app".to_string(), - )); - } - - let mut tx = user_db.begin(&authed).await?; - - let draft_only = sqlx::query_scalar!( - "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - let script = if !draft_only { - require_admin(authed.is_admin, &authed.username)?; - sqlx::query_scalar!( - "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", - path, - w_id - ) - .fetch_one(&db) - .await - .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? - } else { - sqlx::query_scalar!( - "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", - path, - w_id - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? - }; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - path, - w_id - ) - .execute(&db) - .await?; - - if !query.keep_captures.unwrap_or(false) { - sqlx::query!( - "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE", - path, - w_id - ) - .execute(&db) - .await?; - - sqlx::query!( - "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE", - path, - w_id - ) - .execute(&db) - .await?; - } - - audit_log( - &mut *tx, - &authed, - "scripts.delete", - ActionKind::Delete, - &w_id, - Some(&path), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Script { - hash: ScriptHash(0), // Temporary value as it will get removed right after - path: path.to_string(), - parent_path: Some(path.to_string()), - }, - Some(format!("Script '{}' deleted", path)), - true, - None, - ) - .await?; - - sqlx::query!( - "DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND script_hash IS NOT NULL", - path, - w_id - ) - .execute(&db) - .await - .map_err(|e| { - Error::internal_err(format!( - "error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}" - )) - })?; - - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteScriptPath { workspace: w_id, path: path.to_owned() }, - ); - - Ok(Json(script)) -} - -async fn delete_scripts_bulk( - authed: ApiAuthed, - Extension(webhook): Extension, - Extension(db): Extension, - Path(w_id): Path, - Json(request): Json, -) -> JsonResult> { - for path in &request.paths { - check_scopes(&authed, || format!("scripts:write:{}", path))?; - } - - require_admin(authed.is_admin, &authed.username)?; - - if request.paths.contains(&"u/admin/hub_sync".to_string()) && w_id == "admins" { - return Err(Error::BadRequest( - "Cannot delete the global setup app".to_string(), - )); - } - - let mut tx = db.begin().await?; - - let mut deleted_paths = sqlx::query_scalar!( - "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path", - w_id, - &request.paths - ) - .fetch_all(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))?; - - // remove duplicates from deleted_paths - deleted_paths.sort(); - deleted_paths.dedup(); - - sqlx::query!( - "DELETE FROM draft WHERE workspace_id = $1 AND path = ANY($2) AND typ = 'script'", - w_id, - &deleted_paths - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM capture_config WHERE workspace_id = $1 AND path = ANY($2) AND is_flow IS FALSE", - w_id, - &deleted_paths - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM capture WHERE workspace_id = $1 AND path = ANY($2) AND is_flow IS FALSE", - w_id, - &deleted_paths - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "scripts.delete_bulk", - ActionKind::Delete, - &w_id, - Some(&deleted_paths.join(", ")), - Some([("workspace", w_id.as_str())].into()), - ) - .await?; - - tx.commit().await?; - - try_join_all(deleted_paths.iter().map(|path| { - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Script { hash: ScriptHash(0), path: path.clone(), parent_path: None }, - Some(format!("Script '{}' deleted", path)), - true, - None, - ) - })) - .await?; - - sqlx::query!( - "DELETE FROM deployment_metadata WHERE workspace_id = $1 AND path = ANY($2) AND script_hash IS NOT NULL", - w_id, - &deleted_paths - ) - .execute(&db) - .await - .map_err(|e| { - Error::internal_err(format!( - "error deleting deployment metadata for scripts with paths {} in workspace {w_id}: {e:#}", deleted_paths.join(", ") - )) - })?; - - for path in &deleted_paths { - webhook.send_message( - w_id.clone(), - WebhookMessage::DeleteScriptPath { workspace: w_id.clone(), path: path.to_owned() }, - ); - } - - Ok(Json(deleted_paths)) -} - -/// Validates that script debouncing configuration is supported by all workers -/// Returns an error if debouncing is configured but workers are behind required version -async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> { - if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await && !ns.debouncing_settings.is_default() { - tracing::warn!( - "Script debouncing configuration rejected: workers are behind minimum required version for debouncing feature" - ); - Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into() }) - } else if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await - && !ns.debouncing_settings.is_legacy_compatible() - && !*WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT - { - tracing::warn!( - "Script debouncing configuration rejected: workers are behind minimum required version for debouncing feature" - ); - Err(Error::WorkersAreBehind { - feature: "V2 Debouncing".into(), - min_version: "1.597.0".into(), - }) - } else { - Ok(()) - } -} diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index b70a1b98f1..aa1f73405b 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -6,1428 +6,51 @@ * LICENSE-AGPL for a copy of the license. */ -#![allow(non_snake_case)] +// Re-export everything from windmill-api-users +pub use windmill_api_users::users::*; -use sqlx::{Postgres, Transaction}; - -use std::sync::atomic::AtomicBool; use std::sync::Arc; -use std::time::Duration; use crate::db::ApiAuthed; - -pub use crate::auth::Tokened; - use crate::secret_backend_ext::rename_vault_secrets_with_prefix; -use crate::utils::{ - generate_instance_wide_unique_username, get_instance_username_or_create_pending, -}; -use crate::{ - db::DB, utils::require_super_admin, webhook_util::WebhookShared, COOKIE_DOMAIN, IS_SECURE, -}; -use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use argon2::Argon2; use axum::{ - extract::{Extension, Path, Query}, - response::{IntoResponse, Response}, - routing::{delete, get, post}, + extract::{Extension, Path}, + routing::post, Json, Router, }; -use hyper::{header::LOCATION, StatusCode}; -use lazy_static::lazy_static; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use time::OffsetDateTime; -use tower_cookies::{Cookie, Cookies}; -use tracing::Instrument; +use hyper::StatusCode; +use serde::Deserialize; +use windmill_api_auth::require_super_admin; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; -use windmill_common::auth::TOKEN_PREFIX_LEN; -use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; -use windmill_common::oauth2::InstanceEvent; -use windmill_common::users::truncate_token; -use windmill_common::users::COOKIE_NAME; -use windmill_common::utils::paginate; -use windmill_common::worker::CLOUD_HOSTED; -use windmill_common::BASE_URL; +use windmill_common::usernames::VALID_USERNAME; use windmill_common::{ - auth::{get_folders_for_user, get_groups_for_user}, - db::UserDB, - error::{self, Error, JsonResult, Result}, - utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath}, + error::{self, Error, Result}, + DB, }; -use windmill_git_sync::handle_deployment_metadata; - -const COOKIE_PATH: &str = "/"; - -pub fn workspaced_service() -> Router { - Router::new() - .route("/list", get(list_users)) - .route("/list_usage", get(list_user_usage)) - .route("/list_usernames", get(list_usernames)) - .route("/exists", post(exists_username)) - .route("/get/:user", get(get_workspace_user)) - .route("/update/:user", post(update_workspace_user)) - .route("/delete/:user", delete(delete_workspace_user)) - .route("/convert_to_group/:user", post(convert_user_to_group)) - .route("/is_owner/*path", get(is_owner_of_path)) - .route("/whois/:username", get(whois)) - .route("/whoami", get(whoami)) - .route("/leave", post(leave_workspace)) - .route("/username_to_email/:username", get(username_to_email)) -} +/// Wraps the subcrate's global_service with routes that depend on windmill-api internals. pub fn global_service() -> Router { - Router::new() - .route("/exists/:email", get(exists_email)) - .route("/email", get(get_email)) - .route("/whoami", get(global_whoami)) - .route("/list_invites", get(list_invites)) - .route("/decline_invite", post(decline_invite)) - .route("/accept_invite", post(accept_invite)) - .route("/list_as_super_admin", get(list_users_as_super_admin)) + windmill_api_users::users::global_service() .route("/setpassword", post(set_password)) .route("/set_password_of/:user", post(set_password_of_user)) - .route("/set_login_type/:user", post(set_login_type)) .route("/create", post(create_user)) - .route("/update/:user", post(update_user)) - .route("/delete/:user", delete(delete_user)) - .route("/username_info/:user", get(get_instance_username_info)) .route("/rename/:user", post(rename_user)) - .route("/tokens/create", post(create_token)) - .route("/tokens/delete/:token_prefix", delete(delete_token)) - .route("/tokens/list", get(list_tokens)) - .route("/tokens/impersonate", post(impersonate)) - .route("/usage", get(get_usage)) - .route("/all_runnables", get(get_all_runnables)) - .route("/refresh_token", get(refresh_token)) - .route( - "/tutorial_progress", - post(update_tutorial_progress).get(get_tutorial_progress), - ) - .route("/leave_instance", post(leave_instance)) - .route("/export", get(export_global_users)) - .route("/overwrite", post(overwrite_global_users)) .route("/onboarding", post(submit_onboarding_data)) - - // .route("/list_invite_codes", get(list_invite_codes)) - // .route("/create_invite_code", post(create_invite_code)) - // .route("/signup", post(signup)) - // .route("/lost_password", post(lost_password)) - // .route("/use_magic_link", get(use_magic_link)) } +/// Wraps the subcrate's make_unauthed_service with routes that depend on windmill-api internals. pub fn make_unauthed_service() -> Router { - Router::new() - .route("/login", post(login)) - .route("/logout", post(logout).get(logout)) - .route("/is_first_time_setup", get(is_first_time_setup)) - .route("/request_password_reset", post(request_password_reset)) + windmill_api_users::users::make_unauthed_service() .route("/reset_password", post(reset_password)) - .route("/is_smtp_configured", get(is_smtp_configured)) -} - -pub use windmill_api_auth::{ - create_token_internal, fetch_api_authed, get_scope_tags, maybe_refresh_folders, - require_is_writer, require_path_read_access_for_preview, NewToken, OptAuthed, -}; - -#[cfg(feature = "parquet")] -pub use windmill_api_auth::fetch_api_authed_from_permissioned_as; - -#[derive(FromRow, Serialize)] -pub struct User { - pub workspace_id: String, - pub email: String, - pub username: String, - pub is_admin: bool, - pub created_at: chrono::DateTime, - pub operator: bool, - pub disabled: bool, - pub role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub added_via: Option, -} - -#[derive(Serialize)] -pub struct UserWithUsage { - pub email: String, - pub executions: Option, -} - -#[derive(FromRow, Serialize, Debug)] -pub struct GlobalUserInfo { - email: String, - login_type: Option, - super_admin: bool, - devops: bool, - verified: bool, - name: Option, - company: Option, - username: Option, - #[serde(skip_serializing_if = "Option::is_none")] - operator_only: Option, - first_time_user: bool, -} - -#[derive(Serialize, Debug)] -pub struct UserInfo { - pub workspace_id: String, - pub email: String, - pub username: String, - pub is_admin: bool, - pub is_super_admin: bool, - pub created_at: chrono::DateTime, - pub groups: Vec, - pub operator: bool, - pub disabled: bool, - pub role: Option, - pub folders_read: Vec, - pub folders: Vec, - pub folders_owners: Vec, - pub name: Option, -} - -#[derive(FromRow, Serialize)] -pub struct WorkspaceInvite { - pub workspace_id: String, - pub email: String, - pub is_admin: bool, - pub operator: bool, - pub parent_workspace_id: Option, -} - -#[allow(dead_code)] -#[derive(Deserialize)] -pub struct NewUser { - pub email: String, - pub password: String, - pub super_admin: bool, - pub name: Option, - pub company: Option, - pub skip_email: Option, -} - -#[derive(Deserialize)] -pub struct AcceptInvite { - pub workspace_id: String, - pub username: Option, -} - -#[derive(Deserialize)] -pub struct DeclineInvite { - pub workspace_id: String, -} - -#[derive(Deserialize)] -pub struct EditUser { - pub is_super_admin: Option, - pub is_devops: Option, - pub name: Option, -} - -#[derive(Deserialize)] -pub struct EditWorkspaceUser { - pub is_admin: Option, - pub operator: Option, - pub disabled: Option, -} - -#[allow(dead_code)] -#[derive(Deserialize)] -pub struct EditPassword { - pub password: String, -} - -#[derive(Deserialize)] -pub struct EditLoginType { - pub login_type: String, -} - -#[derive(FromRow, Serialize)] -pub struct TruncatedToken { - pub label: Option, - pub token_prefix: Option, - pub expiration: Option>, - pub created_at: chrono::DateTime, - pub last_used_at: chrono::DateTime, - pub scopes: Option>, -} - -// NewToken is re-exported from windmill-api-auth above - -#[derive(Deserialize)] -pub struct Login { - pub email: String, - pub password: String, -} - -lazy_static::lazy_static! { - static ref FIRST_TIME_SETUP: Arc = Arc::new(AtomicBool::new(true)); -} - -pub async fn is_first_time_setup(Extension(db): Extension) -> JsonResult { - if !FIRST_TIME_SETUP.load(std::sync::atomic::Ordering::Relaxed) { - return Ok(Json(false)); - } - let single_user = sqlx::query_scalar!("SELECT 1 FROM password LIMIT 2") - .fetch_all(&db) - .await - .ok() - .unwrap_or_default() - .len() - == 1; - if single_user { - let user_is_admin_and_password_changeme = sqlx::query_scalar!( - "SELECT 1 FROM password WHERE email = 'admin@windmill.dev' AND password_hash = '$argon2id$v=19$m=4096,t=3,p=1$oLJo/lPn/gezXCuFOEyaNw$i0T2tCkw3xUFsrBIKZwr8jVNHlIfoxQe+HfDnLtd12I'" - ).fetch_all(&db) - .await - .ok() - .unwrap_or_default() - .len() == 1; - if user_is_admin_and_password_changeme { - let base_url_is_not_set = - sqlx::query_scalar!("SELECT COUNT(*) FROM global_settings WHERE name = 'base_url'") - .fetch_optional(&db) - .await - .ok() - .flatten() - .flatten() - .unwrap_or(0) - == 0; - if base_url_is_not_set { - return Ok(Json(true)); - } - } - } - FIRST_TIME_SETUP.store(false, std::sync::atomic::Ordering::Relaxed); - Ok(Json(false)) -} - -#[derive(Deserialize)] -struct WorkspaceUsername { - pub username: String, -} - -async fn exists_username( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, - Json(WorkspaceUsername { username }): Json, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)", - &w_id, - &username - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - tx.commit().await?; - Ok(Json(exists)) -} - -async fn list_users( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - if *CLOUD_HOSTED && w_id == "demo" { - require_admin(authed.is_admin, &authed.username)?; - } - let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as!( - User, - " - SELECT * - FROM usr - WHERE workspace_id = $1 - ", - w_id - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -async fn list_user_usage( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - if *CLOUD_HOSTED && w_id == "demo" { - require_admin(authed.is_admin, &authed.username)?; - } - let mut tx = user_db.begin(&authed).await?; - let rows = tokio::time::timeout( - Duration::from_secs(300), - sqlx::query_as!( - UserWithUsage, - " - SELECT usr.email, usage.executions - FROM usr, LATERAL ( - SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions - FROM v2_job_completed c JOIN v2_job j USING (id) - WHERE j.workspace_id = $1 - AND j.kind NOT IN ('flow', 'flowpreview', 'flownode') - AND j.permissioned_as_email = usr.email - AND now() - '1 week'::interval < j.created_at - ) usage - WHERE workspace_id = $1 - ", - w_id - ) - .fetch_all(&mut *tx), - ) - .await - .map_err(|e| Error::internal_err(format!("Timed out while fetching user usage: {e:#}")))??; - tx.commit().await?; - Ok(Json(rows)) -} - -#[derive(Deserialize)] -struct ActiveUsersOnly { - active_only: Option, -} - -async fn list_users_as_super_admin( - authed: ApiAuthed, - Extension(db): Extension, - Query(pagination): Query, - Query(ActiveUsersOnly { active_only }): Query, -) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; - let per_page = pagination.per_page.unwrap_or(10000).max(1); - let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page; - - let rows = if active_only.is_some_and(|x| x) { - sqlx::query_as!( - GlobalUserInfo, - "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), - authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user - FROM password - WHERE email IN (SELECT email FROM active_users) - ORDER BY super_admin DESC, devops DESC - LIMIT $1 OFFSET $2", - per_page as i32, - offset as i32 - ) - .fetch_all(&db) - .await? - } else { - sqlx::query_as!( - GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ - $1 OFFSET $2", - per_page as i32, - offset as i32 - ) - .fetch_all(&db) - .await? - }; - - Ok(Json(rows)) -} - -#[derive(Serialize, Deserialize)] -struct Progress { - progress: u64, - skipped_all: bool, -} -async fn get_tutorial_progress( - authed: ApiAuthed, - Extension(db): Extension, -) -> JsonResult { - let row = sqlx::query!( - "SELECT progress::bigint as progress, skipped_all FROM tutorial_progress WHERE email = $1", - authed.email - ) - .fetch_optional(&db) - .await?; - - if let Some(row) = row { - Ok(Json(Progress { - progress: row.progress.unwrap_or_default() as u64, - skipped_all: row.skipped_all, - })) - } else { - Ok(Json(Progress { progress: 0, skipped_all: false })) - } -} - -async fn update_tutorial_progress( - authed: ApiAuthed, - Extension(db): Extension, - Json(progress): Json, -) -> Result { - sqlx::query!( - "INSERT INTO tutorial_progress (email, progress, skipped_all) VALUES ($2, $1::bigint::bit(64), $3) ON CONFLICT (email) DO UPDATE SET progress = EXCLUDED.progress, skipped_all = EXCLUDED.skipped_all", - progress.progress as i64, - authed.email, - progress.skipped_all - ) - .execute(&db) - .await?; - Ok("tutorial progress updated".to_string()) -} - -async fn list_usernames( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - if *CLOUD_HOSTED && w_id == "demo" { - return Ok(Json(vec![ - authed.username, - "other_usernames_redacted_in_demo_workspace".to_string(), - ])); - } - let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -async fn list_invites( - authed: ApiAuthed, - Extension(db): Extension, -) -> JsonResult> { - let mut tx = db.begin().await?; - let rows = sqlx::query_as!( - WorkspaceInvite, - "SELECT - workspace_invite.workspace_id, - workspace_invite.email, - workspace_invite.is_admin, - workspace_invite.operator, - workspace.parent_workspace_id - FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", - authed.email - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -lazy_static::lazy_static! { - static ref INVALIDATE_ALL_SESSIONS_ON_LOGOUT: bool = std::env::var("INVALIDATE_ALL_SESSIONS_ON_LOGOUT") - .unwrap_or("false".to_string()) - .parse::() - .unwrap_or(false); -} - -#[derive(Deserialize)] -struct LogoutQuery { - rd: Option, -} -async fn logout( - Tokened { token }: Tokened, - cookies: Cookies, - Extension(db): Extension, - Query(LogoutQuery { rd }): Query, -) -> Result { - let mut cookie = Cookie::new(COOKIE_NAME, ""); - cookie.set_path(COOKIE_PATH); - if COOKIE_DOMAIN.is_some() { - cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); - } - cookies.remove(cookie); - let mut tx = db.begin().await?; - - let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { - sqlx::query_scalar!( - "WITH email_lookup AS ( - SELECT email FROM token WHERE token = $1 - ) - DELETE FROM token - WHERE email = (SELECT email FROM email_lookup) AND label = 'session' - RETURNING email", - token - ) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token) - .fetch_optional(&mut *tx) - .await? - }; - - if let Some(email) = email { - let email = email.unwrap_or("noemail".to_string()); - let audit_message = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { - "users.logout_all" - } else { - "users.logout" - }; - audit_log( - &mut *tx, - &AuditAuthor { - email: email.clone(), - username: email, - username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), - }, - audit_message, - ActionKind::Delete, - "global", - Some(&truncate_token(&token)), - None, - ) - .await?; - } - tx.commit().await?; - if let Some(rd) = rd { - Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response()) - } else { - Ok((StatusCode::OK, "logged out successfully".to_string()).into_response()) - } -} - -async fn whoami( - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed, -) -> JsonResult { - let user = get_user(&w_id, &username, &db).await?; - if let Some(user) = user { - Ok(Json(user)) - } else { - Ok(Json(UserInfo { - workspace_id: w_id, - email: email.clone(), - username: email, - name: None, - is_admin, - is_super_admin: is_admin, - created_at: chrono::Utc::now(), - groups: groups, - operator: false, - disabled: false, - role: Some("superadmin".to_string()), - folders_read: folders.clone().into_iter().map(|x| x.0).collect(), - folders: folders - .clone() - .into_iter() - .filter_map(|x| if x.1 { Some(x.0) } else { None }) - .collect(), - folders_owners: folders - .into_iter() - .filter_map(|x| if x.2 { Some(x.0) } else { None }) - .collect(), - })) - } -} - -async fn global_whoami( - Extension(db): Extension, - ApiAuthed { email, .. }: ApiAuthed, - Tokened { token }: Tokened, -) -> JsonResult { - let user = sqlx::query_as!( - GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE \ - email = $1", - email - ) - .fetch_one(&db) - .await - .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}"))); - - if let Ok(user) = user { - Ok(Json(user)) - } else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) { - Ok(Json(GlobalUserInfo { - email: email.clone(), - login_type: Some("superadmin_secret".to_string()), - super_admin: true, - devops: false, - verified: true, - name: None, - company: None, - username: None, - operator_only: None, - first_time_user: false, - })) - } else { - Err(user.unwrap_err()) - } -} - -async fn exists_email(Extension(db): Extension, Path(email): Path) -> JsonResult { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)", - email - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - Ok(Json(exists)) -} - -async fn get_email(ApiAuthed { email, .. }: ApiAuthed) -> Result { - Ok(email) -} - -async fn get_usage( - Extension(db): Extension, - ApiAuthed { email, .. }: ApiAuthed, -) -> Result { - let usage = sqlx::query_scalar!( - " - SELECT usage.usage FROM usage - WHERE is_workspace = false - AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) - AND id = $1", - email - ) - .fetch_optional(&db) - .await? - .unwrap_or(0); - Ok(usage.to_string()) -} - -#[derive(FromRow, Serialize)] -pub struct User2 { - pub workspace_id: String, - pub email: String, - pub username: String, - pub is_admin: bool, - pub created_at: chrono::DateTime, - pub operator: bool, - pub disabled: bool, - pub role: Option, - pub super_admin: bool, - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub added_via: Option, -} - -async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { - let user = sqlx::query_as!( - User2, - "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 - ", - username, - w_id - ) - .fetch_optional(db) - .await?; - let groups = get_groups_for_user( - &w_id, - username, - &user - .as_ref() - .map(|x| x.email.to_string()) - .unwrap_or_else(|| "".to_string()), - db, - ) - .await?; - let folders = get_folders_for_user(&w_id, username, &groups, db).await?; - - Ok(user.map(|usr| UserInfo { - groups, - workspace_id: usr.workspace_id, - email: usr.email, - username: usr.username, - name: usr.name, - is_admin: usr.is_admin, - is_super_admin: usr.super_admin, - created_at: usr.created_at, - operator: usr.operator, - disabled: usr.disabled, - role: usr.role, - folders_read: folders.clone().into_iter().map(|x| x.0).collect(), - folders: folders - .clone() - .into_iter() - .filter_map(|x| if x.1 { Some(x.0) } else { None }) - .collect(), - folders_owners: folders - .into_iter() - .filter_map(|x| if x.2 { Some(x.0) } else { None }) - .collect(), - })) -} - -pub async fn is_owner_of_path( - authed: ApiAuthed, - Path((_w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - if authed.is_admin { - Ok(Json(true)) - } else { - Ok(Json(require_owner_of_path(&authed, path).is_ok())) - } -} - -pub use windmill_api_auth::require_owner_of_path; - -// get_perm_in_extra_perms_for_authed and require_is_writer are re-exported from windmill-api-auth above -async fn whois( - Extension(db): Extension, - Path((w_id, username)): Path<(String, String)>, -) -> JsonResult { - let user_o = get_user(&w_id, &username, &db).await?; - let user = not_found_if_none(user_o, "User", username)?; - Ok(Json(user)) -} - -// async fn create_invite_code( -// ApiAuthed { email, .. }: ApiAuthed, -// Extension(db): Extension, -// Json(nu): Json, -// ) -> Result<(StatusCode, String)> { - -// let mut tx = db.begin().await?; -// require_super_admin(&mut *tx, email).await?; - -// sqlx::query!( -// "INSERT INTO invite_code -// (code, seats_left) -// VALUES ($1, $2)", -// nu.code, -// nu.seats -// ) -// .execute(&mut *tx) -// .await?; - -// tx.commit().await?; - -// Ok(( -// StatusCode::CREATED, -// format!("new invite code {}", nu.code), -// )) -// } - -async fn decline_invite( - authed: ApiAuthed, - Extension(db): Extension, - Json(nu): Json, -) -> Result<(StatusCode, String)> { - let mut tx = db.begin().await?; - - let is_admin = sqlx::query_scalar!( - "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin", - nu.workspace_id, - authed.email, - ) - .fetch_optional(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.decline_invite", - ActionKind::Delete, - &nu.workspace_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - if is_admin.is_some() { - Ok(( - StatusCode::OK, - format!( - "user {} declined invite to workspace {}", - &authed.email, nu.workspace_id - ), - )) - } else { - Err(Error::NotFound(format!( - "invite for {} not found", - authed.email - ))) - } -} - -lazy_static! { - pub static ref VALID_USERNAME: Regex = Regex::new(r#"^[a-zA-Z][a-zA-Z_0-9]*$"#).unwrap(); -} - -async fn accept_invite( - authed: ApiAuthed, - Extension(webhook): Extension, - Extension(db): Extension, - Json(nu): Json, -) -> Result<(StatusCode, String)> { - let mut tx = db.begin().await?; - - let r = sqlx::query!( - "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator", - nu.workspace_id, - authed.email, - ) - .fetch_optional(&mut *tx) - .await?; - - if let Some(r) = r { - let already_in_workspace = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", - &nu.workspace_id, - &authed.email, - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - - if already_in_workspace { - tx.commit().await?; - return Ok(( - StatusCode::CREATED, - format!( - "user {} accepted invite to workspace {}", - &authed.email, nu.workspace_id - ), - )); - } - let username; - (tx, username) = join_workspace( - &nu.workspace_id, - &authed, - nu.username, - r.is_admin, - r.operator, - tx, - ) - .await?; - - audit_log( - &mut *tx, - &ApiAuthed { username: username.clone(), ..authed.clone() }, - "users.accept_invite", - ActionKind::Create, - &nu.workspace_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &username, - &db, - &nu.workspace_id, - windmill_git_sync::DeployedObject::User { email: authed.email.clone() }, - Some(format!("User '{}' accepted invite", &authed.email)), - true, - None, - ) - .await?; - webhook.send_instance_event(InstanceEvent::UserJoinedWorkspace { - email: authed.email.clone(), - workspace: nu.workspace_id.clone(), - username: username, - }); - Ok(( - StatusCode::CREATED, - format!( - "user {} accepted invite to workspace {}", - &authed.email, nu.workspace_id - ), - )) - } else { - Err(Error::NotFound(format!( - "invite for {} not found", - authed.email - ))) - } -} - -async fn join_workspace<'c>( - w_id: &str, - authed: &ApiAuthed, - username: Option, - is_admin: bool, - operator: bool, - mut tx: sqlx::Transaction<'c, sqlx::Postgres>, -) -> error::Result<(sqlx::Transaction<'c, sqlx::Postgres>, String)> { - let automate_username_creation = sqlx::query_scalar!( - "SELECT value FROM global_settings WHERE name = $1", - AUTOMATE_USERNAME_CREATION_SETTING, - ) - .fetch_optional(&mut *tx) - .await? - .map(|v| v.as_bool()) - .flatten() - .unwrap_or(false); - - let username = if automate_username_creation { - if username.is_some() && username.unwrap().len() > 0 { - return Err(Error::BadRequest( - "username is not allowed when username creation is automated".to_string(), - )); - } - get_instance_username_or_create_pending(&mut tx, &authed.email).await? - } else { - let username = username.ok_or(Error::BadRequest("username is required".to_string()))?; - let already_exists_username = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)", - &w_id, - username, - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - - if already_exists_username { - return Err(Error::BadRequest(format!( - "user with username {} already exists in workspace {}", - username, w_id - ))); - } - - if !VALID_USERNAME.is_match(&username) { - return Err(windmill_common::error::Error::BadRequest(format!( - "Usermame can only contain alphanumeric characters and underscores and must start with a letter" - ))); - } - username.to_string() - }; - - let already_exists_email = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", - &w_id, - authed.email, - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - - if already_exists_email { - return Err(Error::BadRequest(format!( - "user with email {} already exists in workspace {}", - authed.email, w_id - ))); - } - - sqlx::query!( - "INSERT INTO usr - (workspace_id, email, username, is_admin, operator) - VALUES ($1, $2, $3, $4, $5)", - &w_id, - authed.email, - username, - is_admin, - operator - ) - .execute(&mut *tx) - .await?; - sqlx::query_as!( - Group, - "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - &w_id, - username, - "all", - ) - .execute(&mut *tx) - .await?; - audit_log( - &mut *tx, - &AuditAuthor { username: username.clone(), ..authed.into() }, - "users.add_to_workspace", - ActionKind::Create, - &w_id, - Some(&authed.email), - None, - ) - .await?; - Ok((tx, username)) -} - -async fn leave_instance(Extension(db): Extension, authed: ApiAuthed) -> Result { - let mut tx = db.begin().await?; - sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.leave", - ActionKind::Delete, - "global", - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Left instance",)) -} - -async fn get_workspace_user( - ApiAuthed { username, is_admin, .. }: ApiAuthed, - Extension(db): Extension, - Path((w_id, username_to_update)): Path<(String, String)>, -) -> Result> { - require_admin(is_admin, &username)?; - - let user = sqlx::query_as!( - User, - "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2", - &username_to_update, - &w_id - ) - .fetch_optional(&db) - .await?; - - let user = not_found_if_none(user, "User", username_to_update)?; - - Ok(Json(user)) -} - -async fn update_workspace_user( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, username_to_update)): Path<(String, String)>, - Json(eu): Json, -) -> Result { - let mut tx = db.begin().await?; - - require_admin(authed.is_admin, &authed.username)?; - - update_workspace_user_internal( - &w_id, - &username_to_update, - eu.is_admin, - eu.operator, - eu.disabled, - &mut tx, - Some(&authed), - ) - .await?; - - let user_email = sqlx::query_scalar!( - "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", - &username_to_update, - &w_id - ) - .fetch_one(&mut *tx) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::User { email: user_email.clone() }, - Some(format!("Updated user '{}'", &user_email)), - true, - None, - ) - .await?; - - Ok(format!("user {} updated", user_email)) -} - -async fn convert_user_to_group( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, username_to_convert)): Path<(String, String)>, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - let mut tx = db.begin().await?; - - // Get user email and current status - let user_info = sqlx::query!( - "SELECT email, is_admin, operator, added_via FROM usr WHERE username = $1 AND workspace_id = $2", - username_to_convert, - &w_id - ) - .fetch_optional(&mut *tx) - .await?; - - let user_info = not_found_if_none(user_info, "User", &username_to_convert)?; - - // Check if user is already a group user - if let Some(added_via) = &user_info.added_via { - if added_via.get("source").and_then(|v| v.as_str()) == Some("instance_group") { - return Err(Error::BadRequest( - "User is already a group user".to_string(), - )); - } - } - - // Find which instance groups this user belongs to that are configured for auto-add in this workspace - let eligible_groups = sqlx::query!( - r#" - SELECT - eig.igroup as group_name, - ws.auto_invite->'instance_groups_roles' as instance_groups_roles - FROM email_to_igroup eig - INNER JOIN workspace_settings ws ON ws.workspace_id = $1 - WHERE eig.email = $2 - AND ws.auto_invite->'instance_groups' ? eig.igroup - "#, - &w_id, - &user_info.email - ) - .fetch_all(&mut *tx) - .await?; - - if eligible_groups.is_empty() { - return Err(Error::BadRequest( - "User is not a member of any instance groups configured for auto-add in this workspace" - .to_string(), - )); - } - - // Determine the group with highest precedence (same logic as process_instance_group_auto_adds) - let roles: std::collections::HashMap = - if let Some(roles_json) = &eligible_groups[0].instance_groups_roles { - serde_json::from_value(roles_json.clone()).unwrap_or_default() - } else { - std::collections::HashMap::new() - }; - - let mut best_group = &eligible_groups[0].group_name; - let mut best_precedence = 0u8; - - for group in &eligible_groups { - let default_role = "developer".to_string(); - let role = roles.get(&group.group_name).unwrap_or(&default_role); - - let precedence = match role.as_str() { - "admin" => 3, - "developer" => 2, - "operator" => 1, - _ => 2, - }; - - if precedence > best_precedence { - best_precedence = precedence; - best_group = &group.group_name; - } - } - - let primary_group_name = best_group; - - // Determine role from group configuration using the selected primary group - let default_role = "developer".to_string(); - let role = roles - .get(primary_group_name) - .unwrap_or(&default_role) - .as_str(); - - let (is_admin, is_operator) = match role { - "admin" => (true, false), - "operator" => (false, true), - _ => (false, false), - }; - - // Update user with instance group information - let instance_group_source = serde_json::json!({ - "source": "instance_group", - "group": primary_group_name - }); - - sqlx::query!( - "UPDATE usr SET added_via = $1, is_admin = $2, operator = $3 WHERE username = $4 AND workspace_id = $5", - instance_group_source, - is_admin, - is_operator, - username_to_convert, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.convert_to_group", - ActionKind::Update, - &w_id, - Some(&username_to_convert), - Some([("group", primary_group_name.as_str()), ("role", role)].into()), - ) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::User { email: user_info.email.clone() }, - Some(format!( - "Converted user '{}' to group user (group: {}, role: {})", - &user_info.email, primary_group_name, role - )), - true, - None, - ) - .await?; - - Ok(format!( - "User {} converted to group user (group: {}, role: {})", - username_to_convert, primary_group_name, role - )) -} - -async fn update_user( - authed: ApiAuthed, - Path(email_to_update): Path, - Extension(db): Extension, - Json(eu): Json, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - let mut new_super_admin: Option = None; - if let Some(sa) = eu.is_super_admin { - sqlx::query_scalar!( - "UPDATE password SET super_admin = $1 WHERE email = $2", - sa, - &email_to_update - ) - .execute(&mut *tx) - .await?; - new_super_admin = Some(sa); - } - - if let Some(dv) = eu.is_devops { - sqlx::query_scalar!( - "UPDATE password SET devops = $1 WHERE email = $2", - dv, - &email_to_update - ) - .execute(&mut *tx) - .await?; - // If super_admin wasn't explicitly set, we still need to refresh tokens - if new_super_admin.is_none() { - new_super_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", - &email_to_update - ) - .fetch_optional(&mut *tx) - .await?; - } - } - - if let Some(sa) = new_super_admin { - // Delete session tokens to force re-login with new privileges - sqlx::query!( - "DELETE FROM token WHERE email = $1 AND label = 'session'", - &email_to_update - ) - .execute(&mut *tx) - .await?; - // Update super_admin flag on non-session tokens (webhooks, API tokens, etc.) - sqlx::query!( - "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", - sa, - &email_to_update - ) - .execute(&mut *tx) - .await?; - } - - if let Some(n) = eu.name { - sqlx::query_scalar!( - "UPDATE password SET name = $1 WHERE email = $2", - n, - &email_to_update - ) - .execute(&mut *tx) - .await?; - } - - audit_log( - &mut *tx, - &authed, - "users.update", - ActionKind::Update, - "global", - Some(&email_to_update), - None, - ) - .await?; - tx.commit().await?; - Ok(format!("email {} updated", &email_to_update)) -} - -async fn delete_user( - authed: ApiAuthed, - Path(email_to_delete): Path, - Extension(db): Extension, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) - .execute(&mut *tx) - .await?; - - let usernames = sqlx::query_scalar!( - "DELETE FROM usr WHERE email = $1 RETURNING username", - &email_to_delete - ) - .fetch_all(&mut *tx) - .await?; - - for username in usernames { - sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) - .execute(&mut *tx) - .await?; - - sqlx::query!("DELETE FROM usr_to_group WHERE usr = $1", &username) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM workspace_invite WHERE email = $1", - &email_to_delete - ) - .execute(&mut *tx) - .await?; - } - - // Remove user from all instance groups email_to_igroup - sqlx::query!( - "DELETE FROM email_to_igroup WHERE email = $1", - &email_to_delete - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.delete", - ActionKind::Delete, - "global", - Some(&email_to_delete), - None, - ) - .await?; - tx.commit().await?; - Ok(format!("email {} deleted", &email_to_delete)) -} - -lazy_static::lazy_static! { - pub static ref NEW_USER_WEBHOOK: Option = std::env::var("NEW_USER_WEBHOOK").ok(); - } async fn create_user( authed: ApiAuthed, Extension(db): Extension, - Extension(webhook): Extension, + Extension(webhook): Extension, Extension(argon2): Extension>>, Json(nu): Json, ) -> Result<(StatusCode, String)> { @@ -1442,152 +65,6 @@ async fn submit_onboarding_data( crate::users_oss::submit_onboarding_data(authed, Extension(db), Json(data)).await } -/// Internal helper for updating workspace user permissions - used by both API and system operations -pub async fn update_workspace_user_internal( - w_id: &str, - username_to_update: &str, - is_admin: Option, - operator: Option, - disabled: Option, - tx: &mut Transaction<'_, Postgres>, - authed: Option<&ApiAuthed>, // None for system operations -) -> Result<()> { - if let Some(a) = is_admin { - sqlx::query_scalar!( - "UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3", - a, - username_to_update, - w_id - ) - .execute(&mut **tx) - .await?; - } - if let Some(a) = operator { - sqlx::query_scalar!( - "UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3", - a, - username_to_update, - w_id - ) - .execute(&mut **tx) - .await?; - } - if let Some(a) = disabled { - sqlx::query_scalar!( - "UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3", - a, - username_to_update, - w_id - ) - .execute(&mut **tx) - .await?; - } - - // Only audit if we have an authenticated user (API calls) - if let Some(auth) = authed { - audit_log( - &mut **tx, - auth, - "users.update", - ActionKind::Update, - w_id, - Some(username_to_update), - None, - ) - .await?; - } - - Ok(()) -} - -/// Internal helper for deleting workspace users - used by both API and system operations -pub async fn delete_workspace_user_internal( - w_id: &str, - username_to_delete: &str, - email_to_delete: &str, - tx: &mut Transaction<'_, Postgres>, - authed: Option<&ApiAuthed>, // None for system operations -) -> Result<()> { - sqlx::query_scalar!( - "DELETE FROM usr WHERE email = $1 AND workspace_id = $2", - email_to_delete, - w_id - ) - .execute(&mut **tx) - .await?; - - sqlx::query!( - "DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2", - username_to_delete, - w_id - ) - .execute(&mut **tx) - .await?; - - // Only audit if we have an authenticated user (API calls) - if let Some(auth) = authed { - audit_log( - &mut **tx, - auth, - "users.delete", - ActionKind::Delete, - w_id, - Some(username_to_delete), - None, - ) - .await?; - } - - Ok(()) -} - -async fn delete_workspace_user( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, username_to_delete)): Path<(String, String)>, -) -> Result { - let mut tx = db.begin().await?; - - require_admin(authed.is_admin, &authed.username)?; - - let email_to_delete_o = sqlx::query_scalar!( - "SELECT email FROM usr where username = $1 AND workspace_id = $2", - username_to_delete, - &w_id, - ) - .fetch_optional(&db) - .await?; - - let email_to_delete = not_found_if_none(email_to_delete_o, "User", &username_to_delete)?; - - delete_workspace_user_internal( - &w_id, - &username_to_delete, - &email_to_delete, - &mut tx, - Some(&authed), - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::User { email: email_to_delete.clone() }, - Some(format!( - "Removed user '{}' from workspace", - &email_to_delete - )), - true, - None, - ) - .await?; - - Ok(format!("username {} deleted", username_to_delete)) -} - async fn set_password( Extension(db): Extension, Extension(argon2): Extension>>, @@ -1609,688 +86,6 @@ async fn set_password_of_user( crate::users_oss::set_password(db, argon2, authed, &email, ep).await } -async fn set_login_type( - Extension(db): Extension, - Path(email): Path, - authed: ApiAuthed, - Json(et): Json, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - - sqlx::query!( - "UPDATE password SET login_type = $1 WHERE email = $2", - et.login_type, - email - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.set_login_type", - ActionKind::Update, - "global", - Some(&email), - None, - ) - .await?; - - tx.commit().await?; - Ok(format!( - "login type of {} updated to {}", - email, et.login_type - )) -} - -#[allow(unreachable_code, unused_variables)] -async fn login( - cookies: Cookies, - Extension(db): Extension, - Extension(argon2): Extension>>, - Json(Login { email, password }): Json, -) -> Result { - #[cfg(feature = "no_auth")] - { - return Ok("no_auth".to_string()); - } - - let mut tx = db.begin().await?; - let email = email.to_lowercase(); - let audit_author = AuditAuthor { - email: email.clone(), - username: email.clone(), - username_override: None, - token_prefix: None, - }; - let email_w_h: Option<(String, String, bool)> = sqlx::query_as( - "SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \ - 'password'", - ) - .bind(&email) - .fetch_optional(&mut *tx) - .await?; - - if let Some((email, hash, super_admin)) = email_w_h { - let parsed_hash = - PasswordHash::new(&hash).map_err(|e| Error::internal_err(e.to_string()))?; - if argon2 - .verify_password(password.as_bytes(), &parsed_hash) - .is_err() - { - audit_log( - &mut *tx, - &audit_author, - "users.login_failure", - ActionKind::Create, - "global", - None, - None, - ) - .await?; - Err(Error::BadRequest("Invalid login".to_string())) - } else { - let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; - - let audit_author = AuditAuthor { - email: email.clone(), - username: email.clone(), - username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), - }; - - audit_log( - &mut *tx, - &audit_author, - "users.login", - ActionKind::Create, - "global", - Some(&truncate_token(&token)), - None, - ) - .await?; - - tx.commit().await?; - Ok(token) - } - } else { - audit_log( - &mut *tx, - &audit_author, - "users.login_failure", - ActionKind::Create, - "global", - None, - None, - ) - .await?; - Err(Error::BadRequest("Invalid login".to_string())) - } -} - -#[derive(Deserialize)] -struct RefreshTokenQuery { - if_expiring_in_less_than_s: Option, -} -async fn refresh_token( - Extension(db): Extension, - Query(query): Query, - Tokened { token }: Tokened, - authed: ApiAuthed, - cookies: Cookies, -) -> Result { - let mut tx = db.begin().await?; - - if let Some(thresh_s) = query.if_expiring_in_less_than_s { - let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &token, thresh_s) - .fetch_optional(&db) - .await? - .flatten() - .unwrap_or(false); - if not_expired { - return Ok("token expiry is far enough".to_string()); - } - } - - let super_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", - &authed.email - ) - .fetch_optional(&mut *tx) - .await? - .unwrap_or(false); - - let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; - - audit_log( - &mut *tx, - &AuditAuthor { - email: authed.email.to_string(), - username: authed.email.to_string(), - username_override: None, - token_prefix: authed.token_prefix, - }, - "users.token.refresh", - ActionKind::Create, - &"global", - Some(&truncate_token(&new_token)), - None, - ) - .await?; - - tx.commit().await?; - Ok("token refreshed".to_string()) -} - -lazy_static::lazy_static! { - static ref MAX_SESSION_VALIDITY_SECONDS: i64 = std::env::var("MAX_SESSION_VALIDITY_SECONDS").ok().unwrap_or_else(|| String::new()).parse::().unwrap_or(3 * 24 * 60 * 60); - static ref INVALIDATE_OLD_SESSIONS: bool = std::env::var("INVALIDATE_OLD_SESSIONS").ok().unwrap_or_else(|| String::new()).parse::().unwrap_or(false); -} - -pub async fn create_session_token<'c>( - email: &str, - super_admin: bool, - tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, - cookies: Cookies, -) -> Result { - let token = rd_string(32); - - if *INVALIDATE_OLD_SESSIONS { - sqlx::query!( - "DELETE FROM token WHERE email = $1 AND label = 'session'", - email - ) - .execute(&mut **tx) - .await?; - - audit_log( - &mut **tx, - &AuditAuthor { - email: email.to_string(), - username: email.to_string(), - username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), - }, - "users.token.invalidate_old_sessions", - ActionKind::Delete, - &"global", - None, - None, - ) - .instrument(tracing::info_span!("token", email)) - .await?; - } - - sqlx::query!( - "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - token, - email, - "session", - &MAX_SESSION_VALIDITY_SECONDS.to_string(), - super_admin - ) - .execute(&mut **tx) - .await?; - - let mut cookie = Cookie::new(COOKIE_NAME, token.clone()); - cookie.set_secure(IS_SECURE.read().await.clone()); - cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax)); - cookie.set_http_only(true); - cookie.set_path(COOKIE_PATH); - if COOKIE_DOMAIN.is_some() { - cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); - } - - let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc(); - expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS); - cookie.set_expires(expire); - cookies.add(cookie); - Ok(token) -} - -// create_token_internal is re-exported from windmill-api-auth above - -async fn create_token( - Extension(db): Extension, - authed: ApiAuthed, - Json(token_config): Json, -) -> Result<(StatusCode, String)> { - let mut tx = db.begin().await?; - - let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; - - tx.commit().await?; - Ok((StatusCode::CREATED, token)) -} - -async fn impersonate( - Extension(db): Extension, - authed: ApiAuthed, - Json(new_token): Json, -) -> Result<(StatusCode, String)> { - let token = rd_string(32); - require_super_admin(&db, &authed.email).await?; - - if new_token.impersonate_email.is_none() { - return Err(Error::BadRequest( - "impersonate_username is required".to_string(), - )); - } - - let impersonated = new_token.impersonate_email.unwrap(); - - let is_super_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", - impersonated - ) - .fetch_optional(&db) - .await? - .unwrap_or(false); - let mut tx = db.begin().await?; - - sqlx::query!( - "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5)", - token, - impersonated, - new_token.label, - new_token.expiration, - is_super_admin - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.impersonate", - ActionKind::Delete, - &"global", - Some(&token[0..10]), - Some([("impersonated", &format!("{impersonated}")[..])].into()), - ) - .instrument(tracing::info_span!("token", email = &impersonated)) - .await?; - tx.commit().await?; - Ok((StatusCode::CREATED, token)) -} - -#[derive(Deserialize)] -struct ListTokenQuery { - exclude_ephemeral: Option, -} - -async fn list_tokens( - Extension(db): Extension, - ApiAuthed { email, .. }: ApiAuthed, - Query(query): Query, - Query(pagination): Query, -) -> JsonResult> { - let (per_page, offset) = paginate(pagination); - let rows = if query.exclude_ephemeral.unwrap_or(false) { - sqlx::query_as!( - TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ - last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - email, - per_page as i64, - offset as i64, - ) - .fetch_all(&db) - .await? - } else { - sqlx::query_as!( - TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ - last_used_at, scopes FROM token WHERE email = $1 - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - email, - per_page as i64, - offset as i64, - ) - .fetch_all(&db) - .await? - }; - Ok(Json(rows)) -} - -async fn delete_token( - Extension(db): Extension, - authed: ApiAuthed, - Path(token_prefix): Path, -) -> Result { - let mut tx = db.begin().await?; - - let tokens_deleted: Vec = sqlx::query_scalar( - "DELETE FROM token - WHERE email = $1 - AND token LIKE concat($2::text, '%') - RETURNING concat(substring(token for 10), '*****')", - ) - .bind(&authed.email) - .bind(&token_prefix) - .fetch_all(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.token.delete", - ActionKind::Delete, - &"global", - Some(&token_prefix), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!( - "deleted {} tokens {:?} with prefix {}", - tokens_deleted.len(), - tokens_deleted, - token_prefix - )) -} - -async fn leave_workspace( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, -) -> Result { - let mut tx = db.begin().await?; - sqlx::query!( - "DELETE FROM usr WHERE workspace_id = $1 AND username = $2", - &w_id, - authed.username - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.leave_workspace", - ActionKind::Delete, - &w_id, - None, - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("left workspace {w_id}")) -} - -#[derive(Serialize)] -struct Runnable { - workspace: String, - endpoint_async: String, - endpoint_sync: String, - summary: String, - description: String, - schema: Option, - kind: String, - path: String, -} - -async fn get_all_runnables( - Extension(db): Extension, - authed: ApiAuthed, - Tokened { token }: Tokened, - Extension(cache): Extension>, -) -> JsonResult> { - let mut tx = db.clone().begin(&authed).await?; - let mut runnables = Vec::new(); - let workspaces = sqlx::query_scalar!( - "SELECT workspace.id as id FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \ - usr.email = $1 AND deleted = false", - authed.email - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - - for workspace in workspaces { - let nauthed = cache - .get_authed(Some(workspace.clone()), &token) - .await - .ok_or_else(|| { - Error::BadRequest(format!("not authorized to access workspace: {workspace}")) - })?; - let mut tx = db.clone().begin(&nauthed).await?; - let flows = sqlx::query!( - "SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema - FROM flow - LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1", - workspace - ) - .fetch_all(&mut *tx) - .await?; - runnables.extend( - flows - .into_iter() - .map(|f| Runnable { - workspace: f.workspace.clone(), - endpoint_async: format!("/w/{}/jobs/run/f/{}", &f.workspace, &f.path), - endpoint_sync: format!( - "/w/{}/jobs/run_wait_result/f/{}", - &f.workspace, &f.path - ), - summary: f.summary, - description: f.description, - schema: f.schema, - kind: "flow".to_string(), - path: f.path, - }) - .collect::>(), - ); - let scripts = sqlx::query!( - "SELECT workspace_id as workspace, path, summary, description, schema FROM script as o - WHERE created_at = (select max(created_at) from script where o.path = path and workspace_id = $1 AND archived = false) - AND workspace_id = $1 and archived = false", workspace - ) - .fetch_all(&mut *tx) - .await?; - runnables.extend( - scripts - .into_iter() - .map(|s| Runnable { - workspace: s.workspace.clone(), - endpoint_async: format!("/w/{}/jobs/run/p/{}", &s.workspace, &s.path), - endpoint_sync: format!( - "/w/{}/jobs/run_wait_result/p/{}", - &s.workspace, &s.path - ), - summary: s.summary, - description: s.description, - schema: s.schema, - kind: "script".to_string(), - path: s.path, - }) - .collect::>(), - ); - tx.commit().await?; - } - Ok(Json(runnables)) -} - -//used by oauth -#[allow(dead_code)] -#[derive(Deserialize, Debug, Clone)] -pub struct LoginUserInfo { - pub email: Option, - pub name: Option, - pub company: Option, - pub preferred_username: Option, - pub displayName: Option, -} - -#[derive(Serialize)] -struct InstanceUsernameInfo { - username: String, - workspace_usernames: Vec, -} - -#[derive(Serialize)] -struct WorkspaceUsernameInfo { - workspace_id: String, - username: String, -} -async fn get_instance_username_info( - ApiAuthed { email, .. }: ApiAuthed, - Path(user_email): Path, - Extension(db): Extension, -) -> JsonResult { - require_super_admin(&db, &email).await?; - let mut tx = db.begin().await?; - let instance_username = match sqlx::query_scalar!( - "SELECT username FROM password WHERE email = $1", - &user_email - ) - .fetch_one(&mut *tx) - .await? - { - Some(username) => username, - None => generate_instance_wide_unique_username(&mut tx, &user_email).await?, - }; - - let workspace_usernames = sqlx::query_as!( - WorkspaceUsernameInfo, - "SELECT workspace_id, username FROM usr WHERE email = $1", - &user_email - ) - .fetch_all(&mut *tx) - .await?; - - Ok(Json(InstanceUsernameInfo { - username: instance_username, - workspace_usernames: workspace_usernames, - })) -} - -async fn username_to_email( - Path((w_id, username)): Path<(String, String)>, - Extension(db): Extension, -) -> Result { - let email = sqlx::query_scalar!( - "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", - &username, - &w_id - ) - .fetch_optional(&db) - .await?; - - let email = not_found_if_none(email, "user", username)?; - - Ok(email) -} - -#[cfg(feature = "enterprise")] -#[derive(Serialize, Deserialize)] -struct ExportedGlobalUser { - email: String, - password_hash: Option, - login_type: String, - super_admin: bool, - verified: bool, - name: Option, - company: Option, - first_time_user: bool, - username: Option, -} - -#[cfg(feature = "enterprise")] -async fn export_global_users( - Extension(db): Extension, - authed: ApiAuthed, -) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - let users = sqlx::query_as!( - ExportedGlobalUser, - "SELECT email, password_hash, login_type, super_admin, verified, name, company, first_time_user, username FROM password" - ) - .fetch_all(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.export_export", - ActionKind::Execute, - "global", - None, - None, - ) - .await?; - - tx.commit().await?; - - Ok(Json(users)) -} - -#[cfg(not(feature = "enterprise"))] -async fn export_global_users() -> JsonResult { - Err(Error::BadRequest( - "This feature is only available in the enterprise version".to_string(), - )) -} - -#[cfg(feature = "enterprise")] -async fn overwrite_global_users( - Extension(db): Extension, - authed: ApiAuthed, - Json(users): Json>, -) -> Result { - require_super_admin(&db, &authed.email).await?; - let mut tx = db.begin().await?; - sqlx::query!("DELETE FROM password") - .execute(&mut *tx) - .await?; - for user in users { - sqlx::query!( - "INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, company, first_time_user, username) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - user.email, - user.password_hash, - user.login_type, - user.super_admin, - user.verified, - user.name, - user.company, - user.first_time_user, - user.username - ) - .execute(&mut *tx) - .await?; - } - audit_log( - &mut *tx, - &authed, - "users.import_global", - ActionKind::Create, - "global", - None, - None, - ) - .await?; - tx.commit().await?; - Ok("loaded global users".to_string()) -} - -#[cfg(not(feature = "enterprise"))] -async fn overwrite_global_users() -> JsonResult { - Err(Error::BadRequest( - "This feature is only available in the enterprise version".to_string(), - )) -} - #[derive(Deserialize)] struct RenameUser { new_username: String, @@ -2866,115 +661,6 @@ async fn update_username_in_workpsace<'c>( Ok(()) } -// Password Reset Types -#[derive(Deserialize)] -pub struct RequestPasswordReset { - pub email: String, -} - -#[derive(Deserialize)] -pub struct ResetPassword { - pub token: String, - pub new_password: String, -} - -#[derive(Serialize)] -pub struct PasswordResetResponse { - pub message: String, -} - -// Password Reset Functions - -/// Check if SMTP is configured -async fn is_smtp_configured(Extension(db): Extension) -> JsonResult { - let smtp = windmill_common::server::load_smtp_config(&db).await?; - Ok(Json(smtp.is_some())) -} - -/// Request a password reset email -async fn request_password_reset( - Extension(db): Extension, - Json(req): Json, -) -> Result> { - let email = req.email.to_lowercase(); - - // Check if SMTP is configured - let smtp = windmill_common::server::load_smtp_config(&db).await?; - let smtp = smtp.ok_or_else(|| { - Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string()) - })?; - - // Check if user exists with password login type - let user_exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')", - &email - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - // Always return success to prevent email enumeration - // But only send email if user exists - if user_exists { - // Generate a secure token - let token = rd_string(32); - - // Delete any existing tokens for this email - sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email) - .execute(&db) - .await?; - - // Insert new token with 1 hour expiration - sqlx::query!( - "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')", - &email, - &token - ) - .execute(&db) - .await?; - - // Get the base URL for the reset link - let base_url = BASE_URL.read().await.clone(); - let base_url = if base_url.is_empty() { - std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string()) - } else { - base_url - }; - - let reset_link = format!("{}/user/reset-password?token={}", base_url, token); - - // Send the email - let subject = "Windmill Password Reset"; - let content = format!( - "You have requested a password reset for your Windmill account.\n\n\ - Click the link below to reset your password:\n\ - {}\n\n\ - This link will expire in 1 hour.\n\n\ - If you did not request this password reset, you can safely ignore this email.", - reset_link - ); - - // Send the email - don't fail the request if email fails - if let Err(e) = windmill_common::email_oss::send_email_plain_text( - subject, - &content, - vec![email.clone()], - smtp, - Some(Duration::from_secs(10)), - ) - .await - { - tracing::error!("Failed to send password reset email to {}: {:?}", email, e); - } - } - - // Always return success to prevent email enumeration - Ok(Json(PasswordResetResponse { - message: "If an account with that email exists, a password reset link has been sent." - .to_string(), - })) -} - /// Reset password using a token async fn reset_password( Extension(db): Extension, diff --git a/backend/windmill-api/src/users_oss.rs b/backend/windmill-api/src/users_oss.rs index 7cb643b84a..c2ac850874 100644 --- a/backend/windmill-api/src/users_oss.rs +++ b/backend/windmill-api/src/users_oss.rs @@ -62,13 +62,6 @@ pub fn hash_password(_argon2: Arc>, _password: String) -> Result, -} - -// Shared structs for bulk delete operations -#[derive(Deserialize)] -pub struct BulkDeleteRequest { - pub paths: Vec, -} - -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 { - 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 { - 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() diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index f5e1f1fbe7..50c3913b47 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -6,93 +6,40 @@ * LICENSE-AGPL for a copy of the license. */ +// Re-export everything from windmill-api-workspaces +pub use windmill_api_workspaces::workspaces::*; + use crate::ai::{AIConfig, AI_REQUEST_CACHE}; use crate::db::ApiAuthed; -use crate::users_oss::send_email_if_possible; -use crate::utils::get_instance_username_or_create_pending; -use crate::BASE_URL; -use crate::{ - db::DB, - users::{WorkspaceInvite, VALID_USERNAME}, - utils::require_super_admin, - webhook_util::WebhookShared, -}; - -use axum::{ - extract::{Extension, Path, Query}, - routing::{delete, get, post}, - Json, Router, -}; -use chrono::Utc; - -use regex::Regex; - -use hex; -use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; -use uuid::Uuid; -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; -use windmill_common::db::UserDB; -use windmill_common::s3_helpers::LargeFileStorage; -use windmill_common::users::username_to_permissioned_as; -use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE}; -use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; -#[cfg(feature = "enterprise")] -use windmill_common::workspaces::GitRepositorySettings; -#[cfg(feature = "enterprise")] -use windmill_common::workspaces::WorkspaceDeploymentUISettings; -use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, - WorkspaceGitSyncSettings, -}; -use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; -use windmill_common::PgDatabase; -use windmill_common::{ - error::{Error, JsonResult, Result}, - global_settings::AUTOMATE_USERNAME_CREATION_SETTING, - oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, - utils::{paginate, rd_string, require_admin, Pagination}, -}; -use windmill_dep_map::scoped_dependency_map::{ - DependencyDependent, DependencyMap, ScopedDependencyMap, -}; -use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject}; - -#[cfg(feature = "enterprise")] -use windmill_common::utils::require_admin_or_devops; - -use hyper::StatusCode; -use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Postgres, Transaction}; -use windmill_common::oauth2::InstanceEvent; -use windmill_common::utils::not_found_if_none; - use crate::teams_oss::{ connect_teams, edit_teams_command, run_teams_message_test_job, workspaces_list_available_teams_channels, workspaces_list_available_teams_ids, }; -lazy_static::lazy_static! { - static ref WORKSPACE_KEY_REGEXP: Regex = Regex::new("^[a-zA-Z0-9]{64}$").unwrap(); -} +use axum::{ + extract::{Extension, Path}, + routing::{get, post}, + Json, Router, +}; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::{ + error::{Error, JsonResult, Result}, + utils::require_admin, + DB, +}; +use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; +#[cfg(feature = "enterprise")] +use axum::extract::Query; +#[cfg(feature = "enterprise")] +use serde::Deserialize; +#[cfg(feature = "enterprise")] +use windmill_common::utils::require_admin_or_devops; + +/// Wraps the subcrate's workspaced_service with routes that depend on windmill-api internals. pub fn workspaced_service() -> Router { - let router = Router::new() - .route("/get_as_superadmin", get(get_workspace_as_superadmin)) - .route("/list_pending_invites", get(list_pending_invites)) - .route("/update", post(edit_workspace)) - .route("/archive", post(archive_workspace)) - .route("/invite_user", post(invite_user)) - .route("/add_user", post(add_user)) - .route("/delete_invite", post(delete_invite)) - .route("/rebuild_dependency_map", post(rebuild_dependency_map)) - .route("/get_dependency_map", get(get_dependency_map)) - .route("/get_dependents/*imported_path", get(get_dependents)) - .route("/get_dependents_amounts", post(get_dependents_amounts)) - .route("/get_settings", get(get_settings)) - .route("/get_deploy_to", get(get_deploy_to)) - .route("/edit_slack_command", post(edit_slack_command)) + let router = windmill_api_workspaces::workspaces::workspaced_service() .route("/edit_teams_command", post(edit_teams_command)) .route( "/available_teams_ids", @@ -103,69 +50,13 @@ pub fn workspaced_service() -> Router { get(workspaces_list_available_teams_channels), ) .route("/connect_teams", post(connect_teams)) - .route( - "/run_slack_message_test_job", - post(run_slack_message_test_job), - ) .route( "/run_teams_message_test_job", post(run_teams_message_test_job), ) - .route("/slack_oauth_config", get(get_slack_oauth_config)) - .route("/slack_oauth_config", post(set_slack_oauth_config)) - .route("/slack_oauth_config", delete(delete_slack_oauth_config)) - .route("/edit_webhook", post(edit_webhook)) - .route("/edit_auto_invite", post(edit_auto_invite)) - .route("/edit_instance_groups", post(edit_instance_groups)) - .route("/edit_deploy_to", post(edit_deploy_to)) - .route( - "/get_secondary_storage_names", - get(get_secondary_storage_names), - ) .route("/tarball", get(crate::workspaces_export::tarball_workspace)) - .route("/is_premium", get(is_premium)) .route("/edit_copilot_config", post(edit_copilot_config)) .route("/get_copilot_info", get(get_copilot_info)) - .route("/edit_error_handler", post(edit_error_handler)) - .route("/edit_success_handler", post(edit_success_handler)) - .route( - "/edit_large_file_storage_config", - post(edit_large_file_storage_config), - ) - .route("/edit_ducklake_config", post(edit_ducklake_config)) - .route("/list_ducklakes", get(list_ducklakes)) - .route("/list_datatables", get(list_datatables)) - .route("/list_datatable_schemas", get(list_datatable_schemas)) - .route("/edit_datatable_config", post(edit_datatable_config)) - .route("/edit_git_sync_config", post(edit_git_sync_config)) - .route("/edit_git_sync_repository", post(edit_git_sync_repository)) - .route( - "/delete_git_sync_repository", - delete(delete_git_sync_repository), - ) - .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) - .route("/edit_default_app", post(edit_default_app)) - .route("/default_app", get(get_default_app)) - .route( - "/default_scripts", - post(edit_default_scripts).get(get_default_scripts), - ) - .route("/set_environment_variable", post(set_environment_variable)) - .route( - "/encryption_key", - get(get_encryption_key).post(set_encryption_key), - ) - .route("/leave", post(leave_workspace)) - .route("/get_workspace_name", get(get_workspace_name)) - .route("/create_fork", post(create_workspace_fork)) - .route("/change_workspace_name", post(change_workspace_name)) - .route("/change_workspace_color", post(change_workspace_color)) - .route( - "/change_workspace_id", - post(crate::workspaces_extra::change_workspace_id), - ) - .route("/usage", get(get_usage)) - .route("/used_triggers", get(get_used_triggers)) .route("/critical_alerts", get(get_critical_alerts)) .route( "/critical_alerts/:id/acknowledge", @@ -175,18 +66,7 @@ pub fn workspaced_service() -> Router { "/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts), ) - .route("/critical_alerts/mute", post(mute_critical_alerts)) - .route("/public_app_rate_limit", post(edit_public_app_rate_limit)) - .route("/operator_settings", post(update_operator_settings)) - .route( - "/create_workspace_fork_branch", - post(create_workspace_fork_branch), - ) - .route( - "/reset_diff_tally/:fork_workspace_id", - post(reset_workspace_diffs), - ) - .route("/compare/:target_workspace_id", get(compare_workspaces)); + .route("/critical_alerts/mute", post(mute_critical_alerts)); #[cfg(all(feature = "stripe", feature = "enterprise"))] { @@ -196,894 +76,6 @@ pub fn workspaced_service() -> Router { #[cfg(not(feature = "stripe"))] router } -pub fn global_service() -> Router { - Router::new() - .route("/list_as_superadmin", get(list_workspaces_as_super_admin)) - .route("/list", get(list_workspaces)) - .route("/users", get(user_workspaces)) - .route("/create", post(create_workspace)) - .route("/create_fork", post(deprecated_create_workspace_fork)) - .route("/exists", post(exists_workspace)) - .route("/exists_username", post(exists_username)) - .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) - .route("/unarchive/:workspace", post(unarchive_workspace)) - .route( - "/delete/:workspace", - delete(crate::workspaces_extra::delete_workspace), - ) - .route( - "/create_workspace_require_superadmin", - get(create_workspace_require_superadmin), - ) -} - -#[derive(FromRow, Serialize)] -struct Workspace { - id: String, - name: String, - owner: String, - deleted: bool, - premium: bool, - color: Option, - parent_workspace_id: Option, -} - -#[derive(FromRow, Serialize, Debug)] -pub struct WorkspaceSettings { - pub workspace_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub slack_team_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub teams_team_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub teams_team_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub teams_team_guid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub slack_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub slack_command_script: Option, - pub teams_command_script: Option, - pub slack_email: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub slack_oauth_client_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub slack_oauth_client_secret: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub customer_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub plan: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub webhook: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub deploy_to: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ai_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub large_file_storage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ducklake: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub datatable: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub git_sync: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub deploy_ui: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_scripts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mute_critical_alerts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub operator_settings: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub git_app_installations: Option, - // Grouped config fields - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_invite: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error_handler: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub success_handler: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_app_execution_limit_per_minute: Option, -} - -/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] -// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] -// pub enum WorkspaceKeyKind { -// Cloud, -// } - -#[derive(Deserialize)] -struct EditCommandScript { - slack_command_script: Option, -} - -#[derive(Deserialize)] -struct RunSlackMessageTestJobRequest { - hub_script_path: String, - channel: String, - test_msg: String, -} - -#[derive(Serialize)] -struct RunSlackMessageTestJobResponse { - job_uuid: String, -} - -#[cfg(feature = "enterprise")] -#[derive(Deserialize)] -struct EditDeployTo { - deploy_to: Option, -} - -#[allow(dead_code)] -#[derive(Deserialize)] -pub struct EditAutoInvite { - pub operator: Option, - pub invite_all: Option, - pub auto_add: Option, -} - -#[derive(Deserialize)] -struct EditWebhook { - webhook: Option, -} - -#[derive(Deserialize, Serialize, Debug)] -struct LargeFileStorageWithSecondary { - #[serde(flatten)] - large_file_storage: LargeFileStorage, - #[serde(default)] - secondary_storage: HashMap, -} -#[derive(Deserialize, Debug)] -struct EditLargeFileStorageConfig { - large_file_storage: Option, -} - -#[derive(Deserialize, Debug)] -struct EditDucklakeConfig { - settings: DucklakeSettings, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct DucklakeSettings { - pub ducklakes: HashMap, -} - -#[derive(Deserialize, Debug)] -struct EditDataTableConfig { - settings: DataTableSettings, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct DataTableSettings { - pub datatables: HashMap, -} - -#[derive(Deserialize)] -struct CreateWorkspace { - id: String, - name: String, - username: Option, - color: Option, -} - -#[derive(Deserialize)] -struct CreateWorkspaceFork { - id: String, - name: String, - color: Option, -} - -#[derive(Deserialize)] -struct EditWorkspace { - name: String, - owner: String, -} - -#[derive(Serialize)] -struct WorkspaceList { - pub email: String, - pub workspaces: Vec, -} - -#[derive(Serialize)] -struct UserWorkspace { - pub id: String, - pub name: String, - pub username: String, - pub color: Option, - pub operator_settings: Option>, - pub parent_workspace_id: Option, - pub disabled: bool, -} - -#[derive(Deserialize)] -struct WorkspaceId { - pub id: String, -} - -#[derive(Deserialize)] -struct ValidateUsername { - pub id: String, - pub username: String, -} - -#[derive(Deserialize)] -pub struct NewWorkspaceInvite { - pub email: String, - pub is_admin: bool, - pub operator: bool, -} - -#[derive(Deserialize)] -pub struct NewWorkspaceUser { - pub email: String, - pub username: Option, - pub is_admin: bool, - pub operator: bool, -} - -// New format for error handler (grouped) -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EditErrorHandlerNew { - pub path: Option, - pub extra_args: Option, - #[serde(default)] - pub muted_on_cancel: bool, - #[serde(default)] - pub muted_on_user_path: bool, -} - -// Legacy format for error handler (flat fields from old CLI) -#[derive(Deserialize)] -pub struct EditErrorHandlerLegacy { - pub error_handler: Option, - pub error_handler_extra_args: Option, - #[serde(default)] - pub error_handler_muted_on_cancel: bool, -} - -// Accepts both old and new formats -#[derive(Deserialize)] -#[serde(untagged)] -pub enum EditErrorHandler { - New(EditErrorHandlerNew), - Legacy(EditErrorHandlerLegacy), -} - -impl EditErrorHandler { - pub fn into_normalized(self) -> EditErrorHandlerNew { - match self { - EditErrorHandler::New(new) => new, - EditErrorHandler::Legacy(legacy) => EditErrorHandlerNew { - path: legacy.error_handler, - extra_args: legacy.error_handler_extra_args, - muted_on_cancel: legacy.error_handler_muted_on_cancel, - muted_on_user_path: false, // Old format doesn't have this field - }, - } - } -} - -// New format for success handler (grouped) -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EditSuccessHandlerNew { - pub path: Option, - pub extra_args: Option, -} - -// Legacy format for success handler (flat fields from old CLI) -#[derive(Deserialize)] -pub struct EditSuccessHandlerLegacy { - pub success_handler: Option, - pub success_handler_extra_args: Option, -} - -// Accepts both old and new formats -#[derive(Deserialize)] -#[serde(untagged)] -pub enum EditSuccessHandler { - New(EditSuccessHandlerNew), - Legacy(EditSuccessHandlerLegacy), -} - -impl EditSuccessHandler { - pub fn into_normalized(self) -> EditSuccessHandlerNew { - match self { - EditSuccessHandler::New(new) => new, - EditSuccessHandler::Legacy(legacy) => EditSuccessHandlerNew { - path: legacy.success_handler, - extra_args: legacy.success_handler_extra_args, - }, - } - } -} - -lazy_static::lazy_static! { - pub static ref EMAIL_REGEXP: Regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); -} - -async fn list_pending_invites( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult> { - require_admin(authed.is_admin, &authed.username)?; - let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as!( - WorkspaceInvite, - "SELECT - workspace_invite.workspace_id, - workspace_invite.email, - workspace_invite.is_admin, - workspace_invite.operator, - workspace.parent_workspace_id - FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id - WHERE workspace_id = $1", - w_id - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(rows)) -} - -async fn is_premium( - authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, -) -> JsonResult { - require_admin(authed.is_admin, &authed.username)?; - #[cfg(feature = "cloud")] - let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id) - .await? - .premium; - #[cfg(not(feature = "cloud"))] - let premium = false; - Ok(Json(premium)) -} - -async fn exists_workspace( - authed: ApiAuthed, - Extension(user_db): Extension, - Json(WorkspaceId { id }): Json, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)", - id - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - tx.commit().await?; - Ok(Json(exists)) -} - -async fn list_workspaces( - authed: ApiAuthed, - Extension(user_db): Extension, -) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - let workspaces = sqlx::query_as!( - Workspace, - "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id - FROM workspace - LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id - JOIN usr ON usr.workspace_id = workspace.id - WHERE usr.email = $1 AND workspace.deleted = false", - authed.email - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(workspaces)) -} - -async fn get_settings( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - let settings = sqlx::query_as!( - WorkspaceSettings, - r#" - SELECT - workspace_id, - slack_team_id, - teams_team_id, - teams_team_name, - teams_team_guid, - slack_name, - slack_command_script, - teams_command_script, - slack_email, - slack_oauth_client_id, - slack_oauth_client_secret, - customer_id, - plan, - webhook, - deploy_to, - ai_config, - large_file_storage, - datatable, - ducklake, - git_sync, - deploy_ui, - default_app, - default_scripts, - mute_critical_alerts, - color, - operator_settings, - git_app_installations, - auto_invite, - error_handler, - success_handler, - public_app_execution_limit_per_minute - FROM - workspace_settings - WHERE - workspace_id = $1 - "#, - &w_id - ) - .fetch_optional(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?; - - tx.commit().await?; - - let settings = not_found_if_none(settings, "workspace settings", &w_id)?; - Ok(Json(settings)) -} - -#[derive(Serialize)] -struct DeployTo { - deploy_to: Option, -} -async fn get_deploy_to( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - let settings = sqlx::query_as!( - DeployTo, - "SELECT deploy_to FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("getting deploy_to: {e:#}")))?; - - tx.commit().await?; - Ok(Json(settings)) -} - -async fn edit_slack_command( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(es): Json, -) -> Result { - require_admin(is_admin, &username)?; - let mut tx = db.begin().await?; - - if es.slack_command_script.is_some() { - let exists_slack_command_with_team_id = sqlx::query_scalar!( - r#" - SELECT EXISTS (SELECT 1 - FROM workspace_settings - WHERE workspace_id <> $1 - AND slack_command_script IS NOT NULL - AND slack_team_id IS NOT NULL - AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1)) - "#, - &w_id - ) - .fetch_one(&mut *tx) - .await?.unwrap_or(false); - - if exists_slack_command_with_team_id { - return Err(Error::BadRequest( - "A workspace connected to the same slack team already has a command script. Please remove it first." - .to_string(), - )); - } - } - - sqlx::query!( - "UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2", - es.slack_command_script, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_command_script", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some( - [( - "script", - es.slack_command_script - .unwrap_or("NO_SCRIPT".to_string()) - .as_str(), - )] - .into(), - ), - ) - .await?; - tx.commit().await?; - - Ok(format!("Edit command script {}", &w_id)) -} - -async fn run_slack_message_test_job( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(req): Json, -) -> JsonResult { - let mut fake_result = HashMap::new(); - fake_result.insert("error".to_string(), to_raw_value(&req.test_msg)); - fake_result.insert("success_result".to_string(), to_raw_value(&req.test_msg)); - - let mut extra_args = HashMap::new(); - extra_args.insert("channel".to_string(), to_raw_value(&req.channel)); - extra_args.insert( - "slack".to_string(), - to_raw_value(&format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}")), - ); - - let uuid = windmill_queue::push_error_handler( - &db, - Uuid::parse_str("00000000-0000-0000-0000-000000000000")?, - None, - Some("slack_message_test".to_string()), - false, - w_id.as_str(), - &format!("script/{}", req.hub_script_path.as_str()), - sqlx::types::Json(&fake_result), - None, - Some(Utc::now()), - Some(sqlx::types::Json(to_raw_value(&extra_args))), - authed.email.as_str(), - false, - false, - None, // Note: we could mark it as high priority to return result quickly to the user - ) - .await?; - - Ok(Json(RunSlackMessageTestJobResponse { - job_uuid: uuid.to_string(), - })) -} - -#[derive(Deserialize)] -struct SetSlackOAuthConfigRequest { - slack_oauth_client_id: String, - slack_oauth_client_secret: String, -} - -#[derive(Serialize)] -struct GetSlackOAuthConfigResponse { - slack_oauth_client_id: Option, - slack_oauth_client_secret: Option, -} - -async fn get_slack_oauth_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult { - require_admin(authed.is_admin, &authed.username)?; - - let settings = sqlx::query!( - "SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&db) - .await?; - - // Mask the secret if it exists - let masked_secret = settings - .slack_oauth_client_secret - .map(|_| "***".to_string()); - - Ok(Json(GetSlackOAuthConfigResponse { - slack_oauth_client_id: settings.slack_oauth_client_id, - slack_oauth_client_secret: masked_secret, - })) -} - -async fn set_slack_oauth_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(req): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - if req.slack_oauth_client_id.is_empty() || req.slack_oauth_client_secret.is_empty() { - return Err(Error::BadRequest( - "Both client ID and client secret are required".to_string(), - )); - } - - let mut tx = db.begin().await?; - - sqlx::query!( - "UPDATE workspace_settings - SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2 - WHERE workspace_id = $3", - &req.slack_oauth_client_id, - &req.slack_oauth_client_secret, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.set_slack_oauth_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("client_id", req.slack_oauth_client_id.as_str())].into()), - ) - .await?; - - tx.commit().await?; - - Ok(format!("Slack OAuth config set for workspace {}", &w_id)) -} - -async fn delete_slack_oauth_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = db.begin().await?; - - sqlx::query!( - "UPDATE workspace_settings - SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL - WHERE workspace_id = $1", - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.delete_slack_oauth_config", - ActionKind::Delete, - &w_id, - Some(&authed.email), - None, - ) - .await?; - - tx.commit().await?; - - Ok(format!( - "Slack OAuth config deleted for workspace {}", - &w_id - )) -} - -#[derive(Deserialize)] -struct GetSecondaryStorageNamesQuery { - #[serde(default)] - include_default: bool, -} - -async fn get_secondary_storage_names( - _authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Query(query): Query, -) -> JsonResult> { - let mut result: Vec = sqlx::query_scalar!( - "SELECT jsonb_object_keys(large_file_storage->'secondary_storage') AS \"secondary_storage_name!: _\" - FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_all(&db) - .await?; - - // If include_default is true, check if primary storage is set and add "_default_" - if query.include_default { - let has_primary_storage: Option = sqlx::query_scalar!( - "SELECT (large_file_storage IS NOT NULL - AND large_file_storage != 'null'::jsonb - AND jsonb_typeof(large_file_storage) = 'object') AS \"has_primary!\" - FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_optional(&db) - .await?; - - if has_primary_storage.unwrap_or(false) { - result.insert(0, "_default_".to_string()); - } - } - - Ok(Json(result)) -} - -#[cfg(feature = "enterprise")] -async fn edit_deploy_to( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(es): Json, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - sqlx::query!( - "UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2", - es.deploy_to, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_deploy_to", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some( - [( - "script", - es.deploy_to.unwrap_or("NO_DEPLOY_TO".to_string()).as_str(), - )] - .into(), - ), - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Settings { setting_type: "deploy_to".to_string() }, - None, - false, - None, - ) - .await?; - - Ok(format!("Edit deploy to for {}", &w_id)) -} - -#[cfg(not(feature = "enterprise"))] -async fn edit_deploy_to() -> Result { - return Err(Error::BadRequest( - "Deploy to is only available on enterprise".to_string(), - )); -} - -pub const BANNED_DOMAINS: &str = include_str!("../banned_domains.txt"); -pub const WM_FORK_PREFIX: &str = "wm-fork-"; -pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000; - -async fn is_allowed_auto_domain(ApiAuthed { email, .. }: ApiAuthed) -> JsonResult { - let domain = email.split('@').last().unwrap(); - return Ok(Json(!BANNED_DOMAINS.contains(domain))); -} - -async fn edit_auto_invite( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(ea): Json, -) -> Result { - crate::workspaces_oss::edit_auto_invite(authed, db, w_id, ea).await -} - -#[cfg(feature = "private")] -async fn edit_instance_groups( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(config): Json, -) -> Result { - crate::workspaces_ee::edit_instance_groups(authed, db, w_id, config).await -} - -#[cfg(not(feature = "private"))] -async fn edit_instance_groups( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_config): Json, -) -> Result { - Err(Error::BadRequest( - "Instance groups are only available on Windmill Enterprise Edition".to_string(), - )) -} - -async fn edit_webhook( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(ew): Json, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - if let Some(webhook) = &ew.webhook { - sqlx::query!( - "UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2", - webhook, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - audit_log( - &mut *tx, - &authed, - "workspaces.edit_webhook", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()), - ) - .await?; - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Settings { setting_type: "webhook".to_string() }, - None, - false, - None, - ) - .await?; - - Ok(format!("Edit webhook for workspace {}", &w_id)) -} async fn edit_copilot_config( authed: ApiAuthed, @@ -1094,7 +86,6 @@ async fn edit_copilot_config( ) -> Result { require_admin(is_admin, &username)?; - // Validate custom prompts length if let Some(ref custom_prompts) = ai_config.custom_prompts { for (mode, prompt) in custom_prompts.iter() { if prompt.len() > MAX_CUSTOM_PROMPT_LENGTH { @@ -1136,13 +127,12 @@ async fn edit_copilot_config( .await?; tx.commit().await?; - // Trigger git sync for AI config changes handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "ai_config".to_string() }, + DeployedObject::Settings { setting_type: "ai_config".to_string() }, Some("AI configuration updated".to_string()), false, None, @@ -1183,3124 +173,6 @@ async fn get_copilot_info( } } -async fn edit_large_file_storage_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - let args_for_audit = format!("{:?}", new_config.large_file_storage); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_large_file_storage_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("large_file_storage", args_for_audit.as_str())].into()), - ) - .await?; - - if let Some(lfs_config) = new_config.large_file_storage { - let serialized_lfs_config = - serde_json::to_value::(lfs_config) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", - serialized_lfs_config, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET large_file_storage = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - - // Trigger git sync for large file storage changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { - setting_type: "large_file_storage".to_string(), - }, - Some("Large file storage configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!( - "Edit large file storage config for workspace {}", - &w_id - )) -} - -async fn list_ducklakes( - _authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult> { - let ducklakes = sqlx::query_scalar!( - r#" - SELECT jsonb_object_keys(ws.ducklake->'ducklakes') AS ducklake_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id - ) - .fetch_all(&db) - .await? - .into_iter() - .filter_map(|s| s) - .collect(); - - Ok(Json(ducklakes)) -} - -async fn list_datatables( - _authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult> { - let datatables = sqlx::query_scalar!( - r#" - SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id - ) - .fetch_all(&db) - .await? - .into_iter() - .filter_map(|s| s) - .collect(); - - Ok(Json(datatables)) -} - -/// Compact column representation: "type" or "type?" for nullable, with "=default" suffix if has default -type CompactColumn = String; - -/// Columns mapped by name to their compact type -type ColumnMap = HashMap; - -/// Tables mapped by name to their columns -type TableMap = HashMap; - -/// Schemas mapped by name to their tables -type SchemaMap = HashMap; - -#[derive(Serialize, Debug)] -struct DataTableSchema { - datatable_name: String, - /// Hierarchical schema: schema_name -> table_name -> column_name -> "type[?][=default]" - schemas: SchemaMap, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -async fn list_datatable_schemas( - _authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult> { - // Get all datatable names for this workspace - let datatable_names: Vec = sqlx::query_scalar!( - r#" - SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id - ) - .fetch_all(&db) - .await? - .into_iter() - .filter_map(|s| s) - .collect(); - - let mut results = Vec::new(); - - for datatable_name in datatable_names { - let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await { - Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None }, - Err(e) => DataTableSchema { - datatable_name, - schemas: HashMap::new(), - error: Some(e.to_string()), - }, - }; - results.push(schema); - } - - Ok(Json(results)) -} - -async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result { - // Get the datatable resource (connection credentials) - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; - - // Parse the resource as PgDatabase - let pg_db: PgDatabase = serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; - - // Connect to the datatable database - let (client, connection) = pg_db.connect().await?; - - // Spawn the connection handler - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::error!("Datatable connection error: {}", e); - } - }); - - // First, get all non-system schemas (including empty ones) - let schema_rows = client - .query( - r#" - SELECT nspname::text AS schema_name - FROM pg_namespace - WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') - AND nspname NOT LIKE 'pg_%' - ORDER BY nspname - "#, - &[], - ) - .await - .map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?; - - // Build hierarchical structure: schema -> table -> column -> compact_type - let mut schema_map: SchemaMap = HashMap::new(); - - // Collect schema names and initialize map - let schema_names: Vec = schema_rows - .iter() - .map(|row| { - let name: String = row.get(0); - schema_map.entry(name.clone()).or_default(); - name - }) - .collect(); - - // Query column information only for the schemas we found - let rows = client - .query( - r#" - SELECT - table_schema::text, - table_name::text, - column_name::text, - udt_name::text, - is_nullable::text, - column_default::text - FROM information_schema.columns - WHERE table_schema = ANY($1) - AND table_name IS NOT NULL - ORDER BY table_schema, table_name, ordinal_position - "#, - &[&schema_names], - ) - .await - .map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?; - - for row in rows { - let table_schema: String = row.get(0); - let table_name: String = row.get(1); - let column_name: String = row.get(2); - let udt_name: String = row.get(3); - let is_nullable: String = row.get(4); - let column_default: Option = row.get(5); - - // Build compact type representation: "type[?][=default]" - let mut compact = udt_name; - if is_nullable == "YES" { - compact.push('?'); - } - if let Some(default) = column_default { - // Truncate long defaults for compactness - let short_default = if default.len() > 30 { - format!("{}...", &default[..27]) - } else { - default - }; - compact.push('='); - compact.push_str(&short_default); - } - - schema_map - .entry(table_schema) - .or_default() - .entry(table_name) - .or_default() - .insert(column_name, compact); - } - - Ok(schema_map) -} - -async fn edit_ducklake_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, email, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - let is_superadmin = require_super_admin(&db, &email).await.is_ok(); - - let mut tx = db.begin().await?; - - let args_for_audit = format!("{:?}", new_config.settings); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_ducklake_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("ducklake", args_for_audit.as_str())].into()), - ) - .await?; - - // Check that non-superadmins are not abusing Instance databases - if !is_superadmin { - let old_ducklakes = sqlx::query_scalar!( - r#" - SELECT ws.ducklake->'ducklakes' AS ducklake_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(serde_json::Value::Null); - let old_ducklakes: HashMap = - serde_json::from_value(old_ducklakes).unwrap_or_default(); - for (name, dl) in new_config.settings.ducklakes.iter() { - if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance { - let old_dl = old_ducklakes.get(name); - if old_dl.is_none() - || old_dl.unwrap().catalog.resource_type - != DucklakeCatalogResourceType::Instance - || old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path - { - return Err(Error::BadRequest( - "Only superadmins can create or modify ducklakes with Instance databases" - .to_string(), - )); - } - } - } - } - - let config: serde_json::Value = serde_json::to_value(new_config.settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET ducklake = $1 WHERE workspace_id = $2", - config, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(format!("Edit ducklake config for workspace {}", &w_id)) -} - -async fn edit_datatable_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, email, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - let is_superadmin = require_super_admin(&db, &email).await.is_ok(); - - let mut tx = db.begin().await?; - - let args_for_audit = format!("{:?}", new_config.settings); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_datatable_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("datatable", args_for_audit.as_str())].into()), - ) - .await?; - - // Check that non-superadmins are not abusing Instance databases - if !is_superadmin { - let old_datatables = sqlx::query_scalar!( - r#" - SELECT ws.datatable->'datatables' AS datatable_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(serde_json::Value::Null); - let old_datatables: HashMap = - serde_json::from_value(old_datatables).unwrap_or_default(); - for (name, dt) in new_config.settings.datatables.iter() { - if dt.database.resource_type == DataTableCatalogResourceType::Instance { - let old_dt = old_datatables.get(name); - if old_dt.is_none() - || old_dt.unwrap().database.resource_type - != DataTableCatalogResourceType::Instance - || old_dt.unwrap().database.resource_path != dt.database.resource_path - { - return Err(Error::BadRequest( - "Only superadmins can create or modify data tables with Instance databases" - .to_string(), - )); - } - } - } - } - - let config: serde_json::Value = serde_json::to_value(new_config.settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2", - config, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(format!("Edit datatable config for workspace {}", &w_id)) -} - -#[derive(Deserialize)] -pub struct EditGitSyncConfig { - #[cfg(feature = "enterprise")] - pub git_sync_settings: Option, -} - -#[cfg(feature = "enterprise")] -#[derive(Deserialize, Debug)] -pub struct EditGitSyncRepository { - pub git_repo_resource_path: String, - pub repository: GitRepositorySettings, -} - -#[cfg(feature = "enterprise")] -#[derive(Deserialize, Debug)] -pub struct DeleteGitSyncRepositoryRequest { - pub git_repo_resource_path: String, -} - -#[cfg(feature = "enterprise")] -fn validate_git_repo_resource_path(path: &str) -> Result<()> { - // Resource paths should follow the pattern: $res:f// or $res:u// - if path.is_empty() { - return Err(Error::BadRequest( - "Resource path cannot be empty".to_string(), - )); - } - - // Must start with $res: prefix - if !path.starts_with("$res:") { - return Err(Error::BadRequest( - "Resource path must start with '$res:'".to_string(), - )); - } - - // Extract the actual path after $res: - let actual_path = &path[5..]; // Remove "$res:" prefix - - // Basic validation: must start with f/ or u/ and contain at least one slash - if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") { - return Err(Error::BadRequest( - "Resource path must start with '$res:f/' or '$res:u/'".to_string(), - )); - } - - // Must have at least 3 parts (type, folder/user, name) - let parts: Vec<&str> = actual_path.split('/').collect(); - if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) { - return Err(Error::BadRequest( - "Invalid resource path format".to_string(), - )); - } - - // Resource name validation (last part) - let resource_name = parts.last().unwrap(); - if !resource_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-') - { - return Err(Error::BadRequest( - "Resource name can only contain alphanumeric characters, underscores, and hyphens" - .to_string(), - )); - } - - Ok(()) -} - -#[cfg(feature = "enterprise")] -fn cleanup_legacy_git_sync_settings_in_memory( - git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, - workspace_id: &str, -) { - // Check if all repositories are in new format (have settings field) - let all_repos_migrated = git_sync_settings - .repositories - .iter() - .all(|repo| repo.settings.is_some()); - - // If all repos are migrated and we still have legacy workspace-level settings - if all_repos_migrated - && (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some()) - { - tracing::info!( - workspace_id = workspace_id, - "All git sync repositories migrated to new format, cleaning up legacy workspace-level settings" - ); - - // Remove workspace-level legacy fields - git_sync_settings.include_path = None; - git_sync_settings.include_type = None; - } -} - -#[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_config( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn edit_git_sync_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - let args_for_audit = format!("{:?}", new_config.git_sync_settings); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_git_sync_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("git_sync_settings", args_for_audit.as_str())].into()), - ) - .await?; - - if let Some(mut git_sync_settings) = new_config.git_sync_settings { - // Clean up legacy workspace-level settings if all repos are migrated - cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); - - let serialized_config = serde_json::to_value::(git_sync_settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", - serialized_config, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET git_sync = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - // Trigger git sync for git sync settings changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, - Some("Git sync configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!("Edit git sync config for workspace {}", &w_id)) -} - -#[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn edit_git_sync_repository( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - - // Validate the resource path format - validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; - - let mut tx = db.begin().await?; - - // First, get the current git sync settings - let current_settings = sqlx::query!( - "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_optional(&mut *tx) - .await?; - - let mut git_sync_settings = if let Some(row) = current_settings { - if let Some(git_sync) = row.git_sync { - serde_json::from_value::(git_sync) - .map_err(|err| Error::internal_err(err.to_string()))? - } else { - WorkspaceGitSyncSettings::default() - } - } else { - WorkspaceGitSyncSettings::default() - }; - - // Audit log before we move the repository - audit_log( - &mut *tx, - &authed, - "workspaces.edit_git_sync_repository", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some( - [ - ( - "repository_path", - new_config.git_repo_resource_path.as_str(), - ), - ("repository_data", &format!("{:?}", new_config.repository)), - ] - .into(), - ), - ) - .await?; - - // Check if repository exists before modifying - let repo_exists = git_sync_settings - .repositories - .iter() - .any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); - - // Find and update the specific repository, or add it if it doesn't exist - let repo_found = git_sync_settings - .repositories - .iter_mut() - .find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); - - if let Some(existing_repo) = repo_found { - // Update existing repository - *existing_repo = new_config.repository; - } else { - // Repository doesn't exist, add it as a new repository - git_sync_settings.repositories.push(new_config.repository); - } - - // Clean up legacy workspace-level settings if all repos are migrated - cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); - - // Save the updated configuration - let serialized_config = serde_json::to_value::(git_sync_settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", - serialized_config, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - // Trigger git sync for individual repository update/add - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, - Some(format!( - "Git sync repository '{}' {}", - new_config.git_repo_resource_path, - if repo_exists { "updated" } else { "added" } - )), - false, - None, - ) - .await?; - - Ok(format!( - "{} git sync repository '{}' for workspace {}", - if repo_exists { "Updated" } else { "Added" }, - new_config.git_repo_resource_path, - &w_id - )) -} - -#[cfg(not(feature = "enterprise"))] -async fn delete_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_request): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn delete_git_sync_repository( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(request): Json, -) -> Result { - require_admin(is_admin, &username)?; - - // For deletion, only validate that path is not empty to allow cleanup of malformed entries - if request.git_repo_resource_path.is_empty() { - return Err(Error::BadRequest( - "Resource path cannot be empty".to_string(), - )); - } - - let mut tx = db.begin().await?; - - // First, get the current git sync settings - let current_settings = sqlx::query!( - "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_optional(&mut *tx) - .await?; - - let mut git_sync_settings = if let Some(row) = current_settings { - if let Some(git_sync) = row.git_sync { - serde_json::from_value::(git_sync) - .map_err(|err| Error::internal_err(err.to_string()))? - } else { - WorkspaceGitSyncSettings::default() - } - } else { - WorkspaceGitSyncSettings::default() - }; - - // Check if repository exists and remove it - let original_count = git_sync_settings.repositories.len(); - git_sync_settings - .repositories - .retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path); - - if git_sync_settings.repositories.len() == original_count { - return Err(Error::BadRequest(format!( - "Repository with path '{}' not found in git sync configuration", - request.git_repo_resource_path - ))); - } - - // Audit log - audit_log( - &mut *tx, - &authed, - "workspaces.delete_git_sync_repository", - ActionKind::Delete, - &w_id, - Some(&authed.email), - Some([("repository_path", request.git_repo_resource_path.as_str())].into()), - ) - .await?; - - // Clean up legacy workspace-level settings if all repos are migrated - cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); - - // Save the updated configuration - let serialized_config = serde_json::to_value::(git_sync_settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", - serialized_config, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - // Trigger git sync for repository deletion - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, - Some(format!( - "Git sync repository '{}' deleted", - request.git_repo_resource_path - )), - false, - None, - ) - .await?; - - Ok(format!( - "Deleted git sync repository '{}' from workspace {}", - request.git_repo_resource_path, &w_id - )) -} - -#[cfg(feature = "enterprise")] -#[derive(Debug, Deserialize)] -struct EditDeployUIConfig { - deploy_ui_settings: Option, -} - -#[cfg(not(feature = "enterprise"))] -async fn edit_deploy_ui_config( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, -) -> Result { - return Err(Error::BadRequest( - "Deployment UI is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] -async fn edit_deploy_ui_config( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - let args_for_audit = format!("{:?}", new_config.deploy_ui_settings); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_deploy_ui_config", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("deployment_ui_settings", args_for_audit.as_str())].into()), - ) - .await?; - - if let Some(deploy_ui_settings) = new_config.deploy_ui_settings { - let serialized_config = - serde_json::to_value::(deploy_ui_settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET deploy_ui = $1 WHERE workspace_id = $2", - serialized_config, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET deploy_ui = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - - Ok(format!("Edit deployment UI config for workspace {}", &w_id)) -} - -#[derive(Deserialize)] -pub struct EditDefaultApp { - #[cfg(feature = "enterprise")] - pub default_app_path: Option, -} - -#[cfg(not(feature = "enterprise"))] -async fn edit_default_app( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Setting a workspace default app is only available on Windmill Enterprise Edition" - .to_string(), - )); -} - -async fn edit_default_scripts( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json>, -) -> Result { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_default_scripts", - ActionKind::Update, - &w_id, - Some(&authed.email), - None, - ) - .await?; - - if let Some(config) = new_config { - sqlx::query!( - "UPDATE workspace_settings SET default_scripts = $1 WHERE workspace_id = $2", - config, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET default_scripts = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - - // Trigger git sync for default scripts changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "default_scripts".to_string() }, - Some("Default scripts configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!("Edit default scripts for workspace {}", &w_id)) -} - -async fn get_default_scripts( - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult> { - let mut tx = db.begin().await?; - let default_scripts = sqlx::query_scalar!( - "SELECT default_scripts FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_optional(&mut *tx) - .await - .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; - tx.commit().await?; - - Ok(Json(default_scripts.flatten())) -} - -#[cfg(feature = "enterprise")] -async fn edit_default_app( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(new_config): Json, -) -> Result { - #[cfg(not(feature = "enterprise"))] - { - return Err(Error::BadRequest( - "Setting a workspace default app is only available on Windmill Enterprise Edition" - .to_string(), - )); - } - - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - let args_for_audit = format!("{:?}", new_config.default_app_path); - audit_log( - &mut *tx, - &authed, - "workspaces.edit_default_app", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("default_app", args_for_audit.as_str())].into()), - ) - .await?; - - if let Some(default_app_path) = new_config.default_app_path { - sqlx::query!( - "UPDATE workspace_settings SET default_app = $1 WHERE workspace_id = $2", - default_app_path, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET default_app = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - - // Trigger git sync for default app changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "default_app".to_string() }, - Some("Default app configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!("Edit default app for workspace {}", &w_id)) -} - -#[derive(Serialize)] -struct WorkspaceDefaultApp { - pub default_app_path: Option, -} -async fn get_default_app( - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult { - let mut tx = db.begin().await?; - let default_app_path = sqlx::query_scalar!( - "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&mut *tx) - .await - .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; - tx.commit().await?; - - Ok(Json(WorkspaceDefaultApp { default_app_path })) -} - -async fn edit_error_handler( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(ee): Json, -) -> Result { - require_admin(is_admin, &username)?; - - // Normalize to new format (handles both old CLI and new CLI requests) - let ee = ee.into_normalized(); - - let mut tx = db.begin().await?; - - sqlx::query_as!( - Group, - "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", - w_id, - "error_handler", - "The group the error handler acts on behalf of", - serde_json::json!({username_to_permissioned_as(&authed.username): true}) - ) - .execute(&mut *tx) - .await?; - - if let Some(path) = &ee.path { - match ee.extra_args.as_ref() { - Some(extra_args) if extra_args.is_object() => { - let Ok(email_recipients) = serde_json::from_value::>>( - extra_args["email_recipients"].to_owned(), - ) else { - return Err(Error::BadRequest( - "Field `email_recipients` expected to be JSON array".to_string(), - )); - }; - - if let Some(email_recipients) = email_recipients { - for email in email_recipients { - if !EMAIL_REGEXP.is_match(&email) { - return Err(Error::BadRequest(format!( - "Invalid email format: {}", - email - ))); - } - } - } - } - None => {} - _ => { - return Err(Error::BadRequest( - "Field `extra_args` expected to be JSON object".to_string(), - )) - } - } - - let mut error_handler = serde_json::json!({ - "path": path, - }); - if let Some(extra_args) = &ee.extra_args { - error_handler["extra_args"] = extra_args.clone(); - } - if ee.muted_on_cancel { - error_handler["muted_on_cancel"] = serde_json::json!(true); - } - if ee.muted_on_user_path { - error_handler["muted_on_user_path"] = serde_json::json!(true); - } - - sqlx::query!( - "UPDATE workspace_settings SET error_handler = $1 WHERE workspace_id = $2", - error_handler, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET error_handler = NULL WHERE workspace_id = $1", - &w_id - ) - .execute(&mut *tx) - .await?; - } - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_error_handler", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("error_handler", &format!("{:?}", ee.path)[..])].into()), - ) - .await?; - tx.commit().await?; - - // Trigger git sync for error handler changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "error_handler".to_string() }, - Some("Error handler configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!("Edit error_handler for workspace {}", &w_id)) -} - -async fn edit_success_handler( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(es): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - // Normalize to new format (handles both old CLI and new CLI requests) - let es = es.into_normalized(); - - let mut tx = db.begin().await?; - - sqlx::query_as!( - Group, - "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", - w_id, - "success_handler", - "The group the success handler acts on behalf of", - serde_json::json!({username_to_permissioned_as(&authed.username): true}) - ) - .execute(&mut *tx) - .await?; - - if let Some(path) = &es.path { - let mut success_handler = serde_json::json!({ - "path": path, - }); - if let Some(extra_args) = &es.extra_args { - success_handler["extra_args"] = extra_args.clone(); - } - - sqlx::query!( - "UPDATE workspace_settings SET success_handler = $1 WHERE workspace_id = $2", - success_handler, - &w_id - ) - .execute(&mut *tx) - .await?; - } else { - sqlx::query!( - "UPDATE workspace_settings SET success_handler = NULL WHERE workspace_id = $1", - &w_id - ) - .execute(&mut *tx) - .await?; - } - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_success_handler", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("success_handler", &format!("{:?}", es.path)[..])].into()), - ) - .await?; - tx.commit().await?; - - // Trigger git sync for success handler changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "success_handler".to_string() }, - Some("Success handler configuration updated".to_string()), - false, - None, - ) - .await?; - - Ok(format!("Edit success_handler for workspace {}", &w_id)) -} - -#[derive(Deserialize)] -struct NewEnvironmentVariable { - name: String, - value: Option, -} - -async fn set_environment_variable( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(NewEnvironmentVariable { value, name }): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = db.begin().await?; - - match value { - Some(value) => { - sqlx::query!( - "INSERT INTO workspace_env (workspace_id, name, value) VALUES ($1, $2, $3) ON CONFLICT (workspace_id, name) DO UPDATE SET value = EXCLUDED.value", - &w_id, - name, - value - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspace.set_environment_variable", - ActionKind::Create, - &w_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Set environment variable {}", name)) - } - None => { - sqlx::query!( - "DELETE FROM workspace_env WHERE workspace_id = $1 AND name = $2", - &w_id, - name - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspace.delete_environment_variable", - ActionKind::Delete, - &w_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Deleted environment variable {}", name)) - } - } -} - -#[derive(Serialize)] -pub struct GetEncryptionKeyResponse { - key: String, -} - -async fn get_encryption_key( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult { - require_admin(authed.is_admin, &authed.username)?; - - let encryption_key_opt = sqlx::query_scalar!( - "SELECT key FROM workspace_key WHERE workspace_id = $1", - w_id - ) - .fetch_optional(&db) - .await?; - - let encryption_key = not_found_if_none(encryption_key_opt, "workspace_encryption_key", w_id)?; - return Ok(Json(GetEncryptionKeyResponse { key: encryption_key })); -} - -#[derive(Deserialize)] -struct SetEncryptionKeyRequest { - new_key: String, - skip_reencrypt: Option, -} - -async fn set_encryption_key( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(request): Json, -) -> Result<()> { - require_super_admin(&db, &authed.email).await?; - - if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) { - return Err(Error::BadRequest( - "Encryption key should be an alphanumeric string of 64 characters".to_string(), - )); - } - - let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; - - sqlx::query!( - "UPDATE workspace_key SET key = $1 WHERE workspace_id = $2", - request.new_key.clone(), - w_id - ) - .execute(&db) - .await?; - - WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); - - if !request.skip_reencrypt.unwrap_or(false) { - let new_encryption_key = build_crypt(&db, w_id.as_str()).await?; - - let mut truncated_new_key = request.new_key.clone(); - truncated_new_key.truncate(8); - tracing::warn!( - "Re-encrypting all secrets for workspace {}. New key is {}***", - w_id, - truncated_new_key - ); - - let all_variables = sqlx::query!( - "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", - w_id - ) - .fetch_all(&db) - .await?; - - for variable in all_variables { - if !variable.is_secret { - continue; - } - let decrypted_value = - decrypt(&previous_encryption_key, variable.value).map_err(|e| { - Error::internal_err(format!( - "Error decrypting variable {}: {}", - variable.path, e - )) - })?; - let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str()); - sqlx::query!( - "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3", - new_encrypted_value, - w_id, - variable.path - ) - .execute(&db) - .await?; - } - } - - // Trigger git sync for encryption key changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() }, - Some("Encryption key updated".to_string()), - false, - None, - ) - .await?; - - return Ok(()); -} - -#[derive(Serialize)] -struct UsedTriggers { - pub websocket_used: bool, - pub http_routes_used: bool, - pub kafka_used: bool, - pub nats_used: bool, - pub postgres_used: bool, - pub mqtt_used: bool, - pub sqs_used: bool, - pub gcp_used: bool, - pub email_used: bool, - pub nextcloud_used: bool, -} - -async fn get_used_triggers( - authed: ApiAuthed, - Extension(user_db): Extension, - Path(w_id): Path, -) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; - let triggers_used = sqlx::query_as!( - UsedTriggers, - r#" - SELECT - EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!", - EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!", - EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!", - EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!", - EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!", - EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!", - EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!", - EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!", - EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!", - EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!" - "#, - w_id - ) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - - Ok(Json(triggers_used)) -} - -async fn get_workspace_as_superadmin( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, -) -> JsonResult { - require_super_admin(&db, &authed.email).await?; - let workspace = sqlx::query_as!( - Workspace, - "SELECT - workspace.id AS \"id!\", - workspace.name AS \"name!\", - workspace.owner AS \"owner!\", - workspace.deleted AS \"deleted!\", - workspace.premium AS \"premium!\", - workspace_settings.color AS \"color\", - workspace.parent_workspace_id AS \"parent_workspace_id\" - FROM workspace - LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id - WHERE workspace.id = $1", - w_id - ) - .fetch_optional(&db) - .await?; - - let workspace = not_found_if_none(workspace, "workspace", w_id)?; - - Ok(Json(workspace)) -} - -async fn list_workspaces_as_super_admin( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Query(pagination): Query, - ApiAuthed { email, .. }: ApiAuthed, -) -> JsonResult> { - require_super_admin(&db, &email).await?; - let (per_page, offset) = paginate(pagination); - - let mut tx = user_db.begin(&authed).await?; - let workspaces = sqlx::query_as!( - Workspace, - "SELECT - workspace.id AS \"id!\", - workspace.name AS \"name!\", - workspace.owner AS \"owner!\", - workspace.deleted AS \"deleted!\", - workspace.premium AS \"premium!\", - workspace_settings.color AS \"color\", - workspace.parent_workspace_id AS \"parent_workspace_id\" - FROM workspace - LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id - LIMIT $1 OFFSET $2", - per_page as i32, - offset as i32 - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(workspaces)) -} - -async fn user_workspaces( - Extension(db): Extension, - ApiAuthed { email, .. }: ApiAuthed, -) -> JsonResult { - let mut tx = db.begin().await?; - let workspaces = sqlx::query_as!( - UserWorkspace, - "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, - CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, - usr.disabled - FROM workspace - JOIN usr ON usr.workspace_id = workspace.id - JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id - WHERE usr.email = $1 AND workspace.deleted = false", - email - ) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json(WorkspaceList { email, workspaces })) -} - -pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> { - if w_id == "global" { - return Err(windmill_common::error::Error::BadRequest( - "'global' is not allowed as a workspace ID".to_string(), - )); - } - let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", w_id) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - if exists { - return Err(windmill_common::error::Error::BadRequest(format!( - "Workspace {} already exists", - w_id - ))); - } - return Ok(()); -} - -lazy_static::lazy_static! { - - pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = { - match std::env::var("CREATE_WORKSPACE_REQUIRE_SUPERADMIN") { - Ok(val) => val == "true", - Err(_) => true, - } - }; - - pub static ref DISABLE_WORKSPACE_FORK: bool = { - match std::env::var("DISABLE_WORKSPACE_FORK") { - Ok(val) => val == "true", - Err(_) => false, - } - }; - -} - -async fn create_workspace_require_superadmin() -> String { - format!("{}", *CREATE_WORKSPACE_REQUIRE_SUPERADMIN) -} - -async fn _check_nb_of_workspaces(db: &DB) -> Result<()> { - let nb_workspaces = sqlx::query_scalar!( - "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = false", - ) - .fetch_one(db) - .await?; - if nb_workspaces.unwrap_or(0) >= 2 { - return Err(Error::BadRequest( - "You have reached the maximum number of workspaces (2 outside of default workspace 'admins') without an enterprise license. Archive/delete another workspace to create a new one" - .to_string(), - )); - } - return Ok(()); -} - -async fn create_workspace( - authed: ApiAuthed, - Extension(db): Extension, - Json(nw): Json, -) -> Result { - if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { - require_super_admin(&db, &authed.email).await?; - } - - #[cfg(not(feature = "enterprise"))] - _check_nb_of_workspaces(&db).await?; - - if *CLOUD_HOSTED { - let nb_workspaces = sqlx::query_scalar!( - "SELECT COUNT(*) FROM workspace WHERE owner = $1", - authed.email - ) - .fetch_one(&db) - .await?; - if nb_workspaces.unwrap_or(0) >= 10 { - return Err(Error::BadRequest( - "You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit" - .to_string(), - )); - } - } - - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - check_w_id_conflict(&mut tx, &nw.id).await?; - sqlx::query!( - "INSERT INTO workspace - (id, name, owner) - VALUES ($1, $2, $3)", - nw.id, - nw.name, - authed.email, - ) - .execute(&mut *tx) - .await?; - sqlx::query!( - "INSERT INTO workspace_settings - (workspace_id, color) - VALUES ($1, $2)", - nw.id, - nw.color, - ) - .execute(&mut *tx) - .await?; - let key = rd_string(64); - sqlx::query!( - "INSERT INTO workspace_key - (workspace_id, kind, key) - VALUES ($1, 'cloud', $2)", - nw.id, - &key - ) - .execute(&mut *tx) - .await?; - - // let mc = magic_crypt::new_magic_crypt!(key, 256); - // sqlx::query!( - // "INSERT INTO variable - // (workspace_id, path, value, is_secret, description) - // VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'), - // ($3, 'g/all/not_secret', $4, false, 'This item is not secret')", - // nw.id, - // crate::variables::encrypt(&mc, "pretty secret value"), - // nw.id, - // "finland does not actually exist", - // ) - // .execute(&mut *tx) - // .await?; - - let automate_username_creation = sqlx::query_scalar!( - "SELECT value FROM global_settings WHERE name = $1", - AUTOMATE_USERNAME_CREATION_SETTING, - ) - .fetch_optional(&mut *tx) - .await? - .map(|v| v.as_bool()) - .flatten() - .unwrap_or(false); - - let username = if automate_username_creation { - if nw.username.is_some() && nw.username.unwrap().len() > 0 { - return Err(Error::BadRequest( - "username is not allowed when username creation is automated".to_string(), - )); - } - get_instance_username_or_create_pending(&mut tx, &authed.email).await? - } else { - nw.username - .ok_or(Error::BadRequest("username is required".to_string()))? - }; - - sqlx::query!( - "INSERT INTO usr - (workspace_id, email, username, is_admin) - VALUES ($1, $2, $3, true)", - nw.id, - authed.email, - username, - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO group_ - VALUES ($1, 'all', 'The group that always contains all users of this workspace')", - nw.id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO usr_to_group - VALUES ($1, 'all', $2)", - nw.id, - username - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.create", - ActionKind::Create, - &nw.id, - Some(nw.name.as_str()), - None, - ) - .await?; - tx.commit().await?; - Ok(format!("Created workspace {}", &nw.id)) -} - -async fn clone_workspace_data( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - // Clone workspace settings (merge with existing basic settings) - update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; - - // Clone workspace environment variables - clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?; - - // Clone folders - clone_folders(tx, source_workspace_id, target_workspace_id).await?; - - // Clone groups - clone_groups(tx, source_workspace_id, target_workspace_id).await?; - - // Clone resource types - clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; - - // Clone resources - clone_resources(tx, source_workspace_id, target_workspace_id).await?; - - // Clone variables with re-encryption - clone_variables(tx, source_workspace_id, target_workspace_id).await?; - - // Clone scripts with new hashes - clone_scripts(tx, source_workspace_id, target_workspace_id).await?; - - // Clone flows with new versions - clone_flows(tx, source_workspace_id, target_workspace_id).await?; - - // Clone flow nodes - clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; - - // Clone apps with new IDs and app scripts - let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; - - // Clone raw apps - clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; - - // Clone workspace runnable dependencies and dependency map - clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; - - // TODO: Enable when git sync is implemented for workspace dependencies. - // // Clone workspace dependencies - // clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?; - Ok(()) -} - -async fn update_workspace_settings( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO workspace_key (workspace_id, kind, key) - SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - sqlx::query!( - r#" - UPDATE workspace_settings - SET - deploy_to = $1, - ai_config = source_ws.ai_config, - large_file_storage = source_ws.large_file_storage, - ducklake = source_ws.ducklake, - datatable = source_ws.datatable, - git_app_installations = source_ws.git_app_installations - FROM workspace_settings source_ws - WHERE source_ws.workspace_id = $1 - AND workspace_settings.workspace_id = $2 - "#, - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - let current_git_sync_settings = sqlx::query!( - "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", - source_workspace_id - ) - .fetch_optional(&mut **tx) - .await?; - - let mut git_sync_settings = if let Some(row) = current_git_sync_settings { - if let Some(git_sync) = row.git_sync { - serde_json::from_value::(git_sync) - .map_err(|err| Error::internal_err(err.to_string()))? - } else { - WorkspaceGitSyncSettings::default() - } - } else { - WorkspaceGitSyncSettings::default() - }; - - // We only keep the first git sync repo that is sync mode (use_individual_branch = false), since it is considered the main one - // Context: see WIN-1559 - git_sync_settings.repositories = git_sync_settings - .repositories - .into_iter() - .filter(|r| !r.use_individual_branch.unwrap_or(false)) - .take(1) - .collect(); - - let serialized_config = serde_json::to_value::(git_sync_settings) - .map_err(|err| Error::internal_err(err.to_string()))?; - - sqlx::query!( - "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", - serialized_config, - target_workspace_id - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_workspace_env( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO workspace_env (workspace_id, name, value) - SELECT $2, name, value - FROM workspace_env - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_folders( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by) - SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by - FROM folder - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_groups( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO group_ (workspace_id, name, summary, extra_perms) - SELECT $2, name, summary, extra_perms - FROM group_ - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - sqlx::query!( - "INSERT INTO usr_to_group (workspace_id, group_, usr) - SELECT $2, group_, usr - FROM usr_to_group - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_resource_types( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension) - SELECT $2, name, schema, description, edited_at, created_by, format_extension - FROM resource_type - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_resources( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by) - SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by - FROM resource - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_variables( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at) - SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at - FROM variable - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_scripts( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - // Clone all scripts directly with a single query - sqlx::query!( - r#"INSERT INTO script ( - workspace_id, hash, path, parent_hashes, summary, description, content, - created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, - envs, concurrent_limit, concurrency_time_window_s, cache_ttl, - dedicated_worker, ws_error_handler_muted, priority, timeout, - delete_after_use, restart_unless_cancelled, concurrency_key, - visible_to_runner_only, no_main_func, codebase, has_preprocessor, - on_behalf_of_email, assets - ) - SELECT - $1, hash, path, parent_hashes, summary, description, content, - created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, - envs, concurrent_limit, concurrency_time_window_s, cache_ttl, - dedicated_worker, ws_error_handler_muted, priority, timeout, - delete_after_use, restart_unless_cancelled, concurrency_key, - visible_to_runner_only, no_main_func, codebase, has_preprocessor, - on_behalf_of_email, assets - FROM script - WHERE workspace_id = $2"#, - target_workspace_id, - source_workspace_id - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_flows( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - // First, clone flows without versions - sqlx::query!( - "INSERT INTO flow ( - workspace_id, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, dependency_job, draft_only, tag, - ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, - concurrency_key, versions, on_behalf_of_email, lock_error_logs - ) - SELECT $2, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, NULL, draft_only, tag, - ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, - concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs - FROM flow - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - // Then clone flow versions - let flow_versions = sqlx::query!( - "SELECT id, workspace_id, path, value, schema, created_by, created_at - FROM flow_version - WHERE workspace_id = $1 - ORDER BY path, created_at", - source_workspace_id - ) - .fetch_all(&mut **tx) - .await?; - - for version in flow_versions { - let new_version_id = sqlx::query_scalar!( - "INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id", - target_workspace_id, - version.path, - version.value, - version.schema, - version.created_by, - version.created_at, - ) - .fetch_one(&mut **tx) - .await?; - - // Update flow to include this version - sqlx::query!( - "UPDATE flow - SET versions = array_append(versions, $1) - WHERE workspace_id = $2 AND path = $3", - new_version_id, - target_workspace_id, - version.path, - ) - .execute(&mut **tx) - .await?; - } - - Ok(()) -} - -async fn clone_flow_nodes( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2) - SELECT $2, - (SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash, - source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2 - FROM flow_node source_fn - WHERE source_fn.workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_apps( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result> { - // Get all apps from source workspace - let apps = sqlx::query!( - "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path - FROM app - WHERE workspace_id = $1", - source_workspace_id - ) - .fetch_all(&mut **tx) - .await?; - - let mut app_id_mapping: HashMap = HashMap::new(); - - // Clone apps with new IDs - for app in apps { - let new_app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", - target_workspace_id, - app.path, - app.summary, - app.policy, - &Vec::::new(), // Start with empty versions array - app.extra_perms, - app.draft_only, - app.custom_path, - ) - .fetch_one(&mut **tx) - .await?; - - app_id_mapping.insert(app.id, new_app_id); - } - - { - // Clone app versions - let app_versions = sqlx::query!( - "SELECT app_id, value, created_by, created_at, raw_app - FROM app_version - WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1) - ORDER BY app_id, created_at", - source_workspace_id - ) - .fetch_all(&mut **tx) - .await?; - - for version in app_versions { - if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) { - sqlx::query!( - "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) - VALUES ($1, $2, $3, $4, $5)", - new_app_id, - version.value, - version.created_by, - version.created_at, - version.raw_app, - ) - .execute(&mut **tx) - .await?; - } - } - } - - // Update app versions arrays - sqlx::query!( - "UPDATE app SET versions = ( - SELECT array_agg(av.id ORDER BY av.created_at) - FROM app_version av - WHERE av.app_id = app.id - ) WHERE workspace_id = $1", - target_workspace_id - ) - .execute(&mut **tx) - .await?; - - // Clone app scripts with recomputed hashes - let app_scripts = sqlx::query!( - "SELECT app, hash, lock, code, code_sha256 - FROM app_script - WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", - source_workspace_id - ) - .fetch_all(&mut **tx) - .await?; - - for app_script in app_scripts { - if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { - // Recompute hash using app_id, code_sha256, and lock - let mut hasher = Sha256::new(); - hasher.update(new_app_id.to_be_bytes()); - hasher.update(hex::decode(&app_script.code_sha256)?); - if let Some(lock) = &app_script.lock { - hasher.update(lock.as_bytes()); - } - let new_hash = hex::encode(hasher.finalize()); - - sqlx::query!( - "INSERT INTO app_script (app, hash, lock, code, code_sha256) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", - new_app_id, - new_hash, - app_script.lock, - app_script.code, - app_script.code_sha256, - ) - .execute(&mut **tx) - .await?; - } - } - - Ok(app_id_mapping) -} - -async fn clone_raw_apps( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - sqlx::query!( - "INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms) - SELECT path, version, $2, summary, edited_at, data, extra_perms - FROM raw_app - WHERE workspace_id = $1", - source_workspace_id, - target_workspace_id, - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn clone_workspace_runnable_dependencies( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - // Clone workspace_runnable_dependencies - sqlx::query!( - "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path) - SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path - FROM workspace_runnable_dependencies - WHERE workspace_id = $2", - target_workspace_id, - source_workspace_id - ) - .execute(&mut **tx) - .await?; - - // Clone dependency_map to preserve import relationships - sqlx::query!( - "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) - SELECT $1, importer_path, importer_kind, imported_path, importer_node_id - FROM dependency_map - WHERE workspace_id = $2", - target_workspace_id, - source_workspace_id - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -#[allow(dead_code)] -async fn clone_workspace_dependencies( - tx: &mut Transaction<'_, Postgres>, - source_workspace_id: &str, - target_workspace_id: &str, -) -> Result<()> { - // Clone workspace_runnable_dependencies - sqlx::query!( - "INSERT INTO workspace_dependencies (workspace_id, language, name, description, content, archived, created_at) - SELECT $1, language, name, description, content, archived, created_at - FROM workspace_dependencies - WHERE workspace_id = $2", - target_workspace_id, - source_workspace_id - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result { - return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string())); -} - -/// Return the uuids of the git sync jobs to create the branch before creating the fork -async fn create_workspace_fork_branch( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(nw): Json, -) -> JsonResult> { - if *CLOUD_HOSTED { - return Err(Error::BadRequest(format!( - "Forking workspaces is not available on app.windmill.dev" - ))); - } - - if *DISABLE_WORKSPACE_FORK { - require_super_admin(&db, &authed.email).await?; - } - - Ok(Json( - handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, - )) -} - -async fn create_workspace_fork( - authed: ApiAuthed, - Extension(db): Extension, - Path(parent_workspace_id): Path, - Json(nw): Json, -) -> Result { - if *CLOUD_HOSTED { - return Err(Error::BadRequest(format!( - "Forking workspaces is not available on app.windmill.dev" - ))); - } - - if *DISABLE_WORKSPACE_FORK { - require_super_admin(&db, &authed.email).await?; - } - - let mut tx: Transaction<'_, Postgres> = db.begin().await?; - - // Generate unique forked workspace ID with wm-fork prefix - if !nw.id.starts_with(WM_FORK_PREFIX) { - return Err(Error::BadRequest(format!( - "The id `{}` is invalid for a forked workspace. It should be prefixed by {}", - nw.id, WM_FORK_PREFIX - ))); - } - - let forked_id = nw.id; - - sqlx::query!( - "INSERT INTO workspace - (id, name, owner, parent_workspace_id) - VALUES ($1, $2, $3, $4)", - forked_id, - nw.name, - authed.email, - parent_workspace_id, - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO workspace_settings - (workspace_id, color) - VALUES ($1, $2)", - forked_id, - nw.color, - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO usr - (workspace_id, email, username, is_admin) - SELECT $1, email, username, is_admin FROM usr - WHERE workspace_id = $3 AND email = $2 - ", - forked_id, - authed.email, - parent_workspace_id, - ) - .execute(&mut *tx) - .await?; - - // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; - - sqlx::query!( - "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) - SELECT $1, email, is_admin, operator - FROM usr - WHERE workspace_id = $2", - &forked_id, - &parent_workspace_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.create_fork", - ActionKind::Create, - &forked_id, - Some(nw.name.as_str()), - None, - ) - .await?; - tx.commit().await?; - Ok(format!("Created forked workspace {}", &forked_id)) -} - -async fn edit_workspace( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, - Json(ew): Json, -) -> Result { - require_admin(is_admin, &username)?; - let mut tx = db.begin().await?; - sqlx::query!( - "UPDATE workspace SET name = $1, owner = $2 WHERE id = $3", - ew.name, - ew.owner, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.update", - ActionKind::Update, - &w_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Updated workspace {}", &w_id)) -} - -/// Archive a workspace: disable schedules, cancel jobs, and mark as deleted. -/// Returns (schedules_disabled_count, jobs_canceled_count). -pub(crate) async fn archive_workspace_impl( - db: &DB, - w_id: &str, - username: &str, -) -> Result<(usize, usize)> { - // Step 1: Disable all schedules and clear their queued jobs - let mut tx = db.begin().await?; - let disabled_schedules = sqlx::query_scalar!( - "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path", - w_id - ) - .fetch_all(&mut *tx) - .await?; - - let schedules_count = disabled_schedules.len(); - tracing::info!( - "Disabled {} schedules in workspace {}", - schedules_count, - w_id - ); - - // Clear all schedule-related jobs using the existing clear_schedule function - for schedule_path in &disabled_schedules { - crate::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?; - } - - // Mark workspace as archived - sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - // Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs) - let jobs_to_cancel = - sqlx::query_scalar!("SELECT id FROM v2_job_queue WHERE workspace_id = $1", w_id) - .fetch_all(db) - .await?; - - let jobs_count = jobs_to_cancel.len(); - tracing::info!( - "Found {} remaining jobs to cancel in workspace {}", - jobs_count, - w_id - ); - - // Step 3: Cancel all remaining jobs using the existing cancel_jobs function - let canceled_count = if !jobs_to_cancel.is_empty() { - let axum::Json(canceled_jobs) = crate::jobs::cancel_jobs( - jobs_to_cancel, - db, - username, - w_id, - false, // force_cancel - ) - .await?; - - let count = canceled_jobs.len(); - tracing::info!("Canceled {} jobs in workspace {}", count, w_id); - count - } else { - 0 - }; - - Ok((schedules_count, canceled_count)) -} - -async fn archive_workspace( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let (schedules_count, canceled_count) = - archive_workspace_impl(&db, &w_id, &authed.username).await?; - - // Audit log - let mut tx = db.begin().await?; - let mut audit_params = HashMap::new(); - audit_params.insert("disabled_schedules", schedules_count.to_string()); - audit_params.insert("canceled_jobs", canceled_count.to_string()); - let audit_params_refs: HashMap<&str, &str> = - audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect(); - - audit_log( - &mut *tx, - &authed, - "workspaces.archive", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some(audit_params_refs), - ) - .await?; - tx.commit().await?; - - Ok(format!( - "Archived workspace {}, disabled {} schedules and canceled {} jobs", - &w_id, schedules_count, canceled_count - )) -} - -async fn leave_workspace( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, -) -> Result { - let mut tx = db.begin().await?; - sqlx::query!( - "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", - &w_id, - &authed.email - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.leave", - ActionKind::Delete, - &w_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Left workspace {}", &w_id)) -} - -async fn unarchive_workspace( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - let mut tx = db.begin().await?; - sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.unarchive", - ActionKind::Update, - &w_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!("Unarchived workspace {}", &w_id)) -} - -async fn invite_user( - ApiAuthed { username, is_admin, .. }: ApiAuthed, - Extension(db): Extension, - Extension(webhook): Extension, - Path(w_id): Path, - Json(mut nu): Json, -) -> Result<(StatusCode, String)> { - require_admin(is_admin, &username)?; - - nu.email = nu.email.to_lowercase(); - - let mut tx = db.begin().await?; - - let already_in_workspace = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", - &w_id, - nu.email - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - - if already_in_workspace { - return Err(Error::BadRequest(format!( - "user with email {} already exists in workspace {}", - nu.email, w_id - ))); - } - - sqlx::query!( - "INSERT INTO workspace_invite - (workspace_id, email, is_admin, operator) - VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email) - DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator", - &w_id, - nu.email, - nu.is_admin, - nu.operator - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - send_email_if_possible( - &format!("Invited to Windmill's workspace: {w_id}"), - &format!( - "You have been granted access to Windmill's workspace {w_id} - -If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", - BASE_URL.read().await.clone() - ), - &nu.email, - ); - - webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace { - email: nu.email.clone(), - workspace: w_id, - }); - - Ok(( - StatusCode::CREATED, - format!("user with email {} invited", nu.email), - )) -} - -async fn add_user( - authed: ApiAuthed, - Extension(db): Extension, - Extension(webhook): Extension, - Path(w_id): Path, - Json(mut nu): Json, -) -> Result<(StatusCode, String)> { - require_admin(authed.is_admin, &authed.username)?; - nu.email = nu.email.to_lowercase(); - - let mut tx = db.begin().await?; - - let already_exists_email = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", - &w_id, - nu.email, - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - - if already_exists_email { - return Err(Error::BadRequest(format!( - "user with email {} already exists in workspace {}", - nu.email, w_id - ))); - } - - let automate_username_creation = sqlx::query_scalar!( - "SELECT value FROM global_settings WHERE name = $1", - AUTOMATE_USERNAME_CREATION_SETTING, - ) - .fetch_optional(&mut *tx) - .await? - .map(|v| v.as_bool()) - .flatten() - .unwrap_or(false); - - let username = if automate_username_creation { - if nu.username.is_some() && nu.username.unwrap().len() > 0 { - return Err(Error::BadRequest( - "username is not allowed when username creation is automated".to_string(), - )); - } - get_instance_username_or_create_pending(&mut tx, &nu.email).await? - } else { - let username = nu - .username - .ok_or(Error::BadRequest("username is required".to_string()))?; - - if !VALID_USERNAME.is_match(&username) { - return Err(windmill_common::error::Error::BadRequest(format!( - "Usermame can only contain alphanumeric characters and underscores and must start with a letter" - ))); - } - - username - }; - - sqlx::query!( - "INSERT INTO usr - (workspace_id, email, username, is_admin, operator) - VALUES ($1, $2, $3, $4, $5)", - &w_id, - nu.email, - username, - nu.is_admin, - nu.operator - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2", - &w_id, - nu.email - ) - .execute(&mut *tx) - .await?; - - sqlx::query_as!( - Group, - "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - &w_id, - username, - "all", - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "users.add_to_workspace", - ActionKind::Create, - &w_id, - Some(&nu.email), - None, - ) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::User { email: nu.email.clone() }, - Some(format!("Added user '{}' to workspace", &nu.email)), - true, - None, - ) - .await?; - - send_email_if_possible( - &format!("Added to Windmill's workspace: {w_id}"), - &format!( - "You have been granted access to Windmill's workspace {w_id} by {} - -If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", - authed.email, - BASE_URL.read().await.clone() - ), - &nu.email, - ); - - webhook.send_instance_event(InstanceEvent::UserAddedWorkspace { - workspace: w_id.clone(), - email: nu.email.clone(), - }); - - Ok(( - StatusCode::CREATED, - format!("user with email {} added", nu.email), - )) -} - -async fn delete_invite( - ApiAuthed { username, is_admin, .. }: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(nu): Json, -) -> Result<(StatusCode, String)> { - require_admin(is_admin, &username)?; - - let mut tx = db.begin().await?; - - sqlx::query!( - "DELETE FROM workspace_invite WHERE - workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4", - &w_id, - nu.email, - nu.is_admin, - nu.operator - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(( - StatusCode::CREATED, - format!("invite to email {} deleted", nu.email), - )) -} - -async fn exists_username( - Extension(db): Extension, - Json(vu): Json, -) -> Result { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", - vu.username, - vu.id - ) - .fetch_one(&db) - .await? - .unwrap_or(true); - - if exists { - return Err(Error::BadRequest("username already taken".to_string())); - } - - Ok("valid username".to_string()) -} - -async fn get_workspace_name( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - let workspace = sqlx::query_scalar!("SELECT name FROM workspace WHERE id = $1", &w_id) - .fetch_one(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(workspace) -} - -async fn get_dependency_map( - authed: ApiAuthed, - Path(w_id): Path, - Extension(user_db): Extension, -) -> JsonResult> { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = user_db.begin(&authed).await?; - let dmap = sqlx::query_as!( - DependencyMap, - " - SELECT workspace_id, importer_path, importer_kind::text, imported_path, importer_node_id - FROM dependency_map WHERE workspace_id = $1", - &w_id - ) - .fetch_all(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(Json(dmap)) -} - -#[axum::debug_handler] -async fn rebuild_dependency_map( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - if *CLOUD_HOSTED { - return Err(Error::BadRequest("Disabled on Cloud".into())); - } - ScopedDependencyMap::rebuild_map(&w_id, &db).await -} - -#[axum::debug_handler] -async fn get_dependents( - Extension(db): Extension, - Path((w_id, imported_path)): Path<(String, String)>, - _authed: ApiAuthed, -) -> JsonResult> { - tracing::debug!( - workspace_id = %w_id, - imported_path = %imported_path, - "API: Getting dependents for imported path" - ); - - let dependents = ScopedDependencyMap::get_dependents(&imported_path, &w_id, &db).await?; - - tracing::debug!( - workspace_id = %w_id, - imported_path = %imported_path, - dependents_count = dependents.len(), - "API: Found dependents: {:?}", - dependents - ); - - Ok(Json(dependents)) -} - -#[derive(Serialize, Debug)] -struct DependentsAmount { - imported_path: String, - count: i64, -} - -#[axum::debug_handler] -async fn get_dependents_amounts( - Extension(db): Extension, - Path(w_id): Path, - Json(imported_paths): Json>, -) -> JsonResult> { - tracing::debug!( - workspace_id = %w_id, - imported_paths = ?imported_paths, - "API: Getting dependents amounts for imported paths" - ); - - let results = sqlx::query_as!( - DependentsAmount, - r#" - SELECT - imported_path, - COUNT(DISTINCT importer_path) as "count!" - FROM dependency_map - WHERE workspace_id = $1 AND imported_path = ANY($2) - GROUP BY imported_path - "#, - w_id, - &imported_paths - ) - .fetch_all(&db) - .await?; - - tracing::debug!( - workspace_id = %w_id, - results_count = results.len(), - "API: Found dependents amounts: {:?}", - results - ); - - Ok(Json(results)) -} - -#[derive(Deserialize)] -struct ChangeWorkspaceName { - new_name: String, -} - -#[derive(Deserialize)] -struct ChangeWorkspaceColor { - color: Option, -} - -async fn change_workspace_name( - authed: ApiAuthed, - Path(w_id): Path, - Extension(db): Extension, - Json(rw): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = db.begin().await?; - - sqlx::query!( - "UPDATE workspace SET name = $1 WHERE id = $2", - &rw.new_name, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspace.change_workspace_name", - ActionKind::Update, - &w_id, - Some(&authed.email), - None, - ) - .await?; - - tx.commit().await?; - - // Trigger git sync for workspace name changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { setting_type: "workspace_name".to_string() }, - Some(format!("Workspace name updated to {}", &rw.new_name)), - false, - None, - ) - .await?; - - Ok(format!("updated workspace name to {}", &rw.new_name)) -} - -async fn change_workspace_color( - authed: ApiAuthed, - Path(w_id): Path, - Extension(db): Extension, - Json(rw): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = db.begin().await?; - - sqlx::query!( - "UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2", - rw.color, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Settings { setting_type: "workspace_color".to_string() }, - None, - false, - None, - ) - .await?; - - Ok(format!( - "updated workspace color to {}", - rw.color.as_deref().unwrap_or("no color") - )) -} - -async fn get_usage(Extension(db): Extension, Path(w_id): Path) -> Result { - let usage = sqlx::query_scalar!( - " - SELECT usage.usage FROM usage - WHERE is_workspace = true - AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) - AND id = $1", - w_id - ) - .fetch_optional(&db) - .await? - .unwrap_or(0); - Ok(usage.to_string()) -} - #[cfg(feature = "enterprise")] pub async fn get_critical_alerts( Extension(db): Extension, @@ -4404,887 +276,3 @@ async fn mute_critical_alerts( pub async fn mute_critical_alerts() -> Error { Error::NotFound("Critical Alerts require EE".to_string()) } - -#[derive(Deserialize)] -pub struct EditPublicAppRateLimitRequest { - pub public_app_execution_limit_per_minute: Option, -} - -async fn edit_public_app_rate_limit( - Extension(db): Extension, - Path(w_id): Path, - authed: ApiAuthed, - Json(req): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - sqlx::query!( - "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2", - req.public_app_execution_limit_per_minute, - &w_id - ) - .execute(&db) - .await?; - - // Cache is invalidated via DB trigger -> notify_event -> polling in main.rs - - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() }, - None, - false, - None, - ) - .await?; - - Ok(format!( - "Updated public app rate limit for workspace: {}", - &w_id - )) -} - -// 5 minutes fallback TTL (in addition to event-based invalidation) -const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300; - -pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result> { - use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE; - - let now = Utc::now().timestamp(); - - if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) { - if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS { - return Ok(rate_limit); - } - } - - let result: Option> = sqlx::query_scalar( - "SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1", - ) - .bind(w_id) - .fetch_optional(db) - .await?; - let rate_limit = result.flatten(); - PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now)); - Ok(rate_limit) -} - -#[derive(Deserialize, Serialize)] -struct ChangeOperatorSettings { - #[serde(default)] - runs: bool, - #[serde(default)] - schedules: bool, - #[serde(default)] - resources: bool, - #[serde(default)] - variables: bool, - #[serde(default)] - assets: bool, - #[serde(default)] - triggers: bool, - #[serde(default)] - audit_logs: bool, - #[serde(default)] - groups: bool, - #[serde(default)] - folders: bool, - #[serde(default)] - workers: bool, -} - -async fn update_operator_settings( - authed: ApiAuthed, - Path(w_id): Path, - Extension(db): Extension, - Json(settings): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - let mut tx = db.begin().await?; - - let settings_json = serde_json::json!(settings); - - sqlx::query!( - "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", - settings_json, - &w_id - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - // Trigger git sync for operator settings changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { - setting_type: "operator_settings".to_string(), - }, - Some("Operator settings updated".to_string()), - false, - None, - ) - .await?; - - Ok("Operator settings updated successfully".to_string()) -} - -#[derive(Serialize)] -pub struct WorkspaceComparison { - pub all_ahead_items_visible: bool, - pub all_behind_items_visible: bool, - pub skipped_comparison: bool, - pub diffs: Vec, - pub summary: CompareSummary, -} - -#[derive(Serialize, Default)] -pub struct CompareSummary { - pub total_diffs: usize, - pub total_ahead: usize, - pub total_behind: usize, - pub scripts_changed: usize, - pub flows_changed: usize, - pub apps_changed: usize, - pub resources_changed: usize, - pub variables_changed: usize, - pub resource_types_changed: usize, - pub folders_changed: usize, - pub conflicts: usize, // Items that are both ahead and behind -} - -async fn reset_workspace_diffs( - authed: ApiAuthed, - Path((w_id, target_workspace_id)): Path<(String, String)>, - Extension(db): Extension, - Extension(user_db): Extension, -) -> JsonResult<()> { - // Needed to compute the has_changes: Option. Otherwise it will be None, and the query will not hit the items - let _ = compare_workspaces( - authed, - Path((w_id.clone(), target_workspace_id.clone())), - Extension(db.clone()), - Extension(user_db), - ) - .await?; - - sqlx::query!( - "DELETE FROM workspace_diff WHERE has_changes = false AND ( - (source_workspace_id = $1 AND fork_workspace_id = $2) - OR (source_workspace_id = $2 AND fork_workspace_id =$1) - )", - target_workspace_id, - w_id, - ) - .execute(&db) - .await?; - - Ok(Json(())) -} - -#[derive(Serialize, Debug, Clone, Default)] -pub struct WorkspaceDiffRow { - kind: String, - path: String, - ahead: i32, - behind: i32, - has_changes: Option, - exists_in_source: Option, - exists_in_fork: Option, -} - -async fn compare_workspaces( - authed: ApiAuthed, - Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>, - Extension(db): Extension, - Extension(user_db): Extension, -) -> JsonResult { - // require_admin(authed.is_admin, &authed.username)?; - - let skipped_comparison: bool = sqlx::query_scalar( - "SELECT EXISTS( - SELECT 1 FROM skip_workspace_diff_tally - WHERE workspace_id = $1 - )", - ) - .bind(&fork_workspace_id) - .fetch_one(&db) - .await?; - - if skipped_comparison { - return Ok(Json(WorkspaceComparison { - all_ahead_items_visible: true, - all_behind_items_visible: true, - skipped_comparison, - diffs: vec![], - summary: Default::default(), - })); - } - - let diff_items = sqlx::query_as!( - WorkspaceDiffRow, - "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff - WHERE source_workspace_id = $1 AND fork_workspace_id = $2", - source_workspace_id, - fork_workspace_id, - ) - .fetch_all(&db) - .await?; - - let mut confirmed_diffs = vec![]; - for item in diff_items { - if let Some(has_changes) = item.has_changes { - if has_changes { - confirmed_diffs.push(item); - } - continue; - } - - let item_comparison = match item.kind.as_str() { - "script" => Some( - compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path) - .await?, - ), - "flow" => Some( - compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path) - .await?, - ), - "app" => Some( - compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?, - ), - "resource" => Some( - compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path) - .await?, - ), - "variable" => Some( - compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path) - .await?, - ), - "resource_type" => Some( - compare_two_resource_types( - &db, - &source_workspace_id, - &fork_workspace_id, - &item.path, - ) - .await?, - ), - "folder" => Some( - compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path) - .await?, - ), - k => { - tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path); - None - // Some(ItemComparison { - // has_changes: true, - // exists_in_source: true, - // exists_in_fork: true, - // }) - } - }; - - if let Some(item_comparison) = item_comparison { - if item_comparison.has_changes { - sqlx::query!( - "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6 - WHERE path = $3 AND kind = $4 AND ( - (source_workspace_id = $1 AND fork_workspace_id = $2) - OR (source_workspace_id = $2 AND fork_workspace_id =$1) - )", - source_workspace_id, - fork_workspace_id, - item.path, - item.kind, - item_comparison.exists_in_source, - item_comparison.exists_in_fork, - ) - .execute(&db) - .await?; - confirmed_diffs.push(WorkspaceDiffRow { - has_changes: Some(item_comparison.has_changes), - exists_in_source: Some(item_comparison.exists_in_source), - exists_in_fork: Some(item_comparison.exists_in_fork), - ..item - }); - } else { - sqlx::query!( - "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND ( - (source_workspace_id = $1 AND fork_workspace_id = $2) - OR (source_workspace_id = $2 AND fork_workspace_id =$1) - )", - source_workspace_id, - fork_workspace_id, - item.path, - item.kind, - ) - .execute(&db) - .await?; - } - } - } - - let visible_diffs = filter_visible_diffs( - &confirmed_diffs, - &source_workspace_id, - &fork_workspace_id, - user_db.begin(&authed).await?, - ) - .await?; - - let summary = CompareSummary { - total_diffs: visible_diffs.len(), - total_ahead: visible_diffs - .iter() - .map(|s| s.ahead) - .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), - total_behind: visible_diffs - .iter() - .map(|s| s.behind) - .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), - scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(), - flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(), - apps_changed: visible_diffs.iter().filter(|s| s.kind == "app").count(), - resources_changed: visible_diffs - .iter() - .filter(|s| s.kind == "resource") - .count(), - variables_changed: visible_diffs - .iter() - .filter(|s| s.kind == "variable") - .count(), - resource_types_changed: visible_diffs - .iter() - .filter(|s| s.kind == "resource_type") - .count(), - folders_changed: visible_diffs.iter().filter(|s| s.kind == "folder").count(), - conflicts: visible_diffs - .iter() - .filter(|s| s.ahead > 0 && s.behind > 0) - .count(), - }; - - let all_ahead_items_visible = summary.total_ahead - == confirmed_diffs - .iter() - .map(|s| s.ahead) - .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); - let all_behind_items_visible = summary.total_behind - == confirmed_diffs - .iter() - .map(|s| s.behind) - .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); - - return Ok(Json(WorkspaceComparison { - all_ahead_items_visible, - all_behind_items_visible, - skipped_comparison: false, - diffs: visible_diffs, - summary, - })); -} - -async fn filter_visible_diffs( - confirmed_diffs: &[WorkspaceDiffRow], - source_workspace_id: &str, - fork_workspace_id: &str, - mut tx: Transaction<'static, Postgres>, -) -> Result> { - // Step 1: Group paths by (workspace, kind) - let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new(); - let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new(); - - for diff in confirmed_diffs { - if diff.exists_in_source.unwrap_or(false) { - source_items.entry(&diff.kind).or_default().push(&diff.path); - } - if diff.exists_in_fork.unwrap_or(false) { - fork_items.entry(&diff.kind).or_default().push(&diff.path); - } - } - - // Step 2: Batch query for each (workspace, kind) combination - let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; - let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; - - // Step 3: Filter diffs based on visibility - let visible_diffs: Vec = confirmed_diffs - .iter() - .filter(|diff| { - let v = (diff.kind.to_string(), diff.path.to_string()); - let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v); - let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v); - source_ok && fork_ok - }) - .cloned() - .collect(); - - Ok(visible_diffs) -} - -async fn query_visible_items<'c>( - tx: &mut Transaction<'c, Postgres>, - workspace_id: &str, - items_by_kind: &HashMap<&str, Vec<&str>>, -) -> Result> { - let mut visible = HashSet::new(); - - for (kind, paths) in items_by_kind { - let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); - - let results = match *kind { - "script" => { - sqlx::query_scalar!( - "SELECT path FROM script - WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - "flow" => { - sqlx::query_scalar!( - "SELECT path FROM flow - WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - "app" => { - sqlx::query_scalar!( - "SELECT path FROM app - WHERE workspace_id = $1 AND path = ANY($2)", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - "resource" => { - sqlx::query_scalar!( - "SELECT path FROM resource - WHERE workspace_id = $1 AND path = ANY($2)", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - "variable" => { - sqlx::query_scalar!( - "SELECT path FROM variable - WHERE workspace_id = $1 AND path = ANY($2)", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - "folder" => { - let a: Vec = paths_vec - .iter() - .map(|p| p.strip_prefix("f/").unwrap_or(p.as_str()).to_string()) - .collect(); - sqlx::query_scalar!( - "SELECT name FROM folder - WHERE workspace_id = $1 AND name = ANY($2)", - workspace_id, - &a, - ) - .fetch_all(&mut **tx) - .await? - .into_iter() - .map(|p| format!("f/{p}")) - .collect() - } - "resource_type" => { - sqlx::query_scalar!( - "SELECT name FROM resource_type - WHERE workspace_id = $1 AND name = ANY($2)", - workspace_id, - &paths_vec - ) - .fetch_all(&mut **tx) - .await? - } - _ => vec![], // Unknown kind - }; - - for path in results { - visible.insert((kind.to_string(), path)); - } - } - - Ok(visible) -} - -#[derive(Debug)] -struct ItemComparison { - has_changes: bool, - exists_in_source: bool, - exists_in_fork: bool, -} - -async fn compare_two_scripts( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - path: &str, -) -> Result { - // Get latest script from each workspace - let source_script = sqlx::query!( - "SELECT hash, created_at, content, summary, description, lock, schema - FROM script - WHERE workspace_id = $1 AND path = $2 AND archived = false - ORDER BY created_at DESC - LIMIT 1", - source_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let target_script = sqlx::query!( - "SELECT hash, created_at, content, summary, description, lock, schema - FROM script - WHERE workspace_id = $1 AND path = $2 AND archived = false - ORDER BY created_at DESC - LIMIT 1", - fork_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_script, &target_script) { - if source.content != target.content - || source.summary != target.summary - || source.description != target.description - || source.lock != target.lock - || source.schema != target.schema - { - has_changes = true; - } - } else if source_script.is_some() || target_script.is_some() { - // The script exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_script.is_some(), - exists_in_fork: target_script.is_some(), - }); -} - -async fn compare_two_flows( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - path: &str, -) -> Result { - // Get latest flow from each workspace - let source_flow = sqlx::query!( - "SELECT value, summary, description, schema - FROM flow - WHERE workspace_id = $1 AND path = $2 AND archived = false", - source_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let target_flow = sqlx::query!( - "SELECT value, summary, description, schema - FROM flow - WHERE workspace_id = $1 AND path = $2 AND archived = false", - fork_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_flow, &target_flow) { - if source.value != target.value - || source.summary != target.summary - || source.description != target.description - || source.schema != target.schema - { - has_changes = true; - } - } else if source_flow.is_some() || target_flow.is_some() { - // The flow exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_flow.is_some(), - exists_in_fork: target_flow.is_some(), - }); -} - -async fn compare_two_apps( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - path: &str, -) -> Result { - // Get app with its latest version data from source workspace - let source_app = sqlx::query!( - "SELECT app.summary, app.policy, app_version.value - FROM app - JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", - source_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let target_app = sqlx::query!( - "SELECT app.summary, app.policy, app_version.value - FROM app - JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", - fork_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata and content differences - if let (Some(source), Some(target)) = (&source_app, &target_app) { - if source.summary != target.summary - || source.policy != target.policy - || source.value != target.value - { - has_changes = true; - } - } else if source_app.is_some() || target_app.is_some() { - // The app exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_app.is_some(), - exists_in_fork: target_app.is_some(), - }); -} - -async fn compare_two_resources( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - path: &str, -) -> Result { - // Get resource from each workspace - let source_resource = sqlx::query!( - "SELECT value, description, resource_type - FROM resource - WHERE workspace_id = $1 AND path = $2", - source_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let target_resource = sqlx::query!( - "SELECT value, description, resource_type - FROM resource - WHERE workspace_id = $1 AND path = $2", - fork_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_resource, &target_resource) { - if source.value != target.value - || source.description != target.description - || source.resource_type != target.resource_type - { - has_changes = true; - } - } else if source_resource.is_some() || target_resource.is_some() { - // The resource exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_resource.is_some(), - exists_in_fork: target_resource.is_some(), - }); -} - -async fn compare_two_variables( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - path: &str, -) -> Result { - // Get variable from each workspace - let source_variable = sqlx::query!( - "SELECT value, is_secret, description - FROM variable - WHERE workspace_id = $1 AND path = $2", - source_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let target_variable = sqlx::query!( - "SELECT value, is_secret, description - FROM variable - WHERE workspace_id = $1 AND path = $2", - fork_workspace_id, - path - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_variable, &target_variable) { - if source.is_secret != target.is_secret - || source.value != target.value - || source.description != target.description - { - has_changes = true; - } - } else if source_variable.is_some() || target_variable.is_some() { - // The variable exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_variable.is_some(), - exists_in_fork: target_variable.is_some(), - }); -} - -async fn compare_two_resource_types( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - name: &str, -) -> Result { - // Get resource type from each workspace - let source_resource_type = sqlx::query!( - "SELECT schema, description, format_extension - FROM resource_type - WHERE workspace_id = $1 AND name = $2", - source_workspace_id, - name - ) - .fetch_optional(db) - .await?; - - let target_resource_type = sqlx::query!( - "SELECT schema, description, format_extension - FROM resource_type - WHERE workspace_id = $1 AND name = $2", - fork_workspace_id, - name - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_resource_type, &target_resource_type) { - if source.schema != target.schema - || source.description != target.description - || source.format_extension != target.format_extension - { - has_changes = true; - } - } else if source_resource_type.is_some() || target_resource_type.is_some() { - // The resource type exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_resource_type.is_some(), - exists_in_fork: target_resource_type.is_some(), - }); -} - -async fn compare_two_folders( - db: &DB, - source_workspace_id: &str, - fork_workspace_id: &str, - name: &str, -) -> Result { - // Get folder from each workspace - let source_folder = sqlx::query!( - "SELECT display_name, owners, extra_perms, summary - FROM folder - WHERE workspace_id = $1 AND name = $2", - source_workspace_id, - name.strip_prefix("f/"), - ) - .fetch_optional(db) - .await?; - - let target_folder = sqlx::query!( - "SELECT display_name, owners, extra_perms, summary - FROM folder - WHERE workspace_id = $1 AND name = $2", - fork_workspace_id, - name.strip_prefix("f/"), - ) - .fetch_optional(db) - .await?; - - let mut has_changes = false; - - // Check metadata differences - if let (Some(source), Some(target)) = (&source_folder, &target_folder) { - if source.display_name != target.display_name - || source.owners != target.owners - || source.extra_perms != target.extra_perms - || source.summary != target.summary - { - has_changes = true; - } - } else if source_folder.is_some() || target_folder.is_some() { - // The folder exists in one of source or target, but not the other, this is considered as a change - has_changes = true - } - - return Ok(ItemComparison { - has_changes, - exists_in_source: source_folder.is_some(), - exists_in_fork: target_folder.is_some(), - }); -} diff --git a/backend/windmill-common/src/email_oss.rs b/backend/windmill-common/src/email_oss.rs index 05eb6bf243..2813ebe458 100644 --- a/backend/windmill-common/src/email_oss.rs +++ b/backend/windmill-common/src/email_oss.rs @@ -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" + ); +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index bde4892ad0..20ba0d4509 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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; diff --git a/backend/windmill-common/src/usernames.rs b/backend/windmill-common/src/usernames.rs new file mode 100644 index 0000000000..645ee90e51 --- /dev/null +++ b/backend/windmill-common/src/usernames.rs @@ -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 { + 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 { + 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) + } + } +} diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 67cac0cbf1..4a6f4604a1 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -51,6 +51,9 @@ use std::sync::atomic::Ordering; use crate::worker::CLOUD_HOSTED; lazy_static::lazy_static! { + pub static ref COOKIE_DOMAIN: Option = std::env::var("COOKIE_DOMAIN").ok(); + pub static ref IS_SECURE: Arc> = 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); diff --git a/backend/windmill-common/src/webhook.rs b/backend/windmill-common/src/webhook.rs index ffc50f932d..f1f8508f74 100644 --- a/backend/windmill-common/src/webhook.rs +++ b/backend/windmill-common/src/webhook.rs @@ -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 = 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"); } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 82511819f8..5d530b05b5 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -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> = Arc::new(RwLock::new("".to_string())); - pub static ref IS_SECURE: Arc> = Arc::new(RwLock::new(false)); - pub static ref COOKIE_DOMAIN: Option = 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() diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 684529e1ce..09c4cd11f1 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -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(()) +}