diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c891560a2..111225c3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [1.416.2](https://github.com/windmill-labs/windmill/compare/v1.416.1...v1.416.2) (2024-11-02) + + +### Bug Fixes + +* apply NO_PROXY and HTTP_PROXY, HTTPS_PROXY more consistently ([567d621](https://github.com/windmill-labs/windmill/commit/567d6216d2631a90fbe59ec6142c38b3b352eea7)) + +## [1.416.1](https://github.com/windmill-labs/windmill/compare/v1.416.0...v1.416.1) (2024-11-01) + + +### Bug Fixes + +* **prometheus:** fix incorrect worker_busy set to 1 ([53f9136](https://github.com/windmill-labs/windmill/commit/53f9136658b9fc1795793d82408c1f1f04adcf06)) + +## [1.416.0](https://github.com/windmill-labs/windmill/compare/v1.415.2...v1.416.0) (2024-11-01) + + +### Features + +* private hub user accessible url setting ([#4617](https://github.com/windmill-labs/windmill/issues/4617)) ([79edf89](https://github.com/windmill-labs/windmill/commit/79edf89bd17827d5f1d946739385327b6c0520bf)) + + +### Bug Fixes + +* **frontend:** improve tag selector for workspace script drawer ([66f6985](https://github.com/windmill-labs/windmill/commit/66f69859ad2de51aef5a133df4ab4397d0f61ccf)) + +## [1.415.2](https://github.com/windmill-labs/windmill/compare/v1.415.1...v1.415.2) (2024-11-01) + + +### Bug Fixes + +* **s3:** align s3 handler additional creds providers ([984c6dd](https://github.com/windmill-labs/windmill/commit/984c6dd10c63097eb195883c4d8a9681ab1b49e0)) + ## [1.415.1](https://github.com/windmill-labs/windmill/compare/v1.415.0...v1.415.1) (2024-10-31) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 98c5a7d9a9..d12b1b2698 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -166,9 +166,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.91" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" +checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13" [[package]] name = "arc-swap" @@ -441,7 +441,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -588,7 +588,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -636,7 +636,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -678,7 +678,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -799,9 +799,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.47.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8776850becacbd3a82a4737a9375ddb5c6832a51379f24443a98e61513f852c" +checksum = "ded855583fa1d22e88fe39fd6062b062376e50a8211989e07cf5e38d52eb3453" dependencies = [ "aws-credential-types", "aws-runtime", @@ -821,9 +821,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0007b5b8004547133319b6c4e87193eee2a0bcb3e4c18c75d09febe9dab7b383" +checksum = "9177ea1192e6601ae16c7273385690d88a7ed386a00b74a6bc894d12103cd933" dependencies = [ "aws-credential-types", "aws-runtime", @@ -843,9 +843,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.47.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fffaa356e7f1c725908b75136d53207fa714e348f365671df14e95a60530ad3" +checksum = "823ef553cf36713c97453e2ddff1eb8f62be7f4523544e2a5db64caf80100f0a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1218,7 +1218,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.85", + "syn 2.0.86", "which 4.4.2", ] @@ -1237,7 +1237,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -1381,7 +1381,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "syn_derive", ] @@ -1484,7 +1484,7 @@ checksum = "bcfcc3cd946cb52f0bbfdbbcfa2f4e24f75ebb6c0e1002f7c25904fada18b9ec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -1599,9 +1599,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.31" +version = "1.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "e3788d6ac30243803df38a3e9991cf37e41210232916d41a8222ae378f912624" dependencies = [ "jobserver", "libc", @@ -1744,7 +1744,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -2124,7 +2124,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -2172,7 +2172,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -2194,7 +2194,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -2715,7 +2715,7 @@ dependencies = [ "quote", "strum 0.25.0", "strum_macros 0.25.3", - "syn 2.0.85", + "syn 2.0.86", "thiserror", ] @@ -2907,7 +2907,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3004,7 +3004,7 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3160,7 +3160,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3180,7 +3180,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3260,9 +3260,9 @@ checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" [[package]] name = "fastdivide" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59668941c55e5c186b8b58c391629af56774ec768f73c08bbcd56f09348eb00b" +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" [[package]] name = "fastrand" @@ -3395,7 +3395,7 @@ checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3427,7 +3427,7 @@ checksum = "e99b8b3c28ae0e84b604c75f721c21dc77afb3706076af5e8216d15fd1deaae3" dependencies = [ "frunk_proc_macro_helpers", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3439,7 +3439,7 @@ dependencies = [ "frunk_core", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3451,7 +3451,7 @@ dependencies = [ "frunk_core", "frunk_proc_macro_helpers", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3575,7 +3575,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -3828,7 +3828,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -4463,7 +4463,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -5113,7 +5113,7 @@ checksum = "a7ce64b975ed4f123575d11afd9491f2e37bbd5813fbfbc0f09ae1fbddea74e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -5152,7 +5152,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "termcolor", "thiserror", ] @@ -5562,7 +5562,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -5839,7 +5839,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -5880,7 +5880,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6062,7 +6062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6115,7 +6115,7 @@ checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" dependencies = [ "proc-macro-rules-macros", "proc-macro2", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6127,7 +6127,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6188,7 +6188,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "syn 2.0.85", + "syn 2.0.86", "thiserror", "typify", "unicode-ident", @@ -6208,7 +6208,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6251,7 +6251,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -6935,7 +6935,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.85", + "syn 2.0.86", "walkdir", ] @@ -7315,7 +7315,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -7462,7 +7462,7 @@ checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -7473,7 +7473,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -7548,7 +7548,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -7603,7 +7603,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -7972,7 +7972,7 @@ checksum = "01b2e185515564f15375f593fb966b5718bc624ba77fe49fa4616ad619690554" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8043,7 +8043,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8066,7 +8066,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.85", + "syn 2.0.86", "tempfile", "tokio", "url", @@ -8218,7 +8218,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8272,7 +8272,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8285,7 +8285,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8393,7 +8393,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8442,7 +8442,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8527,7 +8527,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8634,7 +8634,7 @@ checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8645,7 +8645,7 @@ checksum = "f486687bfb7b5c560868f69ed2d458b880cebc9babebcb67e49f31b55c5bf847" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8668,7 +8668,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8684,9 +8684,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.85" +version = "2.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "e89275301d38033efb81a6e60e3497e734dfcc62571f2854bf4b16690398824c" dependencies = [ "proc-macro2", "quote", @@ -8702,7 +8702,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8728,7 +8728,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -8936,9 +8936,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff6c40d3aedb5e06b57c6f669ad17ab063dd1e63d977c6a88e7f4dfa4f04020" +checksum = "c65998313f8e17d0d553d28f91a0df93e4dbbbf770279c7bc21ca0f09ea1a1f6" dependencies = [ "filetime", "libc", @@ -8978,22 +8978,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "5d171f59dbaa811dbbb1aee1e73db92ec2b122911a48e1390dfe327a821ddede" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "b08be0f17bd307950653ce45db00cd31200d82b624b36e181337d9c7d92765b5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -9218,7 +9218,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -9545,7 +9545,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -9782,7 +9782,7 @@ dependencies = [ "regress", "schemars", "serde_json", - "syn 2.0.85", + "syn 2.0.86", "thiserror", "unicode-ident", ] @@ -9799,7 +9799,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.85", + "syn 2.0.86", "typify-impl", ] @@ -10194,7 +10194,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "wasm-bindgen-shared", ] @@ -10228,7 +10228,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -10261,7 +10261,7 @@ checksum = "b7f89739351a2e03cb94beb799d47fb2cac01759b40ec441f7de39b00cbf7ef0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -10395,7 +10395,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "axum", @@ -10437,7 +10437,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "argon2", @@ -10522,7 +10522,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.415.1" +version = "1.416.2" dependencies = [ "base64 0.21.7", "chrono", @@ -10540,7 +10540,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.415.1" +version = "1.416.2" dependencies = [ "chrono", "serde", @@ -10553,7 +10553,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "rsmq_async", @@ -10568,7 +10568,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "async-stream", @@ -10614,7 +10614,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.415.1" +version = "1.416.2" dependencies = [ "regex", "rsmq_async", @@ -10629,7 +10629,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "bytes", @@ -10651,19 +10651,19 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.415.1" +version = "1.416.2" dependencies = [ "itertools 0.13.0", "lazy_static", "proc-macro2", "quote", "regex", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] name = "windmill-parser" -version = "1.415.1" +version = "1.416.2" dependencies = [ "convert_case 0.6.0", "serde", @@ -10672,7 +10672,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "lazy_static", @@ -10684,7 +10684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "gosyn", @@ -10696,7 +10696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "lazy_static", @@ -10708,7 +10708,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "itertools 0.13.0", @@ -10719,7 +10719,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "itertools 0.13.0", @@ -10730,7 +10730,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "async-recursion", @@ -10748,7 +10748,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -10758,14 +10758,14 @@ dependencies = [ "quote", "regex", "serde_json", - "syn 2.0.85", + "syn 2.0.86", "toml 0.7.8", "windmill-parser", ] [[package]] name = "windmill-parser-sql" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "lazy_static", @@ -10777,7 +10777,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "lazy_static", @@ -10795,7 +10795,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "getrandom 0.2.15", @@ -10816,7 +10816,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "serde_json", @@ -10826,7 +10826,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "async-recursion", @@ -10859,7 +10859,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.415.1" +version = "1.416.2" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -10869,7 +10869,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.415.1" +version = "1.416.2" dependencies = [ "anyhow", "async-recursion", @@ -11235,7 +11235,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "synstructure", ] @@ -11257,7 +11257,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", ] [[package]] @@ -11277,7 +11277,7 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.86", "synstructure", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4790b2f114..1c0abc299c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.415.1" +version = "1.416.2" authors.workspace = true edition.workspace = true @@ -29,7 +29,7 @@ members = [ ] [workspace.package] -version = "1.415.1" +version = "1.416.2" authors = ["Ruben Fiszel "] edition = "2021" @@ -160,7 +160,7 @@ hex = "^0" sql-builder = "^3" argon2 = "^0" quick_cache = "^0" -rand = "0.8.5" +rand = "^0" rand_core = { version = "^0", features = ["std"] } magic-crypt = "^3" git-version = "^0" @@ -172,7 +172,7 @@ urlencoding = "^2" url = "^2" async-oauth2 = "^0" reqwest = { version = "^0.12", features = ["json", "stream", "gzip"] } -time = "0.3.16" +time = "^0" serde_urlencoded = "^0" tokio-tar = "^0" tempfile = "^3" @@ -198,7 +198,7 @@ swc_ecma_visit = "=0.104.8" async-recursion = "^1" -base64 = "0.21.0" +base64 = "^0" base32 = "^0" hmac = "0.12.1" sha2 = "0.10.6" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 2c2cd13f33..e7dd9365ec 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f667fcebdb5a2d1c22c1928d6a316ba57bb43d72 \ No newline at end of file +f136a2f499e0fe7c10c54c79488851980d796eb2 \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 20bd9fa00f..cc24051a7f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -284,7 +284,8 @@ async fn windmill_main() -> anyhow::Result<()> { tracing::info!("Binary is in 'indexer' mode"); #[cfg(not(feature = "tantivy"))] { - panic!("Indexer mode requires the tantivy feature flag"); + tracing::error!("Cannot start the indexer because tantivy is not included in this binary/image. Make sure you are using the EE image if you want to access the full text search features."); + panic!("Indexer mode requires compiling with the tantivy feature flag."); } #[cfg(feature = "tantivy")] Mode::Indexer diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 701e6f8c61..eebb8fd0a9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.415.1 + version: 1.416.2 title: Windmill API contact: diff --git a/backend/windmill-api/src/embeddings.rs b/backend/windmill-api/src/embeddings.rs index 5cc7226753..fd222bdbfa 100644 --- a/backend/windmill-api/src/embeddings.rs +++ b/backend/windmill-api/src/embeddings.rs @@ -53,6 +53,7 @@ use crate::{resources::ResourceType, HTTP_CLIENT}; lazy_static::lazy_static! { pub static ref EMBEDDINGS_DB: Arc>> = Arc::new(RwLock::new(None)); pub static ref MODEL_INSTANCE: Arc>>> = Arc::new(RwLock::new(None)); + pub static ref HUB_EMBEDDINGS_PULLING_INTERVAL_SECS: u64 = std::env::var("HUB_EMBEDDINGS_PULLING_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(3600 * 24); } #[cfg(feature = "embedding")] @@ -607,7 +608,10 @@ pub fn load_embeddings_db(db: &Pool) -> () { drop(model_instance_lock); loop { update_embeddings_db(&db_clone).await; - tokio::time::sleep(std::time::Duration::from_secs(3600 * 24)).await; + tokio::time::sleep(std::time::Duration::from_secs( + *HUB_EMBEDDINGS_PULLING_INTERVAL_SECS, + )) + .await; } } else { tracing::error!( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index dc9dbd2529..3ea78ffd6d 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -84,12 +84,14 @@ mod stripe_ee; mod tracing_init; mod triggers; mod users; +mod users_ee; mod utils; mod variables; mod webhook_util; mod websocket_triggers; mod workers; mod workspaces; +mod workspaces_ee; pub const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version"); diff --git a/backend/windmill-api/src/settings.rs b/backend/windmill-api/src/settings.rs index e8c000c788..0299145a38 100644 --- a/backend/windmill-api/src/settings.rs +++ b/backend/windmill-api/src/settings.rs @@ -28,13 +28,13 @@ use serde::Deserialize; #[cfg(feature = "enterprise")] use windmill_common::ee::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; use windmill_common::{ + email_ee::send_email, error::{self, JsonResult, Result}, global_settings::{ AUTOMATE_USERNAME_CREATION_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, - HUB_BASE_URL_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, }, server::Smtp, - utils::send_email, }; #[cfg(feature = "parquet")] @@ -262,6 +262,7 @@ pub async fn get_global_setting( && !key.starts_with("default_success_handler_") && key != AUTOMATE_USERNAME_CREATION_SETTING && key != HUB_BASE_URL_SETTING + && key != HUB_ACCESSIBLE_URL_SETTING && key != EMAIL_DOMAIN_SETTING { require_super_admin(&db, &authed.email).await?; diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs new file mode 100644 index 0000000000..c9a812c4a0 --- /dev/null +++ b/backend/windmill-api/src/users.rs @@ -0,0 +1,3169 @@ +/* + * 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 std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::db::ApiAuthed; + +#[cfg(feature = "enterprise")] +use crate::ee::ExternalJwks; +use crate::oauth2_ee::InstanceEvent; +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 axum::{ + async_trait, + extract::{Extension, FromRequestParts, OriginalUri, Path, Query}, + http::request::Parts, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use chrono::TimeZone; +use hyper::{header::LOCATION, StatusCode}; +use lazy_static::lazy_static; +use quick_cache::sync::Cache; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; +#[cfg(feature = "enterprise")] +use tokio::sync::RwLock; +use tower_cookies::{Cookie, Cookies}; +use tracing::{Instrument, Span}; +use windmill_audit::audit_ee::{audit_log, AuditAuthor}; +use windmill_audit::ActionKind; +use windmill_common::auth::fetch_authed_from_permissioned_as; +use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; +use windmill_common::users::{truncate_token, username_to_permissioned_as}; +use windmill_common::utils::paginate; +use windmill_common::worker::CLOUD_HOSTED; +use windmill_common::{ + auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, JWT_SECRET}, + db::UserDB, + error::{self, Error, JsonResult, Result}, + users::SUPERADMIN_SECRET_EMAIL, + utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath}, +}; +use windmill_git_sync::handle_deployment_metadata; + +pub const TTL_TOKEN_DB_H: u32 = 72; + +const COOKIE_NAME: &str = "token"; +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("/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("/setpassword", post(set_password)) + .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("/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)) +} + +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)) +} + +fn username_override_from_label(label: Option) -> Option { + match label { + Some(label) + if label.starts_with("webhook-") + || label.starts_with("http-") + || label.starts_with("email-") => + { + Some(label) + } + Some(label) if label.starts_with("ephemeral-script-end-user-") => Some( + label + .trim_start_matches("ephemeral-script-end-user-") + .to_string(), + ), + Some(label) if label == "Ephemeral lsp token" => Some("lsp".to_string()), + Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => { + Some(format!("label-{label}")) + } + _ => None, + } +} + +#[derive(Clone)] +pub struct ExpiringAuthCache { + pub authed: ApiAuthed, + pub expiry: chrono::DateTime, +} + +pub struct AuthCache { + cache: Cache<(String, String), ExpiringAuthCache>, + db: DB, + superadmin_secret: Option, + #[cfg(feature = "enterprise")] + ext_jwks: Option>>, +} + +impl AuthCache { + pub fn new( + db: DB, + superadmin_secret: Option, + #[cfg(feature = "enterprise")] ext_jwks: Option>>, + ) -> Self { + AuthCache { + cache: Cache::new(300), + db, + superadmin_secret, + #[cfg(feature = "enterprise")] + ext_jwks, + } + } + + pub async fn invalidate(&self, w_id: &str, token: String) { + self.cache.remove(&(w_id.to_string(), token)); + } + + pub async fn get_authed(&self, w_id: Option, token: &str) -> Option { + let key = ( + w_id.as_ref().unwrap_or(&"".to_string()).to_string(), + token.to_string(), + ); + let s = self.cache.get(&key).map(|c| c.to_owned()); + match s { + Some(ExpiringAuthCache { authed, expiry }) if expiry > chrono::Utc::now() => { + Some(authed) + } + #[cfg(feature = "enterprise")] + _ if token.starts_with("jwt_ext_") => { + let authed_and_exp = match crate::ee::jwt_ext_auth( + w_id.as_ref(), + token.trim_start_matches("jwt_ext_"), + self.ext_jwks.clone(), + ) + .await + { + Ok(r) => Some(r), + Err(e) => { + tracing::error!("JWT_EXT auth error: {:?}", e); + None + } + }; + + if let Some((authed, exp)) = authed_and_exp.clone() { + self.cache.insert( + key, + ExpiringAuthCache { + authed: authed.clone(), + expiry: chrono::Utc.timestamp_nanos(exp as i64 * 1_000_000_000), + }, + ); + + Some(authed) + } else { + None + } + } + _ if token.starts_with("jwt_") => { + let jwt_secret = JWT_SECRET.read().await; + if !jwt_secret.is_empty() { + let jwt_token = token.trim_start_matches("jwt_"); + + let jwt_result = jsonwebtoken::decode::( + jwt_token, + &jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()), + &jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256), + ); + + match jwt_result { + Ok(payload) => { + if w_id.is_some_and(|w_id| w_id != payload.claims.workspace_id) { + tracing::error!("JWT auth error: workspace_id mismatch"); + return None; + } + + let username_override = + username_override_from_label(payload.claims.label); + let authed = crate::db::ApiAuthed { + email: payload.claims.email, + username: payload.claims.username, + is_admin: payload.claims.is_admin, + is_operator: payload.claims.is_operator, + groups: payload.claims.groups, + folders: payload.claims.folders, + scopes: None, + username_override, + }; + + self.cache.insert( + key, + ExpiringAuthCache { + authed: authed.clone(), + expiry: chrono::Utc + .timestamp_nanos(payload.claims.exp as i64 * 1_000_000_000), + }, + ); + + Some(authed) + } + Err(err) => { + tracing::error!("JWT auth error: {:?}", err); + None + } + } + } else { + tracing::error!("JWT auth error: no jwt secret set"); + None + } + } + _ => { + let user_o = sqlx::query_as::<_, (Option, Option, bool, Option>, Option)>( + "UPDATE token SET last_used_at = now() WHERE token = $1 AND (expiration > NOW() \ + OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) RETURNING owner, email, super_admin, scopes, label", + ) + .bind(token) + .bind(w_id.as_ref()) + .fetch_optional(&self.db) + .await + .ok() + .flatten(); + + if let Some(user) = user_o { + let authed_o = { + match user { + (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + let username_override = username_override_from_label(label); + if let Some((prefix, name)) = owner.split_once('/') { + if prefix == "u" { + let (is_admin, is_operator) = if super_admin { + (true, false) + } else { + let r = sqlx::query!( + "SELECT is_admin, operator FROM usr where username = $1 AND \ + workspace_id = $2 AND disabled = false", + name, + &w_id.as_ref().unwrap() + ) + .fetch_one(&self.db) + .await + .ok(); + if let Some(r) = r { + (r.is_admin, r.operator) + } else { + (false, true) + } + }; + + let w_id = &w_id.unwrap(); + let groups = + get_groups_for_user(w_id, &name, &email, &self.db) + .await + .ok() + .unwrap_or_default(); + + let folders = + get_folders_for_user(w_id, &name, &groups, &self.db) + .await + .ok() + .unwrap_or_default(); + + Some(ApiAuthed { + email: email, + username: name.to_string(), + is_admin, + is_operator, + groups, + folders, + scopes: None, + username_override, + }) + } else { + let groups = vec![name.to_string()]; + let folders = get_folders_for_user( + &w_id.unwrap(), + "", + &groups, + &self.db, + ) + .await + .ok() + .unwrap_or_default(); + Some(ApiAuthed { + email: email, + username: format!("group-{name}"), + is_admin: false, + groups, + is_operator: false, + folders, + scopes: None, + username_override, + }) + } + } else { + let groups = vec![]; + let folders = vec![]; + Some(ApiAuthed { + email: email, + username: owner, + is_admin: super_admin, + is_operator: true, + groups, + folders, + scopes: None, + username_override, + }) + } + } + (_, Some(email), super_admin, scopes, label) => { + let username_override = username_override_from_label(label); + if w_id.is_some() { + let row_o = sqlx::query_as::<_, (String, bool, bool)>( + "SELECT username, is_admin, operator FROM usr where email = $1 AND \ + workspace_id = $2 AND disabled = false", + ) + .bind(&email) + .bind(&w_id.as_ref().unwrap()) + .fetch_optional(&self.db) + .await + .unwrap_or(Some(("error".to_string(), false, false))); + + match row_o { + Some((username, is_admin, is_operator)) => { + let groups = get_groups_for_user( + &w_id.as_ref().unwrap(), + &username, + &email, + &self.db, + ) + .await + .ok() + .unwrap_or_default(); + + let folders = get_folders_for_user( + &w_id.unwrap(), + &username, + &groups, + &self.db, + ) + .await + .ok() + .unwrap_or_default(); + Some(ApiAuthed { + email, + username, + is_admin: is_admin || super_admin, + is_operator, + groups, + folders, + scopes, + username_override, + }) + } + None if super_admin => Some(ApiAuthed { + email: email.clone(), + username: email, + is_admin: super_admin, + is_operator: false, + groups: vec![], + folders: vec![], + scopes, + username_override, + }), + None => None, + } + } else { + Some(ApiAuthed { + email: email.to_string(), + username: email, + is_admin: super_admin, + is_operator: true, + groups: Vec::new(), + folders: Vec::new(), + scopes, + username_override, + }) + } + } + _ => None, + } + }; + if let Some(authed) = authed_o.as_ref() { + self.cache.insert( + key, + ExpiringAuthCache { + authed: authed.clone(), + expiry: chrono::Utc::now() + + chrono::Duration::try_seconds(120).unwrap(), + }, + ); + } + authed_o + } else if self + .superadmin_secret + .as_ref() + .map(|x| x == token) + .unwrap_or(false) + { + Some(ApiAuthed { + email: SUPERADMIN_SECRET_EMAIL.to_string(), + username: "superadmin_secret".to_string(), + is_admin: true, + is_operator: false, + groups: Vec::new(), + folders: Vec::new(), + scopes: None, + username_override: None, + }) + } else { + None + } + } + } + } +} + +async fn extract_token(parts: &mut Parts, state: &S) -> Option { + let auth_header = parts + .headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")); + + let from_cookie = match auth_header { + Some(x) => Some(x.to_owned()), + None => Extension::::from_request_parts(parts, state) + .await + .ok() + .and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())), + }; + + #[derive(Deserialize)] + struct Token { + token: Option, + } + match from_cookie { + Some(token) => Some(token), + None => Query::::from_request_parts(parts, state) + .await + .ok() + .and_then(|token| token.token.clone()), + } +} + +#[derive(Clone, Debug)] +pub struct Tokened { + pub token: String, +} + +struct BruteForceCounter { + counter: AtomicU64, + last_reset: AtomicI64, +} + +lazy_static! { + static ref BRUTE_FORCE_COUNTER: BruteForceCounter = + BruteForceCounter { last_reset: AtomicI64::new(0), counter: AtomicU64::new(0) }; +} + +impl BruteForceCounter { + async fn increment(&self) { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + if self.counter.fetch_add(1, Ordering::Relaxed) > 10000 { + tracing::error!( + "Brute force attack to find valid token detected, sleeping unauthorized response for 2 seconds" + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + if now - self.last_reset.load(Ordering::Relaxed) > 60 { + self.counter.store(0, Ordering::Relaxed); + self.last_reset.store(now, Ordering::Relaxed); + } + } +} + +#[async_trait] +impl FromRequestParts for Tokened +where + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + if parts.method == http::Method::OPTIONS { + return Ok(Tokened { token: "".to_string() }); + }; + let already_tokened = parts.extensions.get::(); + if let Some(tokened) = already_tokened { + Ok(tokened.clone()) + } else { + let token_o = extract_token(parts, state).await; + if let Some(token) = token_o { + let tokened = Self { token }; + parts.extensions.insert(tokened.clone()); + Ok(tokened) + } else { + BRUTE_FORCE_COUNTER.increment().await; + Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned())) + } + } + } +} + +pub async fn maybe_refresh_folders( + path: &str, + w_id: &str, + authed: ApiAuthed, + db: &DB, +) -> ApiAuthed { + if authed.is_admin { + return authed; + } + let splitted = path.split('/').collect::>(); + if splitted.len() >= 2 + && splitted[0] == "f" + && !authed.folders.iter().any(|(f, _, _)| f == splitted[1]) + { + let name = &authed.username; + let groups = get_groups_for_user(w_id, name, &authed.email, db) + .await + .ok() + .unwrap_or_default(); + + let folders = get_folders_for_user(w_id, name, &groups, db) + .await + .ok() + .unwrap_or_default(); + ApiAuthed { folders, ..authed } + } else { + authed + } +} + +#[async_trait] +impl FromRequestParts for ApiAuthed +where + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + if parts.method == http::Method::OPTIONS { + return Ok(ApiAuthed { + email: "".to_owned(), + username: "".to_owned(), + is_admin: false, + is_operator: false, + groups: Vec::new(), + folders: Vec::new(), + scopes: None, + username_override: None, + }); + }; + let already_authed = parts.extensions.get::(); + if let Some(authed) = already_authed { + Ok(authed.clone()) + } else { + let already_tokened = parts.extensions.get::(); + let token_o = if let Some(token) = already_tokened { + Some(token.token.clone()) + } else { + extract_token(parts, state).await + }; + let original_uri = OriginalUri::from_request_parts(parts, state) + .await + .ok() + .map(|x| x.0) + .unwrap_or_default(); + let path_vec: Vec<&str> = original_uri.path().split("/").collect(); + + let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" { + Some(path_vec[3].to_owned()) + } else { + if path_vec.len() >= 5 + && path_vec[0] == "" + && path_vec[2] == "srch" + && path_vec[3] == "w" + { + Some(path_vec[4].to_string()) + } else { + None + } + }; + if let Some(token) = token_o { + if let Ok(Extension(cache)) = + Extension::>::from_request_parts(parts, state).await + { + if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await { + parts.extensions.insert(authed.clone()); + if authed.scopes.as_ref().is_some_and(|scopes| { + scopes + .iter() + .any(|s| s.starts_with("jobs:") || s.starts_with("run:")) + }) && (path_vec.len() < 3 + || (path_vec[4] != "jobs" && path_vec[4] != "jobs_u")) + { + BRUTE_FORCE_COUNTER.increment().await; + return Err(( + StatusCode::UNAUTHORIZED, + format!("Unauthorized scoped token: {:?}", authed.scopes), + )); + } + Span::current().record("username", &authed.username.as_str()); + Span::current().record("email", &authed.email); + + if let Some(workspace_id) = workspace_id { + Span::current().record("workspace_id", &workspace_id); + } + return Ok(authed); + } + } + } + BRUTE_FORCE_COUNTER.increment().await; + Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned())) + } + } +} + +pub fn check_scopes(authed: &ApiAuthed, required: F) -> error::Result<()> +where + F: FnOnce() -> String, +{ + if authed.scopes.as_ref().is_some_and(|scopes| { + scopes + .iter() + .any(|s| s.starts_with("jobs:") || s.starts_with("run:")) + }) { + let req = &required(); + if !authed.scopes.as_ref().unwrap().contains(req) { + return Err(Error::BadRequest(format!("missing required scope: {req}"))); + } + } + Ok(()) +} + +pub fn get_scope_tags(authed: &ApiAuthed) -> Option> { + authed.scopes.as_ref()?.iter().find_map(|s| { + if s.starts_with("if_jobs:filter_tags:") { + Some( + s.trim_start_matches("if_jobs:filter_tags:") + .split(",") + .collect::>(), + ) + } else { + None + } + }) +} + +#[derive(Clone, Debug)] +pub struct OptAuthed(pub Option); + +#[async_trait] +impl FromRequestParts for OptAuthed +where + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + ApiAuthed::from_request_parts(parts, state) + .await + .map(|authed| Self(Some(authed))) + .or_else(|_| Ok(Self(None))) + } +} + +pub async fn fetch_api_authed( + username: String, + email: String, + w_id: &str, + db: &DB, + username_override: String, +) -> error::Result { + let permissioned_as = username_to_permissioned_as(username.as_str()); + let authed = + fetch_authed_from_permissioned_as(permissioned_as, email.clone(), w_id, db).await?; + Ok(ApiAuthed { + username: username, + email: email, + is_admin: authed.is_admin, + is_operator: authed.is_operator, + groups: authed.groups, + folders: authed.folders, + scopes: authed.scopes, + username_override: Some(username_override), + }) +} + +#[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, +} + +#[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, + verified: bool, + name: Option, + company: Option, + username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operator_only: Option, +} + +#[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, +} + +#[derive(FromRow, Serialize)] +pub struct WorkspaceInvite { + pub workspace_id: String, + pub email: String, + pub is_admin: bool, + pub operator: bool, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct NewUser { + pub email: String, + pub password: String, + pub super_admin: bool, + pub name: Option, + pub company: 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 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(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>, +} + +#[derive(Deserialize)] +pub struct NewToken { + pub label: Option, + pub expiration: Option>, + pub impersonate_email: Option, + pub scopes: Option>, + pub workspace_id: Option, +} + +#[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 = sqlx::query_as!( + UserWithUsage, + " + SELECT usr.email, usage.executions + FROM usr + , LATERAL ( + SELECT COALESCE(SUM(duration_ms + 1000)/1000 , 0)::BIGINT executions + FROM completed_job + WHERE workspace_id = $1 + AND job_kind NOT IN ('flow', 'flowpreview') + AND email = usr.email + AND now() - '1 week'::interval < created_at + ) usage + WHERE workspace_id = $1 + ", + w_id + ) + .fetch_all(&mut *tx) + .await?; + 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')), + 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, name, company, username + FROM password + WHERE email IN (SELECT email FROM active_users) + ORDER BY super_admin 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, name, company, username, NULL::bool as operator_only FROM password ORDER BY super_admin 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, +} +async fn get_tutorial_progress( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult { + let res = sqlx::query_scalar!( + "SELECT progress::bigint FROM tutorial_progress WHERE email = $1", + authed.email + ) + .fetch_optional(&db) + .await? + .flatten() + .unwrap_or_default() as u64; + Ok(Json(Progress { progress: res })) +} + +async fn update_tutorial_progress( + authed: ApiAuthed, + Extension(db): Extension, + Json(progress): Json, +) -> Result { + sqlx::query_scalar!( + "INSERT INTO tutorial_progress VALUES ($2, $1::bigint::bit(64)) ON CONFLICT (email) DO UPDATE SET progress = $1::bigint::bit(64)", + progress.progress as i64, + authed.email + ) + .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 * from workspace_invite WHERE email = $1", + authed.email + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +#[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 = 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()); + audit_log( + &mut *tx, + &AuditAuthor { email: email.clone(), username: email, username_override: None }, + "users.logout", + 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, + 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, verified, name, company, username, NULL::bool as operator_only FROM password WHERE \ + email = $1", + email + ) + .fetch_one(&db) + .await + .map_err(|e| Error::InternalErr(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, + verified: true, + name: None, + company: None, + username: None, + operator_only: None, + })) + } 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()) +} + +async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { + let user = sqlx::query_as!( + User, + "SELECT * FROM usr where username = $1 AND workspace_id = $2", + username, + w_id + ) + .fetch_optional(db) + .await?; + let is_super_admin = sqlx::query_scalar!( + "SELECT super_admin FROM password WHERE email = $1", + user.as_ref().map(|x| &x.email) + ) + .fetch_optional(db) + .await? + .unwrap_or(false); + 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, + is_admin: usr.is_admin, + is_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 fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + if !path.is_empty() { + let splitted = path.split("/").collect::>(); + if splitted[0] == "u" { + if splitted[1] == authed.username { + Ok(()) + } else { + Err(Error::BadRequest(format!( + "only the owner {} is authorized to perform this operation", + splitted[1] + ))) + } + } else if splitted[0] == "f" { + crate::folders::require_is_owner(authed, splitted[1]) + } else { + Err(Error::BadRequest(format!( + "Not recognized path kind: {}", + path + ))) + } + } else { + Err(Error::BadRequest(format!( + "Cannot be owner of an empty path" + ))) + } +} + +pub fn get_perm_in_extra_perms_for_authed( + v: serde_json::Value, + authed: &ApiAuthed, +) -> Option { + match v { + serde_json::Value::Object(obj) => { + let mut keys = vec![format!("u/{}", authed.username)]; + for g in authed.groups.iter() { + keys.push(format!("g/{}", g)); + } + let mut res = None; + for k in keys { + if let Some(v) = obj.get(&k) { + if let Some(v) = v.as_bool() { + if v { + return Some(true); + } + res = Some(v); + } + } + } + res + } + _ => None, + } +} + +pub async fn require_is_writer( + authed: &ApiAuthed, + path: &str, + w_id: &str, + db: DB, + query: &str, + kind: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + if !path.is_empty() { + if require_owner_of_path(authed, path).is_ok() { + return Ok(()); + } + if path.starts_with("f/") && path.split('/').count() >= 2 { + let folder = path.split('/').nth(1).unwrap(); + let extra_perms = sqlx::query_scalar!( + "SELECT extra_perms FROM folder WHERE name = $1 AND workspace_id = $2", + folder, + w_id + ) + .fetch_optional(&db) + .await?; + if let Some(perms) = extra_perms { + let is_folder_writer = + get_perm_in_extra_perms_for_authed(perms, authed).unwrap_or(false); + if is_folder_writer { + return Ok(()); + } + } + } + let extra_perms = sqlx::query_scalar(query) + .bind(path) + .bind(w_id) + .fetch_optional(&db) + .await?; + if let Some(perms) = extra_perms { + let perm = get_perm_in_extra_perms_for_authed(perms, authed); + match perm { + Some(true) => Ok(()), + Some(false) => Err(Error::BadRequest(format!( + "User {} is not a writer of {kind} path {path}", + authed.username + ))), + None => Err(Error::BadRequest(format!( + "User {} has neither read or write permission on {kind} {path}", + authed.username + ))), + } + } else { + Err(Error::BadRequest(format!( + "{path} does not exist yet and user {} is not an owner of the parent folder", + authed.username + ))) + } + } else { + Err(Error::BadRequest(format!( + "Cannot be writer of an empty path" + ))) + } +} +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, + Extension(rsmq): 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)), + rsmq, + true, + ) + .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, + Extension(rsmq): 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)?; + + if let Some(a) = eu.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) = eu.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) = eu.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?; + } + + audit_log( + &mut *tx, + &authed, + "users.update", + ActionKind::Update, + &w_id, + Some(&username_to_update), + None, + ) + .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)), + rsmq, + true, + ) + .await?; + + Ok(format!("user {} updated", user_email)) +} + +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?; + + 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?; + } + + 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?; + } + 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(argon2): Extension>>, + Extension(rsmq): Extension>, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + crate::users_ee::create_user(authed, db, webhook, argon2, rsmq, nu).await +} + +async fn delete_workspace_user( + authed: ApiAuthed, + Extension(db): Extension, + Extension(rsmq): 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)?; + + 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?; + + audit_log( + &mut *tx, + &authed, + "users.delete", + ActionKind::Delete, + &w_id, + Some(&username_to_delete), + None, + ) + .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 + )), + rsmq, + true, + ) + .await?; + + Ok(format!("username {} deleted", username_to_delete)) +} + +async fn set_password( + Extension(db): Extension, + Extension(argon2): Extension>>, + authed: ApiAuthed, + Json(ep): Json, +) -> Result { + crate::users_ee::set_password(db, argon2, authed, ep).await +} + +async fn login( + cookies: Cookies, + Extension(db): Extension, + Extension(argon2): Extension>>, + Json(Login { email, password }): Json, +) -> Result { + let mut tx = db.begin().await?; + let email = email.to_lowercase(); + let audit_author = + AuditAuthor { email: email.clone(), username: email.clone(), username_override: None }; + let email_w_h: Option<(String, String, bool, bool)> = sqlx::query_as( + "SELECT email, password_hash, super_admin, first_time_user FROM password WHERE email = $1 AND login_type = \ + 'password'", + ) + .bind(&email) + .fetch_optional(&mut *tx) + .await?; + + if let Some((email, hash, super_admin, first_time_user)) = email_w_h { + let parsed_hash = + PasswordHash::new(&hash).map_err(|e| Error::InternalErr(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 { + if first_time_user { + sqlx::query_scalar!( + "UPDATE password SET first_time_user = false WHERE email = $1", + &email + ) + .execute(&mut *tx) + .await?; + let mut c = Cookie::new("first_time", "1"); + if let Some(domain) = COOKIE_DOMAIN.as_ref() { + c.set_domain(domain); + } + c.set_secure(false); + c.set_expires(time::OffsetDateTime::now_utc() + time::Duration::minutes(15)); + c.set_http_only(false); + c.set_path("/"); + + cookies.add(c); + } + + let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; + + 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())) + } +} + +async fn refresh_token( + Extension(db): Extension, + authed: ApiAuthed, + cookies: Cookies, +) -> Result { + let mut tx = db.begin().await?; + + 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 _ = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; + + tx.commit().await?; + Ok("token refreshed".to_string()) +} + +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); + sqlx::query!( + "INSERT INTO token + (token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, now() + ($4 || ' hours')::interval, $5)", + token, + email, + "session", + TTL_TOKEN_DB_H.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::days(3); + cookie.set_expires(expire); + cookies.add(cookie); + Ok(token) +} + +async fn create_token( + Extension(db): Extension, + authed: ApiAuthed, + Json(new_token): Json, +) -> Result<(StatusCode, String)> { + let token = rd_string(32); + let mut tx = db.begin().await?; + + let is_super_admin = sqlx::query_scalar!( + "SELECT super_admin FROM password WHERE email = $1", + authed.email + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false); + sqlx::query!( + "INSERT INTO token + (token, email, label, expiration, super_admin, scopes, workspace_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + token, + authed.email, + new_token.label, + new_token.expiration, + is_super_admin, + new_token.scopes.as_ref().map(|x| x.as_slice()), + new_token.workspace_id, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "users.token.create", + ActionKind::Create, + &"global", + Some(&token[0..10]), + None, + ) + .instrument(tracing::info_span!("token", email = &authed.email)) + .await?; + 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' + 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, + endpoint_openai_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 + ), + endpoint_openai_sync: format!( + "/w/{}/jobs/openai_sync/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 workspace_id = $1", 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 + ), + endpoint_openai_sync: format!( + "/w/{}/jobs/openai_sync/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 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, +} + +async fn rename_user( + authed: ApiAuthed, + Path(user_email): Path, + Extension(db): Extension, + Json(ru): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + + let mut tx = db.begin().await?; + + let 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)", + &ru.new_username, + &user_email + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if username_conflict { + return Err(Error::BadRequest(format!( + "username {} already used by another user", + &ru.new_username + ))); + } + + if !VALID_USERNAME.is_match(&ru.new_username) { + return Err(windmill_common::error::Error::BadRequest(format!( + "Usermame can only contain alphanumeric characters and underscores and must start with a letter" + ))); + } + + sqlx::query!( + "UPDATE password SET username = $1 WHERE email = $2", + ru.new_username, + user_email + ) + .execute(&mut *tx) + .await?; + + let workspace_usernames = sqlx::query!( + "SELECT workspace_id, username FROM usr WHERE email = $1", + &user_email + ) + .fetch_all(&mut *tx) + .await?; + + for w_u in workspace_usernames { + if ru.new_username == w_u.username { + continue; + } + update_username_in_workpsace( + &mut tx, + &user_email, + &w_u.username, + &ru.new_username, + &w_u.workspace_id, + ) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "users.rename", + ActionKind::Update, + "global", + Some(&user_email), + None, + ) + .await?; + tx.commit().await?; + Ok(format!( + "updated username of user {} to {}", + &user_email, &ru.new_username + )) +} + +async fn update_username_in_workpsace<'c>( + tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, + email: &str, + old_username: &str, + new_username: &str, + w_id: &str, +) -> error::Result<()> { + // ---- instance and workspace users ---- + sqlx::query!( + "UPDATE usr SET username = $1 WHERE email = $2", + new_username, + email + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE usr_to_group SET usr = $1 WHERE usr = $2", + new_username, + old_username + ) + .execute(&mut **tx) + .await?; + + // ---- queue ---- + sqlx::query!( + r#"UPDATE queue SET script_path = REGEXP_REPLACE(script_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE queue SET schedule_path = REGEXP_REPLACE(schedule_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE schedule_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE queue SET permissioned_as = ('u/' || $1) WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE queue SET canceled_by = $1 WHERE canceled_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + sqlx::query!( + "UPDATE queue SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + // ---- completed_job ---- + sqlx::query!( + r#"UPDATE completed_job SET script_path = REGEXP_REPLACE(script_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE completed_job SET schedule_path = REGEXP_REPLACE(schedule_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE schedule_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE completed_job SET permissioned_as = ('u/' || $1) WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE completed_job SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + sqlx::query!( + "UPDATE completed_job SET canceled_by = $1 WHERE canceled_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + // ---- resources---- + sqlx::query!( + r#"UPDATE resource SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE resource_type SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE resource SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE resource SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- variables ---- + + sqlx::query!( + r#"UPDATE variable SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- scripts ---- + sqlx::query!( + r#"UPDATE script SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE script SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + sqlx::query!( + "UPDATE script SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- flows ---- + sqlx::query!( + r#"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, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, value, schema, edited_by, edited_at + FROM flow + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE flow_version SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "DELETE FROM flow WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2", + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE flow SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- draft ---- + sqlx::query!( + r#"UPDATE draft SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['path'], to_jsonb(REGEXP_REPLACE(value->>'path','u/' || $2 || '/(.*)','u/' || $1 || '/\1')))) WHERE value->>'path' LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ).execute(&mut **tx) + .await?; + + // ---- app ---- + sqlx::query!( + r#"UPDATE app SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb('u/' || $1)) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE app SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- app_version ---- + + sqlx::query!( + "UPDATE app_version SET created_by = $1 WHERE created_by = $2 AND EXISTS (SELECT 1 FROM app WHERE workspace_id = $3 AND app.id = app_version.app_id)", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- schedules ---- + + sqlx::query!( + r#"UPDATE schedule SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"UPDATE schedule SET script_path = REGEXP_REPLACE(script_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE schedule SET edited_by = $1 WHERE edited_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + sqlx::query!( + "UPDATE schedule SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- group_ ---- + + sqlx::query!( + "UPDATE group_ SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- folders ---- + + sqlx::query!( + "UPDATE folder SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE folder SET owners = ARRAY_REPLACE(owners, 'u/' || $2, 'u/' || $1) WHERE ('u/' || $2) = ANY(owners) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE folder SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- input ---- + + sqlx::query!( + "UPDATE input SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + // ---- favorite ---- + + sqlx::query!( + "UPDATE favorite SET usr = $1 WHERE usr = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + // ---- capture ---- + + sqlx::query!( + "UPDATE capture SET created_by = $1 WHERE created_by = $2 AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + // ---- token ---- + + sqlx::query!( + "UPDATE token SET owner = ('u/' || $1) WHERE owner = ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await + .unwrap(); + + sqlx::query!( + r#"UPDATE token SET scopes = array(select regexp_replace(unnest(scopes), 'run:([^/]+)/u/' || $2 || '/(.+)', 'run:\1/u/' || $1 || '/\2')) WHERE EXISTS (SELECT 1 FROM UNNEST(scopes) scope WHERE scope LIKE ('run:%/u/' || $2 || '/%')) AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- raw_app ---- + + sqlx::query!( + "UPDATE raw_app SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} diff --git a/backend/windmill-api/src/users_ee.rs b/backend/windmill-api/src/users_ee.rs new file mode 100644 index 0000000000..e0f15a9317 --- /dev/null +++ b/backend/windmill-api/src/users_ee.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use crate::db::ApiAuthed; + +use crate::users::{EditPassword, NewUser}; +use crate::{db::DB, webhook_util::WebhookShared}; +use argon2::Argon2; + +use http::StatusCode; + +use windmill_common::error::{Error, Result}; + +pub async fn create_user( + _authed: ApiAuthed, + _db: DB, + _webhook: WebhookShared, + _argon2: Arc>, + _rsmq: Option, + mut _nu: NewUser, +) -> Result<(StatusCode, String)> { + Err(Error::InternalErr( + "Not implemented in Windmill's Open Source repository".to_string(), + )) +} + +pub async fn set_password( + _db: DB, + _argon2: Arc>, + _authed: ApiAuthed, + _ep: EditPassword, +) -> Result { + Err(Error::InternalErr( + "Not implemented in Windmill's Open Source repository".to_string(), + )) +} + +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-api/src/utils.rs b/backend/windmill-api/src/utils.rs index d8a81ab741..ea4fccc160 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -155,25 +155,6 @@ pub async fn get_instance_username_or_create_pending<'c>( } } -pub async fn get_and_delete_pending_username_or_generate<'c>( - tx: &mut Transaction<'c, Postgres>, - email: &str, -) -> error::Result { - let username = sqlx::query_scalar!("SELECT username FROM pending_user WHERE email = $1", email) - .fetch_optional(&mut **tx) - .await?; - - if let Some(username) = username { - sqlx::query!("DELETE FROM pending_user WHERE email = $1", email) - .execute(&mut **tx) - .await?; - Ok(username) - } else { - let username = generate_instance_wide_unique_username(&mut *tx, email).await?; - 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 cf48781331..018ac8de5f 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -9,14 +9,15 @@ use std::collections::HashMap; use crate::db::ApiAuthed; -use crate::utils::{get_instance_username_or_create_pending, INVALID_USERNAME_CHARS}; +use crate::users_ee::send_email_if_possible; +use crate::utils::get_instance_username_or_create_pending; use crate::BASE_URL; use crate::{ apps::AppWithLastVersion, db::DB, folders::Folder, resources::{Resource, ResourceType}, - users::{send_email_if_possible, WorkspaceInvite, VALID_USERNAME}, + users::{WorkspaceInvite, VALID_USERNAME}, utils::require_super_admin, webhook_util::WebhookShared, }; @@ -34,7 +35,7 @@ use itertools::Itertools; use regex::Regex; use uuid::Uuid; -use windmill_audit::audit_ee::{audit_log, AuditAuthor, AuditAuthorable}; +use windmill_audit::audit_ee::audit_log; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::s3_helpers::LargeFileStorage; @@ -218,11 +219,12 @@ struct EditDeployTo { deploy_to: Option, } +#[allow(dead_code)] #[derive(Deserialize)] -struct EditAutoInvite { - operator: Option, - invite_all: Option, - auto_add: Option, +pub struct EditAutoInvite { + pub operator: Option, + pub invite_all: Option, + pub auto_add: Option, } #[derive(Deserialize)] @@ -573,250 +575,21 @@ async fn edit_deploy_to() -> Result { )); } -const BANNED_DOMAINS: &str = include_str!("../banned_domains.txt"); +pub const BANNED_DOMAINS: &str = include_str!("../banned_domains.txt"); 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 auto_add_user( - email: &str, - w_id: &str, - operator: &bool, - tx: &mut Transaction<'_, Postgres>, - authorable: &impl AuditAuthorable, -) -> Result { - 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 { - get_instance_username_or_create_pending(&mut *tx, &email).await? - } else { - let mut username = email - .split('@') - .next() - .unwrap() - .to_string() - .replace(".", ""); - - 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::InternalErr(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 workspace_id = $2)", - &username, - &w_id - ) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - i += 1; - } - username - }; - - sqlx::query!( - "INSERT INTO usr (workspace_id, username, email, is_admin, operator) VALUES ($1, $2, $3, false, $4) ON CONFLICT DO NOTHING", - &w_id, - &username, - &email, - &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?; - let audit_author = if authorable.username() == authorable.email() && authorable.email() == email - { - // if the user is auto adding themselves (e.g. by joining the instance), we use their newly created workspace username for audit logs - AuditAuthor { - username: username.clone(), - email: email.to_string(), - username_override: None, - } - } else { - AuditAuthor { - username: authorable.username().to_string(), - email: authorable.email().to_string(), - username_override: authorable.username_override().map(|x| x.to_string()), - } - }; - audit_log( - &mut **tx, - &audit_author, - "users.auto_invite_add", - ActionKind::Create, - &w_id, - Some(email), - None, - ) - .await?; - Ok(username) -} - async fn edit_auto_invite( authed: ApiAuthed, Extension(db): Extension, Extension(rsmq): Extension>, Path(w_id): Path, - ApiAuthed { is_admin, email, username, .. }: ApiAuthed, Json(ea): Json, ) -> Result { - require_admin(is_admin, &username)?; - - // #[cfg(not(feature = "enterprise"))] - // { - // return Err(Error::BadRequest( - // "Auto-invite is only available on enterprise".to_string(), - // )); - // } - - let domain = if ea.invite_all.is_some_and(|x| x) { - if *CLOUD_HOSTED { - return Err(Error::BadRequest( - "invite_all is only available locally".to_string(), - )); - } else { - "*" - } - } else { - email.split('@').last().unwrap() - }; - - let mut tx = db.begin().await?; - - let mut users_to_auto_add = Option::None; - - if let (Some(operator), Some(auto_add)) = (ea.operator, ea.auto_add) { - if BANNED_DOMAINS.contains(domain) { - return Err(Error::BadRequest(format!( - "Domain {} is not allowed", - domain - ))); - } - - sqlx::query!( - "UPDATE workspace_settings SET auto_invite_domain = $1, auto_invite_operator = $2, auto_add = $4 WHERE workspace_id = $3", - domain, - operator, - &w_id, - auto_add, - ) - .execute(&mut *tx) - .await?; - - if auto_add { - users_to_auto_add = Some(sqlx::query!( - "SELECT email FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS ( - SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email - )", - &w_id, - domain - ) - .fetch_all(&mut *tx).await?); - - for user in users_to_auto_add.as_ref().unwrap() { - auto_add_user(&user.email, &w_id, &operator, &mut tx, &authed).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 {email}. - - Access the workspace at {}/?workspace={w_id}", - BASE_URL.read().await.clone() - ), - &user.email, - ); - } - } else { - sqlx::query!( - "INSERT INTO workspace_invite - (workspace_id, email, is_admin, operator) - SELECT $1::text, email, false, $3 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS ( - SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email - ) - ON CONFLICT DO NOTHING", - &w_id, - domain, - operator - ) - .execute(&mut *tx) - .await?; - } - } else { - sqlx::query!( - "UPDATE workspace_settings SET auto_invite_domain = NULL, auto_invite_operator = NULL, auto_add = NULL WHERE workspace_id = $1", - &w_id, - ) - .execute(&mut *tx) - .await?; - } - audit_log( - &mut *tx, - &authed, - "workspaces.edit_auto_invite_domain", - ActionKind::Update, - &w_id, - Some(&authed.email), - Some([("operator", &format!("{:?}", ea.operator)[..])].into()), - ) - .await?; - tx.commit().await?; - - if let Some(users) = users_to_auto_add { - for user in users { - handle_deployment_metadata( - &email, - &username, - &db, - &w_id, - windmill_git_sync::DeployedObject::User { email: user.email.clone() }, - Some(format!("Auto-added user '{}' to workspace", &user.email)), - rsmq.clone(), - true, - ) - .await?; - } - } - - Ok(format!( - "Edit auto-invite for workspace {} to {}", - &w_id, domain - )) + crate::workspaces_ee::edit_auto_invite(authed, db, rsmq, w_id, ea).await } async fn edit_webhook( @@ -2001,61 +1774,6 @@ async fn delete_workspace( Ok(format!("Deleted workspace {}", &w_id)) } -pub async fn invite_user_to_all_auto_invite_worspaces( - db: &DB, - email: &str, - rsmq: Option, - authorable: &impl AuditAuthorable, -) -> Result<()> { - let mut tx = db.begin().await?; - let domain = email.split('@').last().unwrap(); - let workspaces = sqlx::query!( - "SELECT workspace_id, auto_invite_operator, auto_add FROM workspace_settings ws WHERE (auto_invite_domain = $1 OR auto_invite_domain = '*') AND NOT EXISTS (SELECT 1 FROM usr WHERE workspace_id = ws.workspace_id AND email = $2)", - domain, - email - ) - .fetch_all(&mut *tx) - .await?; - let mut auto_added_workspace_usernames: Vec<(String, String)> = vec![]; - for r in workspaces { - if r.auto_add.is_some() && r.auto_add.unwrap() { - let operator = r.auto_invite_operator.unwrap_or(false); - let username = - auto_add_user(email, &r.workspace_id, &operator, &mut tx, authorable).await?; - auto_added_workspace_usernames.push((r.workspace_id, username)); - } else { - sqlx::query!( - "INSERT INTO workspace_invite - (workspace_id, email, is_admin, operator) - VALUES ($1, $2, false, $3) - ON CONFLICT DO NOTHING", - r.workspace_id, - email, - r.auto_invite_operator - ) - .execute(&mut *tx) - .await?; - } - } - tx.commit().await?; - - for workspace_username_tuple in auto_added_workspace_usernames { - let (w_id, username) = workspace_username_tuple; - handle_deployment_metadata( - &email, - &username, - db, - &w_id, - windmill_git_sync::DeployedObject::User { email: email.to_string() }, - Some(format!("Auto-added user '{}' to workspace", email)), - rsmq.clone(), - true, - ) - .await?; - } - Ok(()) -} - async fn invite_user( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api/src/workspaces_ee.rs b/backend/windmill-api/src/workspaces_ee.rs new file mode 100644 index 0000000000..565a53b174 --- /dev/null +++ b/backend/windmill-api/src/workspaces_ee.rs @@ -0,0 +1,16 @@ +use crate::{ + db::{ApiAuthed, DB}, + workspaces::EditAutoInvite, +}; + +pub async fn edit_auto_invite( + _authed: ApiAuthed, + _db: DB, + _rsmq: Option, + _w_id: String, + _ea: EditAutoInvite, +) -> windmill_common::error::Result { + Err(windmill_common::error::Error::InternalErr( + "Not implemented on OSS".to_string(), + )) +} diff --git a/backend/windmill-common/src/ee.rs~main b/backend/windmill-common/src/ee.rs~main deleted file mode 100644 index 482b61a0fe..0000000000 --- a/backend/windmill-common/src/ee.rs~main +++ /dev/null @@ -1,73 +0,0 @@ -#[cfg(feature = "enterprise")] -use crate::db::DB; -use crate::ee::LicensePlan::Community; -#[cfg(feature = "enterprise")] -use crate::error; -use serde::Deserialize; -use std::sync::Arc; -use tokio::sync::RwLock; - -lazy_static::lazy_static! { - pub static ref LICENSE_KEY_VALID: Arc> = Arc::new(RwLock::new(true)); - pub static ref LICENSE_KEY_ID: Arc> = Arc::new(RwLock::new("".to_string())); - pub static ref LICENSE_KEY: Arc> = Arc::new(RwLock::new("".to_string())); -} - -pub enum LicensePlan { - Community, - Pro, - Enterprise, -} - -pub async fn get_license_plan() -> LicensePlan { - // Implementation is not open source - return Community; -} - -#[derive(Deserialize)] -#[serde(untagged)] -pub enum CriticalErrorChannel {} - -pub enum CriticalAlertKind { - #[cfg(feature = "enterprise")] - CriticalError, - #[cfg(feature = "enterprise")] - RecoveredCriticalError, -} - -#[cfg(feature = "enterprise")] -pub async fn send_critical_alert( - _error_message: String, - _db: &DB, - _kind: CriticalAlertKind, - _channels: Option>, -) { -} - -#[cfg(feature = "enterprise")] -pub async fn schedule_key_renewal(_http_client: &reqwest::Client, _db: &crate::db::DB) -> () { - // Implementation is not open source -} - -#[cfg(feature = "enterprise")] -pub async fn renew_license_key( - _http_client: &reqwest::Client, - _db: &crate::db::DB, - _key: Option, - _manual: bool, -) -> String { - // Implementation is not open source - "".to_string() -} - -#[cfg(feature = "enterprise")] -pub async fn create_customer_portal_session( - _http_client: &reqwest::Client, - _key: Option, -) -> error::Result { - // Implementation is not open source - Ok("".to_string()) -} - -#[cfg(feature = "enterprise")] -pub async fn worker_groups_alerts(_db: &DB) {} diff --git a/backend/windmill-common/src/ee.rs~main_0 b/backend/windmill-common/src/ee.rs~main_0 deleted file mode 100644 index 2f32756e45..0000000000 --- a/backend/windmill-common/src/ee.rs~main_0 +++ /dev/null @@ -1,76 +0,0 @@ -#[cfg(feature = "enterprise")] -use crate::db::DB; -use crate::ee::LicensePlan::Community; -#[cfg(feature = "enterprise")] -use crate::error; -use serde::Deserialize; -use std::sync::Arc; -use tokio::sync::RwLock; - -lazy_static::lazy_static! { - pub static ref LICENSE_KEY_VALID: Arc> = Arc::new(RwLock::new(true)); - pub static ref LICENSE_KEY_ID: Arc> = Arc::new(RwLock::new("".to_string())); - pub static ref LICENSE_KEY: Arc> = Arc::new(RwLock::new("".to_string())); -} - -pub enum LicensePlan { - Community, - Pro, - Enterprise, -} - -pub async fn get_license_plan() -> LicensePlan { - // Implementation is not open source - return Community; -} - -#[derive(Deserialize)] -#[serde(untagged)] -pub enum CriticalErrorChannel { - Email { email: String }, - Slack { slack_channel: String }, -} - -pub enum CriticalAlertKind { - #[cfg(feature = "enterprise")] - CriticalError, - #[cfg(feature = "enterprise")] - RecoveredCriticalError, -} - -#[cfg(feature = "enterprise")] -pub async fn send_critical_alert( - _error_message: String, - _db: &DB, - _kind: CriticalAlertKind, - _channels: Option>, -) { -} - -#[cfg(feature = "enterprise")] -pub async fn schedule_key_renewal(_http_client: &reqwest::Client, _db: &crate::db::DB) -> () { - // Implementation is not open source -} - -#[cfg(feature = "enterprise")] -pub async fn renew_license_key( - _http_client: &reqwest::Client, - _db: &crate::db::DB, - _key: Option, - _manual: bool, -) -> String { - // Implementation is not open source - "".to_string() -} - -#[cfg(feature = "enterprise")] -pub async fn create_customer_portal_session( - _http_client: &reqwest::Client, - _key: Option, -) -> error::Result { - // Implementation is not open source - Ok("".to_string()) -} - -#[cfg(feature = "enterprise")] -pub async fn worker_groups_alerts(_db: &DB) {} diff --git a/backend/windmill-common/src/email_ee.rs b/backend/windmill-common/src/email_ee.rs new file mode 100644 index 0000000000..42aebbeec3 --- /dev/null +++ b/backend/windmill-common/src/email_ee.rs @@ -0,0 +1,11 @@ +use crate::server::Smtp; + +pub async fn send_email( + _subject: &str, + _content: &str, + _to: Vec, + _smtp: Smtp, + _client_timeout: Option, +) -> crate::error::Result<()> { + Ok(()) +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 36b80c9fd7..b0420ca248 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -27,6 +27,7 @@ pub const OBJECT_STORE_CACHE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; pub const HUB_BASE_URL_SETTING: &str = "hub_base_url"; +pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url"; pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels"; pub const DEV_INSTANCE_SETTING: &str = "dev_instance"; pub const JWT_SECRET_SETTING: &str = "jwt_secret"; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index bcac7b85fb..ad96594ac8 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -22,6 +22,7 @@ pub mod auth; pub mod bench; pub mod db; pub mod ee; +pub mod email_ee; pub mod error; pub mod external_ip; pub mod flow_status; diff --git a/backend/windmill-common/src/s3_helpers.rs b/backend/windmill-common/src/s3_helpers.rs index 198283db2d..4547c29d51 100644 --- a/backend/windmill-common/src/s3_helpers.rs +++ b/backend/windmill-common/src/s3_helpers.rs @@ -117,10 +117,10 @@ pub struct S3Object { #[cfg(feature = "parquet")] pub async fn get_etag_or_empty( - object_store_resource: &ObjectStoreResource, + object_store_resource: &mut ObjectStoreResource, s3_object: S3Object, ) -> Option { - let object_store_client = build_object_store_client(object_store_resource); + let object_store_client = build_object_store_client(object_store_resource).await; if object_store_client.is_err() { return None; } @@ -166,11 +166,11 @@ pub fn render_endpoint( } #[cfg(feature = "parquet")] -pub fn build_object_store_client( +pub async fn build_object_store_client( resource_ref: &ObjectStoreResource, ) -> error::Result> { match resource_ref { - ObjectStoreResource::S3(s3_resource_ref) => build_s3_client(&s3_resource_ref, None), + ObjectStoreResource::S3(s3_resource_ref) => build_s3_client(&s3_resource_ref).await, ObjectStoreResource::Azure(azure_blob_resource_ref) => { build_azure_blob_client(&azure_blob_resource_ref) } @@ -225,10 +225,21 @@ use aws_config::{default_provider::credentials::DefaultCredentialsChain, Region} use object_store::CredentialProvider; #[cfg(feature = "parquet")] -pub fn build_s3_client( - s3_resource_ref: &S3Resource, - credential_providers: Option, -) -> error::Result> { +pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result> { + let static_creds = s3_resource_ref.access_key.as_ref().is_some_and(|x| x != "") + || s3_resource_ref.secret_key.as_ref().is_some_and(|x| x != ""); + + let credentials_provider = if !static_creds { + Some( + DefaultCredentialsChain::builder() + .region(Region::new(s3_resource_ref.region.clone())) + .build() + .await, + ) + } else { + None + }; + let s3_resource = s3_resource_ref.clone(); let endpoint = render_endpoint( s3_resource.endpoint, @@ -244,7 +255,7 @@ pub fn build_s3_client( .with_bucket_name(s3_resource.bucket) .with_endpoint(endpoint); - if let Some(credentials_provider) = credential_providers { + if let Some(credentials_provider) = credentials_provider { store_builder = store_builder.with_credentials(Arc::new(AwsCredentialAdapter { inner: credentials_provider, })); @@ -399,18 +410,7 @@ pub async fn build_s3_client_from_settings( ) -> error::Result> { let region = none_if_empty(settings.region) .unwrap_or_else(|| std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string())); - let access_key = none_if_empty(settings.access_key); - let secret_key = none_if_empty(settings.secret_key); - let credentials_provider = if access_key.is_none() && secret_key.is_none() { - Some( - DefaultCredentialsChain::builder() - .region(Region::new(region.clone())) - .build() - .await, - ) - } else { - None - }; + let s3_resource = S3Resource { endpoint: none_if_empty(settings.endpoint).unwrap_or_else(|| { std::env::var("S3_ENDPOINT").unwrap_or_else(|_| format!("s3.{region}.amazonaws.com")) @@ -419,15 +419,15 @@ pub async fn build_s3_client_from_settings( std::env::var("S3_CACHE_BUCKET").unwrap_or_else(|_| "missingbucket".to_string()) }), region, - access_key, - secret_key, + access_key: settings.access_key, + secret_key: settings.secret_key, use_ssl: !settings.allow_http.unwrap_or(true), path_style: settings.path_style, port: settings.port, token: None, }; - build_s3_client(&s3_resource, credentials_provider) + build_s3_client(&s3_resource).await } #[cfg(feature = "parquet")] diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 3feab853dd..04f95a3406 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -11,13 +11,11 @@ use crate::ee::LICENSE_KEY_ID; use crate::ee::{send_critical_alert, CriticalAlertKind}; use crate::error::{to_anyhow, Error, Result}; use crate::global_settings::UNIQUE_ID_SETTING; -use crate::server::Smtp; use crate::DB; use anyhow::Context; use gethostname::gethostname; use git_version::git_version; -use mail_send::mail_builder::MessageBuilder; -use mail_send::SmtpClientBuilder; + use rand::{distributions::Alphanumeric, thread_rng, Rng}; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -243,58 +241,6 @@ pub fn generate_lock_id(database_name: &str) -> i64 { 0x3d32ad9e * (CRC_IEEE.checksum(database_name.as_bytes()) as i64) } -pub async fn send_email( - subject: &str, - content: &str, - to: Vec, - smtp: Smtp, - client_timeout: Option, -) -> Result<()> { - let mut client = SmtpClientBuilder::new(smtp.host, smtp.port) - .implicit_tls(smtp.tls_implicit.unwrap_or(false)); - if std::env::var("ACCEPT_INVALID_CERTS").is_ok() { - client = client.allow_invalid_certs(); - } - let client = if let (Some(username), Some(password)) = (smtp.username, smtp.password) { - if !username.is_empty() { - client.credentials((username, password)) - } else { - client - } - } else { - client - }; - let message = MessageBuilder::new() - .from(("Windmill", smtp.from.as_str())) - .to(to.clone()) - .subject(subject) - .text_body(content); - - match client_timeout { - Some(timeout) => { - tokio::time::timeout(timeout, client.connect()) - .await - .map_err(to_anyhow)? - .map_err(to_anyhow)? - .send(message) - .await - .map_err(to_anyhow)?; - } - None => { - client - .connect() - .await - .map_err(to_anyhow)? - .send(message) - .await - .map_err(to_anyhow)?; - } - } - tracing::info!("Sent email to {:#?}: {subject}", to); - - return Ok(()); -} - pub async fn report_critical_error(error_message: String, _db: DB) -> () { tracing::error!("CRITICAL ERROR: {error_message}"); diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index 4f23fe1f7d..854b64c389 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -35,7 +35,7 @@ use crate::{ handle_child::handle_child, python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile}, AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, - TZ_ENV, + PROXY_ENVS, TZ_ENV, }; lazy_static::lazy_static! { @@ -148,6 +148,7 @@ async fn install_galaxy_collections( galaxy_command .current_dir(job_dir) .env_clear() + .envs(PROXY_ENVS.clone()) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) // .env("BASE_INTERNAL_URL", base_internal_url) @@ -396,6 +397,7 @@ fi nsjail_cmd .current_dir(job_dir) .env_clear() + .envs(PROXY_ENVS.clone()) // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .env("PATH", PATH_ENV.as_str()) @@ -557,19 +559,12 @@ async fn create_file_resources( } for file_res in &r.file_resources { - let r = get_resource_or_variable_content( - client, - &file_res.resource_path, - job_id.to_string(), - ) - .await?; + let r = + get_resource_or_variable_content(client, &file_res.resource_path, job_id.to_string()) + .await?; let path = file_res.target_path.clone(); - let validated_path = write_file_at_user_defined_location( - job_dir, - path.as_str(), - &r, - ) - .map_err(|e| anyhow!("Couldn't write text file at {}: {}", path, e))?; + let validated_path = write_file_at_user_defined_location(job_dir, path.as_str(), &r) + .map_err(|e| anyhow!("Couldn't write text file at {}: {}", path, e))?; nsjail_mounts.push( define_nsjail_mount(job_dir, &validated_path) @@ -597,13 +592,14 @@ async fn get_resource_or_variable_content( .get_resource_value_interpolated::(&p, Some(job_id)) .await?; - r.get("content").and_then(|v| v.as_str()).ok_or(anyhow!( - "Invalid text file resource {}, `content` field absent or invalid", - p - ))?.to_string() - } - ResourceOrVariablePath::Variable(p) => { - client.get_variable_value(&p).await? + r.get("content") + .and_then(|v| v.as_str()) + .ok_or(anyhow!( + "Invalid text file resource {}, `content` field absent or invalid", + p + ))? + .to_string() } + ResourceOrVariablePath::Variable(p) => client.get_variable_value(&p).await?, }) } diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index ed7a1dd0f6..2c4c46ab7d 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -29,7 +29,7 @@ use crate::{ }, handle_child::handle_child, AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, - POWERSHELL_CACHE_DIR, POWERSHELL_PATH, TZ_ENV, + POWERSHELL_CACHE_DIR, POWERSHELL_PATH, PROXY_ENVS, TZ_ENV, }; #[cfg(windows)] @@ -143,6 +143,7 @@ wait .current_dir(job_dir) .env_clear() .envs(reserved_variables) + .envs(PROXY_ENVS.clone()) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .args(cmd_args) @@ -435,6 +436,7 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", Command::new(NSJAIL_PATH.as_str()) .current_dir(job_dir) .env_clear() + .envs(PROXY_ENVS.clone()) .envs(reserved_variables) .env("TZ", TZ_ENV.as_str()) .env("PATH", PATH_ENV.as_str()) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 4a8c0749bc..5b7c2a06c0 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -26,7 +26,7 @@ use crate::{ handle_child::handle_child, AuthedClientBackgroundTask, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_DEPSTAR_CACHE_DIR, BUN_PATH, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, - NODE_PATH, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, TZ_ENV, + NODE_PATH, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TZ_ENV, }; #[cfg(windows)] @@ -275,6 +275,7 @@ pub async fn install_bun_lockfile( child_cmd .current_dir(job_dir) .env_clear() + .envs(PROXY_ENVS.clone()) .envs(common_bun_proc_envs) .args(vec!["install"]) .stdout(Stdio::piped()) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 406f128917..e22f9be54c 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -736,8 +736,8 @@ async fn arg_value_hash_additions( .await; storage = s3_object.storage.clone(); - if let Some(s3_resource) = s3_resource_opt.ok().flatten() { - let etag = get_etag_or_empty(&s3_resource, s3_object.clone()).await; + if let Some(mut s3_resource) = s3_resource_opt.ok().flatten() { + let etag = get_etag_or_empty(&mut s3_resource, s3_object.clone()).await; tracing::warn!("Enriching s3 arg value with etag: {:?}", etag); result.insert(s3_object.s3.clone(), etag.unwrap_or_default()); // TODO: maybe inject a random value to invalidate the cache? } @@ -793,9 +793,9 @@ pub async fn get_cached_resource_value_if_valid( return None; } for (s3_file_key, s3_file_etag) in s3_etags { - if let Some(object_store_resource) = object_store_resource_opt.clone() { + if let Some(mut object_store_resource) = object_store_resource_opt.clone() { let etag = get_etag_or_empty( - &object_store_resource, + &mut object_store_resource, S3Object { s3: s3_file_key.clone(), storage: cached_resource.storage.clone(), diff --git a/backend/windmill-worker/src/dedicated_worker.rs b/backend/windmill-worker/src/dedicated_worker.rs index da3e25a699..1a332e76ef 100644 --- a/backend/windmill-worker/src/dedicated_worker.rs +++ b/backend/windmill-worker/src/dedicated_worker.rs @@ -77,7 +77,7 @@ pub async fn handle_dedicated_process( ) -> std::result::Result<(), error::Error> { //do not cache local dependencies - use crate::handle_child::process_status; + use crate::{handle_child::process_status, PROXY_ENVS}; let mut child = { let mut cmd = Command::new(command_path); @@ -85,6 +85,7 @@ pub async fn handle_dedicated_process( .env_clear() .envs(context_envs) .envs(envs) + .envs(PROXY_ENVS.clone()) .envs( reserved_variables .iter() diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index c171e52156..470e647383 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -1,3 +1,4 @@ +use crate::PROXY_ENVS; use std::{collections::HashMap, fs::DirBuilder, process::Stdio}; use itertools::Itertools; @@ -189,6 +190,7 @@ func Run(req Req) (interface{{}}, error){{ .env("BASE_INTERNAL_URL", base_internal_url) .env("GOPATH", GO_CACHE_DIR) .env("HOME", HOME_ENV.as_str()) + .envs(PROXY_ENVS.clone()) .args(vec!["build", "main.go"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 5333426f37..2390967613 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -71,7 +71,7 @@ use crate::{ read_result, start_child_process, OccupancyMetrics, }, handle_child::handle_child, - AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, HTTPS_PROXY, HTTP_PROXY, + AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, PROXY_ENVS, LOCK_CACHE_DIR, NO_PROXY, NSJAIL_PATH, PATH_ENV, PIP_CACHE_DIR, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PY311_CACHE_DIR, TZ_ENV, UV_CACHE_DIR, }; @@ -571,6 +571,7 @@ mount {{ .env_clear() // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) + .envs(PROXY_ENVS.clone()) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) @@ -1020,15 +1021,6 @@ pub async fn handle_python_reqs( if let Some(host) = PIP_TRUSTED_HOST.as_ref() { vars.push(("TRUSTED_HOST", host)); } - if let Some(http_proxy) = HTTP_PROXY.as_ref() { - vars.push(("HTTP_PROXY", http_proxy)); - } - if let Some(https_proxy) = HTTPS_PROXY.as_ref() { - vars.push(("HTTPS_PROXY", https_proxy)); - } - if let Some(no_proxy) = NO_PROXY.as_ref() { - vars.push(("NO_PROXY", no_proxy)); - } let _ = write_file( job_dir, @@ -1177,6 +1169,7 @@ pub async fn handle_python_reqs( .current_dir(job_dir) .env_clear() .envs(vars) + .envs(PROXY_ENVS.clone()) .args(vec!["--config", "download.config.proto"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1256,15 +1249,6 @@ pub async fn handle_python_reqs( } let mut envs = vec![("PATH", PATH_ENV.as_str())]; - if let Some(http_proxy) = HTTP_PROXY.as_ref() { - envs.push(("HTTP_PROXY", http_proxy)); - } - if let Some(https_proxy) = HTTPS_PROXY.as_ref() { - envs.push(("HTTPS_PROXY", https_proxy)); - } - if let Some(no_proxy) = NO_PROXY.as_ref() { - envs.push(("NO_PROXY", no_proxy)); - } envs.push(("HOME", HOME_ENV.as_str())); @@ -1275,6 +1259,7 @@ pub async fn handle_python_reqs( let mut flock_cmd = Command::new(FLOCK_PATH.as_str()); flock_cmd .env_clear() + .envs(PROXY_ENVS.clone()) .envs(envs) .args([ "-x", @@ -1299,6 +1284,7 @@ pub async fn handle_python_reqs( pip_cmd .env_clear() .envs(envs) + .envs(PROXY_ENVS.clone()) .env("SystemRoot", SYSTEM_ROOT.as_str()) .args(&command_args[1..]) .stdout(Stdio::piped()) diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 530be394ff..9391fd9a69 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -20,7 +20,7 @@ use crate::{ }, handle_child::handle_child, AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, - RUST_CACHE_DIR, TZ_ENV, + PROXY_ENVS, RUST_CACHE_DIR, TZ_ENV, }; #[cfg(windows)] @@ -192,6 +192,7 @@ pub async fn build_rust_crate( build_rust_cmd .current_dir(job_dir) .env_clear() + .envs(PROXY_ENVS.clone()) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("HOME", HOME_ENV.as_str()) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index cbd0cb351f..d554451d09 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -333,6 +333,20 @@ lazy_static::lazy_static! { pub static ref NO_PROXY: Option = std::env::var("no_proxy").ok().or(std::env::var("NO_PROXY").ok()); pub static ref HTTP_PROXY: Option = std::env::var("http_proxy").ok().or(std::env::var("HTTP_PROXY").ok()); pub static ref HTTPS_PROXY: Option = std::env::var("https_proxy").ok().or(std::env::var("HTTPS_PROXY").ok()); + + pub static ref PROXY_ENVS: Vec<(&'static str, String)> = { + let mut proxy_env = Vec::new(); + if let Some(no_proxy) = NO_PROXY.as_ref() { + proxy_env.push(("NO_PROXY", no_proxy.to_string())); + } + if let Some(http_proxy) = HTTP_PROXY.as_ref() { + proxy_env.push(("HTTP_PROXY", http_proxy.to_string())); + } + if let Some(https_proxy) = HTTPS_PROXY.as_ref() { + proxy_env.push(("HTTPS_PROXY", https_proxy.to_string())); + } + proxy_env + }; pub static ref DENO_PATH: String = std::env::var("DENO_PATH").unwrap_or_else(|_| "/usr/bin/deno".to_string()); pub static ref BUN_PATH: String = std::env::var("BUN_PATH").unwrap_or_else(|_| "/usr/bin/bun".to_string()); pub static ref NPM_PATH: String = std::env::var("NPM_PATH").unwrap_or_else(|_| "/usr/bin/npm".to_string()); @@ -1289,16 +1303,16 @@ pub async fn run_worker { + #[cfg(feature = "prometheus")] + if let Some(wb) = worker_busy.as_ref() { + wb.set(1); + tracing::debug!("set worker busy to 1"); + } + + occupancy_metrics.running_job_started_at = Some(Instant::now()); + last_executed_job = None; jobs_executed += 1; @@ -2083,15 +2097,10 @@ pub fn build_envs( hm }; - if let Some(ref env) = *HTTPS_PROXY { - envs.insert("HTTPS_PROXY".to_string(), env.to_string()); - } - if let Some(ref env) = *HTTP_PROXY { - envs.insert("HTTP_PROXY".to_string(), env.to_string()); - } - if let Some(ref env) = *NO_PROXY { - envs.insert("NO_PROXY".to_string(), env.to_string()); + for (k, v) in PROXY_ENVS.iter() { + envs.insert(k.to_string(), v.to_string()); } + Ok(envs) } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 04c135cfad..3a229dc1e8 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.415.1"; +export const VERSION = "v1.416.2"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/main.ts b/cli/main.ts index 8f27173e1f..562c3fde45 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -60,7 +60,7 @@ export { // } // }); -export const VERSION = "1.415.1"; +export const VERSION = "1.416.2"; const command = new Command() .name("wmill") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e2b8bb9e7..028ac3e356 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.415.1", + "version": "1.416.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.415.1", + "version": "1.416.2", "license": "AGPL-3.0", "dependencies": { "@aws-crypto/sha256-js": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index 181c8ecf9a..9c4f3a2a7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.415.1", + "version": "1.416.2", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index e89584ea44..e3d09ee007 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -666,6 +666,7 @@ $res = json_decode(curl_exec($ch));`)
+ {#if scriptPath && !noHistory}