From cb18ce7375bb77613fabe668c1eebf6d908e9f52 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:27:38 +0000 Subject: [PATCH] feat!: read checks through the control API People can list a target's checks with their newest results, read one check's recent results, and, with view.sql, read its SQL. Checks are keyed by binding_key, now a typed, indexed column on the audit-result mirror, because one audit name such as not_null covers many models and columns. A model rename or a severity or threshold change starts a new check history, and the old one stays readable. SQL reads are audited like node SQL. BREAKING CHANGE: control stores created by 0.188.x are not upgraded. The server refuses them with an error saying to recreate the control store; create a new one when upgrading. --- .../orchestration/check_identity.json | 14 + clients/typescript/src/schema.d.ts | 212 ++++++++++++ .../authentication/password_sessions.rs | 12 + .../src/api/_helpers/resources/mod.rs | 1 + .../api/_helpers/resources/person_checks.rs | 81 +++++ .../sqlbuild-orchestration/src/api/models.rs | 9 +- .../src/store/_helpers/migration.rs | 4 +- .../src/store/schema.sql | 4 +- .../src/warehouse_metadata/_helpers/checks.rs | 73 +++++ .../src/warehouse_metadata/_helpers/mod.rs | 1 + .../main/ingest_audit_results.rs | 1 + .../src/warehouse_metadata/main/mod.rs | 3 + .../main/read_person_check.rs | 33 ++ .../main/read_person_check_sql.rs | 60 ++++ .../main/read_person_checks.rs | 32 ++ .../src/warehouse_metadata/models.rs | 75 +++++ .../tests/api/checks/helpers.rs | 68 ++++ .../tests/api/test_checks.rs | 306 ++++++++++++++++++ .../tests/api/test_contracts.rs | 4 + .../tests/snapshots/helpers.rs | 3 +- fensu.toml | 4 +- 21 files changed, 992 insertions(+), 8 deletions(-) create mode 100644 benchmarks/duckshop/edge_cases/orchestration/check_identity.json create mode 100644 crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs create mode 100644 crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs create mode 100644 crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check.rs create mode 100644 crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_sql.rs create mode 100644 crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_checks.rs create mode 100644 crates/sqlbuild-orchestration/tests/api/checks/helpers.rs create mode 100644 crates/sqlbuild-orchestration/tests/api/test_checks.rs diff --git a/benchmarks/duckshop/edge_cases/orchestration/check_identity.json b/benchmarks/duckshop/edge_cases/orchestration/check_identity.json new file mode 100644 index 0000000000..020286c84e --- /dev/null +++ b/benchmarks/duckshop/edge_cases/orchestration/check_identity.json @@ -0,0 +1,14 @@ +{ + "audit_name": "not_null", + "checks": [ + {"binding_key": "6f1c0e2a-orders-order-id", "attached_target_name": "orders", "attached_column_name": "order_id"}, + {"binding_key": "0b9d4c71-customers-customer-id", "attached_target_name": "customers", "attached_column_name": "customer_id"}, + {"binding_key": "9a3e5d10-archived-orders-order-id", "attached_target_name": "archived_orders", "attached_column_name": "order_id"} + ], + "results": [ + {"check": 0, "result_id": "orders-not-null-1", "occurred_at": "2026-02-03T04:05:06Z", "outcome": "fail", "violation_count": 3, "executed_sql": "SELECT order_id FROM orders WHERE order_id IS NULL /* first 'run' 🧾 */"}, + {"check": 0, "result_id": "orders-not-null-2", "occurred_at": "2026-02-04T04:05:06Z", "outcome": "pass", "violation_count": 0, "executed_sql": "SELECT order_id FROM orders WHERE order_id IS NULL /* second 'run' 🧾 */"}, + {"check": 1, "result_id": "customers-not-null-1", "occurred_at": "2026-02-03T05:05:06Z", "outcome": "error", "violation_count": null, "executed_sql": "SELECT customer_id FROM customers WHERE customer_id IS NULL", "execution_error": "Binder Error: Referenced column \"customer_id\" not found\n\nLINE 1: SELECT customer_id FROM customers WHERE email = 'private@example.com'"}, + {"check": 2, "result_id": "archived-orders-not-null-1", "occurred_at": "2026-02-02T04:05:06Z", "outcome": "pass", "violation_count": 0, "executed_sql": "SELECT order_id FROM archived_orders WHERE order_id IS NULL"} + ] +} diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index e08a30bc2e..1e5cde2100 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -210,6 +210,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/checks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonChecks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readPersonCheckHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readPersonCheckSql"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/connection-tests": { parameters: { query?: never; @@ -769,6 +817,52 @@ export interface components { ChangeNotice: { topic: string; }; + /** @description One stored check result. Its SQL, error texts and evidence rows need their own permissions. */ + CheckResult: { + attached_column_name?: string | null; + attached_target_kind?: string | null; + attached_target_name?: string | null; + attachment_kind: string; + audit_definition_name: string; + audit_description?: string | null; + audit_name: string; + /** @description Identity of the check; a renamed model or changed severity or thresholds starts a new one. */ + binding_key: string; + evaluation_mode: string; + /** Format: uuid */ + event_id: string; + /** Format: int64 */ + evidence_count?: number | null; + evidence_truncated?: string | null; + invocation_id: string; + measured_value?: string | null; + /** Format: int64 */ + minimum_samples?: number | null; + /** Format: date-time */ + occurred_at: string; + outcome: string; + reused: string; + run_id: string; + run_scope_phase: string; + /** Format: int64 */ + sample_count?: number | null; + sample_unit?: string | null; + severity: string; + thresholds_json?: string | null; + /** Format: int64 */ + violation_count?: number | null; + }; + /** @description A check's latest SQL and error texts, which quote SQL; `view.sql` reads them, audited. */ + CheckSql: { + binding_key: string; + /** Format: uuid */ + event_id: string; + evidence_error?: string | null; + evidence_sql?: string | null; + executed_sql?: string | null; + execution_error?: string | null; + measurement_sql?: string | null; + }; CliLoginApprovalRequest: { approve: boolean; browser_code: string; @@ -2127,6 +2221,124 @@ export interface operations { }; }; }; + listPersonChecks: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The newest result of every check */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CheckResult"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; + readPersonCheckHistory: { + parameters: { + query?: { + /** @description Maximum results, newest first; defaults to 50, capped at 1000 */ + limit?: number; + }; + header?: never; + path: { + location_id: string; + target: string; + binding_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Empty for an unknown check */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CheckResult"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; + readPersonCheckSql: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + binding_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": null | components["schemas"]["CheckSql"]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; requestConnectionTest: { 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 20fd69bcbd..f876dd1988 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -109,6 +109,18 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/nodes/{node_key}/sql", get(crate::api::_helpers::resources::person_node_sql::read), ) + .route( + "/api/control/locations/{location_id}/targets/{target}/checks", + get(crate::api::_helpers::resources::person_checks::list), + ) + .route( + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}", + get(crate::api::_helpers::resources::person_checks::history), + ) + .route( + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql", + get(crate::api::_helpers::resources::person_checks::sql), + ) .layer(DefaultBodyLimit::max(PASSWORD_BODY_BYTES)) } diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs index a66f36cbc8..3b6efd68ac 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod client; pub(crate) mod docker_image; pub(crate) mod location_health; pub(crate) mod location_work; +pub(crate) mod person_checks; pub(crate) mod person_location_work; pub(crate) mod person_node_sql; pub(crate) mod person_operations; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs new file mode 100644 index 0000000000..14303e2b89 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs @@ -0,0 +1,81 @@ +//! Check reads use live person grants; SQL needs its own permission and is audited. + +use axum::extract::{Path, Query, State}; +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::store::models::ControlStore; +use crate::warehouse_metadata::main::read_person_check::read_person_check; +use crate::warehouse_metadata::main::read_person_check_sql::read_person_check_sql; +use crate::warehouse_metadata::main::read_person_checks::read_person_checks; +use crate::warehouse_metadata::models::{CheckResult, CheckSql}; + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks", operation_id = "listPersonChecks", + params(("location_id" = Uuid, Path), ("target" = String, Path)), + responses((status = 200, body = Vec, description = "The newest result of every check"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn list( + (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( + read_person_checks( + &store, + (&context, principal), + (location_id, &target), + default_role, + ) + .await?, + )) +} + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}", operation_id = "readPersonCheckHistory", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("binding_key" = String, Path), + ("limit" = Option, Query, description = "Maximum results, newest first; defaults to 50, capped at 1000")), + responses((status = 200, body = Vec, description = "Empty for an unknown check"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn history( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + (Path((location_id, target, binding_key)), Query(options)): ( + Path<(Uuid, String, String)>, + Query, + ), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + read_person_check( + &store, + (&context, principal), + (location_id, &target, &binding_key), + (options.limit.unwrap_or(50), default_role), + ) + .await?, + )) +} + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql", operation_id = "readPersonCheckSql", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("binding_key" = String, Path)), + responses((status = 200, body = Option), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn sql( + (State(store), Extension(context)): (State, Extension), + Path((location_id, target, binding_key)): Path<(Uuid, String, String)>, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + read_person_check_sql( + &store, + (&context, principal), + (location_id, &target, &binding_key), + ) + .await?, + )) +} diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 5db16e69d9..4b125b3979 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize}; use crate::api::_helpers::authentication::password_sessions; use crate::api::_helpers::authentication::{cli_login, oidc}; use crate::api::_helpers::resources::{ - administrators, audit, changes, location_health, location_work, person_location_work, - person_node_sql, person_operations, run_reads, runs, snapshots, trigger_reads, - warehouse_metadata, + administrators, audit, changes, location_health, location_work, person_checks, + person_location_work, person_node_sql, person_operations, run_reads, runs, snapshots, + trigger_reads, warehouse_metadata, }; #[derive(Debug, utoipa::OpenApi)] @@ -24,6 +24,9 @@ use crate::api::_helpers::resources::{ person_operations::statistics, person_operations::fingerprint, person_operations::node_status, + person_checks::list, + person_checks::history, + person_checks::sql, person_location_work::request, person_location_work::result, person_node_sql::read, diff --git a/crates/sqlbuild-orchestration/src/store/_helpers/migration.rs b/crates/sqlbuild-orchestration/src/store/_helpers/migration.rs index f35f06a534..a572753c5a 100644 --- a/crates/sqlbuild-orchestration/src/store/_helpers/migration.rs +++ b/crates/sqlbuild-orchestration/src/store/_helpers/migration.rs @@ -20,7 +20,9 @@ pub(crate) fn verify_schema(rows: &[Row], digest: &str) -> Result<(), ControlErr .any(|row| row.get("checksum").and_then(|value| value.as_str()) != Some(digest)) { return Err(ControlError::Schema( - "control store schema checksum mismatch".into(), + "control store schema checksum mismatch; recreate the control store, because \ + pre-release schemas are not upgraded" + .into(), )); } Ok(()) diff --git a/crates/sqlbuild-orchestration/src/store/schema.sql b/crates/sqlbuild-orchestration/src/store/schema.sql index a2a1bae803..39a45c68a7 100644 --- a/crates/sqlbuild-orchestration/src/store/schema.sql +++ b/crates/sqlbuild-orchestration/src/store/schema.sql @@ -396,9 +396,11 @@ CREATE TABLE sqb_warehouse_audit_results ( outcome TEXT NOT NULL, record_json {json} NOT NULL, received_at {time} NOT NULL, - scan_id {uuid} + scan_id {uuid}, + binding_key TEXT ); CREATE INDEX sqb_audit_results_namespace ON sqb_warehouse_audit_results (location_id,target,state_database,state_schema,observed_at,event_id); +CREATE INDEX sqb_audit_results_binding ON sqb_warehouse_audit_results (location_id,target,binding_key,observed_at,event_id); CREATE TABLE sqb_warehouse_janitor_events ( event_id {uuid} PRIMARY KEY, location_id {uuid} NOT NULL REFERENCES sqb_locations(location_id), diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs new file mode 100644 index 0000000000..a15bd54010 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs @@ -0,0 +1,73 @@ +//! Check reads over the audit-result mirror, keyed by binding key on its own index. + +use std::collections::BTreeSet; + +use serde::Deserialize; +use uuid::Uuid; + +use crate::credentials::models::{Action, PermissionScope}; +use crate::store::errors::ControlError; +use crate::store::main::{query::query, query_records::query_records}; +use crate::store::models::{ControlTransaction, Parameter}; +use crate::warehouse_metadata::models::WarehouseAuditRecord; + +#[derive(Deserialize)] +pub(crate) struct AuditRow { + pub(crate) event_id: Uuid, + pub(crate) record: WarehouseAuditRecord, +} + +/// The newest result of every check in the target. +pub(crate) async fn latest_results( + transaction: ControlTransaction, + (location_id, target): (Uuid, &str), +) -> Result<(ControlTransaction, Vec), ControlError> { + query_records(transaction, + "SELECT a.event_id,a.record_json AS record FROM sqb_warehouse_audit_results a \ + WHERE a.location_id=$1 AND a.target=$2 AND NOT EXISTS (SELECT 1 FROM sqb_warehouse_audit_results b \ + WHERE b.location_id=a.location_id AND b.target=a.target AND b.binding_key=a.binding_key \ + AND (b.observed_at,b.event_id)>(a.observed_at,a.event_id)) \ + ORDER BY a.audit_name,a.binding_key", + vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await +} + +/// One check's results, newest first. +pub(crate) async fn check_results( + transaction: ControlTransaction, + (location_id, target, binding_key): (Uuid, &str, &str), + limit: u32, +) -> Result<(ControlTransaction, Vec), ControlError> { + query_records(transaction, + "SELECT event_id,record_json AS record FROM sqb_warehouse_audit_results \ + WHERE location_id=$1 AND target=$2 AND binding_key=$3 ORDER BY observed_at DESC,event_id DESC LIMIT $4", + vec![Parameter::Id(location_id), Parameter::Text(target.into()), Parameter::Text(binding_key.into()), + Parameter::Integer(i64::from(limit.clamp(1, 1000)))]).await +} + +/// The check's own node when the current snapshot has it, otherwise every current node. +pub(crate) async fn check_scope( + transaction: ControlTransaction, + (location_id, target, attached): (Uuid, &str, Option<&str>), + action: Action, +) -> Result<(ControlTransaction, PermissionScope), ControlError> { + let (transaction, rows) = query(transaction, + "SELECT n.node_key FROM sqb_snapshot_nodes n JOIN sqb_current_snapshots c ON c.snapshot_id=n.snapshot_id \ + WHERE c.location_id=$1 AND c.target=$2", + vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await?; + let mut nodes: BTreeSet = rows + .iter() + .filter_map(|row| row["node_key"].as_str().map(str::to_owned)) + .collect(); + if let Some(attached) = attached.filter(|attached| nodes.contains(*attached)) { + nodes = [attached.to_owned()].into(); + } + Ok(( + transaction, + PermissionScope { + action, + location_id: Some(location_id), + target: Some(target.into()), + nodes, + }, + )) +} diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/mod.rs index 35e0fdc180..6df3ded4b0 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod catalog; +pub(crate) mod checks; pub(crate) mod events; pub(crate) mod fingerprint_reads; pub(crate) mod fingerprints; diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/ingest_audit_results.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/ingest_audit_results.rs index c02579a321..b3e5fd568f 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/ingest_audit_results.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/ingest_audit_results.rs @@ -35,6 +35,7 @@ pub async fn ingest_audit_results( columns: vec![ ("audit_name", Parameter::Text(record.audit_name.clone())), ("outcome", Parameter::Text(record.outcome.clone())), + ("binding_key", Parameter::Text(record.binding_key.clone())), ], record_json: json!(record), }) diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs index 4ebb52f84f..ab5d35815c 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs @@ -9,6 +9,9 @@ pub mod ingest_source_freshness; pub mod ingest_statistics; pub(crate) mod prune_statistics; pub mod read_person_catalog; +pub mod read_person_check; +pub mod read_person_check_sql; +pub mod read_person_checks; pub mod read_person_fingerprint; pub mod read_person_node_status; pub mod read_person_statistics; diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check.rs new file mode 100644 index 0000000000..c3f9a121c1 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check.rs @@ -0,0 +1,33 @@ +//! Live view permission over the whole target and one check's results, newest first. + +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::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; +use crate::warehouse_metadata::_helpers::checks::check_results; +use crate::warehouse_metadata::models::CheckResult; + +pub async fn read_person_check( + store: &ControlStore, + credentials: (&AuditContext, Uuid), + (location_id, target, binding_key): (Uuid, &str, &str), + (limit, default_role): (u32, DefaultRole), +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + credentials, + (location_id, target), + default_role, + ) + .await?; + let (transaction, rows) = + check_results(transaction, (location_id, target, binding_key), limit).await?; + commit(transaction).await?; + Ok(rows + .into_iter() + .map(|row| CheckResult::new(row.event_id, row.record)) + .collect()) +} diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_sql.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_sql.rs new file mode 100644 index 0000000000..399e66df51 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_sql.rs @@ -0,0 +1,60 @@ +//! Live SQL permission on the check's node, its latest SQL and errors, and the sensitive-read audit share one writer fence. + +use serde_json::json; +use uuid::Uuid; + +use crate::authorization::main::authorize_person::authorize_person; +use crate::credentials::main::audit::append_audit::append_audit; +use crate::credentials::models::{Action, AuditContext, DefaultRole}; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; +use crate::warehouse_metadata::_helpers::checks::{check_results, check_scope}; +use crate::warehouse_metadata::models::CheckSql; + +pub async fn read_person_check_sql( + store: &ControlStore, + credentials: (&AuditContext, Uuid), + (location_id, target, binding_key): (Uuid, &str, &str), +) -> Result, ControlError> { + let (transaction, mut rows) = check_results( + begin(store, TransactionMode::Write).await?, + (location_id, target, binding_key), + 1, + ) + .await?; + let latest = rows.pop(); + let attached = latest + .as_ref() + .and_then(|row| row.record.attached_target_name.as_deref()); + let (transaction, scope) = check_scope( + transaction, + (location_id, target, attached), + Action::ViewSql, + ) + .await?; + let (transaction, context) = + authorize_person(transaction, credentials, &scope, DefaultRole::None).await?; + let result = latest.map(|row| CheckSql { + event_id: row.event_id, + binding_key: row.record.binding_key, + executed_sql: row.record.executed_sql, + measurement_sql: row.record.measurement_sql, + evidence_sql: row.record.evidence_sql, + execution_error: row.record.execution_error, + evidence_error: row.record.evidence_error, + }); + let transaction = append_audit( + transaction, + &context, + (Uuid::now_v7(), "sql.read"), + ( + json!(scope), + json!(null), + json!({"binding_key": binding_key, "event_id": result.as_ref().map(|sql| sql.event_id), "found": result.is_some()}), + ), + ) + .await?; + commit(transaction).await?; + Ok(result) +} diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_checks.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_checks.rs new file mode 100644 index 0000000000..13632263e9 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_checks.rs @@ -0,0 +1,32 @@ +//! Live view permission over the whole target and the newest result of every check. + +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::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; +use crate::warehouse_metadata::_helpers::checks::latest_results; +use crate::warehouse_metadata::models::CheckResult; + +pub async fn read_person_checks( + store: &ControlStore, + credentials: (&AuditContext, Uuid), + (location_id, target): (Uuid, &str), + default_role: DefaultRole, +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + credentials, + (location_id, target), + default_role, + ) + .await?; + let (transaction, rows) = latest_results(transaction, (location_id, target)).await?; + commit(transaction).await?; + Ok(rows + .into_iter() + .map(|row| CheckResult::new(row.event_id, row.record)) + .collect()) +} diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs index 61e87d6a6c..35ae53abcb 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs @@ -74,6 +74,81 @@ pub struct WarehouseAuditRecord { pub audit_meta_json: Option, } +/// One stored check result. Its SQL, error texts and evidence rows need their own permissions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CheckResult { + pub event_id: Uuid, + /// Identity of the check; a renamed model or changed severity or thresholds starts a new one. + pub binding_key: String, + pub audit_name: String, + pub audit_definition_name: String, + pub audit_description: Option, + pub attachment_kind: String, + pub attached_target_kind: Option, + pub attached_target_name: Option, + pub attached_column_name: Option, + pub severity: String, + pub evaluation_mode: String, + pub run_scope_phase: String, + pub thresholds_json: Option, + pub occurred_at: DateTime, + pub run_id: String, + pub invocation_id: String, + pub outcome: String, + pub violation_count: Option, + pub measured_value: Option, + pub sample_count: Option, + pub sample_unit: Option, + pub minimum_samples: Option, + pub evidence_count: Option, + pub evidence_truncated: Option, + pub reused: String, +} + +impl CheckResult { + pub(crate) fn new(event_id: Uuid, record: WarehouseAuditRecord) -> Self { + Self { + event_id, + binding_key: record.binding_key, + audit_name: record.audit_name, + audit_definition_name: record.audit_definition_name, + audit_description: record.audit_description, + attachment_kind: record.attachment_kind, + attached_target_kind: record.attached_target_kind, + attached_target_name: record.attached_target_name, + attached_column_name: record.attached_column_name, + severity: record.severity, + evaluation_mode: record.evaluation_mode, + run_scope_phase: record.run_scope_phase, + thresholds_json: record.thresholds_json, + occurred_at: record.occurred_at, + run_id: record.run_id, + invocation_id: record.invocation_id, + outcome: record.outcome, + violation_count: record.violation_count, + measured_value: record.measured_value, + sample_count: record.sample_count, + sample_unit: record.sample_unit, + minimum_samples: record.minimum_samples, + evidence_count: record.evidence_count, + evidence_truncated: record.evidence_truncated, + reused: record.reused, + } + } +} + +/// A check's latest SQL and error texts, which quote SQL; `view.sql` reads them, audited. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CheckSql { + pub event_id: Uuid, + pub binding_key: String, + pub executed_sql: Option, + pub measurement_sql: Option, + pub evidence_sql: Option, + pub execution_error: Option, + pub evidence_error: Option, +} + /// Warehouse cleanup facts do not authorize control-plane deletion or worker recovery. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] diff --git a/crates/sqlbuild-orchestration/tests/api/checks/helpers.rs b/crates/sqlbuild-orchestration/tests/api/checks/helpers.rs new file mode 100644 index 0000000000..ca8d39c2d2 --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/api/checks/helpers.rs @@ -0,0 +1,68 @@ +//! Password sessions, fixture loading and audit reads for the check API tests. + +use reqwest::{Client, StatusCode, header}; +use serde_json::{Value, json}; + +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{ControlStore, TransactionMode}; + +pub(crate) const ORIGIN: &str = "https://control.example.com"; +pub(crate) const PASSWORD: &str = "orders-password-2026"; + +pub(crate) fn fixture(name: &str) -> Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../benchmarks/duckshop/edge_cases/orchestration") + .join(name); + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture")).expect("fixture JSON") +} + +pub(crate) async fn get( + client: &Client, + (url, cookie): (&str, &str), + path: &str, +) -> (StatusCode, Value) { + let response = client + .get(format!("{url}{path}")) + .header(header::HOST, "control.example.com") + .header(header::ORIGIN, ORIGIN) + .header("cookie", cookie) + .send() + .await + .expect("check read"); + let status = response.status(); + (status, response.json().await.unwrap_or(Value::Null)) +} + +pub(crate) async fn login(client: &Client, url: &str, username: &str) -> String { + let response = client + .post(format!("{url}/api/auth/password/login")) + .header(header::HOST, "control.example.com") + .header(header::ORIGIN, ORIGIN) + .json(&json!({"username": username, "password": PASSWORD})) + .send() + .await + .expect("password login"); + assert_eq!(response.status(), StatusCode::OK); + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("cookie")) + .find(|value| value.starts_with("__Host-sqb-session=")) + .expect("browser session") + .split(';') + .next() + .expect("cookie pair") + .to_owned() +} + +pub(crate) async fn latest_audit(store: &ControlStore) -> Value { + let transaction = begin(store, TransactionMode::Read) + .await + .expect("audit cut"); + let (transaction, rows) = query(transaction, + "SELECT action,granting_role,scope_json,after_json FROM sqb_security_audit ORDER BY storage_id DESC LIMIT 1", + vec![]).await.expect("audit"); + commit(transaction).await.expect("close audit cut"); + json!(rows[0]) +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_checks.rs b/crates/sqlbuild-orchestration/tests/api/test_checks.rs new file mode 100644 index 0000000000..3fb234a575 --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/api/test_checks.rs @@ -0,0 +1,306 @@ +//! Check reads through the public API: keyed by binding key, view-gated, with audited SQL. + +use reqwest::{Client, StatusCode}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::api::models::{ApiAuthentication, HumanLogin}; +use crate::api::tests::checks_helpers::{ORIGIN, PASSWORD, fixture, get, latest_audit, login}; +use crate::api::tests::helpers::{start_api_with_authentication, stop_api}; +use crate::api::tests::test_types::WarehouseMetadataTestCase; +use crate::authorization::main::{ + assign_builtin_role::assign_builtin_role, bootstrap_user::bootstrap_user, +}; +use crate::credentials::main::{ + identities::create_principal::create_principal, sessions::login_password::login_password, + sessions::set_password::set_password, +}; +use crate::credentials::models::{ + AuditContext, BuiltinRole, PasswordAuthority, PasswordLogin, RoleAssignmentRequest, +}; +use crate::credentials::tests::location_helpers::create_location; +use crate::platform::main::report_location_health::report_location_health; +use crate::platform::models::{LocationHealth, LocationHealthReport}; +use crate::runs::tests::helpers::snapshot; +use crate::snapshots::main::publish_snapshot::publish_snapshot; +use crate::store::main::migrate_store::migrate_store; +use crate::store::tests::store_helpers::{close_test_store, open_test_store, run}; +use crate::store::tests::store_test_types::Backend; +use crate::warehouse_metadata::main::ingest_audit_results::ingest_audit_results; +use crate::warehouse_metadata::models::MetadataBatch; + +#[test] +fn given_checks_sharing_an_audit_name_when_people_read_them_then_binding_keys_and_sql_permissions_hold() + { + let test_cases = [ + WarehouseMetadataTestCase { + description: "SQLite check reads", + backend: Backend::Sqlite, + expected_observations: 4, + }, + WarehouseMetadataTestCase { + description: "PostgreSQL check reads", + backend: Backend::Postgres, + expected_observations: 4, + }, + ]; + for test_case in test_cases { + run(async { + let fixture_store = open_test_store(test_case.backend).await.expect("store"); + let store = &fixture_store.store; + migrate_store(store).await.expect("migrate"); + let context = AuditContext { + actor: "control-store-owner".into(), + granting_role: "control-store-owner".into(), + request_id: Uuid::now_v7(), + ip_address: Some("127.0.0.1".parse().expect("local peer")), + }; + let admin = bootstrap_user(store, &context, "orders-admin") + .await + .expect("bootstrap"); + let PasswordLogin::Authenticated(admin_session) = ({ + set_password( + store, + &context, + PasswordAuthority::Operator(admin), + PASSWORD, + ) + .await + .expect("admin password"); + login_password(store, &context, ("orders-admin", PASSWORD)) + .await + .expect("admin login") + }) else { + panic!("authenticated administrator"); + }; + let location = create_location(store, "orders", "test-admin") + .await + .expect("location"); + for (name, role) in [ + ("orders-viewer", BuiltinRole::Viewer), + ("orders-developer", BuiltinRole::Developer), + ] { + let person = create_principal(store, &context, name) + .await + .expect("person"); + set_password( + store, + &context, + PasswordAuthority::Operator(person), + PASSWORD, + ) + .await + .expect("password"); + assign_builtin_role( + store, + (&context, admin_session.identity.principal_id), + &RoleAssignmentRequest { + principal_id: person, + role: role.into(), + location_id: Some(location), + target: Some("development".into()), + revoke: false, + }, + ) + .await + .expect("scoped role"); + } + publish_snapshot(store, &snapshot(location)) + .await + .expect("snapshot"); + let session_id = Uuid::now_v7(); + report_location_health( + store, + location, + &LocationHealthReport { + event_id: Uuid::now_v7(), + session_id, + target: "development".into(), + health: LocationHealth::Heartbeat {}, + }, + ) + .await + .expect("reader"); + let data = fixture("check_identity.json"); + let base = fixture("audit_result_history.json")["record"].clone(); + let records: Vec = data["results"] + .as_array() + .expect("results") + .iter() + .map(|result| { + let check = &data["checks"][result["check"].as_u64().expect("check") as usize]; + let mut record = base.clone(); + for (key, value) in check.as_object().expect("check").iter().chain( + result + .as_object() + .expect("result") + .iter() + .filter(|(key, _)| *key != "check"), + ) { + record[key] = value.clone(); + } + record["audit_name"] = data["audit_name"].clone(); + record + }) + .collect(); + assert_eq!(records.len(), test_case.expected_observations); + let batch: MetadataBatch = serde_json::from_value(json!({"kind":"audit_results","session_id":session_id,"generation":1,"records":records, + "scan":{"scan_id":Uuid::now_v7(),"location":{"database":null,"schema":"main"},"complete":false}})).expect("batch"); + assert!( + ingest_audit_results(store, (location, "development"), &batch) + .await + .expect("real audit ingestion") + .applied + ); + + let api = start_api_with_authentication( + store, + ApiAuthentication { + login: HumanLogin::Password(ORIGIN.into()), + ..Default::default() + }, + ) + .await; + let client = Client::new(); + let viewer = login(&client, &api.url, "orders-viewer").await; + let developer = login(&client, &api.url, "orders-developer").await; + let base_path = format!("/api/control/locations/{location}/targets/development/checks"); + let orders_key = data["checks"][0]["binding_key"].as_str().expect("key"); + let customers_key = data["checks"][1]["binding_key"].as_str().expect("key"); + let archived_key = data["checks"][2]["binding_key"].as_str().expect("key"); + + let (status, list) = get(&client, (&api.url, &viewer), &base_path).await; + assert_eq!(status, StatusCode::OK, "{}", test_case.description); + let keys: Vec<(&str, &str, &str)> = list + .as_array() + .expect("checks") + .iter() + .map(|check| { + ( + check["binding_key"].as_str().expect("key"), + check["outcome"].as_str().expect("outcome"), + check["audit_name"].as_str().expect("name"), + ) + }) + .collect(); + assert_eq!( + keys, + [ + (customers_key, "error", "not_null"), + (orders_key, "pass", "not_null"), + (archived_key, "pass", "not_null") + ], + "one entry per binding key, each its newest result" + ); + for disclosed in ["SELECT", "private@example.com"] { + assert!( + !list.to_string().contains(disclosed), + "listing never discloses SQL, errors quoting it, or evidence" + ); + } + + let (status, history) = get( + &client, + (&api.url, &viewer), + &format!("{base_path}/{orders_key}"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let outcomes: Vec<&str> = history + .as_array() + .expect("history") + .iter() + .map(|result| result["outcome"].as_str().expect("outcome")) + .collect(); + assert_eq!(outcomes, ["pass", "fail"], "newest first, own key only"); + assert_eq!(history[1]["violation_count"], 3); + let (_, limited) = get( + &client, + (&api.url, &viewer), + &format!("{base_path}/{orders_key}?limit=1"), + ) + .await; + assert_eq!(limited.as_array().expect("limited").len(), 1); + let (status, unknown) = get( + &client, + (&api.url, &viewer), + &format!("{base_path}/unknown-check"), + ) + .await; + assert_eq!((status, unknown), (StatusCode::OK, json!([]))); + + let sql_path = format!("{base_path}/{orders_key}/sql"); + let (status, _) = get(&client, (&api.url, &viewer), &sql_path).await; + assert_eq!(status, StatusCode::FORBIDDEN, "viewers lack view.sql"); + let denial = latest_audit(store).await; + assert_eq!(denial["action"], "authorization.denied"); + assert_eq!(denial["scope_json"]["action"], "view.sql"); + assert_eq!(denial["scope_json"]["nodes"], json!(["orders"])); + + let (status, sql) = get(&client, (&api.url, &developer), &sql_path).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(sql["binding_key"], orders_key); + assert_eq!(sql["executed_sql"], data["results"][1]["executed_sql"]); + assert_eq!(sql["event_id"], history[0]["event_id"]); + let read = latest_audit(store).await; + assert_eq!(read["action"], "sql.read"); + assert_eq!(read["granting_role"], "developer"); + assert_eq!(read["scope_json"]["nodes"], json!(["orders"])); + assert_eq!( + read["after_json"], + json!({"binding_key": orders_key, "event_id": history[0]["event_id"], "found": true}) + ); + assert!( + !read.to_string().contains("IS NULL"), + "the audit never holds SQL" + ); + + let (status, failed) = get( + &client, + (&api.url, &developer), + &format!("{base_path}/{customers_key}/sql"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + failed["execution_error"], + data["results"][2]["execution_error"] + ); + let (status, _) = get( + &client, + (&api.url, &viewer), + &format!("{base_path}/{archived_key}/sql"), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + latest_audit(store).await["scope_json"]["nodes"], + json!(["customers", "inventory", "orders", "products"]), + "a check on a node outside the snapshot needs the whole target" + ); + + let (status, missing) = get( + &client, + (&api.url, &developer), + &format!("{base_path}/unknown-check/sql"), + ) + .await; + assert_eq!((status, missing), (StatusCode::OK, Value::Null)); + let read = latest_audit(store).await; + assert_eq!(read["action"], "sql.read"); + assert_eq!(read["after_json"]["found"], false); + + for path in [ + base_path.replace("development", "preview"), + base_path.replace(&location.to_string(), &Uuid::now_v7().to_string()), + ] { + assert_eq!( + get(&client, (&api.url, &viewer), &path).await.0, + StatusCode::FORBIDDEN + ); + } + stop_api(api).await; + close_test_store(fixture_store).await; + }); + } +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs index 9d31785d31..e9878e27e2 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs @@ -2,6 +2,8 @@ #[path = "changes/helpers.rs"] mod changes_helpers; +#[path = "checks/helpers.rs"] +pub(crate) mod checks_helpers; #[path = "fingerprint_reads/helpers.rs"] mod fingerprint_reads_helpers; #[path = "helpers.rs"] @@ -32,6 +34,8 @@ mod statistics_retention; mod test_catalog_reads; #[path = "test_changes.rs"] mod test_changes; +#[path = "test_checks.rs"] +mod test_checks; #[path = "test_cli_login.rs"] mod test_cli_login; #[path = "test_contract.rs"] diff --git a/crates/sqlbuild-orchestration/tests/snapshots/helpers.rs b/crates/sqlbuild-orchestration/tests/snapshots/helpers.rs index 5da79b10ec..406a79fc5e 100644 --- a/crates/sqlbuild-orchestration/tests/snapshots/helpers.rs +++ b/crates/sqlbuild-orchestration/tests/snapshots/helpers.rs @@ -287,7 +287,8 @@ pub(super) async fn sqlite_failed_publish_and_schema_floor() -> i64 { .execute_batch(case.update_sql) .expect("apply synthetic schema incompatibility"); assert!( - matches!(migrate_store(&store).await, Err(ControlError::Schema(_))), + matches!(migrate_store(&store).await, + Err(ControlError::Schema(message)) if message.contains("recreate the control store")), "{}", case.description ); diff --git a/fensu.toml b/fensu.toml index f6acf4ff80..e67c674104 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1367,11 +1367,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_location_work.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_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"] 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/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_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/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_metadata/_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_metadata/main/read_person_check.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_checks.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]]