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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: report a workspace app's own signing secret in the Slack agents status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: share the Slack app setup instructions and require the signing secret for agents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: tidy the Slack signing secret copy and show a set secret as masked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: check a workspace app only against its own signing secret, and approvals against their workspace's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: accept a Slack approval signed by the workspace's app or the instance's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-10-22 13:51:09 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 9202a74071
commit 34de8bc06d
30 changed files with 740 additions and 189 deletions
+23 -25
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ExternalLink, Plus, X } from 'lucide-svelte'
import { Plus, X } from 'lucide-svelte'
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
@@ -9,9 +9,7 @@
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
import CopyableCodeBlock from './details/CopyableCodeBlock.svelte'
import { slackAppManifest } from './common/slack/slackAppManifest'
import { yaml } from 'svelte-highlight/languages'
import SlackAppSetup from './common/slack/SlackAppSetup.svelte'
interface Props {
name: string
@@ -133,6 +131,20 @@
bind:password={value['secret']}
/>
</label>
{#if name == 'slack'}
<label for="slack_signing_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Signing Secret</span>
<Password
id="slack_signing_secret"
placeholder="Signing Secret"
bind:password={value['signing_secret']}
/>
<span class="text-2xs text-hint">
Lets Windmill check that requests come from Slack. Required for AI agents; recommended
for <code>/windmill</code> commands.
</span>
</label>
{/if}
{#if name == 'microsoft' || name == 'ms_teams'}
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Tenant Id</span>
@@ -206,22 +218,7 @@
</CollapseLink>
{:else if name == 'slack'}
<CollapseLink text="Set up slack">
<div class="flex flex-col gap-2 text-xs text-primary">
<span>
Create a Slack app <a href="https://api.slack.com/apps?new_app=1" target="_blank"
>in the Slack API console <ExternalLink size={12} class="inline-block" /></a
>: pick "From a manifest", then YAML, and paste the manifest below. Then copy the
Client ID and Client Secret here. Socket Mode and token rotation must stay off. For AI
agents to answer in Slack, set the app's Signing Secret as the server's
<code>SLACK_SIGNING_SECRET</code> environment variable. More in the
<a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank"
>Windmill docs <ExternalLink size={12} class="inline-block" /></a
>.
</span>
<div class="flex flex-col max-h-80 [&>div]:min-h-0">
<CopyableCodeBlock code={slackAppManifest(window.location.origin)} language={yaml} />
</div>
</div>
<SlackAppSetup />
</CollapseLink>
{:else if name == 'microsoft'}
<CollapseLink text="Instructions">
@@ -294,11 +291,12 @@
Follow this guide on <a
href="https://www.windmill.dev/docs/misc/setup_oauth#microsoft-teams"
target="_blank">Windmill Docs</a
> to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret
here. The same app serves the Teams bot (Enterprise Edition) and the Microsoft Teams
OAuth resource: to connect that resource, also add the redirect URI
<code>BASE_URL/oauth/callback/ms_teams</code> and the delegated Microsoft Graph
permissions you need to the app.
>
to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret here.
The same app serves the Teams bot (Enterprise Edition) and the Microsoft Teams OAuth resource:
to connect that resource, also add the redirect URI
<code>BASE_URL/oauth/callback/ms_teams</code> and the delegated Microsoft Graph permissions
you need to the app.
</div>
</CollapseLink>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { ExternalLink } from 'lucide-svelte'
import { yaml } from 'svelte-highlight/languages'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { slackAppManifest } from './slackAppManifest'
</script>
<div class="flex flex-col gap-2 text-xs text-primary">
<span>
Create a Slack app <a href="https://api.slack.com/apps?new_app=1" target="_blank"
>in the Slack API console <ExternalLink size={12} class="inline-block" /></a
>: 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
<a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank"
>Windmill docs <ExternalLink size={12} class="inline-block" /></a
>.
</span>
<div class="flex flex-col max-h-80 [&>div]:min-h-0">
<CopyableCodeBlock code={slackAppManifest(window.location.origin)} language={yaml} />
</div>
</div>
@@ -139,9 +139,13 @@
{:else}
{#if !signingSecretSet}
<Alert type="warning" title="Agents can't answer in Slack yet" size="xs">
The server needs the Slack app's signing secret, so Windmill can tell who sent a
message. An instance admin sets it as the <code>SLACK_SIGNING_SECRET</code> 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}
</Alert>
{/if}
{#if !botName}
@@ -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<void> {
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<void> {
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}
</ToggleButtonGroup>
<div class="text-2xs text-hint"
>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.</div
>The instance app is shared by all workspaces. A workspace app is your
own, for this workspace only.</div
>
</div>
{#if slackAppType === 'instance'}
<Toggle
size="xs"
bind:checked={slackAgents}
options={{
right: 'Let AI agents answer in Slack',
rightTooltip:
'Lets editors share agents in Slack channels, where people ask them by mentioning the bot. Needs users:read, users:read.email and channels:read on the Slack app.'
}}
/>
{#if slackAgentsStatus.current?.signing_secret_set === false}
<Alert type="warning" size="xs" title="Agents won't answer yet">
An instance admin needs to set the Slack app's signing secret as the
<code>SLACK_SIGNING_SECRET</code> environment variable.
</Alert>
{/if}
<Toggle
size="xs"
bind:checked={slackAgents}
options={{
right: 'Let AI agents answer in Slack',
rightTooltip:
'Lets editors share agents in Slack channels, where people ask them by mentioning the bot. Needs users:read, users:read.email and channels:read on the Slack app.'
}}
/>
{#if slackAppType === 'instance' && slackAgentsStatus.current?.signing_secret_set === false}
<Alert type="warning" size="xs" title="Agents won't answer yet">
An instance admin needs to set the Slack app's signing secret in the
instance settings.
</Alert>
{/if}
</div>
{/if}
@@ -1817,6 +1841,32 @@
>Client ID for the Slack app configured at the workspace level</div
>
</div>
<div class="flex flex-col gap-1">
<div class="text-xs text-primary font-normal">Signing secret</div>
<div class="flex gap-2">
<TextInput
inputProps={{
type: 'password',
placeholder: slackSigningSecretSet
? '••••••••••••••••••••••••••••••••'
: 'Enter signing secret'
}}
bind:value={slackSigningSecret}
/>
<Button
unifiedSize="md"
variant="default"
onclick={saveSlackSigningSecret}
disabled={!slackSigningSecret.trim() && !slackSigningSecretSet}
>
{slackSigningSecret.trim() || !slackSigningSecretSet ? 'Save' : 'Remove'}
</Button>
</div>
<div class="text-2xs text-hint"
>Lets Windmill check that requests come from Slack. Required for AI
agents; recommended for <code>/windmill</code> commands.</div
>
</div>
{:else if slackAppType === 'workspace'}
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
@@ -1841,21 +1891,23 @@
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-primary font-semibold text-xs">Signing secret</span>
<TextInput
inputProps={{
type: 'password',
placeholder: 'Enter signing secret'
}}
bind:value={slackSigningSecret}
/>
<span class="text-2xs text-hint"
>Lets Windmill check that requests come from Slack. Required for AI
agents; recommended for <code>/windmill</code> commands.</span
>
</label>
<CollapseLink text="Instructions">
<div class="text-xs text-secondary">
Create a Slack app at{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Slack API
</a>. Set the redirect URI to:{' '}
<code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">
{window.location.origin}/oauth/callback_slack
</code>
</div>
<SlackAppSetup />
</CollapseLink>
<div class="pt-2">
@@ -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"
>