From 2773bb82255402418f0a197f02bcf2ff89f42ce6 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 09:22:58 -0700 Subject: [PATCH] fix: list operators' schedule and trigger drafts under their draft rights Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/operator_manage_rights.rs | 28 +++++++++++++++++-- backend/windmill-api-schedule/src/lib.rs | 8 +++++- backend/windmill-trigger/src/handler.rs | 8 +++++- docs/operator-write-rights.md | 4 +++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs index ac2b5d8312..cfc9441dca 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -242,9 +242,31 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool) -> anyhow: assert_eq!(resp.status(), 200); Ok(resp.json().await?) }; + let schedule_list_has_draft = async || -> anyhow::Result { + let resp = c + .get(format!("{api}/schedules/list?include_draft_only=true")) + .send() + .await?; + assert_eq!(resp.status(), 200); + let rows: Vec = resp.json().await?; + Ok(rows.iter().any(|r| r["path"] == "u/operator/sched_draft")) + }; - let (status, body) = save("trigger_schedule", "u/operator/sched_draft").await?; - assert_eq!(status, 200, "{body}"); + let resp = c + .post(format!("{api}/drafts/update/trigger_schedule/u/operator/sched_draft")) + .json(&json!({ "value": { + "path": "u/operator/sched_draft", + "schedule": "0 0 12 * * *", + "timezone": "UTC", + "script_path": "f/system/hello", + "is_flow": false, + "args": {}, + "enabled": false + } })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert!(schedule_list_has_draft().await?); let (status, body) = save("trigger_http", "u/operator/http_draft").await?; assert_eq!(status, 200, "{body}"); // The rights name schedules and triggers, nothing else. @@ -260,6 +282,8 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool) -> anyhow: ); let (status, body) = save("trigger_schedule", "u/operator/sched_draft").await?; assert_eq!(status, 403, "{body}"); + // The list follows the right too, as the app list follows `builder_apps`. + assert!(!schedule_list_has_draft().await?); let (status, body) = save("trigger_http", "u/operator/http_draft").await?; assert_eq!(status, 200, "{body}"); diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index b8ee9ded91..646052699e 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -916,7 +916,6 @@ async fn list_schedule( // Append the authed user's draft-only schedules; see scripts.rs. if lsq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lsq.path.is_none() && lsq.is_flow.is_none() @@ -927,6 +926,13 @@ async fn list_schedule( && lsq.summary.is_none() && lsq.broad_filter.is_none() && lsq.label.is_none() + && (!authed.is_operator + || windmill_common::workspaces::operator_can_draft( + &db, + &w_id, + Some(UserDraftItemKind::TriggerSchedule), + ) + .await?) { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index ba40ed2c48..6022e0d55f 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -695,12 +695,18 @@ async fn list_triggers( // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind // config can deviate, so drop a row on deserialize failure rather than fail the list. if query.include_draft_only.unwrap_or(false) - && !authed.is_operator && query.page.unwrap_or(0) == 0 && query.path.is_none() && query.is_flow.is_none() && query.path_start.is_none() && query.label.is_none() + && (!authed.is_operator + || windmill_common::workspaces::operator_can_draft( + &db, + &workspace_id, + Some(T::user_draft_item_kind()), + ) + .await?) { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index c1d6cac5d4..2cef9ef5df 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -65,6 +65,10 @@ stay refused even though operators edit those items directly. The AI session cha schedules and triggers only as drafts and then deploys them, so without this rule an operator who may create a schedule on its page could not ask the chat for one. +The schedule and trigger list routes append an operator's draft-only rows under the same +`operator_can_draft` check, so a saved draft shows on its list page exactly while the operator may +still save it. + Deploying a draft goes through the ordinary create and update routes, so the router gate is still what decides the write. The draft rule only has to agree with it: a draft the gate would refuse to deploy should not be saved in the first place. The frontend mirrors the rule in `roleCanDraft`