diff --git a/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json b/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json index 8116485af0..a9896b74f6 100644 --- a/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json +++ b/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json @@ -12,11 +12,11 @@ {"role": "developer", "allowed": true} ], "session_roles": [ - {"role": null, "expected_metadata": 403, "expected_connection": 403, "expected_job": 403, "expected_sql": 403}, - {"role": "viewer", "expected_metadata": 200, "expected_connection": 403, "expected_job": 403, "expected_sql": 403}, - {"role": "operator", "expected_metadata": 200, "expected_connection": 403, "expected_job": 200, "expected_sql": 403}, - {"role": "developer", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200}, - {"role": "admin", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200} + {"role": null, "expected_metadata": 403, "expected_connection": 403, "expected_job": 403, "expected_sql": 403, "expected_prioritised": 403}, + {"role": "viewer", "expected_metadata": 200, "expected_connection": 403, "expected_job": 403, "expected_sql": 403, "expected_prioritised": 403}, + {"role": "operator", "expected_metadata": 200, "expected_connection": 403, "expected_job": 200, "expected_sql": 403, "expected_prioritised": 403}, + {"role": "developer", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200, "expected_prioritised": 403}, + {"role": "admin", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200, "expected_prioritised": 200} ], "job_settings": [ {"full_refresh": false}, diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 2aebecfe52..a66f3d69c7 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -954,7 +954,7 @@ export interface components { * @description Closed permission vocabulary; unknown actions fail deserialization. * @enum {string} */ - Action: "view" | "view.sql" | "view.evidence" | "job.run" | "run.cancel" | "run.rerun" | "run.rewrite_history" | "trigger.toggle" | "sensor.reset_cursor" | "run.adhoc" | "connection.test" | "user.manage" | "role.assign" | "audit.view"; + Action: "view" | "view.sql" | "view.evidence" | "job.run" | "run.cancel" | "run.rerun" | "run.rewrite_history" | "run.prioritise" | "trigger.toggle" | "sensor.reset_cursor" | "run.adhoc" | "connection.test" | "user.manage" | "role.assign" | "audit.view"; /** * @description Administrator membership and person eligibility share the final-admin invariant. * @enum {string} diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs index a6d05a8f58..59a38db9e4 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs @@ -12,7 +12,7 @@ use crate::store::main::query::query; use crate::store::models::{ControlTransaction, Parameter}; use crate::store::types::Row; -const ACTIONS: [Action; 14] = [ +const ACTIONS: [Action; 15] = [ Action::View, Action::ViewSql, Action::ViewEvidence, @@ -20,6 +20,7 @@ const ACTIONS: [Action; 14] = [ Action::RunCancel, Action::RunRerun, Action::RunRewriteHistory, + Action::RunPrioritise, Action::TriggerToggle, Action::SensorResetCursor, Action::RunAdhoc, diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index fc0a74344e..9f9e44b9df 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -89,6 +89,8 @@ pub enum Action { RunRerun, #[serde(rename = "run.rewrite_history")] RunRewriteHistory, + #[serde(rename = "run.prioritise")] + RunPrioritise, #[serde(rename = "trigger.toggle")] TriggerToggle, #[serde(rename = "sensor.reset_cursor")] diff --git a/crates/sqlbuild-orchestration/src/runs/_helpers/requests/permissions.rs b/crates/sqlbuild-orchestration/src/runs/_helpers/requests/permissions.rs index 58baf5f024..479db66fa7 100644 --- a/crates/sqlbuild-orchestration/src/runs/_helpers/requests/permissions.rs +++ b/crates/sqlbuild-orchestration/src/runs/_helpers/requests/permissions.rs @@ -68,5 +68,15 @@ pub(crate) async fn authorize_execution( ) .await?; } + if matches!(action, Action::JobRun | Action::RunRerun) && request.priority > 0 { + scope.action = Action::RunPrioritise; + (transaction, context) = authorize_person( + transaction, + (&context, credentials), + &scope, + DefaultRole::None, + ) + .await?; + } Ok((transaction, context)) } diff --git a/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs b/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs index 552889f4b8..4a8912688e 100644 --- a/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs @@ -16,5 +16,7 @@ pub(crate) struct SessionRoleCase { pub(crate) expected_metadata: u16, pub(crate) expected_connection: u16, pub(crate) expected_job: u16, + /// A job run above priority 0 needs `run.prioritise`, which only Admin grants. + pub(crate) expected_prioritised: u16, pub(crate) expected_sql: u16, } diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index cc253368c7..1ea10bd974 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -186,6 +186,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ let mut last_request = Value::Null; let mut listed_person_runs = 0; let mut launched_person_runs = 0; + let mut prioritised_runs: Vec = Vec::new(); let mut listed_seed_runs = 0; for role_case in roles { for role in role_case.role.iter().copied() { @@ -260,7 +261,10 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ ("parent_run_id", Value::Null), ("trigger_name", Value::Null), ("rerun_of_run_id", Value::Null), - ("priority", json!(0)), + ( + "priority", + json!(i64::from(prioritised_runs.contains(&launched["run_id"]))), + ), ("rewrite_history", json!(false)), ("cancel_requested_at", Value::Null), ] { @@ -421,6 +425,24 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ role_case.expected_job ); launched_person_runs += usize::from(role_case.expected_job == 200); + let urgent = json!({"run_id":Uuid::now_v7(), "location_id":location, "target":"development", "job_key":"orders", "reason":"Refresh orders first", "priority":1}); + assert_eq!( + request( + &provider.client, + session, + (Method::POST, "/api/control/runs"), + (&urgent, &session.csrf) + ) + .await + .status() + .as_u16(), + role_case.expected_prioritised, + "a priority above 0 needs run.prioritise" + ); + let accepted = usize::from(role_case.expected_prioritised == 200); + launched_person_runs += accepted; + prioritised_runs + .extend(std::iter::repeat_n(urgent["run_id"].clone(), accepted)); for (endpoint, request_body) in [(&path[..], &body), ("/api/control/runs", &job)] { @@ -466,6 +488,8 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ } expected_work += usize::from(role_case.expected_connection == 200) * sessions.len(); expected_runs += usize::from(role_case.expected_job == 200) * sessions.len(); + expected_runs += + usize::from(role_case.expected_prioritised == 200) * sessions.len(); assert_eq!( queue_counts(store).await, json!({"work":expected_work,"runs":expected_runs,"claims":0}) diff --git a/crates/sqlbuild-orchestration/tests/runs/test_permissions.rs b/crates/sqlbuild-orchestration/tests/runs/test_permissions.rs index 131c80a45a..6e29e1d3fc 100644 --- a/crates/sqlbuild-orchestration/tests/runs/test_permissions.rs +++ b/crates/sqlbuild-orchestration/tests/runs/test_permissions.rs @@ -209,11 +209,12 @@ fn given_scoped_person_roles_when_jobs_request_sensitive_settings_then_only_admi &JobRunRequest { run_id: source_id, job_key: job_key.into(), + priority: 1, ..request.clone() }, ) .await - .expect("owner creates source with frozen settings"); + .expect("owner creates urgent source with frozen settings"); let cancellation = CancelRunRequest { run_id: source_id, event_id: Uuid::now_v7(), @@ -226,7 +227,7 @@ fn given_scoped_person_roles_when_jobs_request_sensitive_settings_then_only_admi .await .ok(), can_cancel.then_some(expected), - "cancellation permits stopping even sensitive work" + "cancellation permits stopping even sensitive or prioritised work" ); } let source_id = Uuid::now_v7(); diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md index aee81e65cd..2e87b81cc6 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md @@ -164,7 +164,7 @@ permissions = ["view.evidence"] | Selectors | Same grammar as a job's `select`; an unknown tag or name fails publication | | No matching nodes | Publication succeeds with a warning; the grant gives nothing | | Permissions | Any non-administrative action; `user.manage`, `role.assign` and `audit.view` are rejected | -| Runs | `job.run`, `run.adhoc`, `run.rerun` and `run.rewrite_history` need the permission on every node the run writes | +| Runs | `job.run`, `run.adhoc`, `run.rerun` and `run.rewrite_history` need the permission on every node the run writes; a priority above 0 also needs `run.prioritise`, which only Admin grants among built-in roles | | Triggers | `trigger.toggle` and `sensor.reset_cursor` need the permission on every node the trigger's job writes | | Listings | The catalog, statistics and run list need `view` on every node of the current publication; partial coverage is denied, not filtered | | Several roles | Grants add up across a person's roles | diff --git a/ui/src/lib/api/mock/access.ts b/ui/src/lib/api/mock/access.ts index bfd059226a..bdc0f09599 100644 --- a/ui/src/lib/api/mock/access.ts +++ b/ui/src/lib/api/mock/access.ts @@ -113,6 +113,7 @@ const PLAIN: Record = { 'run.cancel': 'cancel runs', 'run.rerun': 'rerun runs', 'run.rewrite_history': 'full refresh or reprocess past data', + 'run.prioritise': 'run ahead of the queue', 'trigger.toggle': 'pause and resume schedules and sensors', 'sensor.reset_cursor': 'reset where a sensor is up to', 'run.adhoc': 'build any selection of nodes', diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 2176c599ce..e1bf968858 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -18,6 +18,7 @@ export const CAPABILITIES = [ 'run.cancel', 'run.rerun', 'run.rewrite_history', + 'run.prioritise', 'trigger.toggle', 'sensor.reset_cursor', 'run.adhoc', diff --git a/ui/src/lib/platform/permission-catalog.ts b/ui/src/lib/platform/permission-catalog.ts index 8686dd3bef..8292fcaef6 100644 --- a/ui/src/lib/platform/permission-catalog.ts +++ b/ui/src/lib/platform/permission-catalog.ts @@ -24,6 +24,7 @@ export const PERMISSIONS: Record = { description: 'Rebuild tables from scratch or reprocess earlier periods, replacing data already built.', risk: 'changes_data' }, + 'run.prioritise': { label: 'Put runs ahead of the queue', description: 'Start or rerun a run with a priority above 0, so it is claimed before waiting runs.', risk: 'operate' }, 'trigger.toggle': { label: 'Pause and resume schedules and sensors', description: 'Stop or restart automatic runs.', risk: 'operate' }, 'sensor.reset_cursor': { label: 'Reset where a sensor is up to', description: 'Move a sensor’s position so it looks at events again.', risk: 'operate' }, 'run.adhoc': { label: 'Build any selection of nodes', description: 'Start an ad-hoc run of nodes outside a declared job, writing to the warehouse.', risk: 'changes_data' }, diff --git a/website/src/content/docs/docs/cli/server.mdx b/website/src/content/docs/docs/cli/server.mdx index 37144b9e86..857db8438d 100644 --- a/website/src/content/docs/docs/cli/server.mdx +++ b/website/src/content/docs/docs/cli/server.mdx @@ -156,7 +156,7 @@ permissions = ["view.evidence"] | Selectors | Same grammar as a job's `select`; an unknown tag or name fails publication | | No matching nodes | Publication succeeds with a warning; the grant gives nothing | | Permissions | Any non-administrative action; `user.manage`, `role.assign` and `audit.view` are rejected | -| Runs | `job.run`, `run.adhoc`, `run.rerun` and `run.rewrite_history` need the permission on every node the run writes | +| Runs | `job.run`, `run.adhoc`, `run.rerun` and `run.rewrite_history` need the permission on every node the run writes; a priority above 0 also needs `run.prioritise`, which only Admin grants among built-in roles | | Triggers | `trigger.toggle` and `sensor.reset_cursor` need the permission on every node the trigger's job writes | | Listings | The catalog, statistics and run list need `view` on every node of the current publication; partial coverage is denied, not filtered | | Several roles | Grants add up across a person's roles |