From 34de8bc06d2b04f7929bb8cf35f9a34d0e0071fa Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 22 Oct 2026 13:51:09 +0200 Subject: [PATCH] feat: slack agents through a workspace's own slack app (#11912) * feat: let Slack agents answer through a workspace's own Slack app, with signing secrets set from the UI The instance Slack app's signing secret can be set in its OAuth setting as well as SLACK_SIGNING_SECRET, and a workspace's own Slack app takes its own in the workspace settings. Each secret only vouches for the workspaces connected through its app, so /windmill keeps working for a workspace whose app has none set. Co-Authored-By: Claude Opus 5.5 * fix: keep Slack signing secrets out of logs and verify an own app without a secret with the instance's Co-Authored-By: Claude Opus 5.5 * fix: report a workspace app's own signing secret in the Slack agents status Co-Authored-By: Claude Opus 5.5 * fix: check Slack approval callbacks against the instance signing secret only An approval can be sent through any Slack resource, so the team's workspace connections do not tell which app signed its callback. Co-Authored-By: Claude Opus 5.5 * fix: share the Slack app setup instructions and require the signing secret for agents Co-Authored-By: Claude Opus 5.5 * fix: tidy the Slack signing secret copy and show a set secret as masked Co-Authored-By: Claude Opus 5.5 * fix: check a workspace app only against its own signing secret, and approvals against their workspace's Co-Authored-By: Claude Opus 5.5 * fix: accept a Slack approval signed by the workspace's app or the instance's Co-Authored-By: Claude Opus 5.5 * fix: check Slack's signature on approvals only when sent through the workspace's Slack connection Any other Slack resource may belong to any Slack app, so its callbacks rest on the payload HMAC alone. Co-Authored-By: Claude Opus 5.5 * fix: address review findings on Slack signature checks and the signing secret field - reject Slack signatures older than five minutes, so a captured request cannot be replayed - let a reachable workspace's command script take a message before another workspace's help reply - remove a saved workspace signing secret from its field: the button reads Remove when it is empty - set the signing secret on connect only when one was entered - tell non-admins who can set the signing secret Co-Authored-By: Claude Opus 5.5 * fix: keep SLACK_SIGNING_SECRET for workspace apps without a secret, and route a verified request only to its workspaces - a workspace on its own app with no secret of its own is checked against SLACK_SIGNING_SECRET, which may hold that app's secret from before workspaces had one; the instance setting's secret still never applies to it - once a secret verifies a request, only the workspaces it verified take it, command scripts included: it proves which app sent the request - compute the replay window with abs_diff, which cannot overflow Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to c8d562aa9d75a7e99bf0ecd1ac224f532452a07e This commit updates the EE repository reference after PR #907 was merged in windmill-ee-private. Previous ee-repo-ref: 0864b0ab943defa8b8d55838ef09ab7dacfac17f New ee-repo-ref: c8d562aa9d75a7e99bf0ecd1ac224f532452a07e Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- ...7c20773dd4c44f19b47e73e24438f5c78ab3.json} | 7 +- ...8a907f16d9e88f648705f2372dfe7c0523879.json | 34 +++ ...b571b8b67d26ffb60dd21e558dfa10612a0ed.json | 28 ++ ...c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json | 14 - ...781a71aeba34da94b9b1aa9893c2837d5cb05.json | 28 ++ ...ec9cb3388b7232a96bfc287f751d1e0092da.json} | 8 +- ...fc9c997dd9427659ff15f6850be861000bdf.json} | 7 +- ...c7ad58cd9add49f45fd50f4fcf909703d01a2.json | 34 +++ ...e8588ad36379991e71b10e49211891219a92.json} | 6 +- ...cf6dea2537923a987be5584fbcb1c0dc69b7f.json | 16 ++ ...53f5823cb10016330b317257c8c668d951c2.json} | 7 +- ...7adac931366debe890df5b25e4110e47e4f87.json | 15 ++ ...f006687bd9de4611203b5dd0c28eacee02842.json | 15 -- ...53aba57d5e8f19a316f76e01922559425d81b.json | 15 ++ backend/ee-repo-ref.txt | 2 +- ...18_workspace_slack_signing_secret.down.sql | 1 + ...5618_workspace_slack_signing_secret.up.sql | 1 + .../windmill-api-workspaces/src/workspaces.rs | 59 ++++- .../src/workspaces_extra.rs | 2 +- backend/windmill-api/openapi.yaml | 31 +++ backend/windmill-api/src/lib.rs | 6 - backend/windmill-api/src/oauth2_oss.rs | 1 + backend/windmill-api/src/slack_agents.rs | 247 +++++++++++++++--- backend/windmill-api/src/slack_approvals.rs | 89 +++++-- .../windmill-common/src/instance_config.rs | 14 +- backend/windmill-oauth/src/lib.rs | 29 +- .../src/lib/components/OAuthSetting.svelte | 48 ++-- .../common/slack/SlackAppSetup.svelte | 21 ++ .../flows/content/AgentUsePanel.svelte | 10 +- .../(logged)/workspace_settings/+page.svelte | 134 +++++++--- 30 files changed, 740 insertions(+), 189 deletions(-) rename backend/.sqlx/{query-44866e040f382b5e12aa21595bd1452aae8ca746348a6ac6b5bba26f5249166f.json => query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json} (78%) create mode 100644 backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json create mode 100644 backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json delete mode 100644 backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json create mode 100644 backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json rename backend/.sqlx/{query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json => query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json} (63%) rename backend/.sqlx/{query-af9c259d71af873d81ad788ab05cd0b52f561bfaac0d4e8d17b1aa0900dcc3bc.json => query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json} (65%) create mode 100644 backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json rename backend/.sqlx/{query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json => query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json} (51%) create mode 100644 backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json rename backend/.sqlx/{query-f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397.json => query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json} (58%) create mode 100644 backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json delete mode 100644 backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json create mode 100644 backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json create mode 100644 backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql create mode 100644 backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql create mode 100644 frontend/src/lib/components/common/slack/SlackAppSetup.svelte 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" >