Files
windmill/backend/windmill-api/src/token.rs
T
35b250e767 feat: let AI agents answer in Slack (#11630)
* feat: let AI agents answer in Slack

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

* fix: dedupe Slack deliveries by message and route only connected workspaces

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

* fix: key Slack conversations with the workspace secret and require signed Slack requests

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

* fix: keep a named agent on its thread after an empty question and flag a missing Slack signing secret

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

* fix: register the slack_agents token scope domain

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

* feat: name the Slack bot, offer Slack only for chat agents, align the agent API tab with webhooks

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

* fix: warn when Slack can't be reached instead of showing a placeholder bot name

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

* feat: chat SDK, streaming examples and a copyable integration prompt on the agent API tab

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

* fix: keep the base path in the chat SDK example, list slack_agents token scopes, bound the bot name lookup

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

* feat: opt into the AI agent Slack scopes at connect, store the signing secret in instance settings, surface the integration prompt

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

* fix: dispatch Slack agents only on a verified request, warn when the connection lacks the agent scopes, register the callback redirect URLs

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

* fix: point the agent chat examples at @windmill-labs/chat-sdk

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

* fix: build the agent API examples on the configured API base URL

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

* fix: say that a channel's default agent takes its /windmill commands

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

* feat: list the channels answered by an agent in the workspace Slack settings

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

* refactor: keep the Slack signing secret in SLACK_SIGNING_SECRET, warn at connect when it is missing, move the integration prompt to a tab

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

* fix: wrap copyable code blocks at word boundaries

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

* fix: open the agent panel on its API tab, put the AI prompt first, list channels like the critical alert channels, cap the manifest's height

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

* feat: group the agent API examples by AI prompt, TypeScript and HTTP, with React and framework-free chat SDK examples

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

* fix: have the agent integration prompt read the token from WINDMILL_TOKEN

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

* fix: trim the agent API tab intro, name the Slack app in mentions, define the SDK example's output

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

* feat: share agents in selected Slack channels, run as their identity

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

* feat: share agents in Slack channels with anyone or workspace members only

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

* feat: set who can ask an agent in Slack and its channels from its settings

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

* fix: treat a missing Slack audience as workspace members

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

* feat: list Slack channels before who can ask, name the run-as user in each choice

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

* fix: name the next deploy's run-as user in Slack choices, turn agents on by default when connecting

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

* feat: answer agents on mentions only, group agent settings into sections

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

* feat: show how to ask an agent in Slack first, keep agent scopes opt-in

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

* feat: list agents shared in Slack in a setting card and table

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

* chore: update ee-repo-ref to 10bc314c95094c0851743cbca31b50a3b37d42fd

This commit updates the EE repository reference after PR #865 was merged in windmill-ee-private.

Previous ee-repo-ref: 47d8fbac461171bd93d888b49cc1c7730a2b3f96

New ee-repo-ref: 10bc314c95094c0851743cbca31b50a3b37d42fd

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-19 23:16:17 +02:00

354 lines
12 KiB
Rust

use axum::{routing::get, Json, Router};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use windmill_common::error::JsonResult;
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct ScopeOption {
pub value: String,
pub label: String,
pub requires_resource_path: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ScopeDomain {
pub name: String,
pub description: Option<String>,
pub scopes: Vec<ScopeOption>,
}
fn build_trigger_scope_domains() -> Vec<ScopeDomain> {
const TRIGGER_DOMAINS: &[(&str, &str)] = &[
("http_triggers", "HTTP"),
("websocket_triggers", "WebSocket"),
("kafka_triggers", "Kafka"),
("nats_triggers", "NATS"),
("mqtt_triggers", "MQTT"),
("amqp_triggers", "AMQP"),
("sqs_triggers", "AWS SQS"),
("gcp_triggers", "GCP Pub/Sub"),
("azure_triggers", "Azure Event Grid"),
("postgres_triggers", "PostgreSQL"),
("email_triggers", "Email"),
];
TRIGGER_DOMAINS
.iter()
.map(|(domain, display_name)| ScopeDomain {
name: format!("{} Triggers", display_name),
description: Some(format!("{} trigger management", display_name)),
scopes: vec![
ScopeOption {
value: format!("{domain}:read"),
label: "Read".to_string(),
requires_resource_path: true,
},
ScopeOption {
value: format!("{domain}:write"),
label: "Write".to_string(),
requires_resource_path: true,
},
],
})
.collect()
}
fn build_standard_scope_domains() -> Vec<ScopeDomain> {
const STANDARD_DOMAINS: &[(&str, &str, &str, bool)] = &[
(
"scripts",
"Scripts",
"Access to automation scripts and workflows",
true,
),
(
"flows",
"Flows",
"Access to automation scripts and workflows",
true,
),
(
"flow_conversations",
"Flow Conversations",
"Flow conversation management",
false,
),
("apps", "Apps", "App management", true),
("raw_apps", "RawApps", "Raw app management", true),
("resources", "Resources", "Resource management", true),
("variables", "Variables", "", true),
(
"schedules",
"Schedules",
"Scheduled tasks and automated triggers",
true,
),
("folders", "Folders", "Folder management", true),
("users", "Users", "User account management", false),
("groups", "Groups", "Group management", false),
("workspaces", "Workspaces", "Workspace management", false),
("audit", "Audit", "Audit log management", false),
("workers", "Workers", "Worker management", false),
("settings", "Settings", "System settings management", false),
(
"service_logs",
"Service Logs",
"Service log management",
false,
),
("configs", "Configs", "Configuration management", false),
("oauth", "OAuth", "OAuth management", false),
("ai", "AI", "AI feature management", false),
(
"ai_evals",
"AI Evals",
"AI agent eval datasets and standalone runs",
false,
),
(
"slack_agents",
"Slack Agents",
"AI agents answering in Slack channels",
false,
),
(
"agent_workers",
"Agent Workers",
"Agent worker management",
false,
),
("drafts", "Drafts", "Draft management", false),
("favorites", "Favorites", "Favorite items management", false),
("inputs", "Inputs", "Input management", false),
("job_helpers", "Job Helpers", "Job helper utilities", false),
(
"openapi",
"OpenAPI",
"OpenAPI documentation management",
false,
),
("capture", "Capture", "Request capture management", false),
(
"concurrency_groups",
"Concurrency Groups",
"Concurrency group management",
false,
),
("oidc", "OIDC", "OIDC management", false),
("acls", "ACLs", "Access Control List management", false),
("indexer", "Indexer", "Search indexer management", false),
("teams", "Teams", "Team management", false),
(
"git_sync",
"Git Sync",
"Git synchronization management",
false,
),
(
"native_triggers",
"Native Triggers",
"Native triggers management",
true,
),
];
STANDARD_DOMAINS
.iter()
.map(|(key, name, desc, req)| {
let mut scopes = vec![
ScopeOption {
value: format!("{key}:read"),
label: "Read".to_string(),
requires_resource_path: *req,
},
ScopeOption {
value: format!("{key}:write"),
label: "Write".to_string(),
requires_resource_path: *req,
},
];
// `apps_u/execute_component` and `apps_u/upload_s3_file` are classified as
// Run actions, so running a deployed app's components needs `apps:run`:
// without it here no supported token can be granted that access.
if *key == "apps" {
scopes.push(ScopeOption {
value: "apps:run".to_string(),
label: "Run".to_string(),
requires_resource_path: *req,
});
}
ScopeDomain {
name: name.to_string(),
description: if desc.is_empty() {
None
} else {
Some(desc.to_string())
},
scopes,
}
})
.collect()
}
lazy_static! {
static ref ALL_SCOPES: Vec<ScopeDomain> = {
let mut groups = vec![ScopeDomain {
name: "Jobs".to_string(),
description: Some("Job management".to_string()),
scopes: vec![
ScopeOption {
value: "jobs:read".to_string(),
label: "Read".to_string(),
requires_resource_path: true,
},
ScopeOption {
value: "jobs:write".to_string(),
label: "Write".to_string(),
requires_resource_path: false,
},
ScopeOption {
value: "jobs:cancel".to_string(),
label: "Cancel".to_string(),
requires_resource_path: true,
},
ScopeOption {
value: "jobs:run:scripts".to_string(),
label: "Run scripts".to_string(),
requires_resource_path: true,
},
ScopeOption {
value: "jobs:run:flows".to_string(),
label: "Run flows".to_string(),
requires_resource_path: true,
},
ScopeOption {
value: "jobs:run:dependencies".to_string(),
label: "Generate dependency locks".to_string(),
requires_resource_path: true,
},
],
}];
// Read-only: `/api/docs/*` exposes only GET routes, so there is no
// `docs:write`. Kept out of build_standard_scope_domains (which mints a
// read+write pair) for that reason.
groups.push(ScopeDomain {
name: "Documentation".to_string(),
description: Some("Read-only documentation search".to_string()),
scopes: vec![ScopeOption {
value: "docs:read".to_string(),
label: "Read".to_string(),
requires_resource_path: false,
}],
});
// Read-only: the `data_metrics/list` route is the only surface and the
// catalog is written at deploy, never through a token. Path-selectable
// because the route filters rows by the caller's `data_metrics:read` path
// grants. Its own domain, not a `scripts` alias, so a metrics token can't
// reach `/scripts` routes.
groups.push(ScopeDomain {
name: "Data Metrics".to_string(),
description: Some("Read-only access to declared measures and dimensions".to_string()),
scopes: vec![ScopeOption {
value: "data_metrics:read".to_string(),
label: "Read".to_string(),
requires_resource_path: true,
}],
});
// Read-only: `trigger_history` is append-only and written by the server
// alone, so there is no `triggers_history:write`. Its own domain rather
// than a `schedules`/`*_triggers` alias: one listing spans every kind,
// and a history row quotes the whole trigger row (a schedule's `args`
// included), so reading it is an explicit grant rather than a side
// effect of being able to read the trigger. Path-selectable because the
// route filters rows by the caller's path grants.
groups.push(ScopeDomain {
name: "Trigger History".to_string(),
description: Some(
"Read-only access to the modification history of schedules and triggers"
.to_string(),
),
scopes: vec![ScopeOption {
value: "triggers_history:read".to_string(),
label: "Read".to_string(),
requires_resource_path: true,
}],
});
groups.extend(build_standard_scope_domains());
groups.extend(build_trigger_scope_domains());
groups
};
}
pub fn global_service() -> Router {
Router::new().route("/list/scopes", get(get_all_available_scopes))
}
async fn get_all_available_scopes() -> JsonResult<Vec<ScopeDomain>> {
Ok(Json(ALL_SCOPES.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
/// The token-scope picker is driven by this catalog, so a scope that is
/// enforced but absent here can't be granted through the supported UI.
#[test]
fn docs_read_scope_is_exposed_read_only() {
let values: Vec<&str> = ALL_SCOPES
.iter()
.flat_map(|d| d.scopes.iter())
.map(|s| s.value.as_str())
.collect();
assert!(
values.contains(&"docs:read"),
"docs:read must be selectable"
);
assert!(!values.contains(&"docs:write"), "docs has no write surface");
}
/// Running a deployed app's components is enforced as a Run action, so `apps:run`
/// must be selectable here or no supported token can be granted that access.
#[test]
fn apps_run_scope_is_exposed_and_path_selectable() {
let apps = ALL_SCOPES
.iter()
.find(|d| d.name == "Apps")
.expect("Apps domain must exist");
let opt = apps
.scopes
.iter()
.find(|s| s.value == "apps:run")
.expect("apps:run must be selectable");
assert!(opt.requires_resource_path, "apps:run is path-scoped");
}
/// The `data_metrics` route enforces its own scope domain, so `data_metrics:read`
/// must be grantable here or no token can ever reach it. It is read-only (the
/// catalog is written at deploy) and path-selectable.
#[test]
fn data_metrics_read_scope_is_exposed_read_only_and_path_selectable() {
let opt = ALL_SCOPES
.iter()
.flat_map(|d| d.scopes.iter())
.find(|s| s.value == "data_metrics:read")
.expect("data_metrics:read must be selectable");
assert!(
opt.requires_resource_path,
"data_metrics:read is path-scoped"
);
assert!(
!ALL_SCOPES
.iter()
.flat_map(|d| d.scopes.iter())
.any(|s| s.value == "data_metrics:write"),
"data_metrics has no write surface"
);
}
}