mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 16:02:14 +00:00
c69f08073a
* fix: expose apps:run in the token scope picker and let apps:write grant it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: confine path-scoped app run/write tokens to the app they name Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: let apps:run read back its own app's S3 files, condense scope comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: let apps:write mint apps:run and extend run read-back to app S3 display routes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop stale embed-token wording from the app S3 helper summary Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
11 KiB
Rust
313 lines
11 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_skills", "AI Skills", "AI skill management", 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,
|
|
}],
|
|
});
|
|
|
|
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"
|
|
);
|
|
}
|
|
}
|