diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0b517c26f7..e47ce1f0a3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.831.0" + ".": "1.833.0" } diff --git a/AGENTS.md b/AGENTS.md index a57215cde5..8c30fed2f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,9 @@ Open-source platform for internal tools, workflows, API integrations, background lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership) and how to run one locally - **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no - role holds: the default role as a floor, where the viewer comes from, why an admin's roles are - not the viewer's, the per-role-set logins and the order their rows and logins are written in + role holds: the default role as a floor, whose roles are added (the job's own, or its viewer's + for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set + logins and the order their rows and logins are written in - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` diff --git a/CHANGELOG.md b/CHANGELOG.md index 98686e7917..0edb6b5dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.833.0](https://github.com/windmill-labs/windmill/compare/v1.832.0...v1.833.0) (2026-10-13) + + +### Features + +* make the assistant aware of the user's role permissions ([#11722](https://github.com/windmill-labs/windmill/issues/11722)) ([633135b](https://github.com/windmill-labs/windmill/commit/633135bb917ce1be711a75991531c3e478d73463)) +* read data as the job's identity, viewer is opt-in per runnable ([#11744](https://github.com/windmill-labs/windmill/issues/11744)) ([b7b3306](https://github.com/windmill-labs/windmill/commit/b7b33061952fde247447fa652663ffd730990d5e)) +* show a raw app's document.title in the page tab ([#11747](https://github.com/windmill-labs/windmill/issues/11747)) ([5153a5e](https://github.com/windmill-labs/windmill/commit/5153a5e7b81e9d56ff860129be7c49f444c3c4e2)) +* shrink the page header in stages and fold page actions by measured width ([#11732](https://github.com/windmill-labs/windmill/issues/11732)) ([3857ce3](https://github.com/windmill-labs/windmill/commit/3857ce36931dff40322db2b765ab1e65763eac42)) + + +### Bug Fixes + +* encrypt ai session backups with aes-256-gcm ([#11734](https://github.com/windmill-labs/windmill/issues/11734)) ([c7ee201](https://github.com/windmill-labs/windmill/commit/c7ee2016559b8083c83c696c41d226d9ca04ed68)) +* error handler picker keeps no per-tab state ([#11742](https://github.com/windmill-labs/windmill/issues/11742)) ([2f9a111](https://github.com/windmill-labs/windmill/commit/2f9a111bbb14a40e1341879e1ef63c3457e7a08d)) +* show the email handler's SMTP status to non-superadmin admins ([#11745](https://github.com/windmill-labs/windmill/issues/11745)) ([130adfd](https://github.com/windmill-labs/windmill/commit/130adfdd20af0e30d87986cf77b61023a9732186)) + +## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13) + + +### Features + +* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5)) + + +### Bug Fixes + +* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73)) +* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c)) +* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6)) + ## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) diff --git a/Dockerfile b/Dockerfile index c6a4ccc55f..4e022d4b53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -152,11 +152,12 @@ ARG WITH_HELM=true ARG WITH_GIT=true ARG features="" -# To change latest stable version: -# 1. Change placeholder in instanceSettings.ts -# 2. Change LATEST_STABLE_PY in dockerfile -# 3. Change #[default] annotation for PyVersion in backend +# Instances keep the Python version they were initialized with, so both are preinstalled: +# LATEST_STABLE_PY is what instances initialized before the version was raised are on +# (PyVAlias::default() in the backend), NEW_INSTANCE_PY the one new instances start on +# (PyVAlias::NEW_INSTANCE). When raising the latter, keep installing every earlier value. ARG LATEST_STABLE_PY=3.12 +ARG NEW_INSTANCE_PY=3.13 ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime ENV UV_PYTHON_PREFERENCE=only-managed @@ -249,6 +250,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - diff --git a/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json b/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json new file mode 100644 index 0000000000..4735c589ac --- /dev/null +++ b/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc" +} diff --git a/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json b/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json new file mode 100644 index 0000000000..bd3f00aa7d --- /dev/null +++ b/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json @@ -0,0 +1,330 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "viewer_data_access", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + null, + true + ] + }, + "hash": "9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096" +} diff --git a/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json b/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json new file mode 100644 index 0000000000..bfda06948d --- /dev/null +++ b/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json @@ -0,0 +1,337 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "viewer_data_access", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + null, + true + ] + }, + "hash": "fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220" +} diff --git a/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json b/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json new file mode 100644 index 0000000000..4801a9b5da --- /dev/null +++ b/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes, viewer_data_access\n FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "groups", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "folders", + "type_info": "JsonbArray" + }, + { + "ordinal": 6, + "name": "end_user_email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "job_token_scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "viewer_data_access", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9ee5635c37..a5af71b50e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -957,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167" dependencies = [ "attribute-derive-macro", - "manyhow 0.14.0", + "manyhow 0.14.1", "proc-macro2", "quote", "syn 3.0.9", @@ -971,7 +971,7 @@ checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98" dependencies = [ "collection_literals", "interpolator", - "manyhow 0.14.0", + "manyhow 0.14.1", "proc-macro-utils", "proc-macro2", "quote", @@ -8036,11 +8036,11 @@ dependencies = [ [[package]] name = "manyhow" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c69ea6e469ca785c846b25dd69eac9e9c916b51d8ada1f212866787ee1da12d" +checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7" dependencies = [ - "manyhow-macros 0.14.0", + "manyhow-macros 0.14.1", "proc-macro2", "quote", "syn 3.0.9", @@ -8059,9 +8059,9 @@ dependencies = [ [[package]] name = "manyhow-macros" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc5c1b2c4000056dae3619fea36e79ce79dabfee4907d2dcad6612b189e370" +checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725" dependencies = [ "proc-macro-utils", "proc-macro2", @@ -15486,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", @@ -15576,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.831.0" +version = "1.833.0" dependencies = [ "async-stream", "async-trait", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "argon2", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15790,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.831.0" +version = "1.833.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15843,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "base64 0.22.1", @@ -15882,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -15905,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15943,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -15979,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", @@ -16015,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -16069,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.831.0" +version = "1.833.0" dependencies = [ "lazy_static", "serde", @@ -16194,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.831.0" +version = "1.833.0" dependencies = [ "argon2", "axum", @@ -16218,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -16232,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.831.0" +version = "1.833.0" dependencies = [ "axum", "chrono", @@ -16271,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.831.0" +version = "1.833.0" dependencies = [ "chrono", "lazy_static", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "axum", @@ -16305,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.833.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16421,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.831.0" +version = "1.833.0" dependencies = [ "chrono", "futures", @@ -16441,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.831.0" +version = "1.833.0" dependencies = [ "regex", "serde", @@ -16458,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16485,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "futures", @@ -16502,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.833.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16518,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -16540,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "arc-swap", @@ -16596,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-stream", @@ -16632,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.833.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16672,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16684,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "gosyn", @@ -16696,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "nu-parser", @@ -16731,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16742,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16754,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16814,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16831,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16844,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde", @@ -16856,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16874,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16890,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16907,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16921,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "const_format", @@ -16998,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.831.0" +version = "1.833.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17009,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17068,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17101,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17128,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17181,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17215,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17252,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17275,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17299,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", @@ -17323,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -17411,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17430,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-once-cell", @@ -17549,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.831.0" +version = "1.833.0" dependencies = [ "bytes", "futures", @@ -18275,18 +18275,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a72480cab2..5cfd7372cc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.831.0" +version = "1.833.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.831.0" +version = "1.833.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 408bf517b1..42fd37a116 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -118785eaa78675089b9a676d24e5d0a2e03f3d9a +0ffb633473c5465513576afc6f54d18f93c7f1e1 diff --git a/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql b/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql new file mode 100644 index 0000000000..6741203ade --- /dev/null +++ b/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql @@ -0,0 +1 @@ +ALTER TABLE job_perms DROP COLUMN IF EXISTS viewer_data_access; diff --git a/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql b/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql new file mode 100644 index 0000000000..5d6a41ece4 --- /dev/null +++ b/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql @@ -0,0 +1,4 @@ +-- Set by the server on a job pushed for an app runnable whose policy says it reads data as its +-- viewer: data table connections that name no role and object storage permission rules then go by +-- end_user_email instead of the job's own identity. +ALTER TABLE job_perms ADD COLUMN IF NOT EXISTS viewer_data_access BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index b5ebd80e2a..a29aecb92d 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -446,7 +446,7 @@ ], "req_body_auth": true }, - "outlook": { + "ms_outlook": { "req_body_auth": true, "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], "scope_options": [ diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index c0d6aa95d4..c0dcab1c78 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.833.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.833.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.833.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.833.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index d72119afd4..ca10c14ff6 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.831.0" +version = "1.833.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d4dee19f82..ab12ae6f6e 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -153,6 +153,9 @@ pub async fn connect( pool_options = pool_options.idle_timeout(Duration::from_secs(60)); } pool_options + .before_acquire(|_, meta| { + Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) }) + }) .after_connect(move |conn, _| { if worker_mode { Box::pin(async move { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 23ab2eaed1..4fae6f34ab 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4469,12 +4469,14 @@ pub async fn monitor_db( // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. // Every ~5 min until one pass has gone through every workspace; spawned so a large instance - // does not hold up the join below. + // does not hold up the join below. The column widening it starts with is tried every + // iteration: a v2 value too long for the narrow column fails to save until it is done. let legacy_secret_upgrade_f = async { if server_mode && iteration.is_some() - && iteration.as_ref().unwrap().should_run(30) - && windmill_common::variables::legacy_secret_upgrade_pending() + && (windmill_common::variables::variable_value_widen_pending() + || (iteration.as_ref().unwrap().should_run(30) + && windmill_common::variables::legacy_secret_upgrade_pending())) { if let Some(db) = conn.as_sql() { let db = db.clone(); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index ac07dc06d6..fa1c049746 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[]) -job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]) +job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]), viewer_data_access(bool) job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool) job_result_stream: job_id(uuid), workspace_id(text), stream(text) job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) diff --git a/backend/tests/ai_sessions.rs b/backend/tests/ai_sessions.rs index 6b33a4141e..b2b8406db3 100644 --- a/backend/tests/ai_sessions.rs +++ b/backend/tests/ai_sessions.rs @@ -1580,3 +1580,163 @@ async fn test_backups_fall_back_to_the_instance_storage(db: Pool) -> a Ok(()) } + +/// Runs its closure when dropped, so process-wide state is put back even when an assertion +/// fails. +struct OnDrop(F); +impl Drop for OnDrop { + fn drop(&mut self) { + (self.0)() + } +} + +/// Once every worker reads AES-256-GCM and this server has been up long enough for any server +/// of the previous release to be gone, objects are written in it: a session backed up as CBC +/// before the switch still restores whole with the GCM pieces added after it, a head at its +/// cap and a push token still read back under the larger ciphertext, and relocated GCM +/// ciphertext does not decrypt for another user. +#[sqlx::test(fixtures("base"))] +async fn test_backups_switch_to_gcm_once_every_worker_reads_it( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::min_version::{ + MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS, MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES, + }; + use windmill_common::workspace_crypt::V2_MAGIC; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let storage_dir = tempfile::tempdir()?; + configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?; + let before = MIN_VERSION.load_full(); + let _restore = OnDrop(move || MIN_VERSION.store(before.clone())); + let _uptime = OnDrop(|| windmill_common::db::set_process_uptime_offset(Default::default())); + let is_gcm = |b: &[u8]| b.starts_with(&V2_MAGIC); + let object = |path: &str| -> Vec { + std::fs::read(user_root(storage_dir.path(), "test@windmill.dev").join(path)).unwrap() + }; + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES.version().clone(), + )); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "whole": true, + "head": { "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" }, + "chats": [{ "id": "c1", "record": { "id": "c1", "title": "BEFORE" } }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let cbc_chat = object("sessions/s1/chats/c1.json"); + assert!(!cbc_chat.is_empty() && !is_gcm(&cbc_chat)); + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(), + )); + // Every worker reads it, but this server was only just started: still CBC. + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ "id": "s1", "chats": [{ "id": "c0", "record": { "id": "c0", "title": "FRESH" } }] }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let fresh_chat = object("sessions/s1/chats/c0.json"); + assert!(!fresh_chat.is_empty() && !is_gcm(&fresh_chat)); + + windmill_common::db::set_process_uptime_offset( + windmill_common::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME, + ); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "chats": [{ "id": "c2", "record": { "id": "c2", "title": "AFTER" } }], + "images": [{ "chat_id": "c2", "id": "i1", "data_url": "data:image/png;base64,AAAA" }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!( + object("sessions/s1/chats/c1.json"), + cbc_chat, + "not rewritten" + ); + assert!(is_gcm(&object("sessions/s1/chats/c2.json"))); + assert!(is_gcm(&object("images/s1/c2/i1"))); + + let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?; + let mut titles: Vec = pulled["sessions"][0]["chats"] + .as_array() + .unwrap() + .iter() + .map(|c| c["record"]["title"].as_str().unwrap().to_string()) + .collect(); + titles.sort(); + assert_eq!(titles, ["AFTER", "BEFORE", "FRESH"], "{pulled}"); + assert_eq!( + pulled["sessions"][0]["images"][0]["data_url"], + "data:image/png;base64,AAAA" + ); + + // A head at its cap, pushed in two parts: the second reads back the push token. + let mut head = json!({ "id": "s2", "workspace_id": "test-workspace", "createdAt": 2, "chatId": "c", "pad": "" }); + let pad = 1024 * 1024 - head.to_string().len(); + head["pad"] = json!("x".repeat(pad)); + assert_eq!(head.to_string().len(), 1024 * 1024); + for part in [ + json!({ "id": "s2", "whole": true, "push": "p2", "opens": true, "partial": true, "head": head }), + json!({ "id": "s2", "whole": true, "push": "p2", "chats": [{ "id": "c", "record": { "id": "c" } }] }), + ] { + let resp = push( + &base, + "SECRET_TOKEN", + json!({ "owner": "test@windmill.dev", "sessions": [part] }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let pushed: Value = resp.json().await?; + assert_eq!(pushed["results"], json!([{ "id": "s2" }]), "{pushed}"); + } + assert!(is_gcm(&object("sessions/s2/head.json"))); + let pulled = pull(&base, "SECRET_TOKEN", &["s2"]).await?; + assert_eq!( + pulled["sessions"][0]["head"]["pad"].as_str().map(str::len), + Some(pad) + ); + assert_eq!(pulled["sessions"][0]["chats"][0]["id"], "c", "{pulled}"); + + let first = user_root(storage_dir.path(), "test@windmill.dev"); + copy_dir( + &first, + &first + .parent() + .unwrap() + .join(calculate_hash("test2@windmill.dev")), + )?; + assert_eq!( + pull(&base, "SECRET_TOKEN_2", &["s1", "s2"]).await?["sessions"], + json!([]), + "relocated ciphertext must not decrypt for another user" + ); + + Ok(()) +} diff --git a/backend/tests/end_user_email.rs b/backend/tests/end_user_email.rs index 0d8ff7be9a..15eb9a64f1 100644 --- a/backend/tests/end_user_email.rs +++ b/backend/tests/end_user_email.rs @@ -509,3 +509,89 @@ async fn test_raw_app_wm_end_user_email(db: Pool) -> anyhow::Result<() Ok(()) } + +/// Connecting to data tables as the viewer is honored on inline runnables only: a path runnable +/// is someone else's script, which must not read with the viewer's roles because the app's +/// policy entry for it says so. +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let app_path = "f/test/viewer_data_access_app"; + let script_path = "script/f/test/get_end_user_email"; + + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/apps/create" + )), + SAME_WS_TOKEN, + ) + .json(&json!({ + "path": app_path, + "summary": "", + "value": { "type": "app", "grid": [], "subgrids": {}, "hiddenInlineScripts": [] }, + "policy": { + "execution_mode": "anonymous", + "on_behalf_of": null, + "on_behalf_of_email": null, + "triggerables_v2": { + "rawscript/894d2f85ea2411e777e7d5787366c9fec4c1a7a5f83482748f1d129196c9f54f": { + "static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true + }, + (script_path): { + "static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true + } + } + } + })) + .send() + .await?; + anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?); + + let execute = |payload: serde_json::Value| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/{app_path}" + )), + SAME_WS_TOKEN, + ) + .json(&payload) + .send() + .await?; + anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?); + Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::()?) + }; + let as_viewer = |job_id: uuid::Uuid| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, bool>( + "SELECT viewer_data_access FROM job_perms WHERE job_id = $1", + ) + .bind(job_id) + .fetch_one(&db) + .await + } + }; + + let inline = execute(json!({ + "args": {}, + "component": "get_email", + "raw_code": { + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" + } + })) + .await?; + assert!(as_viewer(inline).await?); + + let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; + assert!( + !as_viewer(path).await?, + "a path runnable connected as the viewer because its policy entry said so" + ); + Ok(()) +} diff --git a/backend/tests/job_s3_viewer_data_access.rs b/backend/tests/job_s3_viewer_data_access.rs new file mode 100644 index 0000000000..a68b695259 --- /dev/null +++ b/backend/tests/job_s3_viewer_data_access.rs @@ -0,0 +1,112 @@ +//! A job whose `job_perms.viewer_data_access` is set reads workspace object storage as its viewer: +//! the advanced permission rules are checked against the viewer, not the job's own identity, and +//! such a job with no viewer is refused rather than checked as itself. +//! +//! Advanced S3 permissions are an enterprise feature. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +/// A job of `test-user-3`, run for `viewer`, reading data as that viewer or not. +async fn job_token( + db: &Pool, + viewer: Option<&str>, + viewer_data_access: bool, +) -> anyhow::Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag) + VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev', + 'script', 'deno', 'u/test-user-3/app_runnable', 'deno')", + ) + .bind(id) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, end_user_email, viewer_data_access) + VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}', + 'test-workspace', $2, $3)", + ) + .bind(id) + .bind(viewer) + .bind(viewer_data_access) + .execute(db) + .await?; + Ok(windmill_common::auth::create_token_for_owner( + db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &id, + None, + None, + ) + .await?) +} + +#[sqlx::test(fixtures("base"))] +async fn a_job_reading_data_as_its_viewer_gets_the_viewers_storage_paths( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let storage_dir = tempfile::tempdir()?; + for user in ["test-user-2", "test-user-3"] { + let dir = storage_dir.path().join("u").join(user); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("file.txt"), user)?; + } + sqlx::query("UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2") + .bind(json!({ + "type": "FilesystemStorage", + "root_path": storage_dir.path().to_string_lossy(), + "public_resource": null, + "advanced_permissions": [{ "pattern": "u/{username}/**", "allow": "read" }] + })) + .bind("test-workspace") + .execute(&db) + .await?; + + let read = |token: String, user: &'static str| async move { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/job_helpers/download_s3_file" + )) + .query(&[("file_key", format!("u/{user}/file.txt"))]) + .bearer_auth(token) + .send() + .await?; + Ok::<_, anyhow::Error>(resp.status().is_success()) + }; + + let own = job_token(&db, Some("test2@windmill.dev"), false).await?; + assert!(read(own.clone(), "test-user-3").await?); + assert!( + !read(own, "test-user-2").await?, + "a job not reading as its viewer reached the viewer's paths" + ); + + let as_viewer = job_token(&db, Some("test2@windmill.dev"), true).await?; + assert!(read(as_viewer.clone(), "test-user-2").await?); + assert!( + !read(as_viewer, "test-user-3").await?, + "a job reading as its viewer kept its own identity's paths" + ); + + let no_viewer = job_token(&db, None, true).await?; + assert!( + !read(no_viewer, "test-user-3").await?, + "a job reading as a viewer it does not have was checked as itself" + ); + Ok(()) +} diff --git a/backend/tests/s3_proxy_headers.rs b/backend/tests/s3_proxy_headers.rs new file mode 100644 index 0000000000..8f43f8c761 --- /dev/null +++ b/backend/tests/s3_proxy_headers.rs @@ -0,0 +1,29 @@ +//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included, +//! since the presigned route serves workspace objects from the app origin without a login. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_s3_proxy_sets_content_security_headers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00" + )) + .send() + .await?; + + assert!(resp.status().is_client_error(), "status: {}", resp.status()); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + resp.headers()["content-security-policy"], + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + ); + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 023d9f20ed..0acba27c41 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1850,10 +1850,12 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database( #[cfg(all(feature = "private", feature = "enterprise"))] #[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] -async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( +async fn a_connection_naming_no_role_holds_the_default_one_and_its_identitys( db: Pool, ) -> anyhow::Result<()> { - use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess}; + use windmill_common::workspaces::{ + get_datatable_resource_from_db, DatatableAccess, DatatableConnectAs, + }; initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; @@ -1869,20 +1871,25 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'"; - // The job is the app publisher's in every case: only the viewer the server recorded differs. - let connect = |role: Option<&'static str>, viewer: Option<&'static str>| { + let connect_job = |owner: &'static str, role: Option<&'static str>, connect_as| { + let (permissioned_as, email) = match owner { + "test-user" => ("u/test-user", "test@windmill.dev"), + "test-user-2" => ("u/test-user-2", "test2@windmill.dev"), + _ => ("u/test-user-3", "test3@windmill.dev"), + }; get_datatable_resource_from_db( &db, "test-workspace", "main", role, - DatatableAccess::PermissionedAs { - permissioned_as: "u/test-user", - email: "test@windmill.dev", - end_user_email: viewer, - }, + DatatableAccess::PermissionedAs { permissioned_as, email, connect_as }, ) }; + // An app published by the workspace admin `test-user`, run by `viewer`, whose runnable + // connects as its viewer. + let connect = |role: Option<&'static str>, viewer: Option<&'static str>| { + connect_job("test-user", role, DatatableConnectAs::Viewer(viewer)) + }; let set_permissions = |default_role: String, roles: Value| { sqlx::query( "UPDATE workspace_settings @@ -1953,11 +1960,25 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( expected ); - // An admin may use every role, the one owning the tables included. As a viewer they add - // what names them, which here is nothing; nor does a job with no viewer recorded. - for viewer in [Some("test@windmill.dev"), None] { - assert_eq!(connect(None, viewer).await?["user"], names[0].as_str()); - } + // A runnable that does not connect as its viewer holds its own identity's roles, whoever + // views it: here the publisher, an admin, who may use every role but adds only what + // names them, which is nothing. + assert_eq!( + connect_job("test-user", None, DatatableConnectAs::Job).await?["user"], + names[0].as_str(), + "the viewer's roles reached a job that does not connect as its viewer" + ); + // A job with no viewer, a schedule say, holds its owner's roles. + assert_eq!( + connect_job("test-user-2", None, DatatableConnectAs::Job).await?, + all + ); + assert_eq!( + connect(None, Some("test@windmill.dev")).await?["user"], + names[0].as_str() + ); + // Connecting as a viewer it does not have is refused rather than made as the job. + assert!(connect(None, None).await.is_err()); // A named role is that role alone, whoever is viewing. let named: &'static str = Box::leak(names[1].clone().into_boxed_str()); assert_eq!( @@ -1978,29 +1999,28 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( two, "no default changes nothing for a viewer the roles name" ); + assert_eq!( + connect_job("test-user-3", None, DatatableConnectAs::Job).await?, + two + ); + assert_eq!( + connect_job("test-user", None, DatatableConnectAs::Job).await?["user"], + names[0].as_str(), + "the role open to everyone names the admin too" + ); assert!(connect(None, None).await.is_err()); // The same holds for a default the job's own identity is not a tenant of. let mut closed = roles.clone(); closed[ids[0].as_str()] = json!({"tenants": []}); set_permissions(ids[0].clone(), closed).await?; - let as_member = |viewer: Option<&'static str>| { - get_datatable_resource_from_db( - &db, - "test-workspace", - "main", - None, - DatatableAccess::PermissionedAs { - permissioned_as: "u/test-user-3", - email: "test3@windmill.dev", - end_user_email: viewer, - }, - ) - }; assert_eq!( - as_member(Some("test3@windmill.dev")).await?["user"], + connect_job("test-user-3", None, DatatableConnectAs::Job).await?["user"], + names[1].as_str() + ); + assert_eq!( + connect(None, Some("test3@windmill.dev")).await?["user"], names[1].as_str() ); - assert!(as_member(None).await.is_err()); set_permissions(ids[0].clone(), roles).await?; // A set login nobody connects with any more is dropped, a recent one is kept, and a diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index c6c2a1fede..330e96c2fb 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -266,6 +266,22 @@ pub async fn run_end_user_email( } } +/// [`run_end_user_email`] of an inline run of the caller's own code, and whether its data table +/// connections are made as that end user: they are when the calling job's are, since the code is +/// that job's own. +pub async fn run_inline_preview_end_user( + db: &DB, + w_id: &str, + authed: &ApiAuthed, +) -> error::Result<(Option, bool)> { + match authed.job_id { + Some(job_id) => Ok(windmill_common::auth::get_job_perms(db, &job_id, w_id) + .await? + .map_or((None, false), |p| (p.end_user_email, p.viewer_data_access))), + None => Ok((Some(authed.email.clone()), false)), + } +} + #[cfg(feature = "enterprise")] pub async fn check_license_key_valid() -> error::Result<()> { use windmill_common::ee_oss::LICENSE_KEY_VALID; diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 1359e8c142..f9ba1ff86f 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -132,6 +132,15 @@ async fn get_instance_ui( )) } +/// Whether email notifications can go out, for the workspace admins who configure email +/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same +/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too. +async fn get_smtp_configured(Extension(db): Extension, _authed: ApiAuthed) -> JsonResult { + Ok(Json( + SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -152,6 +161,7 @@ pub fn global_service() -> Router { ) .route("/instance_config/yaml", get(get_instance_config_yaml)) .route("/test_smtp", post(test_email)) + .route("/smtp_configured", get(get_smtp_configured)) .route("/test_license_key", post(test_license_key)) .route("/send_stats", post(send_stats)) .route("/get_stats", get(get_stats)) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5295c5765f..d124d34a65 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.831.0 + version: 1.833.0 title: Windmill API contact: @@ -1971,6 +1971,24 @@ paths: application/json: schema: {} + /settings/smtp_configured: + get: + summary: whether instance SMTP is configured + description: | + Readable by any authenticated user. True when this build can send email + and SMTP is set, either in the smtp_settings instance setting or through + the SMTP_* environment variables. + operationId: getSmtpConfigured + tags: + - setting + responses: + "200": + description: whether email notifications can be sent + content: + application/json: + schema: + type: boolean + /settings/test_smtp: post: summary: test smtp diff --git a/backend/windmill-api/src/ai_sessions.rs b/backend/windmill-api/src/ai_sessions.rs index 9e980a54f4..097e5c4862 100644 --- a/backend/windmill-api/src/ai_sessions.rs +++ b/backend/windmill-api/src/ai_sessions.rs @@ -24,7 +24,6 @@ use axum::{ Extension, Json, Router, }; use futures::{StreamExt, TryStreamExt}; -use magic_crypt::MagicCryptTrait; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::sync::Arc; @@ -33,9 +32,11 @@ use windmill_api_workspaces::ai_session_backups::{ fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES, }; use windmill_api_workspaces::workspaces::sessions_retention_days; +use windmill_common::db::{process_uptime, PREVIOUS_RELEASE_ROLLOUT_UPTIME}; use windmill_common::error::{Error, JsonResult, Result}; use windmill_common::utils::calculate_hash; use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt}; +use windmill_common::workspace_crypt::{CipherFormat, MAX_BYTES_OVERHEAD}; use windmill_object_store::object_store_reexports::{ ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload, }; @@ -47,9 +48,9 @@ const PULL_BODY_LIMIT: usize = 64 * 1024; /// A pull answer larger than this hands the remaining ids back as `deferred`. const PULL_RESPONSE_BUDGET: usize = 32 * 1024 * 1024; const MAX_HEAD_BYTES: usize = 1024 * 1024; -/// What the cipher adds to a plaintext at most (a block of padding): an object stored at a -/// cap is that much larger than the cap when read back. -const CIPHER_PADDING: usize = 16; +/// What the cipher adds to a plaintext at most: an object stored at a cap is that much larger +/// than the cap when read back. +const CIPHER_PADDING: usize = MAX_BYTES_OVERHEAD; /// The browser bounds an image to a 1568 px edge and re-encodes past 700 KB; this is /// well above what that produces. const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024; @@ -120,6 +121,15 @@ pub fn workspaced_service() -> Router { ) } +fn backup_format(current: CipherFormat, uptime: std::time::Duration) -> CipherFormat { + // A previous-release server reads only CBC here: GCM waits for it to be rolled out. + if current == CipherFormat::V2 && uptime >= PREVIOUS_RELEASE_ROLLOUT_UPTIME { + CipherFormat::V2 + } else { + CipherFormat::LegacyCbc + } +} + /// What reading an object yields. `Gone`: not there (deleted since the listing, or never /// pushed). `Grown`: larger than expected, so replaced since the listing (or planted), and /// left unread; a pull answers with a page ending before it rather than without it. @@ -214,7 +224,8 @@ impl Backend { } fn seal(&self, plaintext: &[u8]) -> Vec { - self.mc.encrypt_bytes_to_bytes(plaintext) + let format = backup_format(CipherFormat::current(), process_uptime()); + self.mc.encrypt_bytes_in(plaintext, format) } /// Bytes written. @@ -255,7 +266,7 @@ impl Backend { // apart beyond the cipher's padding check, which a wrong key passes now and then. match self .mc - .decrypt_bytes_to_bytes(&bytes) + .decrypt_bytes(&bytes) .ok() .and_then(|plaintext| String::from_utf8(plaintext).ok()) { @@ -1632,3 +1643,32 @@ async fn push( results, })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backups_are_written_in_gcm_only_once_the_previous_release_is_rolled_out() { + let just_short = PREVIOUS_RELEASE_ROLLOUT_UPTIME - std::time::Duration::from_secs(1); + for (current, uptime, expected) in [ + ( + CipherFormat::V2, + PREVIOUS_RELEASE_ROLLOUT_UPTIME, + CipherFormat::V2, + ), + (CipherFormat::V2, just_short, CipherFormat::LegacyCbc), + ( + CipherFormat::LegacyCbc, + PREVIOUS_RELEASE_ROLLOUT_UPTIME * 4, + CipherFormat::LegacyCbc, + ), + ] { + assert_eq!( + backup_format(current, uptime), + expected, + "{current:?} {uptime:?}" + ); + } + } +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index db8a38f2dd..4b67f865f9 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -481,6 +481,12 @@ pub struct PolicyTriggerableInputs { sensitive_inputs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] tag: Option, + /// The runnable reads data as the app's viewer rather than as the app's identity: data table + /// connections that name no role, and object storage advanced permission rules. Honored for + /// inline runnables only: their code is deployed with the app, so it is the publisher who + /// chose to read with the viewer's rights. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + viewer_data_access: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -1009,7 +1015,6 @@ fn raw_app_wrapper_html(secret: &str) -> String { -App
- + {#snippet children({ item })} @@ -418,21 +290,21 @@
- {#if handlerSelected === 'custom'} + {#if draft.selected === 'custom'}
- {#if !handlerPath} + {#if !draft.custom.path}
- {#if handlerPath} + {#if draft.custom.path}

Extra arguments

{#await import('$lib/components/SchemaForm.svelte')} @@ -465,7 +337,7 @@ @@ -475,7 +347,7 @@ {/if}
{/if} - {:else if handlerSelected === 'slack'} + {:else if draft.selected === 'slack'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = slackHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = slackRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = slackSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined }} /> {/if} - {#if workspaceConnectedToSlack && isSlackHandler(handlerPath)} + {#if workspaceConnectedToSlack && draft.slack.path}
{#await import('$lib/components/SchemaForm.svelte')} @@ -515,18 +379,18 @@ schemaFieldTooltip={{ channel: 'Slack channel name without the "#" - example: "windmill-alerts"' }} - bind:args={handlerExtraArgs} + bind:args={draft.slack.args} shouldHideNoInputs className="text-xs" /> {/await} - {#if $enterpriseLicense && isSlackHandler(handlerPath)} + {#if $enterpriseLicense} {#if connectionTestJob !== undefined} @@ -559,7 +423,7 @@ {:else if workspaceConnectedToSlack == undefined} {/if} - {:else if handlerSelected === 'teams'} + {:else if draft.selected === 'teams'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = teamsHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = teamsRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = teamsSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined }} /> {/if} @@ -604,26 +460,26 @@ placeholder="Search Teams channels" workspace={effectiveWorkspace} teamId={teams_team_guid} - selectedChannel={handlerExtraArgs['channel'] + selectedChannel={draft.teams.args['channel'] ? { - channel_id: handlerExtraArgs['channel'], - channel_name: handlerExtraArgs['channel_name'] + channel_id: draft.teams.args['channel'], + channel_name: draft.teams.args['channel_name'] } : undefined} onSelectedChannelChange={(channel) => { - handlerExtraArgs['channel'] = channel?.channel_id - handlerExtraArgs['channel_name'] = channel?.channel_name + draft.teams.args['channel'] = channel?.channel_id + draft.teams.args['channel_name'] = channel?.channel_name }} onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)} />
- {#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams} + {#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams} {#if connectionTestJob !== undefined} @@ -649,7 +505,7 @@ {:else if workspaceConnectedToTeams == undefined} {/if} - {:else if handlerSelected === 'email'} + {:else if draft.selected === 'email'} {#if isCloudHosted()} Email notifications for trigger failures are only available in self-hosted Windmill @@ -662,8 +518,8 @@ handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [], - (recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients) + () => draft.email.args.email_recipients ?? [], + (recipients) => (draft.email.args.email_recipients = recipients) } placeholder="Enter email addresses..." onCreateItem={(email) => { @@ -672,23 +528,24 @@ sendUserToast('Invalid email format', true) return } - const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [] - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email] + draft.email.args.email_recipients = [ + ...(draft.email.args.email_recipients ?? []), + email + ] }} class="w-full" /> - {#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0} + {#if draft.email.args.email_recipients?.length > 0} - {handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[ - EMAIL_RECIPIENTS_KEY - ]?.length === 1 + {draft.email.args.email_recipients.length} email{draft.email.args.email_recipients + .length === 1 ? '' : 's'} configured {/if}
{/if} - {:else if handlerSelected === 'instance_alerts'} + {:else if draft.selected === 'instance_alerts'}
Failed jobs are reported to the Slack, Teams and email channels configured at the instance diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 1a468ac33a..17f984bac9 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -40,7 +40,7 @@ import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import PathEditPopover from '$lib/components/PathEditPopover.svelte' - import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -230,19 +230,14 @@ let pathSnapshot = $state(undefined) let topbarWidth = $state(0) - // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: - // the trail and the summary take ~560px before either truncates, and the full group — the - // dropdown, Diff, the preview trio and Deploy — is ~610px. - const compactBelow = $derived(ownsPageHeader ? 1200 : 720) - const compactTopbar = $derived.by(() => { - const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth - return w > 0 && w < compactBelow - }) - // A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons - // stand down, since there is no room to run a flow beside its own name. - const phoneTopbar = $derived( - ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 ) + // The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons + // stand down, since there is no room to run a flow beside its own name. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) const diffEnabled = $derived(customUi?.topBar?.diff != false) // Nothing to compare against until a deployed version exists. @@ -1588,6 +1583,7 @@ // Frozen while the rename popover is open so the trail holds still as the user types. kind: 'flow', path: pathSnapshot ?? $pathStore, + summary: flowStore.val.summary, summaryContent: flowSummary, // Gated like the summary's own editor: a host that allows neither field keeps the // band's segment, which copies the path. @@ -1597,6 +1593,7 @@ : undefined }} actions={flowHeaderActions} + actionFolds={2} contexts={headerContexts} separator="none" /> diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index b053591fc0..4df66e0362 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line. import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte' import { Menu, Menubar } from '$lib/components/meltComponents' import MeltButton from '$lib/components/meltComponents/MeltButton.svelte' - import BreadcrumbItemContent from './BreadcrumbItemContent.svelte' import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte' import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores' @@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line. import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker' import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' import type { Snippet } from 'svelte' + import { + BAR_STAGES, + FLOOR, + shownPathLevels, + type Part, + type TrailMeasures, + type TrailStage + } from './pageHeaderStages' let { item, section, afterName, actingWorkspaceId, - narrow = false, - nameOnly = false + nameOnly = false, + stage = BAR_STAGES[0], + caps = {}, + scopesGive = false, + measures = $bindable() }: { item?: PageHeaderItem section?: PageHeaderSection @@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line. afterName?: Snippet /** The workspace the page acts on, when it differs from the one the app is pointed at. */ actingWorkspaceId?: string - /** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the - * line before anything else has given way. The names truncate at every width either way. */ - narrow?: boolean + /** Which parts the bar has dropped for room (see pageHeaderStages). */ + stage?: TrailStage + /** Max widths the bar holds the names to, once dropping whole parts is not enough. */ + caps?: Partial> + /** The folders above the name truncate too: even the name at its last floor does not fit. */ + scopesGive?: boolean + /** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */ + measures?: TrailMeasures /** Only the page's own name, with no workspace part and no picker — the band inside a * session's preview frame, where the workspace is the host's and leading the reader out of * it is exactly what that band must not offer. The page still has to say what it is. */ @@ -145,6 +160,9 @@ not this component) — so the lead can run the whole line. // The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the // last segment only for an item that has no summary to follow it. const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary) + /** Only the summary's text names the item: a control offering to add one does not, and the path + * then stays at every stage. */ + const summaryNamesItem = $derived(!!item?.summary?.trim()) // No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the // fork's own name inside, so the family's name is not painted as if it were the fork. @@ -156,15 +174,8 @@ not this component) — so the lead can run the whole line. twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD) ) - // Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that - // one would push a page's buttons off the end of the bar. const pathSegment = $derived( - twMerge( - SEGMENT, - 'gap-1.5 overflow-hidden', - itemIsLast ? LAST : LEAD, - narrow ? 'max-w-[11rem]' : 'max-w-[16rem]' - ) + twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD) ) // Copying is the one thing a segment does that leaves no mark on the page, so it is the one // without a hover fill: the pointer and the confirmation under it are the affordance. A segment @@ -191,6 +202,18 @@ not this component) — so the lead can run the whole line. const scopeKind = $derived(item?.path?.split('/')[0]) /** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */ const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean)) + + // The off-screen copies stand in a fixed-width box for what a page renders into the trail: a + // second render of a page's snippet would be a second live copy of its controls. + let afterNameWidth = $state(0) + let sectionContentWidth = $state(0) + + /** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */ + function capStyle(part: Part, measure: boolean): string | undefined { + if (measure) return `max-width: ${FLOOR[part]}px` + const cap = caps[part] + return cap === undefined ? undefined : `max-width: ${cap}px` + } {#snippet workspaceDisc()} @@ -241,89 +264,129 @@ not this component) — so the lead can run the whole line. {/snippet} -