diff --git a/backend/.sqlx/query-44866e040f382b5e12aa21595bd1452aae8ca746348a6ac6b5bba26f5249166f.json b/backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json similarity index 78% rename from backend/.sqlx/query-44866e040f382b5e12aa21595bd1452aae8ca746348a6ac6b5bba26f5249166f.json rename to backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json index 212f6f067b..e66aabbcfb 100644 --- a/backend/.sqlx/query-44866e040f382b5e12aa21595bd1452aae8ca746348a6ac6b5bba26f5249166f.json +++ b/backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT c.workspace_id, c.agent_path, c.is_default FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2 ORDER BY c.agent_path", + "query": "SELECT c.workspace_id, c.agent_path, c.is_default FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2\n AND ws.workspace_id = ANY($3)\n ORDER BY c.agent_path", "describe": { "columns": [ { @@ -22,7 +22,8 @@ "parameters": { "Left": [ "Text", - "Text" + "Text", + "TextArray" ] }, "nullable": [ @@ -31,5 +32,5 @@ false ] }, - "hash": "44866e040f382b5e12aa21595bd1452aae8ca746348a6ac6b5bba26f5249166f" + "hash": "19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3" } diff --git a/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json b/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json new file mode 100644 index 0000000000..027085c2a7 --- /dev/null +++ b/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, slack_oauth_client_id IS NOT NULL AS \"own_app!\",\n slack_signing_secret\n FROM workspace_settings WHERE slack_team_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + true + ] + }, + "hash": "35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879" +} diff --git a/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json b/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json new file mode 100644 index 0000000000..6c40f884ef --- /dev/null +++ b/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\",\n slack_signing_secret IS NOT NULL AS \"own_secret!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "own_secret!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed" +} diff --git a/backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json b/backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json deleted file mode 100644 index b3482a1535..0000000000 --- a/backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL\n WHERE workspace_id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba" -} diff --git a/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json b/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json new file mode 100644 index 0000000000..a173674b29 --- /dev/null +++ b/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", slack_signing_secret\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + true + ] + }, + "hash": "74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05" +} diff --git a/backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json b/backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json similarity index 63% rename from backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json rename to backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json index 04b960b81a..0481fb751c 100644 --- a/backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json +++ b/backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, slack_command_script, slack_email FROM workspace_settings WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL", + "query": "SELECT workspace_id, slack_command_script, slack_email FROM workspace_settings\n WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL\n AND workspace_id = ANY($2)\n ORDER BY workspace_id = ANY($3) DESC LIMIT 1", "describe": { "columns": [ { @@ -21,7 +21,9 @@ ], "parameters": { "Left": [ - "Text" + "Text", + "TextArray", + "TextArray" ] }, "nullable": [ @@ -30,5 +32,5 @@ false ] }, - "hash": "b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a" + "hash": "8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da" } diff --git a/backend/.sqlx/query-af9c259d71af873d81ad788ab05cd0b52f561bfaac0d4e8d17b1aa0900dcc3bc.json b/backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json similarity index 65% rename from backend/.sqlx/query-af9c259d71af873d81ad788ab05cd0b52f561bfaac0d4e8d17b1aa0900dcc3bc.json rename to backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json index c15c64b3cc..c8c440ac5f 100644 --- a/backend/.sqlx/query-af9c259d71af873d81ad788ab05cd0b52f561bfaac0d4e8d17b1aa0900dcc3bc.json +++ b/backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT c.agent_path FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2 ORDER BY c.agent_path LIMIT 11", + "query": "SELECT c.agent_path FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2\n AND ws.workspace_id = ANY($3)\n ORDER BY c.agent_path LIMIT 11", "describe": { "columns": [ { @@ -12,12 +12,13 @@ "parameters": { "Left": [ "Text", - "Text" + "Text", + "TextArray" ] }, "nullable": [ false ] }, - "hash": "af9c259d71af873d81ad788ab05cd0b52f561bfaac0d4e8d17b1aa0900dcc3bc" + "hash": "acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf" } diff --git a/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json b/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json new file mode 100644 index 0000000000..dfa1689d15 --- /dev/null +++ b/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2" +} diff --git a/backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json b/backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json similarity index 51% rename from backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json rename to backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json index 8025c7654c..fa44c1e169 100644 --- a/backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json +++ b/backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json @@ -1,16 +1,14 @@ { "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2\n WHERE workspace_id = $3", + "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL,\n slack_signing_secret = NULL\n WHERE workspace_id = $1", "describe": { "columns": [], "parameters": { "Left": [ - "Varchar", - "Varchar", "Text" ] }, "nullable": [] }, - "hash": "f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00" + "hash": "cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92" } diff --git a/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json b/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json new file mode 100644 index 0000000000..189593b34c --- /dev/null +++ b/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2,\n slack_signing_secret = CASE WHEN slack_oauth_client_id = $1::varchar\n THEN slack_signing_secret END\n WHERE workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f" +} diff --git a/backend/.sqlx/query-f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397.json b/backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json similarity index 58% rename from backend/.sqlx/query-f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397.json rename to backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json index 30ec07404b..58f404a64d 100644 --- a/backend/.sqlx/query-f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397.json +++ b/backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"\n FROM workspace_settings WHERE slack_team_id = $1\n ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2", + "query": "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"\n FROM workspace_settings ws WHERE slack_team_id = $1\n AND ws.workspace_id = ANY($2)\n ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2", "describe": { "columns": [ { @@ -16,7 +16,8 @@ ], "parameters": { "Left": [ - "Text" + "Text", + "TextArray" ] }, "nullable": [ @@ -24,5 +25,5 @@ null ] }, - "hash": "f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397" + "hash": "e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2" } diff --git a/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json b/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json new file mode 100644 index 0000000000..a654f614bd --- /dev/null +++ b/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET slack_signing_secret = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87" +} diff --git a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json deleted file mode 100644 index a1d873778f..0000000000 --- a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842" -} diff --git a/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json b/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json new file mode 100644 index 0000000000..3b0dbb2f41 --- /dev/null +++ b/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3b07694533..66b973f850 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b3fd7d49f2ba58f5080fabc3a5686c34a0b2684c +c8d562aa9d75a7e99bf0ecd1ac224f532452a07e diff --git a/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql b/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql new file mode 100644 index 0000000000..a93bfaa916 --- /dev/null +++ b/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings DROP COLUMN IF EXISTS slack_signing_secret; diff --git a/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql b/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql new file mode 100644 index 0000000000..2bd78fa59b --- /dev/null +++ b/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS slack_signing_secret TEXT; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 984341c068..3d3a52739e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -113,6 +113,7 @@ pub fn workspaced_service() -> Router { .route("/slack_oauth_config", get(get_slack_oauth_config)) .route("/slack_oauth_config", post(set_slack_oauth_config)) .route("/slack_oauth_config", delete(delete_slack_oauth_config)) + .route("/slack_signing_secret", post(set_slack_signing_secret)) .route("/edit_webhook", post(edit_webhook)) .route("/edit_auto_invite", post(edit_auto_invite)) .route("/edit_instance_groups", post(edit_instance_groups)) @@ -1770,6 +1771,7 @@ struct SetSlackOAuthConfigRequest { struct GetSlackOAuthConfigResponse { slack_oauth_client_id: Option, slack_oauth_client_secret: Option, + slack_signing_secret: Option, } async fn get_slack_oauth_config( @@ -1780,7 +1782,8 @@ async fn get_slack_oauth_config( require_admin(authed.is_admin, &authed.username)?; let settings = sqlx::query!( - "SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", + "SELECT slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret + FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&db) @@ -1794,6 +1797,7 @@ async fn get_slack_oauth_config( Ok(Json(GetSlackOAuthConfigResponse { slack_oauth_client_id: settings.slack_oauth_client_id, slack_oauth_client_secret: masked_secret, + slack_signing_secret: settings.slack_signing_secret.map(|_| "***".to_string()), })) } @@ -1851,8 +1855,11 @@ async fn set_slack_oauth_config( let mut tx = db.begin().await?; sqlx::query!( + // Another app's signing secret would reject every request the new app sends. "UPDATE workspace_settings - SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2 + SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2, + slack_signing_secret = CASE WHEN slack_oauth_client_id = $1::varchar + THEN slack_signing_secret END WHERE workspace_id = $3", &req.slack_oauth_client_id, &req.slack_oauth_client_secret, @@ -1889,6 +1896,51 @@ async fn set_slack_oauth_config( Ok(format!("Slack OAuth config set for workspace {}", &w_id)) } +#[derive(Deserialize)] +struct SetSlackSigningSecretRequest { + slack_signing_secret: Option, +} + +/// The signing secret of the workspace's own Slack app, which authenticates what Slack sends for it. +async fn set_slack_signing_secret( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let secret = req + .slack_signing_secret + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET slack_signing_secret = $1 WHERE workspace_id = $2", + secret, + &w_id + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "workspaces.set_slack_signing_secret", + if secret.is_some() { + ActionKind::Update + } else { + ActionKind::Delete + }, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Slack signing secret updated for workspace {w_id}")) +} + async fn delete_slack_oauth_config( authed: ApiAuthed, Extension(db): Extension, @@ -1900,7 +1952,8 @@ async fn delete_slack_oauth_config( sqlx::query!( "UPDATE workspace_settings - SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL + SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL, + slack_signing_secret = NULL WHERE workspace_id = $1", &w_id ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index f3618c81eb..64aa5c6a08 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -135,7 +135,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c4c09b5761..edbd4165d9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4824,6 +4824,10 @@ paths: type: string nullable: true description: Masked with *** if set + slack_signing_secret: + type: string + nullable: true + description: Masked with *** if set post: summary: set workspace slack oauth config operationId: setWorkspaceSlackOauthConfig @@ -4868,6 +4872,33 @@ paths: schema: type: string + /w/{workspace}/workspaces/slack_signing_secret: + post: + summary: set the signing secret of the workspace's own slack app + operationId: setWorkspaceSlackSigningSecret + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: The Slack app's signing secret, null or empty to remove it + required: true + content: + application/json: + schema: + type: object + properties: + slack_signing_secret: + type: string + nullable: true + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/edit_teams_command: post: summary: edit teams command diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index a4fa57ba3f..446a585d35 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -9,8 +9,6 @@ use crate::db::ApiAuthed; #[cfg(feature = "embedding")] use crate::embeddings::load_embeddings_db; -#[cfg(feature = "oauth2")] -use crate::oauth2_oss::SlackVerifier; #[cfg(feature = "smtp")] use crate::smtp_server_oss::SmtpServer; use axum::middleware::from_fn_with_state; @@ -250,10 +248,6 @@ pub use windmill_oauth::OAUTH_CLIENTS; #[cfg(feature = "oauth2")] lazy_static::lazy_static! { - pub static ref SLACK_SIGNING_SECRET: Option = std::env::var("SLACK_SIGNING_SECRET") - .ok() - .map(|x| SlackVerifier::new(x).unwrap()); - // Comma-separated Slack v2 OAuth bot scopes requested when connecting a workspace. // Must be a subset of the bot scopes declared in the Slack app manifest. Default matches // Windmill's recommended manifest at docs.windmill.dev/docs/misc/setup_oauth. diff --git a/backend/windmill-api/src/oauth2_oss.rs b/backend/windmill-api/src/oauth2_oss.rs index 9b8bcd1294..0513cd5c68 100644 --- a/backend/windmill-api/src/oauth2_oss.rs +++ b/backend/windmill-api/src/oauth2_oss.rs @@ -82,6 +82,7 @@ pub async fn build_oauth_clients( logins: HashMap::default(), connects: HashMap::default(), slack: None, + slack_signing_secret: None, }); } diff --git a/backend/windmill-api/src/slack_agents.rs b/backend/windmill-api/src/slack_agents.rs index 6945955bcb..dae32285df 100644 --- a/backend/windmill-api/src/slack_agents.rs +++ b/backend/windmill-api/src/slack_agents.rs @@ -62,19 +62,190 @@ struct SlackAgentsStatus { signing_secret_set: bool, } -async fn status(_authed: ApiAuthed) -> Json { - Json(SlackAgentsStatus { signing_secret_set: signing_secret_set() }) +async fn status( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + Ok(Json(SlackAgentsStatus { + signing_secret_set: signing_secret_set(&db, &w_id).await?, + })) } -/// Agents answer only when the server verifies Slack's request signatures. -fn signing_secret_set() -> bool { +/// Which of the workspaces connected to a Slack team a request may reach. +#[derive(Clone, Debug, Default)] +pub struct SlackRequest { + /// Those whose app's signing secret verified it or, when none did, those whose app has none + /// set, which take requests unauthenticated. + pub reachable: Vec, + /// Those whose app's signing secret verified it, the only ones agents answer for. + pub verified: Vec, +} + +fn env_signing_secret() -> Option { + std::env::var("SLACK_SIGNING_SECRET") + .ok() + .filter(|s| !s.is_empty()) +} + +fn instance_signing_secrets() -> Vec { #[cfg(feature = "oauth2")] { - crate::SLACK_SIGNING_SECRET.is_some() + env_signing_secret() + .into_iter() + .chain(crate::OAUTH_CLIENTS.load().slack_signing_secret.clone()) + .filter(|s| !s.is_empty()) + .collect() } #[cfg(not(feature = "oauth2"))] { - false + vec![] + } +} + +/// Agents answer only when the server verifies Slack's request signatures, with the secret of the +/// app the workspace is connected through: its own app's, or else the instance's. +async fn signing_secret_set(db: &DB, w_id: &str) -> Result { + let own_app = sqlx::query!( + "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", + slack_signing_secret IS NOT NULL AS \"own_secret!\" + FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await?; + Ok(match own_app { + Some(r) if r.own_app => r.own_secret || env_signing_secret().is_some(), + _ => !instance_signing_secrets().is_empty(), + }) +} + +/// Whether a Slack request was signed by the app a workspace is connected through: `None` when that +/// app has no secret set, which leaves the request unauthenticated. A workspace's own app is checked +/// against its own secret, never the instance setting's: the instance's app signs nothing for it, so +/// setting an instance secret must not start rejecting its requests. Without its own secret it falls +/// back to `SLACK_SIGNING_SECRET`, which may hold that app's secret from before workspaces had one. +#[cfg(feature = "oauth2")] +fn workspace_verified( + own_app: bool, + own_secret: Option, + instance_verified: Option, + matches: impl Fn(&str) -> bool, +) -> Option { + if own_app { + own_secret.or_else(env_signing_secret).map(|s| matches(&s)) + } else { + instance_verified + } +} + +#[cfg(feature = "oauth2")] +fn slack_signature_matcher<'a>( + sig: &'a str, + ts: &'a str, + body: &'a str, +) -> impl Fn(&str) -> bool + 'a { + // Slack's recommended replay window: a captured request stops verifying after it. + let fresh = ts + .parse::() + .is_ok_and(|t| chrono::Utc::now().timestamp().abs_diff(t) <= 5 * 60); + move |secret: &str| { + fresh + && windmill_oauth::SlackVerifier::new(secret) + .is_ok_and(|v| v.verify(ts, body, sig).is_ok()) + } +} + +#[cfg(feature = "oauth2")] +fn instance_verified(matches: &impl Fn(&str) -> bool) -> Option { + let instance = instance_signing_secrets(); + (!instance.is_empty()).then(|| instance.iter().any(|s| matches(s))) +} + +#[cfg(feature = "oauth2")] +fn signature_rejected() -> Result { + Err(Error::BadRequest( + "Slack signature verification failed".to_string(), + )) +} + +/// Checks a Slack request, for each workspace connected to `team_id`, against the signing secret +/// of the app it is connected through (see [`workspace_verified`]). For a team no workspace is +/// connected to, only the instance's secret applies. An error when the request can reach no +/// workspace. +#[cfg(feature = "oauth2")] +pub async fn verify_slack_request( + db: &DB, + team_id: &str, + sig: &str, + ts: &str, + body: &str, +) -> Result { + let matches = slack_signature_matcher(sig, ts, body); + let instance_verified = instance_verified(&matches); + + let workspaces = sqlx::query!( + "SELECT workspace_id, slack_oauth_client_id IS NOT NULL AS \"own_app!\", + slack_signing_secret + FROM workspace_settings WHERE slack_team_id = $1", + team_id + ) + .fetch_all(db) + .await?; + if workspaces.is_empty() { + return match instance_verified { + Some(false) => signature_rejected(), + _ => Ok(SlackRequest::default()), + }; + } + let mut req = SlackRequest::default(); + for w in workspaces { + let verified = workspace_verified( + w.own_app, + w.slack_signing_secret, + instance_verified, + &matches, + ); + if verified != Some(false) { + req.reachable.push(w.workspace_id.clone()); + } + if verified == Some(true) { + req.verified.push(w.workspace_id); + } + } + if req.reachable.is_empty() { + return signature_rejected(); + } + // A matching secret proves which app sent the request, so a workspace that merely has no secret + // to rule it out must not take it. + if !req.verified.is_empty() { + req.reachable = req.verified.clone(); + } + Ok(req) +} + +/// Checks a Slack approval callback sent through `w_id`'s Slack connection against the secret of +/// the app it is connected through (see [`workspace_verified`]). +#[cfg(feature = "oauth2")] +pub async fn verify_approval_slack_request( + db: &DB, + w_id: &str, + sig: &str, + ts: &str, + body: &str, +) -> Result<()> { + let matches = slack_signature_matcher(sig, ts, body); + let (own_app, own_secret) = sqlx::query!( + "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", slack_signing_secret + FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .map_or((false, None), |s| (s.own_app, s.slack_signing_secret)); + match workspace_verified(own_app, own_secret, instance_verified(&matches), &matches) { + Some(false) => signature_rejected(), + _ => Ok(()), } } @@ -199,7 +370,7 @@ async fn get_agent_slack( }; Ok(Json(AgentSlack { slack_team_name, - signing_secret_set: signing_secret_set(), + signing_secret_set: signing_secret_set(&db, &w_id).await?, bot_name, missing_scopes: missing_scopes.unwrap_or_default(), runs_as, @@ -454,9 +625,9 @@ pub struct SlackMessage { pub text: String, pub ts: String, pub thread_ts: Option, - /// Whether this request's Slack signature was verified. Carried from the check rather than - /// re-read from the setting, which can be enabled between the two. - pub signed: bool, + /// The workspaces whose Slack app's signing secret verified this request. Carried from the + /// check rather than re-read from the settings, which can change between the two. + pub verified: Vec, } enum Route { @@ -477,7 +648,7 @@ struct SharedHere { /// `false` leaves the message to the workspace's command script. pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result { // Without it the Slack endpoints take unsigned requests, and anyone could post as a channel. - if !msg.signed { + if msg.verified.is_empty() { return Ok(false); } let text = decode_entities(&msg.text); @@ -490,9 +661,12 @@ pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result { "SELECT c.workspace_id, c.agent_path, c.is_default FROM slack_agent_channel c JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id AND ws.slack_team_id = c.slack_team_id - WHERE c.slack_team_id = $1 AND c.channel_id = $2 ORDER BY c.agent_path", + WHERE c.slack_team_id = $1 AND c.channel_id = $2 + AND ws.workspace_id = ANY($3) + ORDER BY c.agent_path", msg.team_id, - msg.channel_id + msg.channel_id, + &msg.verified ) .fetch_all(db) .await?; @@ -543,20 +717,22 @@ pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result { "channel_default" }, ), - None => match workspace_without_command_script(db, &msg.team_id).await? { - Some(w_id) => { - return start_answer( - db, - w_id, - Route::Help { note: None }, - "help", - question, - msg, - ) - .await; + None => { + match workspace_without_command_script(db, &msg.team_id, &msg.verified).await? { + Some(w_id) => { + return start_answer( + db, + w_id, + Route::Help { note: None }, + "help", + question, + msg, + ) + .await; + } + None => return Ok(false), } - None => return Ok(false), - }, + } }, }; let w_id = here.workspace_id.clone(); @@ -671,12 +847,18 @@ fn decode_entities(text: &str) -> String { /// The team's workspace when no workspace of the team has a command script to take the message: /// the only one connected to it. -async fn workspace_without_command_script(db: &DB, team_id: &str) -> Result> { +async fn workspace_without_command_script( + db: &DB, + team_id: &str, + verified: &[String], +) -> Result> { let rows = sqlx::query!( "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\" - FROM workspace_settings WHERE slack_team_id = $1 + FROM workspace_settings ws WHERE slack_team_id = $1 + AND ws.workspace_id = ANY($2) ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2", - team_id + team_id, + verified ) .fetch_all(db) .await?; @@ -692,9 +874,12 @@ async fn help_text(db: &DB, msg: &SlackMessage, note: Option) -> Result< "SELECT c.agent_path FROM slack_agent_channel c JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id AND ws.slack_team_id = c.slack_team_id - WHERE c.slack_team_id = $1 AND c.channel_id = $2 ORDER BY c.agent_path LIMIT 11", + WHERE c.slack_team_id = $1 AND c.channel_id = $2 + AND ws.workspace_id = ANY($3) + ORDER BY c.agent_path LIMIT 11", msg.team_id, - msg.channel_id + msg.channel_id, + &msg.verified ) .fetch_all(db) .await?; diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs index 204b30010d..8635cc3e1e 100644 --- a/backend/windmill-api/src/slack_approvals.rs +++ b/backend/windmill-api/src/slack_approvals.rs @@ -138,37 +138,70 @@ struct PrivateMetadata { skin: ApprovalSkin, } -// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify -// the Slack request signature (which also defeats replay). It is NOT the primary defense: -// the secret is unset in the default deployment, so authorization of the sensitive actions -// is instead anchored on a per-workspace HMAC over the callback payload itself (see -// `verify_slack_payload`), which holds even when this check is a no-op. -#[cfg(feature = "oauth2")] -fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> { - if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() { - let sig = headers - .get("X-Slack-Signature") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let ts = headers - .get("X-Slack-Request-Timestamp") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - sv.verify(ts, body, sig) - .map_err(|_| Error::BadRequest("Slack signature verification failed".to_string()))?; +/// Slack's signature over a callback, checked once the workspace it belongs to is known. +#[cfg_attr(not(feature = "oauth2"), allow(dead_code))] +struct SlackCallbackSignature { + sig: String, + ts: String, + body: String, +} + +impl SlackCallbackSignature { + fn new(headers: &HeaderMap, body: &str) -> Self { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + Self { + sig: header("X-Slack-Signature"), + ts: header("X-Slack-Request-Timestamp"), + body: body.to_string(), + } } - Ok(()) +} + +// Transport-level check, which also defeats replay. It is NOT the primary defense: authorization of +// the sensitive actions is anchored on a per-workspace HMAC over the callback payload itself (see +// `verify_slack_payload`). Only an approval sent through the workspace's Slack connection comes from +// an app whose secret Windmill knows; any other resource may belong to any Slack app, so its +// callbacks are left to the HMAC. +#[cfg(feature = "oauth2")] +async fn verify_slack_callback_signature( + db: &DB, + w_id: &str, + resource_path: &str, + request: &SlackCallbackSignature, +) -> Result<(), Error> { + if resource_path != windmill_common::oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH { + return Ok(()); + } + crate::slack_agents::verify_approval_slack_request( + db, + w_id, + &request.sig, + &request.ts, + &request.body, + ) + .await } #[cfg(not(feature = "oauth2"))] -fn verify_slack_callback_signature(_headers: &HeaderMap, _body: &str) -> Result<(), Error> { +async fn verify_slack_callback_signature( + _db: &DB, + _w_id: &str, + _resource_path: &str, + _request: &SlackCallbackSignature, +) -> Result<(), Error> { Ok(()) } /// HMAC keyed on the per-workspace encryption key (the same trust anchor as resume-URL /// signatures). Used to authenticate the `/api/slack` callback payload itself so the /// unauthenticated route cannot be driven into decrypting arbitrary workspace variables, -/// regardless of whether `SLACK_SIGNING_SECRET` is configured. +/// whether or not a Slack signing secret is set. type SlackPayloadHmac = Hmac; /// Domain-separation tag prepended to every Slack-payload MAC. The workspace key is also used @@ -232,8 +265,7 @@ pub async fn slack_app_callback_handler( headers: HeaderMap, body: Bytes, ) -> Result { - let body_str = String::from_utf8_lossy(&body); - verify_slack_callback_signature(&headers, &body_str)?; + let request = SlackCallbackSignature::new(&headers, &String::from_utf8_lossy(&body)); let form_data: SlackFormData = serde_urlencoded::from_bytes(&body) .map_err(|e| Error::BadRequest(format!("invalid form data: {}", e)))?; @@ -244,10 +276,10 @@ pub async fn slack_app_callback_handler( match payload.r#type { PayloadType::ViewSubmission => { - handle_submission(authed, opt_tokened, db, &payload, "resume").await? + handle_submission(authed, opt_tokened, db, &payload, &request, "resume").await? } PayloadType::ViewClosed => { - handle_submission(authed, opt_tokened, db, &payload, "cancel").await? + handle_submission(authed, opt_tokened, db, &payload, &request, "cancel").await? } _ => { if let Some(actions) = &payload.actions { @@ -274,8 +306,8 @@ pub async fn slack_app_callback_handler( // payload was minted by `send_slack_message` with an HMAC over // (w_id, job_id, path) keyed on the workspace key. Without a valid // signature an unauthenticated caller cannot reach the decryption - // below for an arbitrary variable, even when SLACK_SIGNING_SECRET - // is unset. + // below for an arbitrary variable, even when no Slack signing + // secret is set. verify_slack_payload( &db, w_id, @@ -283,6 +315,7 @@ pub async fn slack_app_callback_handler( parsed_value.signature.as_deref(), ) .await?; + verify_slack_callback_signature(&db, w_id, path, &request).await?; // Map any lookup/decryption failure to a generic error: the // raw error echoes the probed `path`/`w_id` back, which would be @@ -411,6 +444,7 @@ async fn handle_submission( opt_tokened: OptTokened, db: DB, payload: &Payload, + request: &SlackCallbackSignature, action: &str, ) -> Result<(), Error> { let view = payload @@ -455,6 +489,7 @@ async fn handle_submission( // the resume/cancel and reach the decryption below with a swapped path. Require the // workspace-keyed HMAC minted when the modal was built. verify_slack_payload(&db, w_id, &[resource_path.as_bytes()], signature.as_deref()).await?; + verify_slack_callback_signature(&db, w_id, &resource_path, request).await?; // Use the common handler to process the resume/cancel action handle_resume_action( diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index bcee690373..b90645c2b9 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1231,15 +1231,17 @@ fn mask_nested_sensitive(key: &str, value: &serde_json::Value) -> serde_json::Va } } // Settings that are maps-of-objects where each child has a sensitive sub-field. - const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[("oauths", "secret")]; - for &(parent_key, child_field) in NESTED_MAP_SENSITIVE { + const NESTED_MAP_SENSITIVE: &[(&str, &[&str])] = &[("oauths", &["secret", "signing_secret"])]; + for &(parent_key, child_fields) in NESTED_MAP_SENSITIVE { if key == parent_key { if let serde_json::Value::Object(entries) = value { let mut masked = entries.clone(); for (_entry_key, entry_val) in masked.iter_mut() { if let serde_json::Value::Object(ref mut obj) = entry_val { - if let Some(v) = obj.get(child_field) { - obj.insert(child_field.to_string(), redact_json_value(v)); + for &child_field in child_fields { + if let Some(v) = obj.get(child_field) { + obj.insert(child_field.to_string(), redact_json_value(v)); + } } } } @@ -2912,10 +2914,12 @@ mod tests { #[test] fn format_setting_value_redacts_oauth_secrets() { let val = serde_json::json!({ - "google": {"id": "client-id", "secret": "my-super-secret-12345"} + "google": {"id": "client-id", "secret": "my-super-secret-12345"}, + "slack": {"id": "s", "secret": "s", "signing_secret": "slack-signing-secret-678"} }); let formatted = format_setting_value("oauths", &val); assert!(!formatted.contains("my-super-secret-12345")); + assert!(!formatted.contains("slack-signing-secret-678")); assert!(formatted.contains("client-id")); assert!(formatted.contains("****")); } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index a30d6007f8..3b32f0436f 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -51,7 +51,8 @@ lazy_static::lazy_static! { pub static ref OAUTH_CLIENTS: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee(AllClients { logins: HashMap::new(), connects: HashMap::new(), - slack: None + slack: None, + slack_signing_secret: None, }); } @@ -252,6 +253,13 @@ pub struct OAuthClient { pub tenant: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// The Slack app's signing secret, which authenticates the requests Slack sends Windmill. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "windmill_common::utils::empty_as_none" + )] + pub signing_secret: Option, } impl std::fmt::Debug for OAuthClient { @@ -265,6 +273,10 @@ impl std::fmt::Debug for OAuthClient { .field("login_config", &self.login_config) .field("tenant", &self.tenant) .field("grant_types", &self.grant_types) + .field( + "signing_secret", + &self.signing_secret.as_ref().map(|_| "***"), + ) .finish() } } @@ -286,11 +298,23 @@ fn default_grant_types() -> Vec { } /// Container for all OAuth clients (login, connect, and slack) -#[derive(Debug)] pub struct AllClients { pub logins: BasicClientsMap, pub connects: BasicClientsMap, pub slack: Option, + /// The instance Slack app's signing secret, from its OAuth setting. + pub slack_signing_secret: Option, +} + +impl std::fmt::Debug for AllClients { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AllClients") + .field("logins", &self.logins) + .field("connects", &self.connects) + .field("slack", &self.slack) + .field("slack_signing_secret", &self.slack_signing_secret.as_ref().map(|_| "***")) + .finish() + } } /// Slack token response from OAuth flow @@ -515,6 +539,7 @@ pub async fn build_client_credentials_oauth_client( .map(|e| e.grant_types.clone()) .unwrap_or_else(default_grant_types), tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()), + signing_secret: None, }; let base_url = (**BASE_URL.load()).clone(); diff --git a/frontend/src/lib/components/OAuthSetting.svelte b/frontend/src/lib/components/OAuthSetting.svelte index 0aa1de0025..974cae68e5 100644 --- a/frontend/src/lib/components/OAuthSetting.svelte +++ b/frontend/src/lib/components/OAuthSetting.svelte @@ -1,5 +1,5 @@ + +
+ + Create a Slack app in the Slack API console : pick "From a manifest", then YAML, and paste the manifest below. Then copy the Client ID, + Client Secret and Signing Secret here. Socket Mode and token rotation must stay off. More in the + Windmill docs . + +
+ +
+
diff --git a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte index 83c8fe4217..ee5f761b69 100644 --- a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte +++ b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte @@ -139,9 +139,13 @@ {:else} {#if !signingSecretSet} - The server needs the Slack app's signing secret, so Windmill can tell who sent a - message. An instance admin sets it as the SLACK_SIGNING_SECRET environment - variable. + Windmill needs the Slack app's signing secret to tell who sent a message. + {#if isAdmin} + A workspace's own Slack app takes it in the workspace settings, the instance's app + in the instance settings. + {:else} + A workspace or instance admin can set it. + {/if} {/if} {#if !botName} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d13e213ea4..a51ca2c199 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -97,6 +97,7 @@ } from '$lib/components/sessions/sessionState.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' + import SlackAppSetup from '$lib/components/common/slack/SlackAppSetup.svelte' import { validateWebhookUrl, validateEncryptionKey } from '$lib/validators/workspaceSettings' import DataTableSettings, { convertDataTableSettingsFromBackend, @@ -180,11 +181,12 @@ // links here with `agents=true`. let slackAgents = $state($page.url.searchParams.get('agents') === 'true') let slackAppType: 'instance' | 'workspace' = $state('instance') - // Agents verify Slack's requests with the server's signing secret, which only the instance's - // Slack app is signed with, so the toggle is offered for that app alone. - let slackAgentsScopes = $derived(slackAgents && slackAppType === 'instance') + // Agents answer only once Windmill can verify Slack's requests with the signing secret of the + // app the workspace connects through. + let slackSigningSecret: string = $state('') + let slackSigningSecretSet: boolean = $state(false) const slackAgentsStatus = resource( - () => (slackAgentsScopes ? $workspaceStore : undefined), + () => (slackAgents ? $workspaceStore : undefined), async (workspace) => workspace ? await SlackAgentsService.getSlackAgentsStatus({ workspace }) : undefined ) @@ -863,6 +865,7 @@ slackOAuthClientId = config.slack_oauth_client_id || '' slackOAuthClientSecret = config.slack_oauth_client_secret || '' slackOAuthConfigLoaded = !!config.slack_oauth_client_id + slackSigningSecretSet = !!config.slack_signing_secret } catch (e) { console.error('Failed to load Slack OAuth config:', e) } @@ -898,15 +901,38 @@ slack_oauth_client_secret: slackOAuthClientSecret } }) + if (slackSigningSecret.trim()) { + await WorkspaceService.setWorkspaceSlackSigningSecret({ + workspace: $workspaceStore, + requestBody: { slack_signing_secret: slackSigningSecret } + }) + } // Redirect to OAuth flow - window.location.href = `/api/oauth/connect_slack?workspace=${$workspaceStore}` + window.location.href = `/api/oauth/connect_slack?workspace=${$workspaceStore}${slackAgents ? '&agents=true' : ''}` } catch (e) { sendUserToast('Failed to save Slack OAuth configuration', true) console.error(e) } } + async function saveSlackSigningSecret(): Promise { + if (!$workspaceStore) return + const secret = slackSigningSecret.trim() || null + try { + await WorkspaceService.setWorkspaceSlackSigningSecret({ + workspace: $workspaceStore, + requestBody: { slack_signing_secret: secret } + }) + slackSigningSecret = '' + slackSigningSecretSet = !!secret + sendUserToast(secret ? 'Signing secret saved' : 'Signing secret removed') + } catch (e) { + sendUserToast('Failed to save the signing secret', true) + console.error(e) + } + } + async function deleteSlackOAuthConfig(): Promise { if (!$workspaceStore) return @@ -924,6 +950,7 @@ slackOAuthClientId = '' slackOAuthClientSecret = '' slackOAuthConfigLoaded = false + slackSigningSecretSet = false await loadSettings() sendUserToast('Workspace Slack app deleted') } catch (e) { @@ -1748,7 +1775,7 @@ } }} onSelect={editSlackCommand} - connectHref="/api/oauth/connect_slack{slackAgentsScopes ? '?agents=true' : ''}" + connectHref="/api/oauth/connect_slack{slackAgents ? '?agents=true' : ''}" createScriptHref="/scripts/add?hub=hub%2F28071%2Fslack%2Fexample_of_responding_to_a_slack_command_slack" createFlowHref="/flows/add?hub=28" documentationLink="https://www.windmill.dev/docs/integrations/slack" @@ -1778,27 +1805,24 @@ {/snippet}
Use the Slack app configured at the instance level if you want to use - the same Slack app for all workspaces. Configure your Slack app here - if you want to use a specific Slack app for this workspace.
The instance app is shared by all workspaces. A workspace app is your + own, for this workspace only. - {#if slackAppType === 'instance'} - - {#if slackAgentsStatus.current?.signing_secret_set === false} - - An instance admin needs to set the Slack app's signing secret as the - SLACK_SIGNING_SECRET environment variable. - - {/if} + + {#if slackAppType === 'instance' && slackAgentsStatus.current?.signing_secret_set === false} + + An instance admin needs to set the Slack app's signing secret in the + instance settings. + {/if} {/if} @@ -1817,6 +1841,32 @@ >Client ID for the Slack app configured at the workspace level +
+
Signing secret
+
+ + +
+
Lets Windmill check that requests come from Slack. Required for AI + agents; recommended for /windmill commands.
+
{:else if slackAppType === 'workspace'}
+ + -
- Create a Slack app at{' '} - - Slack API - . Set the redirect URI to:{' '} - - {window.location.origin}/oauth/callback_slack - -
+
@@ -1863,7 +1915,9 @@ size="xs" variant="accent" onclick={saveAndConnectSlack} - disabled={!slackOAuthClientId || !slackOAuthClientSecret} + disabled={!slackOAuthClientId || + !slackOAuthClientSecret || + (slackAgents && !slackSigningSecret.trim())} startIcon={{ icon: Slack }} btnClasses="w-fit" >