diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 8c9d52f41d..57810a34ff 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -434,6 +434,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/runs/{run_id}/nodes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonRunNodes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/statistics": { parameters: { query?: never; @@ -450,6 +466,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/triggers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonTriggers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonTicks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/runs": { parameters: { query?: never; @@ -3022,6 +3070,51 @@ export interface operations { }; }; }; + listPersonRunNodes: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunNodeRecord"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + /** @description Run not found for a person who may view the whole target */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; readPersonStatistics: { parameters: { query: { @@ -3074,6 +3167,91 @@ export interface operations { }; }; }; + listPersonTriggers: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TriggerRecord"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; + listPersonTicks: { + parameters: { + query?: { + /** @description Maximum recent ticks; defaults to 100, capped at 1000 */ + limit?: number; + }; + header?: never; + path: { + location_id: string; + target: string; + trigger_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TickRecord"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + /** @description Trigger not found for a person who may view the whole target */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; requestJobRun: { parameters: { query?: never; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs index 8839bd24df..35f6359639 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -97,6 +97,18 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/runs", get(crate::api::_helpers::resources::person_operations::recent_runs), ) + .route( + "/api/control/locations/{location_id}/targets/{target}/runs/{run_id}/nodes", + get(crate::api::_helpers::resources::person_reads::run_nodes), + ) + .route( + "/api/control/locations/{location_id}/targets/{target}/triggers", + get(crate::api::_helpers::resources::person_reads::triggers), + ) + .route( + "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks", + get(crate::api::_helpers::resources::person_reads::ticks), + ) .route( "/api/control/locations/{location_id}/targets/{target}/catalog", get(crate::api::_helpers::resources::person_operations::catalog), diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs index 88a30e20e2..a4a82ccea6 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod person_location_work; pub(crate) mod person_maintenance; pub(crate) mod person_node_sql; pub(crate) mod person_operations; +pub(crate) mod person_reads; pub(crate) mod router; pub(crate) mod run_reads; pub(crate) mod runs; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs new file mode 100644 index 0000000000..cd4392dea9 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -0,0 +1,99 @@ +//! Person reads of run nodes, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use uuid::Uuid; + +use crate::api::errors::ApiError; +use crate::api::models::ReadQuery; +use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole}; +use crate::declarations::main::list_person_triggers::list_person_triggers; +use crate::declarations::models::TriggerRecord; +use crate::runs::main::list_person_run_nodes::list_person_run_nodes; +use crate::runs::models::RunNodeRecord; +use crate::scheduler::main::list_person_ticks::list_person_ticks; +use crate::scheduler::models::TickRecord; +use crate::store::models::ControlStore; + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/runs/{run_id}/nodes", operation_id = "listPersonRunNodes", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("run_id" = Uuid, Path)), + responses((status = 200, body = Vec), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody), + (status = 404, description = "Run not found for a person who may view the whole target")), + security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn run_nodes( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + Path((location_id, target, run_id)): Path<(Uuid, String, Uuid)>, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + list_person_run_nodes( + &store, + (&context, principal), + (location_id, &target, run_id), + default_role, + ) + .await? + .map(Json) + .ok_or_else(|| not_found("run not found")) +} + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers", operation_id = "listPersonTriggers", + params(("location_id" = Uuid, Path), ("target" = String, Path)), + responses((status = 200, body = Vec), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn triggers( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + Path((location_id, target)): Path<(Uuid, String)>, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + list_person_triggers( + &store, + (&context, principal), + (location_id, &target), + default_role, + ) + .await?, + )) +} + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks", operation_id = "listPersonTicks", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("trigger_id" = Uuid, Path), + ("limit" = Option, Query, description = "Maximum recent ticks; defaults to 100, capped at 1000")), + responses((status = 200, body = Vec), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody), + (status = 404, description = "Trigger not found for a person who may view the whole target")), + security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn ticks( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + (Path((location_id, target, trigger_id)), Query(options)): ( + Path<(Uuid, String, Uuid)>, + Query, + ), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + list_person_ticks( + &store, + (&context, principal), + ( + (location_id, &target), + (trigger_id, options.limit.unwrap_or(100)), + ), + default_role, + ) + .await? + .map(Json) + .ok_or_else(|| not_found("trigger not found")) +} + +fn not_found(message: &str) -> ApiError { + ApiError { + status: StatusCode::NOT_FOUND, + message: message.into(), + decision_id: None, + } +} diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index e0c65b37f2..17dc9477f4 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -7,7 +7,7 @@ use crate::api::_helpers::authentication::{cli_login, oidc}; use crate::api::_helpers::resources::{ administrators, audit, changes, location_health, location_work, person_bootstrap, person_checks, person_location_work, person_maintenance, person_node_sql, person_operations, - run_reads, runs, snapshots, trigger_reads, warehouse_metadata, + person_reads, run_reads, runs, snapshots, trigger_reads, warehouse_metadata, }; #[derive(Debug, utoipa::OpenApi)] @@ -20,6 +20,9 @@ use crate::api::_helpers::resources::{ person_operations::rerun, person_operations::change_trigger, person_operations::recent_runs, + person_reads::run_nodes, + person_reads::triggers, + person_reads::ticks, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, diff --git a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs index 54eefbd50e..66851f5c97 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs @@ -14,3 +14,4 @@ pub(crate) mod read_audit_events; pub(crate) mod read_person_access; pub mod read_security_audit; pub(crate) mod refuse_unknown; +pub(crate) mod row_in_target; diff --git a/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs b/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs new file mode 100644 index 0000000000..7b72aab83e --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs @@ -0,0 +1,35 @@ +//! Whether a run or trigger, including a removed trigger, belongs to one location and target. + +use uuid::Uuid; + +use crate::store::errors::ControlError; +use crate::store::main::query::query; +use crate::store::models::{ControlTransaction, Parameter, TargetRow}; + +pub(crate) async fn row_in_target( + transaction: ControlTransaction, + (location_id, target): (Uuid, &str), + row: TargetRow, +) -> Result<(ControlTransaction, bool), ControlError> { + let (sql, id) = match row { + TargetRow::Run(run_id) => ( + "SELECT run_id FROM sqb_runs WHERE run_id=$1 AND location_id=$2 AND target=$3", + run_id, + ), + TargetRow::Trigger(trigger_id) => ( + "SELECT trigger_id FROM sqb_triggers WHERE trigger_id=$1 AND location_id=$2 AND target=$3", + trigger_id, + ), + }; + let (transaction, rows) = query( + transaction, + sql, + vec![ + Parameter::Id(id), + Parameter::Id(location_id), + Parameter::Text(target.into()), + ], + ) + .await?; + Ok((transaction, !rows.is_empty())) +} diff --git a/crates/sqlbuild-orchestration/src/declarations/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/declarations/_helpers/mod.rs index 90ce175a05..dd82f3f543 100644 --- a/crates/sqlbuild-orchestration/src/declarations/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/declarations/_helpers/mod.rs @@ -3,5 +3,6 @@ pub(crate) mod documents; pub(crate) mod operator_change; pub(crate) mod operator_state; pub(crate) mod publication; +pub(crate) mod reads; pub(crate) mod trigger_scope; pub(crate) mod trigger_state; diff --git a/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs b/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs new file mode 100644 index 0000000000..a69248fd01 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs @@ -0,0 +1,19 @@ +//! Trigger reads inside a caller's transaction, so authorization and results share one view. + +use uuid::Uuid; + +use crate::declarations::models::TriggerRecord; +use crate::store::errors::ControlError; +use crate::store::main::query_records::query_records; +use crate::store::models::{ControlTransaction, Parameter}; + +pub(crate) async fn triggers_in( + transaction: ControlTransaction, + (location_id, target): (Uuid, &str), +) -> Result<(ControlTransaction, Vec), ControlError> { + query_records(transaction, + "SELECT t.trigger_id, t.name, t.job_key, t.definition_json, s.status, s.last_tick_for, s.next_due_at \ + FROM sqb_triggers t JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ + WHERE t.location_id=$1 AND t.target=$2 AND t.removed_in_snapshot_id IS NULL ORDER BY t.name", + vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await +} diff --git a/crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs b/crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs new file mode 100644 index 0000000000..472327f34c --- /dev/null +++ b/crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs @@ -0,0 +1,29 @@ +//! A person lists triggers only with view on every current node of the target. + +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::declarations::_helpers::reads::triggers_in; +use crate::declarations::models::TriggerRecord; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; + +pub async fn list_person_triggers( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + (location_id, target): (Uuid, &str), + default_role: DefaultRole, +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let (transaction, triggers) = triggers_in(transaction, (location_id, target)).await?; + commit(transaction).await?; + Ok(triggers) +} diff --git a/crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs b/crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs index fbddbed582..f28763272e 100644 --- a/crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs +++ b/crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs @@ -2,19 +2,19 @@ use uuid::Uuid; +use crate::declarations::_helpers::reads::triggers_in; use crate::declarations::models::TriggerRecord; use crate::store::errors::ControlError; -use crate::store::main::read_records::read_records; -use crate::store::models::{ControlStore, Parameter}; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; pub async fn list_triggers( store: &ControlStore, location_id: Uuid, target: &str, ) -> Result, ControlError> { - read_records(store, - "SELECT t.trigger_id, t.name, t.job_key, t.definition_json, s.status, s.last_tick_for, s.next_due_at \ - FROM sqb_triggers t JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ - WHERE t.location_id=$1 AND t.target=$2 AND t.removed_in_snapshot_id IS NULL ORDER BY t.name", - vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await + let transaction = begin(store, TransactionMode::Read).await?; + let (transaction, triggers) = triggers_in(transaction, (location_id, target)).await?; + commit(transaction).await?; + Ok(triggers) } diff --git a/crates/sqlbuild-orchestration/src/declarations/main/mod.rs b/crates/sqlbuild-orchestration/src/declarations/main/mod.rs index 23cab85fba..1a14985c37 100644 --- a/crates/sqlbuild-orchestration/src/declarations/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/declarations/main/mod.rs @@ -2,6 +2,7 @@ pub mod change_person_trigger; pub mod change_trigger; pub(crate) mod frozen_definitions; pub mod group_dependencies; +pub mod list_person_triggers; pub mod list_trigger_events; pub mod list_triggers; pub mod pause_development_triggers; diff --git a/crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs b/crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs index de9d1ba696..e3f248bb67 100644 --- a/crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs +++ b/crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs @@ -2,7 +2,7 @@ use uuid::Uuid; -use crate::runs::models::RunRecord; +use crate::runs::models::{RunNodeRecord, RunRecord}; use crate::store::errors::ControlError; use crate::store::main::query_records::query_records; use crate::store::models::{ControlTransaction, Parameter}; @@ -17,3 +17,13 @@ pub(crate) async fn list_runs_in( FROM sqb_runs WHERE location_id=$1 AND target=$2 ORDER BY queued_at DESC, run_id DESC LIMIT $3", vec![Parameter::Id(location_id), Parameter::Text(target.into()), Parameter::Integer(i64::from(limit.min(1000)))]).await } + +pub(crate) async fn run_nodes_in( + transaction: ControlTransaction, + run_id: Uuid, +) -> Result<(ControlTransaction, Vec), ControlError> { + query_records(transaction, + "SELECT node_key, status, started_at, ended_at, rows_affected, error_code, skipped_because_of \ + FROM sqb_run_nodes WHERE run_id=$1 AND kind='node' ORDER BY node_key", + vec![Parameter::Id(run_id)]).await +} diff --git a/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs b/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs new file mode 100644 index 0000000000..4634d81121 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs @@ -0,0 +1,37 @@ +//! A person sees a run's nodes only with view on every current node; an unknown run is refused alike. + +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::authorization::main::row_in_target::row_in_target; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::runs::_helpers::lifecycle::reads::run_nodes_in; +use crate::runs::models::RunNodeRecord; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TargetRow, TransactionMode}; + +/// `None` when the run is not in this location and target; authorization runs first either way. +pub async fn list_person_run_nodes( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + (location_id, target, run_id): (Uuid, &str, Uuid), + default_role: DefaultRole, +) -> Result>, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let (transaction, known) = + row_in_target(transaction, (location_id, target), TargetRow::Run(run_id)).await?; + if !known { + commit(transaction).await?; + return Ok(None); + } + let (transaction, nodes) = run_nodes_in(transaction, run_id).await?; + commit(transaction).await?; + Ok(Some(nodes)) +} diff --git a/crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs b/crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs index 29b4c84060..c50964137a 100644 --- a/crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs +++ b/crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs @@ -2,18 +2,19 @@ use uuid::Uuid; +use crate::runs::_helpers::lifecycle::reads::run_nodes_in; use crate::runs::models::RunNodeRecord; use crate::store::errors::ControlError; -use crate::store::main::read_records::read_records; -use crate::store::models::{ControlStore, Parameter}; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; /// The API authorizes visibility of the run before reading its nodes. pub async fn list_run_nodes( store: &ControlStore, run_id: Uuid, ) -> Result, ControlError> { - read_records(store, - "SELECT node_key, status, started_at, ended_at, rows_affected, error_code, skipped_because_of \ - FROM sqb_run_nodes WHERE run_id=$1 AND kind='node' ORDER BY node_key", - vec![Parameter::Id(run_id)]).await + let transaction = begin(store, TransactionMode::Read).await?; + let (transaction, nodes) = run_nodes_in(transaction, run_id).await?; + commit(transaction).await?; + Ok(nodes) } diff --git a/crates/sqlbuild-orchestration/src/runs/main/mod.rs b/crates/sqlbuild-orchestration/src/runs/main/mod.rs index bc36f01e6c..ce37545bfb 100644 --- a/crates/sqlbuild-orchestration/src/runs/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/runs/main/mod.rs @@ -4,6 +4,7 @@ pub mod change_run; pub mod claim_next_run; pub(crate) mod frozen_job_request; pub mod heartbeat_run; +pub mod list_person_run_nodes; pub mod list_person_runs; pub mod list_run_claims; pub mod list_run_nodes; diff --git a/crates/sqlbuild-orchestration/src/scheduler/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/scheduler/_helpers/mod.rs index bee69ea1d3..aa49638c2b 100644 --- a/crates/sqlbuild-orchestration/src/scheduler/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/scheduler/_helpers/mod.rs @@ -3,5 +3,6 @@ pub(crate) mod drain_due_triggers; pub(crate) mod drain_sensors; pub(crate) mod expansion; pub(crate) mod persistence; +pub(crate) mod reads; pub(crate) mod tick_retention; pub(crate) mod ticks; diff --git a/crates/sqlbuild-orchestration/src/scheduler/_helpers/reads.rs b/crates/sqlbuild-orchestration/src/scheduler/_helpers/reads.rs new file mode 100644 index 0000000000..ac1d3463e5 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/scheduler/_helpers/reads.rs @@ -0,0 +1,19 @@ +//! Tick history inside a caller's transaction, so authorization and results share one view. + +use uuid::Uuid; + +use crate::scheduler::models::TickRecord; +use crate::store::errors::ControlError; +use crate::store::main::query_records::query_records; +use crate::store::models::{ControlTransaction, Parameter}; + +pub(crate) async fn ticks_in( + transaction: ControlTransaction, + trigger_id: Uuid, + limit: u32, +) -> Result<(ControlTransaction, Vec), ControlError> { + query_records(transaction, + "SELECT tick_id, trigger_id, scheduled_for, evaluated_at, epoch, outcome, run_id, covered_by_run_id \ + FROM sqb_ticks WHERE trigger_id=$1 ORDER BY scheduled_for DESC LIMIT $2", + vec![Parameter::Id(trigger_id), Parameter::Integer(i64::from(limit.min(1000)))]).await +} diff --git a/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs b/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs new file mode 100644 index 0000000000..7062347159 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs @@ -0,0 +1,41 @@ +//! A person sees a trigger's ticks only with view on every current node; an unknown trigger is refused alike. + +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::authorization::main::row_in_target::row_in_target; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::scheduler::_helpers::reads::ticks_in; +use crate::scheduler::models::TickRecord; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TargetRow, TransactionMode}; + +/// `None` when the trigger is not in this location and target; authorization runs first either way. +pub async fn list_person_ticks( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + ((location_id, target), (trigger_id, limit)): ((Uuid, &str), (Uuid, u32)), + default_role: DefaultRole, +) -> Result>, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let (transaction, known) = row_in_target( + transaction, + (location_id, target), + TargetRow::Trigger(trigger_id), + ) + .await?; + if !known { + commit(transaction).await?; + return Ok(None); + } + let (transaction, ticks) = ticks_in(transaction, trigger_id, limit).await?; + commit(transaction).await?; + Ok(Some(ticks)) +} diff --git a/crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs b/crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs index 77699477a3..6ecd4d98bf 100644 --- a/crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs +++ b/crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs @@ -2,18 +2,19 @@ use uuid::Uuid; +use crate::scheduler::_helpers::reads::ticks_in; use crate::scheduler::models::TickRecord; use crate::store::errors::ControlError; -use crate::store::main::read_records::read_records; -use crate::store::models::{ControlStore, Parameter}; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; pub async fn list_ticks( store: &ControlStore, trigger_id: Uuid, limit: u32, ) -> Result, ControlError> { - read_records(store, - "SELECT tick_id, trigger_id, scheduled_for, evaluated_at, epoch, outcome, run_id, covered_by_run_id \ - FROM sqb_ticks WHERE trigger_id=$1 ORDER BY scheduled_for DESC LIMIT $2", - vec![Parameter::Id(trigger_id), Parameter::Integer(i64::from(limit.min(1000)))]).await + let transaction = begin(store, TransactionMode::Read).await?; + let (transaction, ticks) = ticks_in(transaction, trigger_id, limit).await?; + commit(transaction).await?; + Ok(ticks) } diff --git a/crates/sqlbuild-orchestration/src/scheduler/main/mod.rs b/crates/sqlbuild-orchestration/src/scheduler/main/mod.rs index f91c3d1180..5875aa88b5 100644 --- a/crates/sqlbuild-orchestration/src/scheduler/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/scheduler/main/mod.rs @@ -5,6 +5,7 @@ pub(in crate::scheduler) mod _plan_ticks; pub(in crate::scheduler) mod _previous_cron_time; pub(in crate::scheduler) mod _scheduler_cycle; pub(crate) mod check_lease; +pub mod list_person_ticks; pub mod list_ticks; pub(crate) mod next_schedule_time; pub mod parse_cron; diff --git a/crates/sqlbuild-orchestration/src/store/models.rs b/crates/sqlbuild-orchestration/src/store/models.rs index 9366364a4b..0e93f7ed72 100644 --- a/crates/sqlbuild-orchestration/src/store/models.rs +++ b/crates/sqlbuild-orchestration/src/store/models.rs @@ -37,6 +37,13 @@ pub(crate) struct ControlEvent<'a> { pub(crate) payload: serde_json::Value, } +/// A row whose location and target decide whether a person's target-scoped read may name it. +#[derive(Clone, Copy, Debug)] +pub(crate) enum TargetRow { + Run(Uuid), + Trigger(Uuid), +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TransactionMode { Read, diff --git a/crates/sqlbuild-orchestration/tests/api/test_denials.rs b/crates/sqlbuild-orchestration/tests/api/test_denials.rs index f1342a76cf..7f173d7916 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_denials.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_denials.rs @@ -20,8 +20,9 @@ use crate::store::tests::store_helpers::{close_test_store, open_test_store, run} use crate::store::tests::store_test_types::Backend; /// Location-scoped reads and the action each needs. -const READS: [(&str, &str); 9] = [ +const READS: [(&str, &str); 10] = [ ("runs", "view"), + ("triggers", "view"), ("changes", "view"), ("catalog?schema=orders", "view"), ("statistics?schema=orders&name=orders", "view"), @@ -130,6 +131,42 @@ fn given_refused_requests_when_people_send_them_then_no_node_is_named_and_unknow }) .collect(); cases.extend([ + ( + "run nodes".to_owned(), + "view", + vec![ + ( + read(inventory, &format!("runs/{}/nodes", queued.run_id)), + None, + ), + (read(inventory, &format!("runs/{unknown_run}/nodes")), None), + ( + read(roles.location_id, &format!("runs/{unknown_run}/nodes")), + None, + ), + ], + ), + ( + "trigger ticks".to_owned(), + "view", + vec![ + ( + read(inventory, &format!("triggers/{trigger_id}/ticks")), + None, + ), + ( + read(inventory, &format!("triggers/{unknown_trigger}/ticks")), + None, + ), + ( + read( + roles.location_id, + &format!("triggers/{unknown_trigger}/ticks"), + ), + None, + ), + ], + ), ( "job run".to_owned(), "job.run", diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index 911fc980b2..f776628da0 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -235,6 +235,59 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ role_case.expected_metadata == StatusCode::OK.as_u16() ); assert!(!page.to_string().contains("select 1 as id")); + let known_reads = [ + format!("{metadata}/{}/nodes", seed_run.run_id), + format!("/api/control/locations/{location}/targets/development/triggers"), + format!( + "/api/control/locations/{location}/targets/development/triggers/{}/ticks", + trigger.trigger_id + ), + ]; + let unknown_reads = [ + format!("{metadata}/{}/nodes", Uuid::now_v7()), + format!( + "/api/control/locations/{location}/targets/development/triggers/{}/ticks", + Uuid::now_v7() + ), + ]; + for read_path in &known_reads { + let detail = request( + &provider.client, + session, + (Method::GET, read_path), + (&Value::Null, &session.csrf), + ) + .await; + assert_eq!( + detail.status().as_u16(), + role_case.expected_metadata, + "{read_path}" + ); + let detail: Value = detail.json().await.expect("detail response"); + assert_eq!( + detail.is_array(), + role_case.expected_metadata == StatusCode::OK.as_u16(), + "{read_path}" + ); + } + for read_path in &unknown_reads { + let missing = request( + &provider.client, + session, + (Method::GET, read_path), + (&Value::Null, &session.csrf), + ) + .await; + assert_eq!( + missing.status().as_u16(), + if role_case.expected_metadata == StatusCode::OK.as_u16() { + StatusCode::NOT_FOUND.as_u16() + } else { + role_case.expected_metadata + }, + "{read_path}" + ); + } let sql = request( &provider.client, session, diff --git a/fensu.toml b/fensu.toml index 9458e916fc..fbabf3ab4c 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1377,11 +1377,11 @@ path = "src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py" reason = "The SQL Server adapter intentionally keeps overrideable backend behavior on one class." [[dupes.allowlist]] -paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/location_health.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/location_work.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_location_work.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_maintenance.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_node_sql.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_operations.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/run_reads.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/runs.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/snapshots.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/trigger_reads.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/warehouse_metadata.rs"] +paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/location_health.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/location_work.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_location_work.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_maintenance.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_node_sql.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_operations.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/run_reads.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/runs.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/snapshots.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/trigger_reads.rs", "crates/sqlbuild-orchestration/src/api/_helpers/resources/warehouse_metadata.rs"] reason = "Each function is one utoipa-annotated axum route: OpenAPI needs one annotated handler per operation, and each body only checks the caller's scope and calls one store operation. Scope checks and store work live in their shared owners." [[dupes.allowlist]] -paths = ["crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs", "crates/sqlbuild-orchestration/src/credentials/_helpers/active_session.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs", "crates/sqlbuild-orchestration/src/platform/main/read_location_work.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_runs.rs", "crates/sqlbuild-orchestration/src/runs/main/list_runs.rs", "crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/checks.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/attempts.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/facts.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_current_record.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_documents.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_nodes.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_catalog.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_check.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_checks.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_maintenance.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_microbatch_coverage.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_statistics.rs"] +paths = ["crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs", "crates/sqlbuild-orchestration/src/credentials/_helpers/active_session.rs", "crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs", "crates/sqlbuild-orchestration/src/platform/main/read_location_work.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_runs.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_runs.rs", "crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/checks.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs", "crates/sqlbuild-orchestration/src/store/main/read_records.rs", "crates/sqlbuild-orchestration/src/platform/main/location_work.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/attempts.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/facts.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_current_record.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_documents.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_nodes.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_catalog.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_check.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_checks.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_maintenance.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_microbatch_coverage.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_statistics.rs"] reason = "Each is one control-store read with its own SQL and record type. Transactions, person authorization and decoding go through the shared begin_authorized, query_records, read_records and query_bodies helpers; what remains alike is the call shape." [[dupes.allowlist]]