fix: list operators' schedule and trigger drafts under their draft rights

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 09:22:58 -07:00
co-authored by Claude Opus 5.5
parent 7d526084a8
commit 2773bb8225
4 changed files with 44 additions and 4 deletions
@@ -242,9 +242,31 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool<Postgres>) -> anyhow:
assert_eq!(resp.status(), 200);
Ok(resp.json().await?)
};
let schedule_list_has_draft = async || -> anyhow::Result<bool> {
let resp = c
.get(format!("{api}/schedules/list?include_draft_only=true"))
.send()
.await?;
assert_eq!(resp.status(), 200);
let rows: Vec<serde_json::Value> = 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<Postgres>) -> 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}");
+7 -1
View File
@@ -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,
+7 -1
View File
@@ -695,12 +695,18 @@ async fn list_triggers<T: TriggerCrud>(
// 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,
+4
View File
@@ -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`