From 7c1daaf8cc6ee38c5134f015366a275aa208de23 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:15:16 +0000 Subject: [PATCH] feat: read check evidence through the control API People with view.evidence on a check's model can read the sample rows stored with one of its results. Columns are the rows' keys in sorted order, the order the warehouse stores them in, and a row without a key holds null there. Every read is audited, and a read whose audit cannot be written discloses nothing. The SQL and evidence reads now share one authorize, read and audit path. --- .../orchestration/check_identity.json | 59 +++++++++-- clients/typescript/src/schema.d.ts | 66 +++++++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/person_checks.rs | 27 ++++- .../sqlbuild-orchestration/src/api/models.rs | 9 ++ .../src/warehouse_metadata/_helpers/checks.rs | 98 ++++++++++++++++++- .../src/warehouse_metadata/main/mod.rs | 1 + .../main/read_person_check_evidence.rs | 27 +++++ .../main/read_person_check_sql.rs | 68 +++++-------- .../src/warehouse_metadata/models.rs | 13 +++ .../tests/api/test_checks.rs | 85 ++++++++++++++++ fensu.toml | 1 - 12 files changed, 397 insertions(+), 61 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_evidence.rs diff --git a/benchmarks/duckshop/edge_cases/orchestration/check_identity.json b/benchmarks/duckshop/edge_cases/orchestration/check_identity.json index 020286c84e..8ba0ca5e42 100644 --- a/benchmarks/duckshop/edge_cases/orchestration/check_identity.json +++ b/benchmarks/duckshop/edge_cases/orchestration/check_identity.json @@ -1,14 +1,59 @@ { "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"} + { + "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"} + { + "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' 🧾 */", + "evidence_json": "[{\"amount\":1.5,\"order_id\":\"o-1\"},{\"is_late\":true,\"order_id\":\"o-2\"}]", + "evidence_count": 2, + "evidence_truncated": "false" + }, + { + "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", + "evidence_json": "[\"o-9 malformed\"]" + } ] } diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 1e5cde2100..c2f2ab1fdb 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -242,6 +242,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/results/{event_id}/evidence": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readPersonCheckEvidence"]; + 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; @@ -817,6 +833,17 @@ export interface components { ChangeNotice: { topic: string; }; + /** @description One result's stored evidence rows; `view.evidence` reads them, audited. */ + CheckEvidence: { + binding_key: string; + /** @description Every row key, in sorted order; the warehouse stores rows with sorted keys. */ + columns: string[]; + /** Format: uuid */ + event_id: string; + /** @description Scalar cells aligned to `columns`; a row without a key holds null there. */ + rows: Record[][]; + truncated?: string | null; + }; /** @description One stored check result. Its SQL, error texts and evidence rows need their own permissions. */ CheckResult: { attached_column_name?: string | null; @@ -2301,6 +2328,45 @@ export interface operations { }; }; }; + readPersonCheckEvidence: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + binding_key: string; + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": null | components["schemas"]["CheckEvidence"]; + }; + }; + /** @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; 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 f876dd1988..171a2401d1 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -121,6 +121,10 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql", get(crate::api::_helpers::resources::person_checks::sql), ) + .route( + "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/results/{event_id}/evidence", + get(crate::api::_helpers::resources::person_checks::evidence), + ) .layer(DefaultBodyLimit::max(PASSWORD_BODY_BYTES)) } diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs index 14303e2b89..8f210a579c 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_checks.rs @@ -1,17 +1,18 @@ -//! Check reads use live person grants; SQL needs its own permission and is audited. +//! Check reads use live person grants; SQL and evidence need their own permissions and are 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::api::models::{CheckResultPath, 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_evidence::read_person_check_evidence; 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}; +use crate::warehouse_metadata::models::{CheckEvidence, 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)), @@ -79,3 +80,23 @@ pub(crate) async fn sql( .await?, )) } + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/results/{event_id}/evidence", operation_id = "readPersonCheckEvidence", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("binding_key" = String, Path), ("event_id" = Uuid, 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 evidence( + (State(store), Extension(context)): (State, Extension), + Path(path): Path, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + read_person_check_evidence( + &store, + (&context, principal), + (path.location_id, &path.target, &path.binding_key), + path.event_id, + ) + .await?, + )) +} diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 4b125b3979..16288de89a 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -27,6 +27,7 @@ use crate::api::_helpers::resources::{ person_checks::list, person_checks::history, person_checks::sql, + person_checks::evidence, person_location_work::request, person_location_work::result, person_node_sql::read, @@ -86,6 +87,14 @@ pub(crate) struct RunRecallQuery { pub(crate) limit: Option, } +#[derive(Debug, Deserialize)] +pub(crate) struct CheckResultPath { + pub(crate) location_id: uuid::Uuid, + pub(crate) target: String, + pub(crate) binding_key: String, + pub(crate) event_id: uuid::Uuid, +} + #[derive(Debug, Deserialize)] pub(crate) struct RunRecallPath { pub(crate) location_id: uuid::Uuid, diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs index a15bd54010..34340e5fd7 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/checks.rs @@ -3,13 +3,16 @@ use std::collections::BTreeSet; use serde::Deserialize; +use serde_json::json; use uuid::Uuid; -use crate::credentials::models::{Action, PermissionScope}; +use crate::authorization::main::authorize_person::authorize_person; +use crate::credentials::main::audit::append_audit::append_audit; +use crate::credentials::models::{Action, AuditContext, DefaultRole, PermissionScope}; use crate::store::errors::ControlError; -use crate::store::main::{query::query, query_records::query_records}; +use crate::store::main::{commit::commit, query::query, query_records::query_records}; use crate::store::models::{ControlTransaction, Parameter}; -use crate::warehouse_metadata::models::WarehouseAuditRecord; +use crate::warehouse_metadata::models::{CheckEvidence, WarehouseAuditRecord}; #[derive(Deserialize)] pub(crate) struct AuditRow { @@ -31,6 +34,62 @@ pub(crate) async fn latest_results( vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await } +/// One result of one check. +pub(crate) async fn check_result( + transaction: ControlTransaction, + (location_id, target, binding_key): (Uuid, &str, &str), + event_id: Uuid, +) -> Result<(ControlTransaction, Option), ControlError> { + let (transaction, mut rows) = 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 AND event_id=$4", + vec![ + Parameter::Id(location_id), + Parameter::Text(target.into()), + Parameter::Text(binding_key.into()), + Parameter::Id(event_id), + ], + ) + .await?; + Ok((transaction, rows.pop())) +} + +/// Stored evidence rows as columns in sorted key order and aligned cells. +pub(crate) fn evidence(row: AuditRow) -> Result { + let objects: Vec> = row + .record + .evidence_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|_| { + ControlError::Schema("stored check evidence is not an array of objects".into()) + })? + .unwrap_or_default(); + let columns: Vec = objects + .iter() + .flat_map(|object| object.keys().cloned()) + .collect::>() + .into_iter() + .collect(); + let mut rows: Vec> = Vec::with_capacity(objects.len()); + for object in &objects { + let mut cells: Vec = Vec::with_capacity(columns.len()); + for column in &columns { + cells.push(object.get(column).cloned().unwrap_or_default()); + } + rows.push(cells); + } + Ok(CheckEvidence { + event_id: row.event_id, + binding_key: row.record.binding_key, + columns, + rows, + truncated: row.record.evidence_truncated, + }) +} + /// One check's results, newest first. pub(crate) async fn check_results( transaction: ControlTransaction, @@ -44,8 +103,39 @@ pub(crate) async fn check_results( Parameter::Integer(i64::from(limit.clamp(1, 1000)))]).await } +/// Authorizes `action` on the row's check, projects the row, and audits the read before commit. +pub(crate) async fn sensitive_check_read( + (transaction, credentials): (ControlTransaction, (&AuditContext, Uuid)), + (location_id, target, binding_key): (Uuid, &str, &str), + (row, action, audit_action): (Option, Action, &str), + project: impl FnOnce(AuditRow) -> Result, +) -> Result, ControlError> { + let attached = row + .as_ref() + .and_then(|row| row.record.attached_target_name.as_deref()); + let (transaction, scope) = + check_scope(transaction, (location_id, target, attached), action).await?; + let (transaction, context) = + authorize_person(transaction, credentials, &scope, DefaultRole::None).await?; + let event_id = row.as_ref().map(|row| row.event_id); + let result = row.map(project).transpose()?; + let transaction = append_audit( + transaction, + &context, + (Uuid::now_v7(), audit_action), + ( + json!(scope), + json!(null), + json!({"binding_key": binding_key, "event_id": event_id, "found": result.is_some()}), + ), + ) + .await?; + commit(transaction).await?; + Ok(result) +} + /// The check's own node when the current snapshot has it, otherwise every current node. -pub(crate) async fn check_scope( +async fn check_scope( transaction: ControlTransaction, (location_id, target, attached): (Uuid, &str, Option<&str>), action: Action, diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs index ab5d35815c..722d6939c3 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/mod.rs @@ -10,6 +10,7 @@ pub mod ingest_statistics; pub(crate) mod prune_statistics; pub mod read_person_catalog; pub mod read_person_check; +pub mod read_person_check_evidence; pub mod read_person_check_sql; pub mod read_person_checks; pub mod read_person_fingerprint; diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_evidence.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_evidence.rs new file mode 100644 index 0000000000..8132c3e6f5 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_check_evidence.rs @@ -0,0 +1,27 @@ +//! Live evidence permission on the check's node, one result's rows and the sensitive-read audit share one writer fence. + +use uuid::Uuid; + +use crate::credentials::models::{Action, AuditContext}; +use crate::store::errors::ControlError; +use crate::store::main::begin::begin; +use crate::store::models::{ControlStore, TransactionMode}; +use crate::warehouse_metadata::_helpers::checks::{check_result, evidence, sensitive_check_read}; +use crate::warehouse_metadata::models::CheckEvidence; + +pub async fn read_person_check_evidence( + store: &ControlStore, + credentials: (&AuditContext, Uuid), + check: (Uuid, &str, &str), + event_id: Uuid, +) -> Result, ControlError> { + let (transaction, row) = + check_result(begin(store, TransactionMode::Write).await?, check, event_id).await?; + sensitive_check_read( + (transaction, credentials), + check, + (row, Action::ViewEvidence, "evidence.read"), + evidence, + ) + .await +} 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 index 399e66df51..6e615cd52b 100644 --- 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 @@ -1,60 +1,36 @@ //! 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::credentials::models::{Action, AuditContext}; use crate::store::errors::ControlError; -use crate::store::main::{begin::begin, commit::commit}; +use crate::store::main::begin::begin; use crate::store::models::{ControlStore, TransactionMode}; -use crate::warehouse_metadata::_helpers::checks::{check_results, check_scope}; +use crate::warehouse_metadata::_helpers::checks::{check_results, sensitive_check_read}; 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), + check: (Uuid, &str, &str), ) -> Result, ControlError> { - let (transaction, mut rows) = check_results( - begin(store, TransactionMode::Write).await?, - (location_id, target, binding_key), - 1, + let (transaction, mut rows) = + check_results(begin(store, TransactionMode::Write).await?, check, 1).await?; + sensitive_check_read( + (transaction, credentials), + check, + (rows.pop(), Action::ViewSql, "sql.read"), + |row| { + Ok(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, + }) + }, ) - .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) + .await } diff --git a/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs b/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs index 35ae53abcb..31c94dcb87 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_metadata/models.rs @@ -149,6 +149,19 @@ pub struct CheckSql { pub evidence_error: Option, } +/// One result's stored evidence rows; `view.evidence` reads them, audited. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CheckEvidence { + pub event_id: Uuid, + pub binding_key: String, + /// Every row key, in sorted order; the warehouse stores rows with sorted keys. + pub columns: Vec, + /// Scalar cells aligned to `columns`; a row without a key holds null there. + #[schema(value_type = Vec>)] + pub rows: Vec>, + pub truncated: 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/test_checks.rs b/crates/sqlbuild-orchestration/tests/api/test_checks.rs index 3fb234a575..24e1fc9acc 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_checks.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_checks.rs @@ -24,6 +24,8 @@ 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::fault_helpers::{install_fault, remove_fault}; +use crate::store::tests::fault_types::TransactionFault; 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; @@ -164,6 +166,7 @@ fn given_checks_sharing_an_audit_name_when_people_read_them_then_binding_keys_an let client = Client::new(); let viewer = login(&client, &api.url, "orders-viewer").await; let developer = login(&client, &api.url, "orders-developer").await; + let administrator = login(&client, &api.url, "orders-admin").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"); @@ -299,6 +302,88 @@ fn given_checks_sharing_an_audit_name_when_people_read_them_then_binding_keys_an StatusCode::FORBIDDEN ); } + let failed_id = history[1]["event_id"].as_str().expect("event"); + let evidence_path = format!("{base_path}/{orders_key}/results/{failed_id}/evidence"); + let (status, _) = get(&client, (&api.url, &developer), &evidence_path).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "developers lack view.evidence" + ); + let denial = latest_audit(store).await; + assert_eq!(denial["scope_json"]["action"], "view.evidence"); + assert_eq!(denial["scope_json"]["nodes"], json!(["orders"])); + let (status, evidence) = get(&client, (&api.url, &administrator), &evidence_path).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + evidence, + json!({"event_id": failed_id, "binding_key": orders_key, + "columns": ["amount", "is_late", "order_id"], + "rows": [[1.5, null, "o-1"], [null, true, "o-2"]], "truncated": "false"}), + "sorted keys become columns; missing keys become null cells" + ); + let read = latest_audit(store).await; + assert_eq!(read["action"], "evidence.read"); + assert_eq!( + read["after_json"], + json!({"binding_key": orders_key, "event_id": failed_id, "found": true}) + ); + assert!( + !read.to_string().contains("o-1"), + "the audit never holds evidence" + ); + let (status, mismatched) = get( + &client, + (&api.url, &administrator), + &evidence_path.replace(orders_key, customers_key), + ) + .await; + assert_eq!( + (status, mismatched), + (StatusCode::OK, Value::Null), + "a result is read only under its own check" + ); + let (_, archived) = get( + &client, + (&api.url, &administrator), + &format!("{base_path}/{archived_key}"), + ) + .await; + let malformed_path = format!( + "{base_path}/{archived_key}/results/{}/evidence", + archived[0]["event_id"].as_str().expect("event") + ); + let (status, refusal) = get(&client, (&api.url, &administrator), &malformed_path).await; + assert_eq!( + (status, refusal), + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": "control-store operation failed"}) + ), + "malformed stored evidence is never echoed" + ); + let fault = TransactionFault { + description: "evidence read requires durable audit", + table: "sqb_security_audit", + operation: "INSERT", + condition: "WHEN NEW.action='evidence.read'", + sqlite_body: "SELECT RAISE(ABORT, 'synthetic evidence audit failure')", + postgres_body: "RAISE EXCEPTION 'synthetic evidence audit failure'", + }; + install_fault(store, &fault).await; + let (status, refusal) = get(&client, (&api.url, &administrator), &evidence_path).await; + assert_eq!( + status, + StatusCode::INTERNAL_SERVER_ERROR, + "{}", + fault.description + ); + assert!( + !refusal.to_string().contains("o-1"), + "no evidence without its audit" + ); + remove_fault(store, &fault).await; + stop_api(api).await; close_test_store(fixture_store).await; }); diff --git a/fensu.toml b/fensu.toml index e67c674104..146aa9b37e 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1641,7 +1641,6 @@ construction_required = [ "Action=A permission is live only when a route requires it; role tables and classifiers do not count.", ] exceptions = [ - "Action::ViewEvidence=Kept under D129; reader comes with the UI epic.", "InaccessibleReason=Wire enum that serde builds from Python scope records.", "ScopeDiagnosticCode=Wire enum that serde builds from Python scope diagnostics.", "TickOutcome::Covered=The scheduler writes it as SQL text and serde reads it back.",