Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions clients/typescript/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listPersonTriggerChanges"];
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;
Expand Down Expand Up @@ -1920,6 +1936,28 @@ export interface components {
/** Format: uuid */
principal_id: string;
};
/** @description One operator change of a trigger, as a person reads it: what changed, who changed it, when and why. */
PersonTriggerChange: {
/** @description The audited actor, such as `principal:<id>` for a person. */
actor: string;
/** @description The person's name when the actor is a person who still exists. */
actor_name?: string | null;
change: components["schemas"]["TriggerChange"];
/**
* Format: uuid
* @description The change's own ID, as recorded in the security audit.
*/
event_id: string;
/** Format: date-time */
occurred_at: string;
/** @description The operator's reason, with credential-shaped text redacted. */
reason: string;
/**
* Format: uuid
* @description The publication that was current when the change was made.
*/
snapshot_id: string;
};
/** @description A trigger as people list it, with its newest tick so a list needs no read per trigger. */
PersonTriggerRecord: components["schemas"]["TriggerRecord"] & {
latest_tick?: null | components["schemas"]["TickRecord"];
Expand Down Expand Up @@ -4509,6 +4547,55 @@ export interface operations {
};
};
};
listPersonTriggerChanges: {
parameters: {
query?: {
/** @description Maximum recent changes; defaults to 100, capped at 1000 */
limit?: number;
};
header?: never;
path: {
location_id: string;
target: string;
trigger_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Operator changes, newest first */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonTriggerChange"][];
};
};
/** @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;
};
};
};
listPersonTicks: {
parameters: {
query?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ pub(crate) fn person_routes() -> Router<ControlStore> {
"/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}/triggers/{trigger_id}/changes",
get(crate::api::_helpers::resources::person_reads::trigger_changes),
)
.route(
"/api/control/locations/{location_id}/targets/{target}/snapshots",
get(crate::api::_helpers::resources::person_reads::snapshots),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ use crate::api::errors::ApiError;
use crate::api::models::{ReadQuery, RunEventsQuery, RunNodePath, StatementSqlQuery};
use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole};
use crate::declarations::main::list_person_jobs::list_person_jobs;
use crate::declarations::main::list_person_trigger_changes::list_person_trigger_changes;
use crate::declarations::main::list_person_triggers::list_person_triggers;
use crate::declarations::main::read_person_job::read_person_job;
use crate::declarations::models::{JobDetailRecord, JobRecord, PersonTriggerRecord};
use crate::declarations::models::{
JobDetailRecord, JobRecord, PersonTriggerChange, PersonTriggerRecord,
};
use crate::platform::main::read_person_location_health::read_person_location_health;
use crate::platform::main::read_person_problems::read_person_problems;
use crate::platform::models::{LocationHealthObservation, PlatformProblems};
Expand Down Expand Up @@ -333,6 +336,37 @@ pub(crate) async fn ticks(
.ok_or_else(|| not_found("trigger not found"))
}

#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes", operation_id = "listPersonTriggerChanges",
params(("location_id" = Uuid, Path), ("target" = String, Path), ("trigger_id" = Uuid, Path),
("limit" = Option<u32>, Query, description = "Maximum recent changes; defaults to 100, capped at 1000")),
responses((status = 200, body = Vec<PersonTriggerChange>, description = "Operator changes, newest first"),
(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 trigger_changes(
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
Extension(default_role): Extension<DefaultRole>,
(Path((location_id, target, trigger_id)), Query(options)): (
Path<(Uuid, String, Uuid)>,
Query<ReadQuery>,
),
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
) -> Result<Json<Vec<PersonTriggerChange>>, ApiError> {
list_person_trigger_changes(
&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"))
}

#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/snapshots", operation_id = "listPersonSnapshots",
params(("location_id" = Uuid, Path), ("target" = String, Path),
("limit" = Option<u32>, Query, description = "Maximum recent publications; defaults to 20, capped at 100")),
Expand Down
1 change: 1 addition & 0 deletions crates/sqlbuild-orchestration/src/api/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use crate::api::_helpers::resources::{
person_reads::job,
person_reads::triggers,
person_reads::ticks,
person_reads::trigger_changes,
person_reads::snapshots,
person_reads::health,
person_reads::problems,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
//! Whether a run or trigger, including a removed trigger, belongs to one location and target.
//! A person reads one run or trigger, including a removed trigger, only with view on the whole target.
use uuid::Uuid;

use crate::authorization::main::authorize_listing::authorize_listing;
use crate::credentials::models::{AuditContext, DefaultRole};
use crate::store::errors::ControlError;
use crate::store::main::query::query;
use crate::store::models::{ControlTransaction, Parameter, TargetRow};
use crate::store::main::{begin::begin, commit::commit, query::query};
use crate::store::models::{
ControlStore, ControlTransaction, Parameter, TargetRow, TransactionMode,
};

/// Authorizes first, then `None` (after committing) when the row is not in this location and target.
pub(crate) async fn row_in_target(
transaction: ControlTransaction,
(location_id, target): (Uuid, &str),
row: TargetRow,
) -> Result<(ControlTransaction, bool), ControlError> {
store: &ControlStore,
credentials: (&AuditContext, Uuid),
((location_id, target), row): ((Uuid, &str), TargetRow),
default_role: DefaultRole,
) -> Result<Option<ControlTransaction>, ControlError> {
let (transaction, _) = authorize_listing(
begin(store, TransactionMode::Write).await?,
credentials,
(location_id, target),
default_role,
)
.await?;
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",
Expand All @@ -31,5 +44,9 @@ pub(crate) async fn row_in_target(
],
)
.await?;
Ok((transaction, !rows.is_empty()))
if rows.is_empty() {
commit(transaction).await?;
return Ok(None);
}
Ok(Some(transaction))
}
49 changes: 48 additions & 1 deletion crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
use uuid::Uuid;

use crate::declarations::models::{JobRecord, StoredJob, TriggerRecord};
use sqlbuild_core::redaction::main::redact_text::redact_text;

use crate::declarations::models::{
JobRecord, PersonTriggerChange, StoredJob, TriggerChangeRequest, TriggerRecord,
};
use crate::scheduler::models::TickRecord;
use crate::store::errors::ControlError;
use crate::store::main::query::query;
use crate::store::main::query_records::query_records;
use crate::store::models::{ControlTransaction, Parameter};

Expand All @@ -19,6 +24,48 @@ pub(crate) async fn triggers_in(
vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await
}

/// A trigger's operator changes from the security audit, newest first, with a person actor's name.
pub(crate) async fn trigger_changes_in(
transaction: ControlTransaction,
trigger_id: Uuid,
limit: u32,
) -> Result<(ControlTransaction, Vec<PersonTriggerChange>), ControlError> {
let (transaction, rows) = query(
transaction,
"SELECT a.scope_json, a.occurred_at, p.name AS actor_name FROM sqb_security_audit a \
LEFT JOIN sqb_principals p ON 'principal:' || CAST(p.principal_id AS TEXT)=a.actor \
WHERE a.action='trigger.changed' AND a.scope_json ->> 'trigger_id'=$1 \
ORDER BY a.occurred_at DESC, a.event_id DESC LIMIT $2",
vec![
Parameter::Text(trigger_id.to_string()),
Parameter::Integer(i64::from(limit.min(1000))),
],
)
.await?;
let changes = rows
.into_iter()
.map(|row| {
let request: TriggerChangeRequest =
serde_json::from_value(row["scope_json"]["request"].clone())
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
let snapshot_id = serde_json::from_value(row["scope_json"]["snapshot_id"].clone())
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
let occurred_at = serde_json::from_value(row["occurred_at"].clone())
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
Ok(PersonTriggerChange {
event_id: request.event_id,
change: request.change,
actor: request.actor,
actor_name: row["actor_name"].as_str().map(str::to_owned),
reason: redact_text(&request.reason),
occurred_at,
snapshot_id,
})
})
.collect::<Result<_, ControlError>>()?;
Ok((transaction, changes))
}

pub(crate) async fn jobs_in(
transaction: ControlTransaction,
(location_id, target): (Uuid, &str),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! A person sees a trigger's on/off history only with view on every current node; an unknown trigger is refused alike.
use uuid::Uuid;

use crate::authorization::main::row_in_target::row_in_target;
use crate::credentials::models::{AuditContext, DefaultRole};
use crate::declarations::_helpers::reads::trigger_changes_in;
use crate::declarations::models::PersonTriggerChange;
use crate::store::errors::ControlError;
use crate::store::main::commit::commit;
use crate::store::models::{ControlStore, TargetRow};

/// `None` when the trigger is not in this location and target; authorization runs first either way.
pub async fn list_person_trigger_changes(
store: &ControlStore,
(context, credentials): (&AuditContext, Uuid),
((location_id, target), (trigger_id, limit)): ((Uuid, &str), (Uuid, u32)),
default_role: DefaultRole,
) -> Result<Option<Vec<PersonTriggerChange>>, ControlError> {
let Some(transaction) = row_in_target(
store,
(context, credentials),
((location_id, target), TargetRow::Trigger(trigger_id)),
default_role,
)
.await?
else {
return Ok(None);
};
let (transaction, changes) = trigger_changes_in(transaction, trigger_id, limit).await?;
commit(transaction).await?;
Ok(Some(changes))
}
1 change: 1 addition & 0 deletions crates/sqlbuild-orchestration/src/declarations/main/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod change_trigger;
pub(crate) mod frozen_definitions;
pub mod group_dependencies;
pub mod list_person_jobs;
pub mod list_person_trigger_changes;
pub mod list_person_triggers;
pub mod list_trigger_events;
pub mod list_triggers;
Expand Down
17 changes: 17 additions & 0 deletions crates/sqlbuild-orchestration/src/declarations/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ pub struct PersonTriggerRecord {
pub latest_tick: Option<crate::scheduler::models::TickRecord>,
}

/// One operator change of a trigger, as a person reads it: what changed, who changed it, when and why.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PersonTriggerChange {
/// The change's own ID, as recorded in the security audit.
pub event_id: uuid::Uuid,
pub change: TriggerChange,
/// The audited actor, such as `principal:<id>` for a person.
pub actor: String,
/// The person's name when the actor is a person who still exists.
pub actor_name: Option<String>,
/// The operator's reason, with credential-shaped text redacted.
pub reason: String,
pub occurred_at: DateTime<Utc>,
/// The publication that was current when the change was made.
pub snapshot_id: uuid::Uuid,
}

/// Operator changes are immutable facts; clearing an override restores the published code default.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
Expand Down
Loading
Loading