Files
windmill/backend/windmill-api/src/token.rs
T
Ruben Fiszelandwindmill-internal-app[bot] cfcfe298dd feat(ai-chat): make reusable skills ai_skill resources you select per workspace (#10914)
* feat(ai-chat): make reusable skills ai_skill resources you select per workspace

* chore: pin the ee ref to the skill telemetry counters

* fix: address review findings on skill authoring, import and migration

* fix: enforce skill selection in read_skill and stop imports clobbering resources

* feat: carry format_extension from the hub into synced resource types

* fix: let an edit set or clear a resource type's format_extension

* fix: regenerate the sqlx cache and close the review round findings

* fix: close the round-2 findings on folder ACLs, cached sync and truncation

* refactor: make the skills migration non-destructive and use design-system inputs

* fix: close the round-4 findings on folder owners, startup sync and truncation

* fix: clear obsolete extensions, guard folder owners, and report skipped skills

* fix: honor explicit-null extensions and report same-type migration conflicts

* fix: scope skill actions to the committed workspace and paginate the listing

* fix: keep the drawer scoped to the live workspace and surface truncation

* fix: discard a skills refresh for a workspace the chat has left

* chore: update ee-repo-ref to 6efe7a73c745c2e1377a34498523c00d89010a3d

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

Previous ee-repo-ref: 55998c142bc72edd08532748af1974b16035658d

New ee-repo-ref: 6efe7a73c745c2e1377a34498523c00d89010a3d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-01 12:51:27 +00:00

338 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,
),
(
"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: false,
},
ScopeOption {
value: "jobs:write".to_string(),
label: "Write".to_string(),
requires_resource_path: false,
},
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,
},
],
}];
// 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"
);
}
}