Files
windmill/backend/windmill-api/src/token.rs
T
Ruben Fiszel 633d7bcb2e feat: add trigger_history table with source tracking (#10696)
* feat: add trigger_history table with source tracking

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate trigger history reads on scopes and harden its writers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: filter trigger history scopes in SQL and match the cleared-handler diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record a trigger restore from the trashbin in its history

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record bulk http trigger creates and document the recording boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: lock the trigger row when capturing its history preimage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: only record an auto-disable that actually flipped the schedule

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: state the auto-disable invariant once instead of at four call sites

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: render trigger history changes as a structured field diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make a server-initiated disable atomic with its history row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: note that the auto-disable savepoint takes no pool connection

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: note the flow fallback is the last chance to disable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never leave a trigger enabled because its history row failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: retry the disable history row instead of dropping it on first failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use the design-system Button for the change-value expander

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold the trigger row lock across its disable history row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the history-loss alert out of the listener cancellation race

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the history workspace through the trigger-workspace seam

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:57:11 +02:00

333 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_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,
}],
});
// 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"
);
}
}