fix: carry a job's scopes on its completion so a re-run keeps the caller's cap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL
This commit is contained in:
Ruben Fiszel
2026-10-02 20:21:42 +02:00
co-authored by Claude Opus 5.5
parent 434e42b0ee
commit 5ac15db691
5 changed files with 319 additions and 10 deletions
@@ -0,0 +1,253 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,\n COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",\n p.job_token_scopes AS \"job_token_scopes?\"\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n LEFT JOIN job_perms p ON p.job_id = j.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 9,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 12,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 15,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 17,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 18,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 19,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 21,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 22,
"name": "build_binary_only!",
"type_info": "Bool"
},
{
"ordinal": 23,
"name": "job_token_scopes?",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
true
]
},
"hash": "5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544"
}
+1
View File
@@ -245,6 +245,7 @@ fn make_mini(id: Uuid, runnable_path: &str) -> MiniCompletedJob {
cache_ignore_s3_path: None,
runnable_settings_handle: None,
build_binary_only: false,
job_token_scopes: None,
}
}
+30 -10
View File
@@ -769,16 +769,24 @@ async fn restart_perpetual_runs_at_path(
/// The effective token scopes of an earlier job, as the ceiling of a job that re-runs it
/// (a retry, a perpetual restart): the re-run never holds a wider token than the run it
/// replaces. Read while that job still holds its `job_perms` row, which the monitor sweeps
/// once the job has left the queue.
async fn stored_job_token_scopes(db: &DB, job_id: Uuid) -> Result<Option<Vec<String>>, Error> {
Ok(sqlx::query_scalar!(
/// replaces. Read from its `job_perms` row, or from `carried` (the scopes it was pulled with)
/// once the monitor has swept that row after the job left the queue.
async fn stored_job_token_scopes(
db: &DB,
job_id: Uuid,
carried: Option<&Vec<String>>,
) -> Result<Option<Vec<String>>, Error> {
let row = sqlx::query_scalar!(
"SELECT job_token_scopes FROM job_perms WHERE job_id = $1",
job_id
)
.fetch_optional(db)
.await?
.flatten())
.await?;
Ok(match row {
Some(scopes) => scopes,
// Swept since the job left the queue: the scopes carried from its pull.
None => carried.cloned(),
})
}
/// A run of an earlier version at the path, and what its replacement inherits from it.
@@ -873,7 +881,7 @@ async fn restart_perpetual_run(
// an earlier version the next pass picks up.
return Ok(());
}
let scope_ceiling = stored_job_token_scopes(db, run.id).await?;
let scope_ceiling = stored_job_token_scopes(db, run.id, None).await?;
let (_, tx) = push(
db,
PushIsolationLevel::Transaction(tx),
@@ -2121,7 +2129,9 @@ async fn restart_job_if_perpetual_inner(
.await?
.flatten()
.unwrap_or_default();
let scope_ceiling = stored_job_token_scopes(db, queued_job.id).await?;
let scope_ceiling =
stored_job_token_scopes(db, queued_job.id, queued_job.job_token_scopes.as_ref())
.await?;
let (_uuid, tx) = push(
db,
tx,
@@ -2441,7 +2451,8 @@ pub async fn maybe_enqueue_native_script_retry(
)
.await?;
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let scope_ceiling = stored_job_token_scopes(db, job.id).await?;
let scope_ceiling =
stored_job_token_scopes(db, job.id, job.job_token_scopes.as_ref()).await?;
let (new_id, mut tx) = match push(
db,
tx,
@@ -3699,6 +3710,10 @@ pub struct MiniCompletedJob {
/// the server would reject every completion it sends, not just build jobs.
#[serde(default)]
pub build_binary_only: bool,
/// The job's effective token scopes, carried from the pull: a re-run (retry, perpetual
/// restart) caps itself with them after the job's `job_perms` row may have been swept.
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
}
impl From<QueuedJobV2> for MiniCompletedJob {
@@ -3729,6 +3744,7 @@ impl From<QueuedJobV2> for MiniCompletedJob {
// `QueuedJobV2` carries no args, and nothing reaches the restart gate
// through this conversion — the worker completes jobs from the pulled job.
build_binary_only: false,
job_token_scopes: None,
}
}
}
@@ -3759,6 +3775,7 @@ impl From<MiniPulledJob> for MiniCompletedJob {
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
runnable_settings_handle: job.runnable_settings_handle,
job_token_scopes: job.job_token_scopes.clone(),
build_binary_only: crate::binary_prebuild::is_build_binary_job(
job.args.as_ref().map(|x| &x.0),
),
@@ -3791,6 +3808,7 @@ impl From<Arc<MiniPulledJob>> for MiniCompletedJob {
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
runnable_settings_handle: job.runnable_settings_handle,
job_token_scopes: job.job_token_scopes.clone(),
build_binary_only: crate::binary_prebuild::is_build_binary_job(
job.args.as_ref().map(|x| &x.0),
),
@@ -5761,8 +5779,10 @@ pub fn get_mini_completed_job<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres>
"SELECT
j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,
j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,
COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\"
COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",
p.job_token_scopes AS \"job_token_scopes?\"
FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id
LEFT JOIN job_perms p ON p.job_id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
id,
w_id
@@ -45,6 +45,7 @@ mod native_retry {
cache_ignore_s3_path: None,
runnable_settings_handle: handle,
build_binary_only: false,
job_token_scopes: None,
}
}
@@ -165,6 +166,39 @@ mod native_retry {
Ok(())
}
// A retry keeps the restriction of the run it replaces even once that run's `job_perms`
// row is gone (swept after it left the queue): the completed job carries its scopes.
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
async fn retry_keeps_the_restriction_of_a_swept_run(db: Pool<Postgres>) -> anyhow::Result<()> {
let retry = Retry {
constant: ConstantDelay { attempts: 1, seconds: 1 },
exponential: Default::default(),
retry_if: None,
};
let handle = insert_rs(
RunnableSettings {
debouncing_settings: None,
concurrency_settings: None,
retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?,
},
&db,
)
.await?;
let root_id = Uuid::new_v4();
let mut root = mini(root_id, None, handle);
root.job_token_scopes = Some(vec!["jobs:run".to_string()]);
assert!(maybe_enqueue_native_script_retry(&db, &root, &None, &no_result).await?);
let (r1_id, ..) = retry_by_attempt(&db, root_id, 1).await.expect("retry 1 exists");
let scopes: Option<Vec<String>> =
sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1")
.bind(r1_id)
.fetch_one(&db)
.await?;
assert_eq!(scopes, Some(vec!["jobs:run".to_string()]));
Ok(())
}
// Cancellation always wins over a pending retry.
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
async fn canceled_job_does_not_retry(db: Pool<Postgres>) -> anyhow::Result<()> {
@@ -93,6 +93,7 @@ mod schedule_push {
cache_ignore_s3_path: None,
runnable_settings_handle: None,
build_binary_only: false,
job_token_scopes: None,
}
}