fix: batch cancel dropping jobs from other workspaces (#8887)

* fix: batch cancel silently dropping jobs from other workspaces

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

* fix: gate cross-workspace batch cancel on all_workspaces flag

Enforce path workspace unless all_workspaces=true, so cross-workspace
selections only succeed when the UI is actually in all-workspaces mode.

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

* sqlx

* fix: gate cancel_selection all_workspaces on admins workspace

Matches the convention used by count_queue_jobs and count_completed_jobs_detail
in the same file — cross-workspace scope is only honored when the path
workspace is "admins", preventing clients in regular workspaces from dropping
workspace scoping by passing all_workspaces=true.

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-04-20 20:00:34 +00:00
committed by GitHub
co-authored by Ruben Fiszel Claude Opus 4.7 claude[bot]
parent 7dde9824b0
commit 10d1a932d5
5 changed files with 78 additions and 35 deletions
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.id AS \"id!\" FROM v2_job j LEFT JOIN v2_job_queue q USING (id) WHERE j.id = ANY($1) AND j.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind AND ($2::text[] IS NULL OR j.tag = ANY($2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "78f5684086a8c28a602c583389e974fffd7f74b10618b1467426623973c10131"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.id AS \"id!\", j.workspace_id AS \"workspace_id!\" FROM v2_job j LEFT JOIN v2_job_queue q USING (id) WHERE j.id = ANY($1) AND j.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind AND ($2::text[] IS NULL OR j.tag = ANY($2)) AND ($3::text IS NULL OR j.workspace_id = $3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"UuidArray",
"TextArray",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "945e11f704352945d8937cfd43c023e1a7ea6386cbd3db8c20dbb88c245b28bd"
}
+4
View File
@@ -11168,6 +11168,10 @@ paths:
in: query
schema:
type: boolean
- name: all_workspaces
in: query
schema:
type: boolean
requestBody:
description: uuids of the jobs to cancel
required: true
+42 -11
View File
@@ -2085,6 +2085,7 @@ async fn list_queue_jobs(
#[derive(Deserialize)]
pub struct CancelSelectionQuery {
force_cancel: Option<bool>,
all_workspaces: Option<bool>,
}
async fn cancel_selection(
@@ -2097,23 +2098,53 @@ async fn cancel_selection(
) -> error::JsonResult<Vec<Uuid>> {
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec());
let jobs_to_cancel = sqlx::query_scalar!(
"SELECT j.id AS \"id!\" FROM v2_job j LEFT JOIN v2_job_queue q USING (id) WHERE j.id = ANY($1) AND j.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind AND ($2::text[] IS NULL OR j.tag = ANY($2))",
// Only honor all_workspaces when the path workspace is "admins", matching
// the convention used by count_queue_jobs / count_completed_jobs_detail.
// Outside the admins workspace, always scope to the path w_id so a client
// cannot drop workspace scoping by passing all_workspaces=true.
let all_workspaces = w_id == "admins" && query.all_workspaces.unwrap_or(false);
let path_w_id = if all_workspaces {
None
} else {
Some(w_id.as_str())
};
let rows = sqlx::query!(
"SELECT j.id AS \"id!\", j.workspace_id AS \"workspace_id!\" FROM v2_job j LEFT JOIN v2_job_queue q USING (id) WHERE j.id = ANY($1) AND j.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind AND ($2::text[] IS NULL OR j.tag = ANY($2)) AND ($3::text IS NULL OR j.workspace_id = $3)",
&jobs,
tags.as_ref().map(|v| v.as_slice())
tags.as_ref().map(|v| v.as_slice()),
path_w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
cancel_jobs(
jobs_to_cancel,
&db,
authed.username.as_str(),
w_id.as_str(),
query.force_cancel.unwrap_or(false),
)
.await
// cancel_jobs enforces workspace_id per row, so dispatch one call per
// workspace so cross-workspace selections (all_workspaces=true) aren't
// silently dropped. In single-workspace mode the SQL filter above ensures
// this collapses to a single call.
let mut jobs_by_workspace: HashMap<String, Vec<Uuid>> = HashMap::new();
for row in rows {
jobs_by_workspace
.entry(row.workspace_id)
.or_default()
.push(row.id);
}
let force_cancel = query.force_cancel.unwrap_or(false);
let mut cancelled = Vec::new();
for (workspace_id, ids) in jobs_by_workspace {
let Json(mut w_cancelled) = cancel_jobs(
ids,
&db,
authed.username.as_str(),
workspace_id.as_str(),
force_cancel,
)
.await?;
cancelled.append(&mut w_cancelled);
}
Ok(Json(cancelled))
}
async fn list_filtered_job_uuids(
+2 -1
View File
@@ -305,7 +305,8 @@
const uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: uuidsToCancel,
forceCancel: forceCancel
forceCancel: forceCancel,
allWorkspaces: filters.val.all_workspaces || undefined
})
selectedIds = []
jobsLoader?.loadJobs(true, true)