From 1e4aa20ade249d29e443e0f6fb8da13d27ef4e96 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:46:22 +0000 Subject: [PATCH 01/16] feat(ui): wire the lineage and changes pages to the live control API Add a person read of a target's published snapshots with the subjects each one changed, behind the same listing authorisation as jobs. The Changes page reads it, and the Lineage page builds its graph and inspector from the catalog listing, so neither page needs mock data. --- clients/typescript/src/schema.d.ts | 79 +++++++++++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/person_reads.rs | 27 +++++- .../sqlbuild-orchestration/src/api/models.rs | 1 + .../snapshots/main/list_person_snapshots.rs | 84 +++++++++++++++++++ .../src/snapshots/main/mod.rs | 1 + .../src/snapshots/models.rs | 23 +++++ .../tests/api/test_person_sessions.rs | 1 + .../tests/api/test_reads.rs | 51 ++++++++++- fensu.toml | 1 - ui/src/lib/api/http-changes.test.ts | 61 ++++++++++++++ ui/src/lib/api/http-changes.ts | 65 ++++++++++++++ ui/src/lib/api/http-client.ts | 16 +++- ui/src/lib/api/types/build.ts | 8 +- ui/src/lib/nav.test.ts | 5 +- ui/src/lib/nav.ts | 2 +- ui/src/routes/changes/+page.svelte | 13 +-- ui/src/routes/changes/utils.ts | 10 ++- ui/src/routes/lineage/+page.svelte | 21 ++++- 19 files changed, 451 insertions(+), 22 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs create mode 100644 ui/src/lib/api/http-changes.test.ts create mode 100644 ui/src/lib/api/http-changes.ts diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 117fb8c855..35c7f687f4 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -482,6 +482,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonSnapshots"]; + 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; @@ -1488,6 +1504,28 @@ export interface components { }; /** @enum {string} */ PlatformEventKind: "heartbeat" | "scheduler_no_leader" | "tick_overdue" | "calendar_horizon_low" | "location_broken" | "launch_failed"; + /** @description A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. */ + PublishedChange: { + change_kind: string; + subject_key: string; + }; + /** @description One publication as people browse it: its commit, environment and changed definitions. */ + PublishedSnapshot: { + /** @description Added, changed and removed nodes and roles, in publication order. */ + changes: components["schemas"]["PublishedChange"][]; + /** @description Digest of the lock file, Python and `sqb` versions; a change means dependencies changed. */ + environment_hash: string; + /** Format: int64 */ + generation: number; + git_author?: string | null; + git_dirty: boolean; + git_revision?: string | null; + /** Format: date-time */ + published_at: string; + /** Format: uuid */ + snapshot_id: string; + sqb_version: string; + }; /** @description One invocation's records; the server owns its claim namespace, start time and version. */ RecallBatch: { invocation_id: string; @@ -3286,6 +3324,47 @@ export interface operations { }; }; }; + listPersonSnapshots: { + parameters: { + query?: { + /** @description Maximum recent publications; defaults to 20, capped at 100 */ + limit?: number; + }; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Publications newest first, each with its changed nodes and roles */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishedSnapshot"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; readPersonStatistics: { parameters: { query: { 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 f40bb3d87d..5bce2d5b43 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -117,6 +117,10 @@ pub(crate) fn person_routes() -> Router { "/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}/snapshots", + get(crate::api::_helpers::resources::person_reads::snapshots), + ) .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/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs index 79fbb670ad..aa10e0c867 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -1,4 +1,4 @@ -//! Person reads of run nodes, catalog nodes, jobs, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden. +//! Person reads of run nodes, catalog nodes, jobs, triggers, ticks and publications; refusal precedes every lookup, so unknown objects look forbidden. use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -15,6 +15,8 @@ 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::snapshots::main::list_person_snapshots::list_person_snapshots; +use crate::snapshots::models::PublishedSnapshot; use crate::store::models::ControlStore; use crate::warehouse_metadata::main::list_person_catalog_nodes::list_person_catalog_nodes; use crate::warehouse_metadata::models::CatalogNodeRecord; @@ -136,6 +138,29 @@ pub(crate) async fn ticks( .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, Query, description = "Maximum recent publications; defaults to 20, capped at 100")), + responses((status = 200, body = Vec, description = "Publications newest first, each with its changed nodes and roles"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn snapshots( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + (Path((location_id, target)), Query(options)): (Path<(Uuid, String)>, Query), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + list_person_snapshots( + &store, + (&context, principal), + ((location_id, &target), options.limit.unwrap_or(20)), + default_role, + ) + .await?, + )) +} + fn not_found(message: &str) -> ApiError { ApiError { status: StatusCode::NOT_FOUND, diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 530893c084..9571345a46 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -25,6 +25,7 @@ use crate::api::_helpers::resources::{ person_reads::jobs, person_reads::triggers, person_reads::ticks, + person_reads::snapshots, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, diff --git a/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs new file mode 100644 index 0000000000..eaf79c0e86 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs @@ -0,0 +1,84 @@ +//! A person browses a target's publications only with view on every current node, like its jobs. + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::snapshots::models::{PublishedChange, PublishedSnapshot}; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit, decode_row::decode_row, query::query}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; +use crate::store::types::Row; + +/// Newest first, at most `limit` (capped at 100) publications with their changes. +pub async fn list_person_snapshots( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + ((location_id, target), limit): ((Uuid, &str), u32), + default_role: DefaultRole, +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let scope = vec![ + Parameter::Id(location_id), + Parameter::Text(target.into()), + Parameter::Integer(i64::from(limit.min(100))), + ]; + let (transaction, published) = query(transaction, + "SELECT snapshot_id, generation, git_revision, CASE WHEN git_dirty THEN 1 ELSE 0 END AS git_dirty, git_author, environment_hash, sqb_version, published_at \ + FROM sqb_snapshots WHERE location_id=$1 AND target=$2 ORDER BY generation DESC LIMIT $3", + scope.clone()).await?; + let (transaction, changed) = query(transaction, + "SELECT c.snapshot_id, c.change_kind, c.subject_key FROM sqb_snapshot_changes c \ + WHERE c.snapshot_id IN (SELECT s.snapshot_id FROM sqb_snapshots s WHERE s.location_id=$1 AND s.target=$2 \ + ORDER BY s.generation DESC LIMIT $3) ORDER BY c.snapshot_id, c.change_seq", + scope).await?; + commit(transaction).await?; + let mut changes: BTreeMap> = BTreeMap::new(); + for row in changed { + let snapshot_id = id_of(&row)?; + changes + .entry(snapshot_id) + .or_default() + .push(decode_row(row)?); + } + published + .into_iter() + .map(|row| { + let mut snapshot = snapshot_of(row)?; + snapshot.changes = changes.remove(&snapshot.snapshot_id).unwrap_or_default(); + Ok(snapshot) + }) + .collect() +} + +fn snapshot_of(row: Row) -> Result { + let snapshot_id = id_of(&row)?; + let text = |key: &str| row[key].as_str().map(String::from); + let published_at: DateTime = serde_json::from_value(row["published_at"].clone()) + .map_err(|error| ControlError::Invalid(format!("stored publication time: {error}")))?; + Ok(PublishedSnapshot { + snapshot_id, + generation: row["generation"].as_i64().unwrap_or_default(), + git_revision: text("git_revision"), + git_dirty: row["git_dirty"].as_i64() == Some(1), + git_author: text("git_author"), + environment_hash: text("environment_hash").unwrap_or_default(), + sqb_version: text("sqb_version").unwrap_or_default(), + published_at, + changes: Vec::new(), + }) +} + +fn id_of(row: &Row) -> Result { + serde_json::from_value(row["snapshot_id"].clone()) + .map_err(|error| ControlError::Invalid(format!("stored snapshot id: {error}"))) +} diff --git a/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs b/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs index 342c47fd5a..8c05cf753a 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs @@ -2,6 +2,7 @@ pub(super) mod _publish_snapshot; pub mod current_snapshot; pub mod current_snapshot_record; pub(crate) mod job_write_set; +pub mod list_person_snapshots; pub mod next_remote_generation; pub(crate) mod prune_snapshots; pub(crate) mod publish_development_snapshot; diff --git a/crates/sqlbuild-orchestration/src/snapshots/models.rs b/crates/sqlbuild-orchestration/src/snapshots/models.rs index 4fef7aee74..0428d221c6 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/models.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/models.rs @@ -122,3 +122,26 @@ impl Default for SnapshotRetention { } } } + +/// One publication as people browse it: its commit, environment and changed definitions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct PublishedSnapshot { + pub snapshot_id: Uuid, + pub generation: i64, + pub git_revision: Option, + pub git_dirty: bool, + pub git_author: Option, + /// Digest of the lock file, Python and `sqb` versions; a change means dependencies changed. + pub environment_hash: String, + pub sqb_version: String, + pub published_at: DateTime, + /// Added, changed and removed nodes and roles, in publication order. + pub changes: Vec, +} + +/// A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PublishedChange { + pub change_kind: String, + pub subject_key: String, +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index f2d7d6a691..cc253368c7 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -288,6 +288,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ format!("/api/control/locations/{location}/targets/development/nodes"), format!("/api/control/locations/{location}/targets/development/jobs"), format!("/api/control/locations/{location}/targets/development/triggers"), + format!("/api/control/locations/{location}/targets/development/snapshots"), format!( "/api/control/locations/{location}/targets/development/triggers/{}/ticks", trigger.trigger_id diff --git a/crates/sqlbuild-orchestration/tests/api/test_reads.rs b/crates/sqlbuild-orchestration/tests/api/test_reads.rs index e3adc23031..2f3db1edf0 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_reads.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_reads.rs @@ -1,12 +1,14 @@ //! Real HTTP reads keep both location and target scope, including guessed nested resource IDs. +use crate::authorization::main::bootstrap_user::bootstrap_user; use crate::scheduler::tests::contracts::ticks::helpers::calendar_text; +use crate::snapshots::main::list_person_snapshots::list_person_snapshots; use std::collections::BTreeMap; use std::time::Duration; use crate::credentials::main::tokens::issue_token::issue_token; use crate::credentials::main::tokens::revoke_token::revoke_token; -use crate::credentials::models::{AuditContext, CredentialScope}; +use crate::credentials::models::{AuditContext, CredentialScope, DefaultRole}; use crate::declarations::main::change_trigger::change_trigger; use crate::declarations::main::list_triggers::list_triggers; use crate::declarations::models::{TriggerChange, TriggerChangeRequest}; @@ -323,6 +325,53 @@ fn given_published_locations_and_scheduled_work_when_read_through_http_then_reso ); assert_eq!(ticks[0]["outcome"], "requested"); assert_eq!(ticks[0]["scheduled_for"], calendar_text(2)); + let administrator = bootstrap_user(store, &audit, "orders-admin") + .await + .expect("administrator"); + let mut extra = input.clone(); + extra.generation += 1; + extra.nodes.remove("products"); + extra.git_dirty = !input.git_dirty; + publish_snapshot(store, &extra) + .await + .expect("second publication"); + input.generation = extra.generation; + let history = list_person_snapshots( + store, + (&audit, administrator), + ((location_id, "development"), 20), + DefaultRole::None, + ) + .await + .expect("person snapshots"); + assert_eq!( + history.iter().map(|s| s.generation).collect::>(), + [extra.generation, published.generation], + "newest first, this target only" + ); + assert_eq!( + json!(history[0].changes), + json!([{"change_kind": "removed", "subject_key": "products"}]), + "only the subjects that changed" + ); + assert!(!history[1].changes.is_empty()); + assert_eq!( + (history[0].git_dirty, history[1].git_dirty), + (extra.git_dirty, input.git_dirty), + "the working-tree flag survives each store's boolean type" + ); + assert_eq!( + list_person_snapshots( + store, + (&audit, administrator), + ((location_id, "development"), 1), + DefaultRole::None, + ) + .await + .expect("bounded snapshots") + .len(), + 1 + ); for path in [&run_path, &event_path, &tick_path, &invocation_path] { assert_eq!( read_json(&client, (&format!("{path}?limit=1"), &token.bearer)) diff --git a/fensu.toml b/fensu.toml index bee7f18a71..7c1f8e7b53 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1637,7 +1637,6 @@ allowed = [ "sqb_platform_sink_deliveries.delivered_at=Audit history read through audit exports: when an event reached the platform sink.", "sqb_security_audit_deliveries.delivered_at=Audit history read through audit exports: when an event reached a security audit sink.", "sqb_run_requests.evaluation_id=Reader comes with the operations UI: a sensor's run requests show the evaluation that made them.", - "sqb_snapshots.git_author=Reader comes with the operations UI: the change feed shows who published each snapshot.", "sqb_write_claims=Read through its primary-key conflict: an insert that conflicts means another run already holds the node.", "sqb_warehouse_microbatches.execution_run_id=Reader comes with the UI epic (D132), which filters on it: the run that executed the batch.", "sqb_warehouse_microbatches.record_type=Reader comes with the UI epic (D132), which filters on it: the batch state.", diff --git a/ui/src/lib/api/http-changes.test.ts b/ui/src/lib/api/http-changes.test.ts new file mode 100644 index 0000000000..164b1a45ff --- /dev/null +++ b/ui/src/lib/api/http-changes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { components } from '$control/schema.d.ts'; +import { toSnapshotChanges } from './http-changes'; +import type { Run } from './types/operate'; + +type W = components['schemas']; + +function snapshot(id: string, generation: number, environment: string, changes: [string, string][]): W['PublishedSnapshot'] { + return { + snapshot_id: id, + generation, + git_revision: generation === 2 ? 'a1b2c3d' : null, + git_dirty: generation === 2, + git_author: generation === 2 ? 'Avery Example' : null, + environment_hash: environment, + sqb_version: '0.200.0', + published_at: `2026-11-0${generation}T09:00:00Z`, + changes: changes.map(([change_kind, subject_key]) => ({ change_kind, subject_key })) + }; +} + +function run(id: string, snapshotId: string, queuedAt: string, parentRunId: string | null = null): Run { + return { id, snapshotId, parentRunId, jobKey: 'orders', kind: 'job', status: 'success', startedAt: queuedAt, queuedAt } as Run; +} + +describe('change feed mapping', () => { + const newest = snapshot('s-2', 2, 'env-bbbbbbbbbbbbbbbb', [ + ['removed', 'model:stock'], + ['changed', 'seed:regions'], + ['added', 'role:analyst'] + ]); + const oldest = snapshot('s-1', 1, 'env-aaaaaaaaaaaaaaaa', [['added', 'model:orders']]); + // Runs are listed newest first, as the server returns them. + const runs = [run('r-late', 's-2', '2026-11-02T11:00:00Z'), run('r-child', 's-2', '2026-11-02T10:30:00Z', 'r-early'), run('r-early', 's-2', '2026-11-02T10:00:00Z'), run('r-old', 's-1', '2026-11-01T10:00:00Z')]; + const [second, first] = toSnapshotChanges([newest, oldest], runs); + + it('splits roles from nodes and keeps each change kind', () => { + expect(second.nodes.map((n) => [n.op, n.key, n.kind])).toEqual([ + ['removed', 'model:stock', null], + ['changed', 'seed:regions', 'seed'] + ]); + expect(second.roles.map((r) => [r.op, r.role])).toEqual([['added', 'analyst']]); + }); + + it('reports an environment change only against the previous publication', () => { + expect(second.environment).toMatchObject({ before: 'env-aaaaaaaa', after: 'env-bbbbbbbb' }); + expect(second.previousGeneration).toBe(1); + expect(first.environment).toBeNull(); + expect(first.previousGeneration).toBeNull(); + }); + + it("lists each deploy's own top-level runs, oldest first", () => { + expect(second.followingRuns.map((r) => r.id)).toEqual(['r-early', 'r-late']); + expect(first.followingRuns.map((r) => r.id)).toEqual(['r-old']); + }); + + it('keeps missing git facts empty instead of inventing them', () => { + expect([second.gitRevision, second.gitAuthor, second.gitDirty]).toEqual(['a1b2c3d', 'Avery Example', true]); + expect([first.gitRevision, first.gitAuthor, first.publishedBy]).toEqual([null, null, null]); + }); +}); diff --git a/ui/src/lib/api/http-changes.ts b/ui/src/lib/api/http-changes.ts new file mode 100644 index 0000000000..518a43ed28 --- /dev/null +++ b/ui/src/lib/api/http-changes.ts @@ -0,0 +1,65 @@ +import type { components } from '$control/schema.d.ts'; +import type { ChangeOp, NodeChange, RoleChange, SnapshotChange } from './types/build'; +import type { NodeKind, Run } from './types/operate'; + +type Wire = components['schemas']; + +/** How many runs of each deploy the feed lists. */ +const FOLLOWING_RUNS = 8; + +const NODE_KINDS: Record = { seed: 'seed', source: 'source' }; + +function opOf(kind: string): ChangeOp { + return kind === 'added' || kind === 'removed' ? kind : 'changed'; +} + +function nodeChange(change: Wire['PublishedChange']): NodeChange { + const prefix = change.subject_key.split(':', 1)[0]; + return { + key: change.subject_key, + kind: NODE_KINDS[prefix] ?? null, + op: opOf(change.change_kind), + formerKey: null, + compiledOnly: false, + detail: '', + columns: [] + }; +} + +function roleChange(change: Wire['PublishedChange']): RoleChange { + return { role: change.subject_key.slice('role:'.length), op: opOf(change.change_kind), detail: '', coverage: null }; +} + +/** + * Publications newest first, as the change feed shows them. The server records which nodes and + * roles changed, not job, trigger, column or cost changes, so those stay empty. Each deploy's runs + * are the listed runs pinned to its snapshot, oldest first. + */ +export function toSnapshotChanges(wire: Wire['PublishedSnapshot'][], runs: Run[]): SnapshotChange[] { + return wire.map((snapshot, index): SnapshotChange => { + const previous = wire[index + 1] ?? null; + const environmentChanged = previous !== null && previous.environment_hash !== snapshot.environment_hash; + return { + snapshotId: snapshot.snapshot_id, + generation: snapshot.generation, + previousGeneration: previous?.generation ?? null, + gitRevision: snapshot.git_revision ?? null, + gitDirty: snapshot.git_dirty, + gitAuthor: snapshot.git_author ?? null, + publishedAt: snapshot.published_at, + publishedBy: null, + nodes: snapshot.changes.filter((c) => !c.subject_key.startsWith('role:')).map(nodeChange), + orchestration: [], + roles: snapshot.changes.filter((c) => c.subject_key.startsWith('role:')).map(roleChange), + environment: environmentChanged + ? { before: previous.environment_hash.slice(0, 12), after: snapshot.environment_hash.slice(0, 12), detail: 'Dependencies or the sqb version changed' } + : null, + followingRuns: runs + .filter((r) => r.snapshotId === snapshot.snapshot_id && !r.parentRunId) + .reverse() + .slice(0, FOLLOWING_RUNS) + .map(({ id, jobKey, kind, status, startedAt, queuedAt }) => ({ id, jobKey, kind, status, startedAt, queuedAt })), + costChanges: null + }; + }); +} diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 70a1df67b9..03baee8ca5 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -3,11 +3,12 @@ import type { components, paths } from '$control/schema.d.ts'; import { ApiError, type ApiClient } from './client'; import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-checks'; import { toCatalogNodes } from './http-catalog'; +import { toSnapshotChanges } from './http-changes'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; -import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql } from './types/build'; +import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; type Wire = components['schemas']; @@ -36,7 +37,8 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheck', 'getCheckSql', 'getCheckEvidence', - 'listCatalogNodes' + 'listCatalogNodes', + 'listSnapshotChanges' ]); /** A UI location is one server location and target; its id carries both. */ @@ -244,6 +246,16 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt if (!data) throw new ApiError(errorText(error, response), response.status); return toCatalogNodes(data); }, + async listSnapshotChanges(scope: Scope, query: { limit?: number } = {}): Promise { + const { locationId, target } = parseLocationKey(scope.locationId); + const [snapshots, runs] = await Promise.all([ + http.GET('/api/control/locations/{location_id}/targets/{target}/snapshots', { params: { path: { location_id: locationId, target }, query: { limit: query.limit } } }), + recentRuns(scope) + ]); + if (snapshots.response.status === 401) onUnauthorized(); + if (!snapshots.data) throw new ApiError(errorText(snapshots.error, snapshots.response), snapshots.response.status); + return toSnapshotChanges(snapshots.data, runs); + }, listChecks: async (scope: Scope): Promise => (await checkResults(scope)).map(toCheck), getCheck: async (scope: Scope, id: string): Promise => checkDetailOf(await checkResults(scope, id)), async getCheckSql(scope: Scope, checkId: string): Promise { diff --git a/ui/src/lib/api/types/build.ts b/ui/src/lib/api/types/build.ts index 6bc9353ffb..2eb5eb15b8 100644 --- a/ui/src/lib/api/types/build.ts +++ b/ui/src/lib/api/types/build.ts @@ -315,11 +315,13 @@ export type SnapshotChange = { snapshotId: string; generation: number; previousGeneration: number | null; - gitRevision: string; + /** Null when the project was published from outside a git checkout. */ + gitRevision: string | null; gitDirty: boolean; - gitAuthor: string; + gitAuthor: string | null; publishedAt: Iso; - publishedBy: Principal; + /** Null when the server does not record who published. */ + publishedBy: Principal | null; nodes: NodeChange[]; orchestration: OrchestrationChange[]; roles: RoleChange[]; diff --git a/ui/src/lib/nav.test.ts b/ui/src/lib/nav.test.ts index 8cdc115678..f0eaa47e62 100644 --- a/ui/src/lib/nav.test.ts +++ b/ui/src/lib/nav.test.ts @@ -16,8 +16,9 @@ describe('visibleNav', () => { ]); }); - it('keeps lineage hidden until node detail is served, even when the catalog list is', () => { - expect(labels(serving('listCatalogNodes'), ['view'])).toEqual([['Build', ['Catalog']]]); + it('shows lineage with the catalog list, since the graph needs no node detail', () => { + expect(labels(serving('listCatalogNodes'), ['view'])).toEqual([['Build', ['Lineage', 'Catalog']]]); + expect(labels(serving('getCatalogNode'), ['view'])).toEqual([]); }); it('hides admin pages from people without an admin capability, even when the server serves them', () => { diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts index 269b0c967e..789191fae9 100644 --- a/ui/src/lib/nav.ts +++ b/ui/src/lib/nav.ts @@ -26,7 +26,7 @@ export const NAV: readonly NavGroup[] = [ { section: 'Build', items: [ - { label: 'Lineage', href: '/lineage', icon: GitBranchIcon, loads: 'getCatalogNode' }, + { label: 'Lineage', href: '/lineage', icon: GitBranchIcon, loads: 'listCatalogNodes' }, { label: 'Catalog', href: '/catalog', icon: DatabaseIcon, loads: 'listCatalogNodes', badge: { value: (c) => c.catalogNodes, tone: 'muted' } }, { label: 'Checks', href: '/checks', icon: CheckCircleIcon, loads: 'listChecks', badge: { value: (c) => c.failingChecks, tone: 'warning' } }, { label: 'Changes', href: '/changes', icon: GitCompareIcon, loads: 'listSnapshotChanges' } diff --git a/ui/src/routes/changes/+page.svelte b/ui/src/routes/changes/+page.svelte index dbb27390dd..ba98226c03 100644 --- a/ui/src/routes/changes/+page.svelte +++ b/ui/src/routes/changes/+page.svelte @@ -12,7 +12,7 @@ import { runStatusLabel, runStatusTone } from '$lib/status'; import { changeOpLabel, changeOpTone, formatCredits } from '$lib/build-area/labels'; import { summarizeChange } from '$lib/build-area/changes'; - import { orchestrationHref, percentChange } from './utils'; + import { nodeHref, orchestrationHref, percentChange } from './utils'; type Area = 'all' | 'nodes' | 'orchestration' | 'roles' | 'environment'; @@ -32,7 +32,7 @@ if (!hasArea(c, area)) return false; const q = query.trim().toLowerCase(); if (!q) return true; - return [c.gitRevision, c.gitAuthor, String(c.generation), ...c.nodes.map((n) => n.key), ...c.nodes.map((n) => n.formerKey ?? ''), ...c.orchestration.map((o) => o.key), ...c.roles.map((r) => r.role)].some((s) => s.toLowerCase().includes(q)); + return [c.gitRevision ?? '', c.gitAuthor ?? '', String(c.generation), ...c.nodes.map((n) => n.key), ...c.nodes.map((n) => n.formerKey ?? ''), ...c.orchestration.map((o) => o.key), ...c.roles.map((r) => r.role)].some((s) => s.toLowerCase().includes(q)); }) ); const areaOptions: { value: Area; label: string }[] = [ @@ -62,12 +62,12 @@
Version {c.generation} - {c.gitRevision}{c.gitDirty ? ' · with uncommitted changes' : ''} + {#if c.gitRevision}{c.gitRevision}{c.gitDirty ? ' · with uncommitted changes' : ''}{/if} {summarizeChange(c)}
- {c.gitAuthor} - deployed by {c.publishedBy.displayName} · {formatAgo(c.publishedAt, app.now)} + {#if c.gitAuthor}{c.gitAuthor}{/if} + {c.publishedBy ? `deployed by ${c.publishedBy.displayName} · ` : 'published '}{formatAgo(c.publishedAt, app.now)}
@@ -76,9 +76,10 @@

Nodes {c.nodes.length}

{#each c.nodes as n (n.key + n.op)} + {@const href = nodeHref(n, app.client.supports('getCatalogNode'))}
{changeOpLabel[n.op]} - {#if n.op === 'removed'}{n.key}{:else}{n.key}{/if} + {#if href}{n.key}{:else}{n.key}{/if} {n.detail}{#if n.compiledOnly}compiled only{/if}
{#each n.columns as col (col.name)} diff --git a/ui/src/routes/changes/utils.ts b/ui/src/routes/changes/utils.ts index 145a2de178..ae85baf1c3 100644 --- a/ui/src/routes/changes/utils.ts +++ b/ui/src/routes/changes/utils.ts @@ -1,6 +1,6 @@ // Pure helpers for the Changes page. -import type { OrchestrationChange } from '$lib/api'; +import type { NodeChange, OrchestrationChange } from '$lib/api'; export function orchestrationHref(change: OrchestrationChange): string | null { if (change.op === 'removed') return null; @@ -11,3 +11,11 @@ export function orchestrationHref(change: OrchestrationChange): string | null { export function percentChange(before: number, after: number): number { return before ? Math.round(((after - before) / before) * 100) : 0; } + +/** A changed node's page: its catalog detail, or the lineage graph when the server has no detail read. */ +export function nodeHref(change: NodeChange, catalogDetail: boolean): string | null { + if (change.op === 'removed') return null; + if (catalogDetail) return `/catalog/${change.key}${change.op === 'changed' ? '?tab=drift' : ''}`; + const graphNode = change.kind !== null || change.key.startsWith('model:') || !change.key.includes(':'); + return graphNode ? `/lineage?select=${encodeURIComponent(change.key)}` : null; +} diff --git a/ui/src/routes/lineage/+page.svelte b/ui/src/routes/lineage/+page.svelte index 83f904fb3f..3bb38719e1 100644 --- a/ui/src/routes/lineage/+page.svelte +++ b/ui/src/routes/lineage/+page.svelte @@ -243,11 +243,14 @@ const primary = $derived(selectedNodes[0] ?? nodes.find((n) => n.id === scaleStartId)); const primarySlug = $derived(primary?.data.name ?? ''); // Inspector detail for the primary selection; the scale-test graph is synthetic, so it has none. + // Without a detail read the inspector shows what the catalog listing reports. + const detailSupported = app.client.supports('getCatalogNode'); const detail = createResource( - (client, scope) => (scaleMode || !primarySlug ? Promise.resolve(null) : client.getCatalogNode(scope, String(primarySlug))), + (client, scope) => (scaleMode || !primarySlug || !detailSupported ? Promise.resolve(null) : client.getCatalogNode(scope, String(primarySlug))), ['runs', 'checks', 'snapshot', 'node_status'] ); const d = $derived(detail.data && detail.data.key === primarySlug ? detail.data : null); + const listed = $derived(detailSupported ? null : (catalogByKey.get(String(primarySlug)) ?? null)); const isSource = $derived(d?.kind === 'source'); const hasSql = $derived(!!d?.sql); @@ -455,7 +458,7 @@ - (runOpen = true)}>▷ Execute{runSelection.length ? ` ${runSelection.length}` : ''} + {#if app.client.supports('launchAdhocRun')} (runOpen = true)}>▷ Execute{runSelection.length ? ` ${runSelection.length}` : ''}{/if} @@ -632,6 +635,16 @@

A synthetic graph for layout and rendering at scale; nodes have no catalog detail.

+ {:else if listed} + +
+
Kind
{kindLine(listed)}
+ {#if listed.relation}
Relation
{relationName(listed.relation)}
{/if} + {#if listed.kind !== 'source'}
Last built
{#if listed.lastBuild}{:else}No build recorded{/if}
{/if} + {#if listed.tags.length}
Tags
{listed.tags.join(', ')}
{/if} + {#if listed.path}
File
{listed.path}
{/if} +
+
{:else if !d}

{detail.error ?? 'Loading…'}

{:else if isSource} @@ -675,9 +688,9 @@ {/if} - {#if d?.description} + {#if (d ?? listed)?.description} -

{d.description}

+

{(d ?? listed)?.description}

{/if} From 07937044a05da1e84640e1eafd0cb737f1bce052 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:58:29 +0000 Subject: [PATCH 02/16] feat(server): page the security audit newest first with actor names Person reads of the security audit accept an exclusive upper cursor that returns the newest entries first, and name each principal actor. Exports keep their payload unchanged. --- clients/typescript/src/schema.d.ts | 4 + .../src/api/_helpers/resources/audit.rs | 8 +- .../sqlbuild-orchestration/src/api/models.rs | 1 + .../src/authorization/_helpers/mod.rs | 1 + .../authorization/_helpers/principal_names.rs | 29 ++++++ .../authorization/main/read_audit_events.rs | 17 +++- .../authorization/main/read_security_audit.rs | 9 +- .../src/credentials/models.rs | 3 + .../src/webhooks/_helpers/events.rs | 3 +- .../tests/credentials/test_audit.rs | 91 ++++++++++++++----- 10 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/authorization/_helpers/principal_names.rs diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 35c7f687f4..acc45842d1 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1802,6 +1802,8 @@ export interface components { SecurityAuditEvent: { action: string; actor: string; + /** @description The person's name when `actor` is a principal; person reads set it, exports leave it out. */ + actor_name?: string | null; after_json: unknown; before_json: unknown; /** Format: uuid */ @@ -2540,6 +2542,8 @@ export interface operations { query?: { /** @description Exclusive storage-order cursor */ after?: number; + /** @description Exclusive upper cursor; when set, the page is newest first */ + before?: number; limit?: number; }; header?: never; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs index 2ec377e95e..1cf398f92d 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/audit.rs @@ -11,7 +11,8 @@ use crate::credentials::models::{AuditContext, SecurityAuditEvent}; use crate::store::models::ControlStore; #[utoipa::path(get, path = "/api/control/audit", operation_id = "readSecurityAudit", - params(("after" = Option, Query, description = "Exclusive storage-order cursor"), ("limit" = Option, Query)), + params(("after" = Option, Query, description = "Exclusive storage-order cursor"), + ("before" = Option, Query, description = "Exclusive upper cursor; when set, the page is newest first"), ("limit" = Option, Query)), responses((status = 200, body = Vec), (status = 400, body = crate::api::models::ApiErrorBody), (status = 401, description = "Session rejected"), (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] @@ -24,7 +25,10 @@ pub(crate) async fn read( read_security_audit( &store, (&context, principal), - (options.after.unwrap_or(0), options.limit.unwrap_or(100)), + ( + (options.after.unwrap_or(0), options.before), + options.limit.unwrap_or(100), + ), ) .await?, )) diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 9571345a46..1a639b5176 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -87,6 +87,7 @@ pub(crate) struct ReadQuery { #[serde(deny_unknown_fields)] pub(crate) struct ReadAuditQuery { pub(crate) after: Option, + pub(crate) before: Option, pub(crate) limit: Option, } diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs index 831d2d7b1e..fd4b1031c4 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs @@ -3,3 +3,4 @@ pub(crate) mod administrator_roles; pub(crate) mod administrators; pub(crate) mod custom_roles; pub(crate) mod person_grants; +pub(crate) mod principal_names; diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/principal_names.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/principal_names.rs new file mode 100644 index 0000000000..5e6605f309 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/principal_names.rs @@ -0,0 +1,29 @@ +//! Audit actors name principals by ID; people read them by name. + +use std::collections::BTreeMap; + +use crate::store::errors::ControlError; +use crate::store::main::query::query; +use crate::store::models::ControlTransaction; + +/// Each principal's name, keyed by its audit actor text, `principal:`. +pub(crate) async fn principal_names( + transaction: ControlTransaction, +) -> Result<(ControlTransaction, BTreeMap), ControlError> { + let (transaction, rows) = query( + transaction, + "SELECT principal_id, name FROM sqb_principals", + Vec::new(), + ) + .await?; + let names: BTreeMap = rows + .iter() + .filter_map(|row| { + Some(( + format!("principal:{}", row["principal_id"].as_str()?), + row["name"].as_str()?.to_owned(), + )) + }) + .collect(); + Ok((transaction, names)) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/read_audit_events.rs b/crates/sqlbuild-orchestration/src/authorization/main/read_audit_events.rs index 8791f4349e..1d1281a142 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/read_audit_events.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/read_audit_events.rs @@ -7,17 +7,26 @@ use crate::store::models::{ControlTransaction, Parameter}; pub(crate) async fn read_audit_events( transaction: ControlTransaction, - (after, limit): (i64, u32), + ((after, before), limit): ((i64, Option), u32), ) -> Result<(ControlTransaction, Vec), ControlError> { if after < 0 || !(1..=100).contains(&limit) { return Err(ControlError::Invalid( "audit pages require a nonnegative storage cursor and a limit between 1 and 100".into(), )); } + let (window, order) = match before { + Some(_) => ("storage_id>$1 AND storage_id<$3", "DESC"), + None => ("storage_id>$1", "ASC"), + }; + let mut parameters = vec![ + Parameter::Integer(after), + Parameter::Integer(i64::from(limit)), + ]; + parameters.extend(before.map(Parameter::Integer)); let (transaction, rows) = query(transaction, - "SELECT storage_id,event_id,occurred_at,actor,action,granting_role,request_id,ip_address,scope_json,before_json,after_json \ - FROM sqb_security_audit WHERE storage_id>$1 ORDER BY storage_id LIMIT $2", - vec![Parameter::Integer(after), Parameter::Integer(i64::from(limit))]).await?; + &format!("SELECT storage_id,event_id,occurred_at,actor,action,granting_role,request_id,ip_address,scope_json,before_json,after_json \ + FROM sqb_security_audit WHERE {window} ORDER BY storage_id {order} LIMIT $2"), + parameters).await?; Ok(( transaction, rows.into_iter().map(decode_row).collect::>()?, diff --git a/crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs b/crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs index 81df5bd0ff..9e1a768c48 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs @@ -2,6 +2,7 @@ use uuid::Uuid; +use crate::authorization::_helpers::principal_names::principal_names; use crate::authorization::main::begin_authorized::begin_authorized; use crate::authorization::main::read_audit_events::read_audit_events; use crate::credentials::models::{ @@ -14,7 +15,7 @@ use crate::store::models::ControlStore; pub async fn read_security_audit( store: &ControlStore, (context, credentials): (&AuditContext, Uuid), - (after, limit): (i64, u32), + (cursor, limit): ((i64, Option), u32), ) -> Result, ControlError> { let (transaction, _) = begin_authorized( store, @@ -23,7 +24,11 @@ pub async fn read_security_audit( DefaultRole::None, ) .await?; - let (transaction, events) = read_audit_events(transaction, (after, limit)).await?; + let (transaction, mut events) = read_audit_events(transaction, (cursor, limit)).await?; + let (transaction, names) = principal_names(transaction).await?; commit(transaction).await?; + for event in &mut events { + event.actor_name = names.get(&event.actor).cloned(); + } Ok(events) } diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index fc0a74344e..e9b2ac3978 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -39,6 +39,9 @@ pub struct SecurityAuditEvent { pub scope_json: serde_json::Value, pub before_json: serde_json::Value, pub after_json: serde_json::Value, + /// The person's name when `actor` is a principal; person reads set it, exports leave it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_name: Option, } /// Installation configuration: rows past the horizon stay until every sink and retry lookup is done. diff --git a/crates/sqlbuild-orchestration/src/webhooks/_helpers/events.rs b/crates/sqlbuild-orchestration/src/webhooks/_helpers/events.rs index 50c830a7a8..07c97c1511 100644 --- a/crates/sqlbuild-orchestration/src/webhooks/_helpers/events.rs +++ b/crates/sqlbuild-orchestration/src/webhooks/_helpers/events.rs @@ -20,7 +20,8 @@ pub(crate) async fn read_events( )) } WebhookSource::SecurityAudit => { - let (transaction, events) = read_audit_events(transaction, (after, 100)).await?; + let (transaction, events) = + read_audit_events(transaction, ((after, None), 100)).await?; Ok(( transaction, events diff --git a/crates/sqlbuild-orchestration/tests/credentials/test_audit.rs b/crates/sqlbuild-orchestration/tests/credentials/test_audit.rs index 247721fd30..b101ac85c7 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/test_audit.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/test_audit.rs @@ -113,16 +113,39 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ .expect("role assignment") ); assert!(matches!( - read_security_audit(&replica, (&context, ordinary_credentials), (0, 100)).await, + read_security_audit( + &replica, + (&context, ordinary_credentials), + ((0, None), 100) + ) + .await, Err(ControlError::Denied { .. }) )); } - let existing = read_security_audit(store, (&context, admin_credentials), (0, 100)) - .await - .expect("existing security facts"); - let upgraded = read_security_audit(&replica, (&context, admin_credentials), (0, 100)) - .await - .expect("replica audit"); + let existing = + read_security_audit(store, (&context, admin_credentials), ((0, None), 100)) + .await + .expect("existing security facts"); + assert_eq!( + existing + .iter() + .filter(|event| event.actor == format!("principal:{admin}")) + .map(|event| event.actor_name.as_deref()) + .collect::>(), + [Some("orders-admin")].into(), + "a principal actor is named" + ); + assert!( + existing + .iter() + .filter(|event| !event.actor.starts_with("principal:")) + .all(|event| event.actor_name.is_none()), + "other actors keep only their actor text" + ); + let upgraded = + read_security_audit(&replica, (&context, admin_credentials), ((0, None), 100)) + .await + .expect("replica audit"); assert_eq!( upgraded .iter() @@ -180,9 +203,10 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ .expect("durable clock fact"); commit(transaction).await.expect("commit clock fact"); } - let first = read_security_audit(&replica, (&context, admin_credentials), (cursor, 2)) - .await - .expect("first bounded page"); + let first = + read_security_audit(&replica, (&context, admin_credentials), ((cursor, None), 2)) + .await + .expect("first bounded page"); assert_eq!( first.len(), test_case.expected_page_length, @@ -206,7 +230,7 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ let last = read_security_audit( store, (&context, admin_credentials), - (first[1].storage_id, 2), + ((first[1].storage_id, None), 2), ) .await .expect("strict continuation"); @@ -214,14 +238,29 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ last.iter().map(|event| event.event_id).collect::>(), ids[2..] ); + let newest = read_security_audit( + store, + (&context, admin_credentials), + ((cursor, Some(last[0].storage_id)), 100), + ) + .await + .expect("newest-first window"); + assert_eq!( + newest + .iter() + .map(|event| event.event_id) + .collect::>(), + [ids[1], ids[0]], + "both bounds are exclusive and the window reads newest first" + ); let cursor = last[0].storage_id; assert!( - read_security_audit(store, (&context, admin_credentials), (cursor, 100)) + read_security_audit(store, (&context, admin_credentials), ((cursor, None), 100)) .await .expect("empty continuation") .is_empty() ); - for bounds in [(-1, 1), (0, 0), (0, 101)] { + for bounds in [((-1, None), 1), ((0, None), 0), ((0, None), 101)] { assert!(matches!( read_security_audit(store, (&context, admin_credentials), bounds).await, Err(ControlError::Invalid(_)) @@ -243,17 +282,22 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ ); remove_fault(store, &fault).await; assert!( - read_security_audit(&replica, (&context, admin_credentials), (cursor, 100)) - .await - .expect("failed audit did not publish") - .is_empty() + read_security_audit( + &replica, + (&context, admin_credentials), + ((cursor, None), 100) + ) + .await + .expect("failed audit did not publish") + .is_empty() ); create_principal(store, &context, "orders-rollback") .await .expect("failed principal did not persist"); - let after = read_security_audit(store, (&context, admin_credentials), (cursor, 100)) - .await - .expect("successful retry"); + let after = + read_security_audit(store, (&context, admin_credentials), ((cursor, None), 100)) + .await + .expect("successful retry"); assert_eq!(after.len(), 1); assert_eq!(after[0].storage_id, cursor + 1); let cursor = after[0].storage_id; @@ -266,7 +310,7 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ right.expect("second concurrent append") ); let concurrent = - read_security_audit(store, (&context, admin_credentials), (cursor, 100)) + read_security_audit(store, (&context, admin_credentials), ((cursor, None), 100)) .await .expect("serialized writers"); assert_eq!( @@ -312,11 +356,12 @@ fn given_security_audit_pages_when_clock_and_authority_change_then_commit_order_ .expect("live role revocation") ); assert!(matches!( - read_security_audit(&replica, (&context, admin_credentials), (0, 100)).await, + read_security_audit(&replica, (&context, admin_credentials), ((0, None), 100)) + .await, Err(ControlError::Denied { .. }) )); assert!( - !read_security_audit(&replica, (&context, ordinary_credentials), (0, 100)) + !read_security_audit(&replica, (&context, ordinary_credentials), ((0, None), 100)) .await .expect("replacement administrator reads audit") .is_empty() From eb7c8803e8b6362fda40680555358d43108286f6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:03:55 +0000 Subject: [PATCH 03/16] feat(ui): wire the audit log to the security audit read The audit log reads the newest security facts first, lists a denial under the action it refused with its decision ID and reason, and names principal actors. Its actor and action filters come from the entries, and links to the person page and access checker stay hidden until the people read exists. --- ui/src/lib/api/http-audit.test.ts | 57 ++++++++++++++++++++ ui/src/lib/api/http-audit.ts | 66 +++++++++++++++++++++++ ui/src/lib/api/http-client.ts | 24 ++++++++- ui/src/lib/api/types/platform.ts | 3 +- ui/src/lib/platform/permission-catalog.ts | 31 ++++++++--- ui/src/routes/admin/audit/+page.svelte | 35 ++++++++---- 6 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 ui/src/lib/api/http-audit.test.ts create mode 100644 ui/src/lib/api/http-audit.ts diff --git a/ui/src/lib/api/http-audit.test.ts b/ui/src/lib/api/http-audit.test.ts new file mode 100644 index 0000000000..7ebccefbf4 --- /dev/null +++ b/ui/src/lib/api/http-audit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { auditMatches, toAuditEntry } from './http-audit'; + +const base = { + storage_id: 7, + event_id: 'e-1', + occurred_at: '2026-11-08T10:00:00Z', + request_id: 'r-1', + ip_address: '127.0.0.1', + before_json: null +}; + +describe('toAuditEntry', () => { + it('lists a denial under the refused action with its decision and reason', () => { + const entry = toAuditEntry({ + ...base, + actor: 'principal:p-2', + actor_name: 'orders-viewer', + action: 'authorization.denied', + granting_role: 'none', + scope_json: { action: 'run.cancel', location_id: 'loc-1', target: 'prod', nodes: [] }, + after_json: { decision_id: 'd-9', allowed: false, reason: 'missing permission' } + }); + expect(entry).toMatchObject({ + actor: { kind: 'user', name: 'orders-viewer' }, + action: 'run.cancel', + outcome: 'denied', + grantingRole: null, + decisionId: 'd-9', + summary: 'missing permission', + after: null, + scope: { locationId: 'loc-1:prod', subject: null } + }); + }); + + it('keeps an allowed change with its role, values and remaining scope', () => { + const entry = toAuditEntry({ + ...base, + actor: 'scheduler/s-1', + action: 'token.issued', + granting_role: 'scheduler', + scope_json: { kind: 'run', run_id: 'run-4' }, + after_json: { token_id: 't-1' } + }); + expect(entry).toMatchObject({ + actor: { kind: 'system', name: 'scheduler/s-1' }, + action: 'token.issued', + outcome: 'allowed', + grantingRole: 'scheduler', + decisionId: null, + after: '{"token_id":"t-1"}', + scope: { locationId: null, subject: 'run_id=run-4' } + }); + expect(auditMatches(entry, { outcome: 'denied' })).toBe(false); + expect(auditMatches(entry, { actor: 'scheduler/s-1', action: 'token.issued' })).toBe(true); + }); +}); diff --git a/ui/src/lib/api/http-audit.ts b/ui/src/lib/api/http-audit.ts new file mode 100644 index 0000000000..0000c6d0d3 --- /dev/null +++ b/ui/src/lib/api/http-audit.ts @@ -0,0 +1,66 @@ +import type { components } from '$control/schema.d.ts'; +import { locationKey } from './http-client'; +import type { AuditEntry, AuditQuery } from './types/platform'; + +type Wire = components['schemas']; + +const DENIED = 'authorization.denied'; + +function fieldsOf(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function textOf(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +function jsonText(value: unknown): string | null { + return value === null || value === undefined ? null : JSON.stringify(value); +} + +/** The scope's location in UI terms, and its remaining fields as the subject. */ +function scopeOf(scope: Record): AuditEntry['scope'] { + const { location_id: location, target, action: _action, kind: _kind, ...rest } = scope; + const locationId = textOf(location); + const subject = Object.entries(rest) + .filter(([, value]) => value !== null && !(Array.isArray(value) && value.length === 0)) + .map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`) + .join(' '); + return { + locationId: locationId && textOf(target) ? locationKey(locationId, target as string) : locationId, + subject: subject || null + }; +} + +/** A denial is listed under the action it refused, with its decision ID and reason. */ +export function toAuditEntry(wire: Wire['SecurityAuditEvent']): AuditEntry { + const denied = wire.action === DENIED; + const scope = fieldsOf(wire.scope_json); + const after = fieldsOf(wire.after_json); + const name = wire.actor_name ?? wire.actor; + return { + id: wire.event_id, + at: wire.occurred_at, + actor: { kind: wire.actor_name ? 'user' : 'system', name, displayName: name }, + action: denied ? (textOf(scope.action) ?? DENIED) : wire.action, + outcome: denied ? 'denied' : 'allowed', + scope: scopeOf(scope), + grantingRole: denied ? null : wire.granting_role, + decisionId: denied ? textOf(after.decision_id) : null, + evidenceRead: wire.action === 'evidence.read', + summary: denied ? (textOf(after.reason) ?? '') : '', + before: jsonText(wire.before_json), + after: denied ? null : jsonText(wire.after_json), + ip: wire.ip_address ?? '', + requestId: wire.request_id + }; +} + +export function auditMatches(entry: AuditEntry, query: AuditQuery): boolean { + return ( + (!query.actor || entry.actor.name === query.actor) && + (!query.action || entry.action === query.action) && + (!query.outcome || entry.outcome === query.outcome) && + (!query.evidenceOnly || entry.evidenceRead) + ); +} diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 03baee8ca5..401465a6b0 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -3,11 +3,13 @@ import type { components, paths } from '$control/schema.d.ts'; import { ApiError, type ApiClient } from './client'; import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-checks'; import { toCatalogNodes } from './http-catalog'; +import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; +import type { AuditEntry, AuditQuery } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -38,9 +40,14 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheckSql', 'getCheckEvidence', 'listCatalogNodes', - 'listSnapshotChanges' + 'listSnapshotChanges', + 'listAuditEntries' ]); +/** The server's largest audit page, and how many the audit log reads before it stops looking. */ +const AUDIT_PAGE = 100; +const AUDIT_PAGES = 10; + /** A UI location is one server location and target; its id carries both. */ export function locationKey(locationId: string, target: string): string { return `${locationId}:${target}`; @@ -256,6 +263,21 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt if (!snapshots.data) throw new ApiError(errorText(snapshots.error, snapshots.response), snapshots.response.status); return toSnapshotChanges(snapshots.data, runs); }, + /** Newest first, reading at most `AUDIT_PAGES` server pages to find `limit` matching entries. */ + async listAuditEntries(query: AuditQuery = {}): Promise { + const limit = query.limit ?? 100; + const found: AuditEntry[] = []; + let before = Number.MAX_SAFE_INTEGER; + for (let page = 0; page < AUDIT_PAGES && found.length < limit; page++) { + const { data, error, response } = await http.GET('/api/control/audit', { params: { query: { before, limit: AUDIT_PAGE } } }); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + found.push(...data.map(toAuditEntry).filter((entry) => auditMatches(entry, query))); + if (data.length < AUDIT_PAGE) break; + before = data[data.length - 1].storage_id; + } + return found.slice(0, limit); + }, listChecks: async (scope: Scope): Promise => (await checkResults(scope)).map(toCheck), getCheck: async (scope: Scope, id: string): Promise => checkDetailOf(await checkResults(scope, id)), async getCheckSql(scope: Scope, checkId: string): Promise { diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index 4c130b04f8..92defd33c6 100644 --- a/ui/src/lib/api/types/platform.ts +++ b/ui/src/lib/api/types/platform.ts @@ -395,7 +395,8 @@ export type AuditEntry = { id: string; at: Iso; actor: Principal; - action: Capability | 'session.login'; + /** A capability, or a recorded event such as `token.issued`; a denial carries the refused capability. */ + action: string; outcome: AuditOutcome; scope: AssignmentScope & { subject: string | null }; /** Role that granted the action, or for denials null. */ diff --git a/ui/src/lib/platform/permission-catalog.ts b/ui/src/lib/platform/permission-catalog.ts index 8686dd3bef..5c90e8c1c4 100644 --- a/ui/src/lib/platform/permission-catalog.ts +++ b/ui/src/lib/platform/permission-catalog.ts @@ -72,13 +72,32 @@ export function requiresText(action: Capability): string { return `Requires “${PERMISSIONS[action].label}” (${action})`; } -const AUDIT_ONLY: Record, string> = { - 'session.login': 'Signed in' +const AUDIT_ONLY: Record = { + 'session.login': 'Signed in', + 'admin.granted': 'Changed administrators', + 'authorization.denied': 'Denied', + 'cli.login.started': 'Started CLI sign-in', + 'cli.login.decided': 'Decided CLI sign-in', + 'cli.login.exchanged': 'Completed CLI sign-in', + 'evidence.read': 'Read sample rows', + 'job.run_requested': 'Requested a job run', + 'location.created': 'Created location', + 'oidc.flow.created': 'Started SSO sign-in', + 'oidc.authenticated': 'Signed in with SSO', + 'password.changed': 'Changed password', + 'principal.created': 'Added person', + 'role.assigned': 'Changed role', + 'run.cancel_requested': 'Requested cancel', + 'run.rerun_requested': 'Requested rerun', + 'session.created': 'Signed in', + 'session.revoked': 'Signed out', + 'sql.read': 'Read SQL', + 'token.issued': 'Issued token', + 'token.revoked': 'Revoked token', + 'trigger.changed': 'Changed trigger' }; /** Plain name of an audited action, including the audit-only ones. */ -export function auditActionLabel(action: AuditEntry['action']): string { - return action in PERMISSIONS ? PERMISSIONS[action as Capability].label : AUDIT_ONLY[action as keyof typeof AUDIT_ONLY]; +export function auditActionLabel(action: string): string { + return action in PERMISSIONS ? PERMISSIONS[action as Capability].label : (AUDIT_ONLY[action] ?? action); } - -export const AUDIT_ACTIONS: AuditEntry['action'][] = [...CAPABILITIES, 'session.login']; diff --git a/ui/src/routes/admin/audit/+page.svelte b/ui/src/routes/admin/audit/+page.svelte index fe37712c67..8b0539a81b 100644 --- a/ui/src/routes/admin/audit/+page.svelte +++ b/ui/src/routes/admin/audit/+page.svelte @@ -11,7 +11,7 @@ import CopyButton from '$lib/components/copy-button.svelte'; import type { AuditOutcome } from '$lib/api'; import { checkHref, personHref } from '$lib/platform/access'; - import { AUDIT_ACTIONS, auditActionLabel } from '$lib/platform/permission-catalog'; + import { auditActionLabel } from '$lib/platform/permission-catalog'; import { formatAgo, formatStamp } from '$lib/format/time'; import { formatAssignmentScope } from '$lib/platform/labels'; import { app } from '$lib/state/app.svelte'; @@ -30,8 +30,11 @@ const everyone = createResource((client) => (app.can('audit.view') ? client.listAuditEntries({ limit: 500 }) : Promise.resolve([]))); const actors = $derived([...new Set((everyone.data ?? []).map((e) => e.actor.name))].sort()); + const actions = $derived([...new Set((everyone.data ?? []).map((e) => e.action))].sort()); const rows = $derived(entries.data ?? []); const locations = $derived(app.bootstrap?.locations ?? []); + // The person page and the access checker both read people. + const linksPeople = $derived(app.client.supports('listAccounts')); @@ -55,7 +58,7 @@ - {#if e.actor.kind === 'user'}{e.actor.name}{:else}{e.actor.name}{/if} + {#if e.actor.kind === 'user' && linksPeople}{e.actor.name}{:else}{e.actor.name}{/if}
{e.ip}
{auditActionLabel(e.action)} {#if e.evidenceRead} read sample rows{/if} - + {formatAssignmentScope(e.scope, locations)} - {#if e.scope.subject}
{e.scope.subject}
{/if} + {#if e.scope.subject}
{e.scope.subject}
{/if} {#if e.decisionId}
- {e.decisionId} + {e.decisionId} - Check this + {#if linksPeople}Check this{/if}
{/if} {e.grantingRole ?? '—'} {e.summary} -
+
{#if e.before !== null || e.after !== null}{e.before ?? '∅'} → {e.after ?? '∅'} ·{/if} {e.requestId}
@@ -172,7 +175,21 @@ font-size: 11.5px; } .detail { - max-width: 420px; + max-width: 320px; + } + .where .clip { + max-width: 200px; + } + .decision .clip { + max-width: 110px; + } + .detail .clip { + max-width: 300px; + } + .clip { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .decision { display: flex; From 96ad1ba7bd17c1ded85226c9afa75f4470008411 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:22:19 +0000 Subject: [PATCH 04/16] feat(server): list people and role holdings for administrators Add GET /api/admin/people, which needs user.manage everywhere, and GET /api/admin/roles, which needs role.assign everywhere. People carry sign-in facts only: password presence, SSO groups, live session count and last sign-in. Role holdings list person assignments and SSO group mappings. --- .../administrator_lifecycle.json | 10 +- clients/typescript/src/schema.d.ts | 131 +++++++++++++++++- .../authentication/password_sessions.rs | 7 +- .../api/_helpers/resources/administrators.rs | 30 +++- .../sqlbuild-orchestration/src/api/models.rs | 2 + .../src/authorization/main/list_people.rs | 72 ++++++++++ .../authorization/main/list_role_holdings.rs | 51 +++++++ .../src/authorization/main/mod.rs | 2 + .../src/credentials/models.rs | 42 ++++++ .../tests/api/person_sessions/test_types.rs | 1 + .../tests/api/test_person_sessions.rs | 58 ++++++++ 11 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/authorization/main/list_people.rs create mode 100644 crates/sqlbuild-orchestration/src/authorization/main/list_role_holdings.rs diff --git a/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json b/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json index 8116485af0..da57815e82 100644 --- a/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json +++ b/benchmarks/duckshop/edge_cases/orchestration/administrator_lifecycle.json @@ -12,11 +12,11 @@ {"role": "developer", "allowed": true} ], "session_roles": [ - {"role": null, "expected_metadata": 403, "expected_connection": 403, "expected_job": 403, "expected_sql": 403}, - {"role": "viewer", "expected_metadata": 200, "expected_connection": 403, "expected_job": 403, "expected_sql": 403}, - {"role": "operator", "expected_metadata": 200, "expected_connection": 403, "expected_job": 200, "expected_sql": 403}, - {"role": "developer", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200}, - {"role": "admin", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200} + {"role": null, "expected_metadata": 403, "expected_connection": 403, "expected_job": 403, "expected_sql": 403, "expected_admin": 403}, + {"role": "viewer", "expected_metadata": 200, "expected_connection": 403, "expected_job": 403, "expected_sql": 403, "expected_admin": 403}, + {"role": "operator", "expected_metadata": 200, "expected_connection": 403, "expected_job": 200, "expected_sql": 403, "expected_admin": 403}, + {"role": "developer", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200, "expected_admin": 403}, + {"role": "admin", "expected_metadata": 200, "expected_connection": 200, "expected_job": 200, "expected_sql": 200, "expected_admin": 200} ], "job_settings": [ {"full_refresh": false}, diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index acc45842d1..1d25acfe23 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1,5 +1,21 @@ // Generated from sqb server --openapi. Run npm run generate; do not edit. export interface paths { + "/api/admin/people": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPeople"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/people/{principal_id}": { parameters: { query?: never; @@ -23,7 +39,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["listRoleHoldings"]; put?: never; post: operations["assignBuiltinRole"]; delete?: never; @@ -1197,6 +1213,14 @@ export interface components { source_event_id: string; version_hash: string; }; + /** @description A role every member of an SSO group holds. */ + GroupRole: { + group_name: string; + /** Format: uuid */ + location_id?: string | null; + role_key: string; + target?: string | null; + }; HeartbeatResponse: { cancel_requested: boolean; }; @@ -1492,6 +1516,38 @@ export interface components { /** Format: uuid */ principal_id: string; }; + /** @description A role assigned to one person, everywhere or on one location and target. */ + PersonRole: { + /** Format: uuid */ + assignment_id: string; + /** Format: uuid */ + location_id?: string | null; + name: string; + /** Format: uuid */ + principal_id: string; + role_key: string; + target?: string | null; + }; + /** @description A person as administrators see them: how they sign in, their SSO groups and live sessions. */ + PersonSummary: { + /** + * Format: int64 + * @description Unrevoked, unexpired browser and CLI sessions. + */ + active_sessions: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + disabled_at?: string | null; + /** @description Groups the identity provider reported at the person's last SSO sign-in. */ + groups: string[]; + has_password: boolean; + /** Format: date-time */ + last_signed_in_at?: string | null; + name: string; + /** Format: uuid */ + principal_id: string; + }; /** @description Observed physical evidence is not a live warehouse-existence guarantee. */ PhysicalEvidence: { observation: components["schemas"]["CatalogObservation"]; @@ -1580,6 +1636,11 @@ export interface components { role: components["schemas"]["RoleKey"]; target?: string | null; }; + /** @description Who holds which role where: assigned to people here, or mapped to SSO groups in server config. */ + RoleHoldings: { + assignments: components["schemas"]["PersonRole"][]; + group_roles: components["schemas"]["GroupRole"][]; + }; /** @description A built-in role by name, or a custom role a location publishes. */ RoleKey: components["schemas"]["BuiltinRole"] | string; /** @description This is a server-owned instruction, not an arbitrary status supplied by a worker. */ @@ -2170,6 +2231,40 @@ export interface components { } export type $defs = Record; export interface operations { + listPeople: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonSummary"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; changeAdministrator: { parameters: { query?: never; @@ -2214,6 +2309,40 @@ export interface operations { }; }; }; + listRoleHoldings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleHoldings"]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; assignBuiltinRole: { 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 5bce2d5b43..f259c54582 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -65,9 +65,14 @@ pub(crate) fn person_routes() -> Router { "/api/admin/people/{principal_id}", post(crate::api::_helpers::resources::administrators::change), ) + .route( + "/api/admin/people", + get(crate::api::_helpers::resources::administrators::people), + ) .route( "/api/admin/roles", - post(crate::api::_helpers::resources::administrators::assign), + post(crate::api::_helpers::resources::administrators::assign) + .get(crate::api::_helpers::resources::administrators::holdings), ) .route( "/api/control/runs", diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs index 89fbada6a9..4031c8df16 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs @@ -8,8 +8,12 @@ use crate::api::errors::ApiError; use crate::api::models::MutationResponse; use crate::authorization::main::assign_builtin_role::assign_builtin_role; use crate::authorization::main::change_administrator::change_administrator; +use crate::authorization::main::list_people::list_people; +use crate::authorization::main::list_role_holdings::list_role_holdings; use crate::credentials::models::AuthenticatedPerson; -use crate::credentials::models::{AdministratorChange, AuditContext, RoleAssignmentRequest}; +use crate::credentials::models::{ + AdministratorChange, AuditContext, PersonSummary, RoleAssignmentRequest, RoleHoldings, +}; use crate::store::models::ControlStore; #[utoipa::path(post, path = "/api/admin/people/{principal_id}", operation_id = "changeAdministrator", @@ -42,3 +46,27 @@ pub(crate) async fn assign( applied: assign_builtin_role(&store, (&context, principal), &request).await?, })) } + +#[utoipa::path(get, path = "/api/admin/people", operation_id = "listPeople", + responses((status = 200, body = Vec), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn people( + (State(store), Extension(context)): (State, Extension), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + list_people(&store, (&context, principal), chrono::Utc::now()).await?, + )) +} + +#[utoipa::path(get, path = "/api/admin/roles", operation_id = "listRoleHoldings", + responses((status = 200, body = RoleHoldings), (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn holdings( + (State(store), Extension(context)): (State, Extension), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result, ApiError> { + Ok(Json( + list_role_holdings(&store, (&context, principal)).await?, + )) +} diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 1a639b5176..a80ef4bcec 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -14,6 +14,8 @@ use crate::api::_helpers::resources::{ #[openapi(paths( administrators::change, administrators::assign, + administrators::people, + administrators::holdings, audit::read, person_operations::request_job, person_operations::cancel_run, diff --git a/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs b/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs new file mode 100644 index 0000000000..db9dfd4f5a --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs @@ -0,0 +1,72 @@ +//! Administrators who manage people list every principal with sign-in facts, never secrets. + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde_json::json; +use uuid::Uuid; + +use crate::authorization::main::begin_authorized::begin_authorized; +use crate::credentials::models::{ + Action, AuditContext, DefaultRole, PermissionScope, PersonSummary, +}; +use crate::store::errors::ControlError; +use crate::store::main::{commit::commit, decode_row::decode_row, query::query}; +use crate::store::models::{ControlStore, Parameter}; +use crate::store::types::Row; + +/// Everyone by name, with sessions still live at `now`; requires `user.manage` everywhere. +pub async fn list_people( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + now: DateTime, +) -> Result, ControlError> { + let (transaction, _) = begin_authorized( + store, + (context, credentials), + &PermissionScope::action(Action::UserManage, None), + DefaultRole::None, + ) + .await?; + let (transaction, people) = query(transaction, + "SELECT p.principal_id, p.name, p.created_at, p.disabled_at, \ + CASE WHEN c.principal_id IS NULL THEN 0 ELSE 1 END AS has_password, \ + (SELECT COUNT(*) FROM sqb_sessions s WHERE s.principal_id=p.principal_id \ + AND s.revoked_at IS NULL AND s.expires_at>$1) AS active_sessions, \ + (SELECT MAX(s.created_at) FROM sqb_sessions s WHERE s.principal_id=p.principal_id) AS last_signed_in_at \ + FROM sqb_principals p LEFT JOIN sqb_password_credentials c ON c.principal_id=p.principal_id ORDER BY p.name", + vec![Parameter::Time(now)]).await?; + let (transaction, memberships) = query( + transaction, + "SELECT principal_id, group_name FROM sqb_person_groups ORDER BY group_name", + Vec::new(), + ) + .await?; + commit(transaction).await?; + let mut groups: BTreeMap> = BTreeMap::new(); + for row in &memberships { + if let (Some(id), Some(group)) = (row["principal_id"].as_str(), row["group_name"].as_str()) + { + groups.entry(id.into()).or_default().push(group.into()); + } + } + people + .into_iter() + .map(|row| person_of(row, &groups)) + .collect() +} + +fn person_of( + mut row: Row, + groups: &BTreeMap>, +) -> Result { + let member_of = row["principal_id"] + .as_str() + .and_then(|id| groups.get(id)) + .cloned() + .unwrap_or_default(); + let has_password = row["has_password"].as_i64() == Some(1); + row.insert("groups".into(), json!(member_of)); + row.insert("has_password".into(), json!(has_password)); + decode_row(row) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/list_role_holdings.rs b/crates/sqlbuild-orchestration/src/authorization/main/list_role_holdings.rs new file mode 100644 index 0000000000..28207113a2 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/list_role_holdings.rs @@ -0,0 +1,51 @@ +//! Administrators who assign roles see every assignment and SSO group mapping. + +use uuid::Uuid; + +use crate::authorization::main::begin_authorized::begin_authorized; +use crate::credentials::models::{ + Action, AuditContext, DefaultRole, PermissionScope, RoleHoldings, +}; +use crate::store::errors::ControlError; +use crate::store::main::{commit::commit, decode_row::decode_row, query::query}; +use crate::store::models::ControlStore; + +/// Assignments by person name, then group mappings by group; requires `role.assign` everywhere. +pub async fn list_role_holdings( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), +) -> Result { + let (transaction, _) = begin_authorized( + store, + (context, credentials), + &PermissionScope::action(Action::RoleAssign, None), + DefaultRole::None, + ) + .await?; + let (transaction, assignments) = query( + transaction, + "SELECT a.assignment_id, a.principal_id, p.name, a.role_key, a.location_id, a.target \ + FROM sqb_role_assignments a JOIN sqb_principals p ON p.principal_id=a.principal_id \ + ORDER BY p.name, a.role_key, a.location_id, a.target", + Vec::new(), + ) + .await?; + let (transaction, group_roles) = query( + transaction, + "SELECT group_name, role_key, location_id, target FROM sqb_group_roles \ + ORDER BY group_name, role_key, location_id, target", + Vec::new(), + ) + .await?; + commit(transaction).await?; + Ok(RoleHoldings { + assignments: assignments + .into_iter() + .map(decode_row) + .collect::>()?, + group_roles: group_roles + .into_iter() + .map(decode_row) + .collect::>()?, + }) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs index 66851f5c97..408b92f28b 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs @@ -7,6 +7,8 @@ pub mod change_administrator; pub mod compile_roles; pub(crate) mod deny_authorization; pub mod grant_admin; +pub mod list_people; +pub mod list_role_holdings; pub(crate) mod person_lists_target; pub(crate) mod publish_custom_roles; pub(crate) mod publish_group_roles; diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index e9b2ac3978..47d5c42d33 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -44,6 +44,48 @@ pub struct SecurityAuditEvent { pub actor_name: Option, } +/// A person as administrators see them: how they sign in, their SSO groups and live sessions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PersonSummary { + pub principal_id: Uuid, + pub name: String, + pub created_at: DateTime, + pub disabled_at: Option>, + pub has_password: bool, + /// Groups the identity provider reported at the person's last SSO sign-in. + pub groups: Vec, + /// Unrevoked, unexpired browser and CLI sessions. + pub active_sessions: i64, + pub last_signed_in_at: Option>, +} + +/// Who holds which role where: assigned to people here, or mapped to SSO groups in server config. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RoleHoldings { + pub assignments: Vec, + pub group_roles: Vec, +} + +/// A role assigned to one person, everywhere or on one location and target. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PersonRole { + pub assignment_id: Uuid, + pub principal_id: Uuid, + pub name: String, + pub role_key: String, + pub location_id: Option, + pub target: Option, +} + +/// A role every member of an SSO group holds. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GroupRole { + pub group_name: String, + pub role_key: String, + pub location_id: Option, + pub target: Option, +} + /// Installation configuration: rows past the horizon stay until every sink and retry lookup is done. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AuditRetention { diff --git a/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs b/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs index 552889f4b8..ad725dfe56 100644 --- a/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/api/person_sessions/test_types.rs @@ -17,4 +17,5 @@ pub(crate) struct SessionRoleCase { pub(crate) expected_connection: u16, pub(crate) expected_job: u16, pub(crate) expected_sql: u16, + pub(crate) expected_admin: u16, } diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index cc253368c7..5ecf59f0d0 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -321,6 +321,64 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ "{read_path}" ); } + for admin_read in ["/api/admin/people", "/api/admin/roles"] { + let listed = request( + &provider.client, + session, + (Method::GET, admin_read), + (&Value::Null, &session.csrf), + ) + .await; + assert_eq!( + listed.status().as_u16(), + role_case.expected_admin, + "{admin_read}" + ); + let listed: Value = listed.json().await.expect("admin read"); + if role_case.expected_admin != StatusCode::OK.as_u16() { + continue; + } + let entries = listed + .as_array() + .or(listed["assignments"].as_array()) + .expect("listed entries"); + let own = entries + .iter() + .find(|entry| entry["principal_id"] == json!(person)) + .expect("the person is listed"); + let mut keys: Vec<&str> = own + .as_object() + .expect("entry") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + if listed.is_array() { + assert_eq!( + keys, + [ + "active_sessions", + "created_at", + "disabled_at", + "groups", + "has_password", + "last_signed_in_at", + "name", + "principal_id" + ], + "sign-in facts only, never secrets" + ); + assert!(own["active_sessions"].as_i64() >= Some(1)); + } else { + assert!( + entries + .iter() + .any(|entry| entry["principal_id"] == json!(person) + && entry["role_key"] == "admin"), + "the person's admin assignment is listed" + ); + } + } let jobs: Value = request( &provider.client, session, From 5fa1089a8390e7ca2a8e465ead6352b14654a266 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:30:46 +0000 Subject: [PATCH 05/16] feat(ui): wire the people page to the people and role reads The people page lists everyone with their highest role, its source and last sign-in. A person's sign-in source is shown only when the server knows it. Links to the person page and access checker, and the risky actions filter, stay hidden until their reads exist. --- .../tests/api/test_person_sessions.rs | 79 ++++++++++--------- ui/src/lib/api/http-client.ts | 22 +++++- ui/src/lib/api/http-people.test.ts | 38 +++++++++ ui/src/lib/api/http-people.ts | 58 ++++++++++++++ ui/src/lib/api/types/platform.ts | 5 +- ui/src/lib/state/access-directory.svelte.ts | 6 +- ui/src/routes/admin/audit/+page.svelte | 6 +- ui/src/routes/admin/people/+page.svelte | 18 +++-- .../routes/admin/people/[name]/+page.svelte | 2 +- 9 files changed, 178 insertions(+), 56 deletions(-) create mode 100644 ui/src/lib/api/http-people.test.ts create mode 100644 ui/src/lib/api/http-people.ts diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index 5ecf59f0d0..71c844a5d4 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -321,6 +321,8 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ "{read_path}" ); } + let granted = usize::from(role_case.expected_admin == StatusCode::OK.as_u16()); + let mut admin_reads = Vec::new(); for admin_read in ["/api/admin/people", "/api/admin/roles"] { let listed = request( &provider.client, @@ -334,51 +336,50 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ role_case.expected_admin, "{admin_read}" ); - let listed: Value = listed.json().await.expect("admin read"); - if role_case.expected_admin != StatusCode::OK.as_u16() { - continue; - } - let entries = listed - .as_array() - .or(listed["assignments"].as_array()) - .expect("listed entries"); - let own = entries - .iter() - .find(|entry| entry["principal_id"] == json!(person)) - .expect("the person is listed"); - let mut keys: Vec<&str> = own + admin_reads.push(listed.json::().await.expect("admin read")); + } + let own: Vec<&Value> = admin_reads[0] + .as_array() + .into_iter() + .flatten() + .filter(|entry| entry["principal_id"] == json!(person)) + .collect(); + assert_eq!(own.len(), granted, "an administrator lists the person"); + for entry in own { + let mut keys: Vec<&str> = entry .as_object() - .expect("entry") + .expect("person") .keys() .map(String::as_str) .collect(); keys.sort_unstable(); - if listed.is_array() { - assert_eq!( - keys, - [ - "active_sessions", - "created_at", - "disabled_at", - "groups", - "has_password", - "last_signed_in_at", - "name", - "principal_id" - ], - "sign-in facts only, never secrets" - ); - assert!(own["active_sessions"].as_i64() >= Some(1)); - } else { - assert!( - entries - .iter() - .any(|entry| entry["principal_id"] == json!(person) - && entry["role_key"] == "admin"), - "the person's admin assignment is listed" - ); - } + assert_eq!( + keys, + [ + "active_sessions", + "created_at", + "disabled_at", + "groups", + "has_password", + "last_signed_in_at", + "name", + "principal_id" + ], + "sign-in facts only, never secrets" + ); + assert!(entry["active_sessions"].as_i64() >= Some(1)); } + assert_eq!( + admin_reads[1]["assignments"] + .as_array() + .into_iter() + .flatten() + .filter(|entry| entry["principal_id"] == json!(person) + && entry["role_key"] == "admin") + .count(), + granted, + "an administrator sees the person's admin assignment" + ); let jobs: Value = request( &provider.client, session, diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 401465a6b0..b0d72ac0bc 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -5,11 +5,12 @@ import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-chec import { toCatalogNodes } from './http-catalog'; import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; +import { toAccount, toRoleAssignments } from './http-people'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; -import type { AuditEntry, AuditQuery } from './types/platform'; +import type { Account, AuditEntry, AuditQuery, RoleAssignment } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -41,7 +42,9 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheckEvidence', 'listCatalogNodes', 'listSnapshotChanges', - 'listAuditEntries' + 'listAuditEntries', + 'listAccounts', + 'listRoleAssignments' ]); /** The server's largest audit page, and how many the audit log reads before it stops looking. */ @@ -278,6 +281,14 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt } return found.slice(0, limit); }, + listAccounts: async (): Promise => (await people()).map(toAccount), + async listRoleAssignments(): Promise { + const { data, error, response } = await http.GET('/api/admin/roles'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + // Group members come from the people read; without it, SSO-derived roles stay unlisted. + return toRoleAssignments(data, await people().catch(() => [])); + }, listChecks: async (scope: Scope): Promise => (await checkResults(scope)).map(toCheck), getCheck: async (scope: Scope, id: string): Promise => checkDetailOf(await checkResults(scope, id)), async getCheckSql(scope: Scope, checkId: string): Promise { @@ -358,6 +369,13 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt } /** The newest result of every check, or one check's results newest first. */ + async function people() { + const { data, error, response } = await http.GET('/api/admin/people'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return data; + } + async function checkResults(scope: Scope, bindingKey?: string) { const { locationId, target } = parseLocationKey(scope.locationId); const { data, error, response } = bindingKey diff --git a/ui/src/lib/api/http-people.test.ts b/ui/src/lib/api/http-people.test.ts new file mode 100644 index 0000000000..518daeb696 --- /dev/null +++ b/ui/src/lib/api/http-people.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { toAccount, toRoleAssignments } from './http-people'; + +const person = (name: string, groups: string[], has_password = false) => ({ + principal_id: `p-${name}`, + name, + created_at: '2026-11-01T00:00:00Z', + disabled_at: null, + has_password, + groups, + active_sessions: 0, + last_signed_in_at: null +}); + +describe('people', () => { + it('expands a group role to its members only, and scopes it to the location key', () => { + const people = [person('ada', ['data-admins']), person('vic', ['analysts']), person('sam', [], true)]; + const assignments = toRoleAssignments( + { + assignments: [{ assignment_id: 'a-1', principal_id: 'p-sam', name: 'sam', role_key: 'viewer', location_id: 'loc-1', target: 'prod' }], + group_roles: [{ group_name: 'data-admins', role_key: 'admin', location_id: null, target: null }] + }, + people + ); + expect(assignments.map((a) => [a.principal.name, a.role, a.source, a.ssoGroup, a.scope.locationId])).toEqual([ + ['sam', 'viewer', 'manual', null, 'loc-1:prod'], + ['ada', 'admin', 'sso', 'data-admins', null] + ]); + }); + + it('reports a sign-in source only when the server knows one', () => { + expect([toAccount(person('sam', [], true)).source, toAccount(person('ada', ['g'])).source, toAccount(person('vic', [])).source]).toEqual([ + 'password', + 'oidc', + null + ]); + }); +}); diff --git a/ui/src/lib/api/http-people.ts b/ui/src/lib/api/http-people.ts new file mode 100644 index 0000000000..9f3af49421 --- /dev/null +++ b/ui/src/lib/api/http-people.ts @@ -0,0 +1,58 @@ +import type { components } from '$control/schema.d.ts'; +import type { Account, RoleAssignment } from './types/platform'; + +type Wire = components['schemas']; + +/** A person's sign-in source as far as the server knows it: a password, or SSO groups. */ +export function toAccount(wire: Wire['PersonSummary']): Account { + return { + kind: 'user', + name: wire.name, + displayName: wire.name, + email: null, + source: wire.has_password ? 'password' : wire.groups.length ? 'oidc' : null, + ssoGroups: wire.groups, + createdAt: wire.created_at, + lastSeenAt: wire.last_signed_in_at ?? null, + disabled: wire.disabled_at != null, + activeSessions: wire.active_sessions + }; +} + +function scopeKey(locationId: string | null | undefined, target: string | null | undefined): string | null { + return locationId ? (target ? `${locationId}:${target}` : locationId) : null; +} + +/** Assignments given here, then one SSO assignment per member of each mapped group. */ +export function toRoleAssignments(holdings: Wire['RoleHoldings'], people: Wire['PersonSummary'][]): RoleAssignment[] { + const person = (name: string) => ({ kind: 'user' as const, name, displayName: name }); + const manual: RoleAssignment[] = holdings.assignments.map((a) => ({ + id: a.assignment_id, + principal: person(a.name), + role: a.role_key, + scope: { locationId: scopeKey(a.location_id, a.target) }, + source: 'manual', + ssoGroup: null, + definedIn: null, + assignedBy: null, + assignedAt: null, + stale: false + })); + const viaGroups: RoleAssignment[] = holdings.group_roles.flatMap((g) => + people + .filter((p) => p.groups.includes(g.group_name)) + .map((p) => ({ + id: `sso:${g.group_name}:${g.role_key}:${scopeKey(g.location_id, g.target) ?? '*'}:${p.principal_id}`, + principal: person(p.name), + role: g.role_key, + scope: { locationId: scopeKey(g.location_id, g.target) }, + source: 'sso' as const, + ssoGroup: g.group_name, + definedIn: null, + assignedBy: null, + assignedAt: null, + stale: false + })) + ); + return [...manual, ...viaGroups]; +} diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index 92defd33c6..bc1dc8c6c2 100644 --- a/ui/src/lib/api/types/platform.ts +++ b/ui/src/lib/api/types/platform.ts @@ -296,7 +296,8 @@ export type AccountSource = 'oidc' | 'trusted_proxy' | 'password'; export type Account = Principal & { kind: 'user'; email: string | null; - source: AccountSource; + /** Null when the server can't tell, such as a person with no password who hasn't used SSO. */ + source: AccountSource | null; ssoGroups: string[]; createdAt: Iso; lastSeenAt: Iso | null; @@ -369,7 +370,7 @@ export type RoleAssignment = { /** For policy and built-in assignments: where they are set, `path:line`. */ definedIn: string | null; assignedBy: Principal | null; - assignedAt: Iso; + assignedAt: Iso | null; /** The role no longer exists in code; the assignment shows but grants nothing. */ stale: boolean; }; diff --git a/ui/src/lib/state/access-directory.svelte.ts b/ui/src/lib/state/access-directory.svelte.ts index ad6bd39db1..84539ae8fa 100644 --- a/ui/src/lib/state/access-directory.svelte.ts +++ b/ui/src/lib/state/access-directory.svelte.ts @@ -11,10 +11,12 @@ export function createAccessDirectory() { const canAssign = () => app.can('role.assign'); const accounts = createResource((client) => (app.can('user.manage') || canAssign() ? client.listAccounts().catch(() => [] as Account[]) : Promise.resolve([] as Account[]))); const assignments = createResource((client) => (canAssign() ? client.listRoleAssignments() : Promise.resolve([] as RoleAssignment[]))); - const overview = createResource((client) => (app.can('user.manage') || canAssign() ? client.listAccessOverview() : Promise.resolve([] as AccessOverview[]))); + const overview = createResource((client) => + (app.can('user.manage') || canAssign()) && client.supports('listAccessOverview') ? client.listAccessOverview() : Promise.resolve([] as AccessOverview[]) + ); const roles = createResource(async (client) => { const map = new Map(); - if (!canAssign()) return map; + if (!canAssign() || !client.supports('listRoles')) return map; const locations = app.bootstrap?.locations ?? []; const lists = await Promise.all(locations.map((l) => client.listRoles(l.id))); locations.forEach((l, i) => map.set(l.id, lists[i])); diff --git a/ui/src/routes/admin/audit/+page.svelte b/ui/src/routes/admin/audit/+page.svelte index 8b0539a81b..b1412ebbb0 100644 --- a/ui/src/routes/admin/audit/+page.svelte +++ b/ui/src/routes/admin/audit/+page.svelte @@ -33,8 +33,8 @@ const actions = $derived([...new Set((everyone.data ?? []).map((e) => e.action))].sort()); const rows = $derived(entries.data ?? []); const locations = $derived(app.bootstrap?.locations ?? []); - // The person page and the access checker both read people. - const linksPeople = $derived(app.client.supports('listAccounts')); + const linksPeople = $derived(app.client.supports('effectiveAccess')); + const checksDecisions = $derived(app.client.supports('lookupDecision')); @@ -110,7 +110,7 @@
{e.decisionId} - {#if linksPeople}Check this{/if} + {#if checksDecisions}Check this{/if}
{/if} diff --git a/ui/src/routes/admin/people/+page.svelte b/ui/src/routes/admin/people/+page.svelte index 91ad7db394..615f799646 100644 --- a/ui/src/routes/admin/people/+page.svelte +++ b/ui/src/routes/admin/people/+page.svelte @@ -25,6 +25,7 @@ const d = people.directory; const allowed = $derived(app.can('user.manage') || app.can('role.assign')); const locations = $derived(app.bootstrap?.locations ?? []); + const opensPeople = $derived(app.client.supports('effectiveAccess')); const sources: AssignmentSource[] = ['manual', 'sso', 'policy', 'built_in']; @@ -46,7 +47,7 @@

Everyone who has signed in, with what they can do in each location. People sign in with single sign-on, or sqb login on the command line. - Open someone to see their effective access and why. + {#if opensPeople}Open someone to see their effective access and why.{/if}

@@ -54,13 +55,13 @@ ({ value: r, label: r }))} all="Any role" /> ({ value: l.id, label: l.name }))} all="All locations" /> ({ value: s, label: assignmentSourceLabel[s] }))} all="Any source" /> - {/if} {/snippet} {#snippet actions()} - + {#if app.client.supports('explainAccess')}{/if} {/snippet}
@@ -77,13 +78,13 @@ {#each people.shown as a (a.name)} {@const risky = d.riskyOf(a.name)} - goto(personHref(a))}> + opensPeople && goto(personHref(a))}>
- e.stopPropagation()}>{a.displayName} -
{a.name} · {a.source === 'oidc' ? 'SSO' : accountSourceLabel[a.source]}
+ {#if opensPeople} e.stopPropagation()}>{a.displayName}{:else}{a.displayName}{/if} +
{a.name} · {a.source === 'oidc' ? 'SSO' : a.source ? accountSourceLabel[a.source] : 'no password'}
@@ -145,4 +146,7 @@ gap: 4px; color: var(--sb-warning) !important; } + tr.static { + cursor: default; + } diff --git a/ui/src/routes/admin/people/[name]/+page.svelte b/ui/src/routes/admin/people/[name]/+page.svelte index 5bebd8f738..037735ebfa 100644 --- a/ui/src/routes/admin/people/[name]/+page.svelte +++ b/ui/src/routes/admin/people/[name]/+page.svelte @@ -126,7 +126,7 @@ Date: Sun, 8 Nov 2026 11:37:34 +0000 Subject: [PATCH 06/16] fix(server): count no active sessions for a disabled person Disabling a person blocks their sessions without revoking them, so the people read no longer counts those sessions as active. --- .../src/authorization/main/list_people.rs | 2 +- .../src/credentials/models.rs | 2 +- .../tests/api/test_person_live.rs | 27 +++++++++++++++++++ ui/src/lib/api/http-client.ts | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs b/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs index db9dfd4f5a..de67a734f9 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/list_people.rs @@ -32,7 +32,7 @@ pub async fn list_people( "SELECT p.principal_id, p.name, p.created_at, p.disabled_at, \ CASE WHEN c.principal_id IS NULL THEN 0 ELSE 1 END AS has_password, \ (SELECT COUNT(*) FROM sqb_sessions s WHERE s.principal_id=p.principal_id \ - AND s.revoked_at IS NULL AND s.expires_at>$1) AS active_sessions, \ + AND s.revoked_at IS NULL AND s.expires_at>$1 AND p.disabled_at IS NULL) AS active_sessions, \ (SELECT MAX(s.created_at) FROM sqb_sessions s WHERE s.principal_id=p.principal_id) AS last_signed_in_at \ FROM sqb_principals p LEFT JOIN sqb_password_credentials c ON c.principal_id=p.principal_id ORDER BY p.name", vec![Parameter::Time(now)]).await?; diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index 47d5c42d33..cb9cb43fc1 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -54,7 +54,7 @@ pub struct PersonSummary { pub has_password: bool, /// Groups the identity provider reported at the person's last SSO sign-in. pub groups: Vec, - /// Unrevoked, unexpired browser and CLI sessions. + /// Unrevoked, unexpired browser and CLI sessions; none while the person is disabled. pub active_sessions: i64, pub last_signed_in_at: Option>, } diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_live.rs b/crates/sqlbuild-orchestration/tests/api/test_person_live.rs index b8abf89554..600de855ad 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_live.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_live.rs @@ -216,6 +216,33 @@ fn given_people_with_different_roles_when_they_bootstrap_and_subscribe_then_only .await .expect("disabled bootstrap") .status(); + let people: Value = client + .get(format!("{}/api/admin/people", api.url)) + .header(header::HOST, "control.example.com") + .header( + header::COOKIE, + format!("__Host-sqb-session={}", roles.administrator.bearer), + ) + .send() + .await + .expect("people") + .json() + .await + .expect("people body"); + let member = people + .as_array() + .into_iter() + .flatten() + .find(|person| person["principal_id"] == json!(roles.member_id)) + .expect("disabled member is listed"); + assert_eq!( + ( + member["disabled_at"].is_string(), + &member["active_sessions"] + ), + (true, &json!(0)), + "a disabled person's sessions no longer count as active" + ); let logout = client .post(format!("{}/api/auth/logout", api.url)) .header(header::HOST, "control.example.com") diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index b0d72ac0bc..adb5e0b2f7 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -368,7 +368,6 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return data; } - /** The newest result of every check, or one check's results newest first. */ async function people() { const { data, error, response } = await http.GET('/api/admin/people'); if (response.status === 401) onUnauthorized(); @@ -376,6 +375,7 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return data; } + /** The newest result of every check, or one check's results newest first. */ async function checkResults(scope: Scope, bindingKey?: string) { const { locationId, target } = parseLocationKey(scope.locationId); const { data, error, response } = bindingKey From 5494e247a73bd663b588948f42a5abae7229f0f1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:28:38 +0000 Subject: [PATCH 07/16] feat(server): let people read a target's location health Add GET /api/control/locations/{location_id}/targets/{target}/health behind the same listing gate as jobs. It returns the target's newest health observations and instance check-ins, shared with the machine read through one helper. --- clients/typescript/src/schema.d.ts | 56 ++++++++++++++++- .../authentication/password_sessions.rs | 4 ++ .../api/_helpers/resources/person_reads.rs | 24 ++++++++ .../sqlbuild-orchestration/src/api/models.rs | 1 + .../src/platform/_helpers/mod.rs | 1 + .../src/platform/_helpers/recent_health.rs | 60 +++++++++++++++++++ .../src/platform/main/location_health.rs | 54 ++--------------- .../src/platform/main/mod.rs | 1 + .../main/read_person_location_health.rs | 33 ++++++++++ .../tests/api/test_location_health.rs | 33 +++++++++- .../tests/api/test_person_sessions.rs | 1 + 11 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs create mode 100644 crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 1d25acfe23..4aba6e0688 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -370,6 +370,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readPersonLocationHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/jobs": { parameters: { query?: never; @@ -1532,7 +1548,7 @@ export interface components { PersonSummary: { /** * Format: int64 - * @description Unrevoked, unexpired browser and CLI sessions. + * @description Unrevoked, unexpired browser and CLI sessions; none while the person is disabled. */ active_sessions: number; /** Format: date-time */ @@ -3129,6 +3145,44 @@ export interface operations { }; }; }; + readPersonLocationHealth: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description This target's newest health observations and instance check-ins */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LocationHealthObservation"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; listPersonJobs: { 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 f259c54582..bcc76af513 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -126,6 +126,10 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/snapshots", get(crate::api::_helpers::resources::person_reads::snapshots), ) + .route( + "/api/control/locations/{location_id}/targets/{target}/health", + get(crate::api::_helpers::resources::person_reads::health), + ) .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/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs index aa10e0c867..181d5833d1 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -11,6 +11,8 @@ use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole} use crate::declarations::main::list_person_jobs::list_person_jobs; use crate::declarations::main::list_person_triggers::list_person_triggers; use crate::declarations::models::{JobRecord, TriggerRecord}; +use crate::platform::main::read_person_location_health::read_person_location_health; +use crate::platform::models::LocationHealthObservation; 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; @@ -161,6 +163,28 @@ pub(crate) async fn snapshots( )) } +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/health", operation_id = "readPersonLocationHealth", + params(("location_id" = Uuid, Path), ("target" = String, Path)), + responses((status = 200, body = Vec, description = "This target's newest health observations and instance check-ins"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn health( + (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_location_health( + &store, + (&context, principal), + (location_id, &target), + default_role, + ) + .await?, + )) +} + fn not_found(message: &str) -> ApiError { ApiError { status: StatusCode::NOT_FOUND, diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index a80ef4bcec..c28dcbec2f 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -28,6 +28,7 @@ use crate::api::_helpers::resources::{ person_reads::triggers, person_reads::ticks, person_reads::snapshots, + person_reads::health, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs index 0c2b80f5ca..791b027cff 100644 --- a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod connection_tests; pub(crate) mod liveness; pub(crate) mod location_heartbeat; pub(crate) mod observations; +pub(crate) mod recent_health; pub(crate) mod retention; pub(crate) mod watchdog; pub(crate) mod work_client; diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs new file mode 100644 index 0000000000..b050c540b2 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs @@ -0,0 +1,60 @@ +//! The newest health observations of a location: reported events and instance liveness rows. + +use uuid::Uuid; + +use crate::platform::models::LocationHealthObservation; +use crate::store::errors::ControlError; +use crate::store::main::decode_row::decode_row; +use crate::store::main::query::query; +use crate::store::models::{ControlTransaction, Parameter}; + +/// At most 100, newest first, from today's and yesterday's reports plus each instance's last check-in. +pub(crate) async fn recent_health( + transaction: ControlTransaction, + location_id: Uuid, +) -> Result<(ControlTransaction, Vec), ControlError> { + let today = transaction.now.date_naive(); + let yesterday = today - chrono::TimeDelta::days(1); + let (transaction, rows) = query( + transaction, + "SELECT received_at, payload FROM (\ + SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ + WHERE invocation_id=$1 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) current_day \ + UNION ALL \ + SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ + WHERE invocation_id=$2 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) previous_day\ + ) observations ORDER BY received_at DESC, invocation_sequence DESC LIMIT 100", + vec![ + Parameter::Text(format!("location/{location_id}/{today}")), + Parameter::Text(format!("location/{location_id}/{yesterday}")), + ], + ) + .await?; + let mut observations: Vec = rows + .into_iter() + .map(|mut row| { + let report = row + .remove("payload") + .and_then(|mut payload| payload.get_mut("report").map(serde_json::Value::take)) + .ok_or_else(|| ControlError::Invalid("location health report is missing".into()))?; + row.insert("report".into(), report); + decode_row(row) + }) + .collect::>()?; + let (transaction, instances) = query( + transaction, + "SELECT last_seen_at AS received_at, health_json AS report FROM sqb_location_instances \ + WHERE location_id=$1 ORDER BY last_seen_at DESC LIMIT 100", + vec![Parameter::Id(location_id)], + ) + .await?; + observations.extend( + instances + .into_iter() + .map(decode_row) + .collect::, _>>()?, + ); + observations.sort_by_key(|observation| std::cmp::Reverse(observation.received_at)); + observations.truncate(100); + Ok((transaction, observations)) +} diff --git a/crates/sqlbuild-orchestration/src/platform/main/location_health.rs b/crates/sqlbuild-orchestration/src/platform/main/location_health.rs index 977a61e6c4..49fce2a188 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/location_health.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/location_health.rs @@ -2,62 +2,18 @@ use uuid::Uuid; +use crate::platform::_helpers::recent_health::recent_health; use crate::platform::models::LocationHealthObservation; use crate::store::errors::ControlError; -use crate::store::main::begin::begin; -use crate::store::main::commit::commit; -use crate::store::main::decode_row::decode_row; -use crate::store::main::query::query; -use crate::store::models::{ControlStore, Parameter, TransactionMode}; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; pub async fn location_health( store: &ControlStore, location_id: Uuid, ) -> Result, ControlError> { - let transaction = begin(store, TransactionMode::Read).await?; - let today = transaction.now.date_naive(); - let yesterday = today - chrono::TimeDelta::days(1); - let (transaction, rows) = query( - transaction, - "SELECT received_at, payload FROM (\ - SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ - WHERE invocation_id=$1 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) current_day \ - UNION ALL \ - SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ - WHERE invocation_id=$2 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) previous_day\ - ) observations ORDER BY received_at DESC, invocation_sequence DESC LIMIT 100", - vec![ - Parameter::Text(format!("location/{location_id}/{today}")), - Parameter::Text(format!("location/{location_id}/{yesterday}")), - ], - ) - .await?; - let mut observations: Vec = rows - .into_iter() - .map(|mut row| { - let report = row - .remove("payload") - .and_then(|mut payload| payload.get_mut("report").map(serde_json::Value::take)) - .ok_or_else(|| ControlError::Invalid("location health report is missing".into()))?; - row.insert("report".into(), report); - decode_row(row) - }) - .collect::>()?; - let (transaction, instances) = query( - transaction, - "SELECT last_seen_at AS received_at, health_json AS report FROM sqb_location_instances \ - WHERE location_id=$1 ORDER BY last_seen_at DESC LIMIT 100", - vec![Parameter::Id(location_id)], - ) - .await?; - observations.extend( - instances - .into_iter() - .map(decode_row) - .collect::, _>>()?, - ); - observations.sort_by_key(|observation| std::cmp::Reverse(observation.received_at)); - observations.truncate(100); + let (transaction, observations) = + recent_health(begin(store, TransactionMode::Read).await?, location_id).await?; commit(transaction).await?; Ok(observations) } diff --git a/crates/sqlbuild-orchestration/src/platform/main/mod.rs b/crates/sqlbuild-orchestration/src/platform/main/mod.rs index ecf74ceb6f..fe0e22b625 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/mod.rs @@ -5,6 +5,7 @@ pub mod location_health; pub mod location_work; pub(crate) mod read_location_work; pub mod read_person_connection_test; +pub mod read_person_location_health; pub(crate) mod read_platform_events; pub mod report_location_health; pub mod report_remote_location_health; diff --git a/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs new file mode 100644 index 0000000000..b8a559d466 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs @@ -0,0 +1,33 @@ +//! People read a target's health only with view on every current node, like its listings. + +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::platform::_helpers::recent_health::recent_health; +use crate::platform::models::LocationHealthObservation; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TransactionMode}; + +/// The location's newest observations for this target only, newest first. +pub async fn read_person_location_health( + 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, observations) = recent_health(transaction, location_id).await?; + commit(transaction).await?; + Ok(observations + .into_iter() + .filter(|observation| observation.report.target == target) + .collect()) +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_location_health.rs b/crates/sqlbuild-orchestration/tests/api/test_location_health.rs index db5ebfe4fa..556d5669a9 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_location_health.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_location_health.rs @@ -1,5 +1,7 @@ +use crate::authorization::main::bootstrap_user::bootstrap_user; use crate::credentials::main::tokens::issue_token::issue_token; -use crate::credentials::models::{AuditContext, CredentialScope}; +use crate::credentials::models::{AuditContext, CredentialScope, DefaultRole}; +use crate::platform::main::read_person_location_health::read_person_location_health; use crate::platform::main::report_remote_location_health::report_remote_location_health; use crate::platform::models::{LocationHealth, LocationHealthObservation, LocationHealthReport}; use crate::snapshots::main::publish_snapshot::publish_snapshot; @@ -171,6 +173,35 @@ fn given_scoped_location_observations_when_retried_or_read_then_history_and_auth ); assert_eq!(observations[0].report, connection); assert_eq!(observations[1].report, heartbeat); + let audit = AuditContext { + actor: "test-admin".into(), + granting_role: "admin".into(), + request_id: Uuid::now_v7(), + ip_address: None, + }; + let administrator = bootstrap_user(store, &audit, "orders-admin") + .await + .expect("administrator"); + let person_read = |target: &'static str| { + read_person_location_health( + store, + (&audit, administrator), + (location_id, target), + DefaultRole::None, + ) + }; + assert_eq!( + person_read("preview #1").await.expect("person health"), + observations, + "a person sees the same observations for the reporting target" + ); + assert!( + person_read("another target") + .await + .expect("other target health") + .is_empty(), + "another target's read leaves this target's observations out" + ); let events = read_recall_events(store, 0, 100) .await .expect("read durable events"); diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index 71c844a5d4..d1533549d9 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -289,6 +289,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ format!("/api/control/locations/{location}/targets/development/jobs"), format!("/api/control/locations/{location}/targets/development/triggers"), format!("/api/control/locations/{location}/targets/development/snapshots"), + format!("/api/control/locations/{location}/targets/development/health"), format!( "/api/control/locations/{location}/targets/development/triggers/{}/ticks", trigger.trigger_id From fd394083d66124aa318ea1844dc37ae390c1dec2 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:40:16 +0000 Subject: [PATCH 08/16] feat(ui): show live location health on the Locations page Health comes from each location's check-ins and observations: no check-in for 90 seconds is broken, a failed compile at or after the connected generation is falling back, and an unhealthy connection is degraded. People without listing view see the health as unknown. Server status, detail links and the deploy policy column appear only when supported. --- fensu.toml | 2 +- ui/src/lib/api/http-client.ts | 24 ++++++++++- ui/src/lib/api/http-locations.test.ts | 33 ++++++++++++++ ui/src/lib/api/http-locations.ts | 62 +++++++++++++++++++++++++++ ui/src/lib/api/types/platform.ts | 6 ++- ui/src/lib/platform/labels.ts | 2 +- ui/src/routes/locations/+page.svelte | 31 ++++++++++---- 7 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 ui/src/lib/api/http-locations.test.ts create mode 100644 ui/src/lib/api/http-locations.ts diff --git a/fensu.toml b/fensu.toml index 7c1f8e7b53..363656d382 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1381,7 +1381,7 @@ paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrator 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/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_jobs.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"] +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_jobs.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/platform/main/read_person_location_health.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]] diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index adb5e0b2f7..b45308f9cb 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -5,12 +5,13 @@ import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-chec import { toCatalogNodes } from './http-catalog'; import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; +import { toLocationSummary } from './http-locations'; import { toAccount, toRoleAssignments } from './http-people'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; -import type { Account, AuditEntry, AuditQuery, RoleAssignment } from './types/platform'; +import type { Account, AuditEntry, AuditQuery, LocationSummary, RoleAssignment } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -44,7 +45,8 @@ const SUPPORTED: ReadonlySet = new Set([ 'listSnapshotChanges', 'listAuditEntries', 'listAccounts', - 'listRoleAssignments' + 'listRoleAssignments', + 'listLocations' ]); /** The server's largest audit page, and how many the audit log reads before it stops looking. */ @@ -281,6 +283,24 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt } return found.slice(0, limit); }, + /** Each location the person sees, with health only where they may list the whole target. */ + async listLocations(): Promise { + const { data, error, response } = await http.GET('/api/control/bootstrap'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return Promise.all( + toBootstrap(data).locations.map(async (location) => { + const { locationId, target } = parseLocationKey(location.id); + const path = { location_id: locationId, target }; + const [health, snapshots] = await Promise.all([ + http.GET('/api/control/locations/{location_id}/targets/{target}/health', { params: { path } }), + http.GET('/api/control/locations/{location_id}/targets/{target}/snapshots', { params: { path, query: { limit: 1 } } }) + ]); + if (health.response.status === 401 || snapshots.response.status === 401) onUnauthorized(); + return toLocationSummary(location, health.data ?? null, snapshots.data?.[0] ?? null, Date.now()); + }) + ); + }, listAccounts: async (): Promise => (await people()).map(toAccount), async listRoleAssignments(): Promise { const { data, error, response } = await http.GET('/api/admin/roles'); diff --git a/ui/src/lib/api/http-locations.test.ts b/ui/src/lib/api/http-locations.test.ts new file mode 100644 index 0000000000..05d99ab2b8 --- /dev/null +++ b/ui/src/lib/api/http-locations.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { connectionTestOf, healthOf } from './http-locations'; + +const now = Date.parse('2026-11-08T12:00:00Z'); +const at = (secondsAgo: number) => new Date(now - secondsAgo * 1000).toISOString(); +type Health = Parameters[0][number]['report']['health']; +const seen = (secondsAgo: number, health: Health, session = 's-1') => ({ + received_at: at(secondsAgo), + report: { event_id: `e-${secondsAgo}`, session_id: session, target: 'prod', health } +}); + +describe('location health', () => { + it('is broken without a check-in in the last 90 seconds, whatever was reported before', () => { + expect(healthOf([seen(91, { kind: 'heartbeat' })], now)).toEqual({ health: 'broken', liveInstances: 0, lastSeenAt: at(91) }); + }); + + it('counts each live instance once', () => { + const observations = [seen(10, { kind: 'heartbeat' }), seen(20, { kind: 'connection', generation: 2, healthy: true }), seen(30, { kind: 'heartbeat' }, 's-2')]; + expect(healthOf(observations, now)).toMatchObject({ health: 'healthy', liveInstances: 2, lastSeenAt: at(10) }); + }); + + it('falls back after a failed load until a newer generation connects', () => { + const failed = seen(20, { kind: 'compile_failed', generation: 3 }); + expect(healthOf([seen(5, { kind: 'heartbeat' }), failed, seen(40, { kind: 'connection', generation: 2, healthy: true })], now).health).toBe('falling_back'); + expect(healthOf([seen(5, { kind: 'connection', generation: 4, healthy: true }), failed], now).health).toBe('healthy'); + }); + + it('needs attention when the latest connection check failed', () => { + const observations = [seen(5, { kind: 'connection', generation: 2, healthy: false }), seen(60, { kind: 'connection', generation: 1, healthy: true })]; + expect(healthOf(observations, now).health).toBe('degraded'); + expect(connectionTestOf(observations)).toMatchObject({ outcome: 'failed', at: at(5) }); + }); +}); diff --git a/ui/src/lib/api/http-locations.ts b/ui/src/lib/api/http-locations.ts new file mode 100644 index 0000000000..d08a1dadb8 --- /dev/null +++ b/ui/src/lib/api/http-locations.ts @@ -0,0 +1,62 @@ +import type { components } from '$control/schema.d.ts'; +import type { Location, LocationHealth } from './types'; +import type { ConnectionTest, LocationSummary } from './types/platform'; + +type Wire = components['schemas']; + +/** Locations report every 30 seconds; the server treats two missed reports as down. */ +const LIVE_WINDOW_MS = 90_000; + +function latestOf(observations: Wire['LocationHealthObservation'][], kind: K) { + return observations.find((o) => o.report.health.kind === kind) as (Wire['LocationHealthObservation'] & { report: { health: Extract } }) | undefined; +} + +/** Health from the newest observations: down without a recent check-in, then load failures, then connection failures. */ +export function healthOf(observations: Wire['LocationHealthObservation'][], now: number): { health: LocationHealth; liveInstances: number; lastSeenAt: string | null } { + const live = new Set(observations.filter((o) => now - Date.parse(o.received_at) <= LIVE_WINDOW_MS).map((o) => o.report.session_id)); + const lastSeenAt = observations.reduce((latest, o) => (!latest || o.received_at > latest ? o.received_at : latest), null); + const failed = latestOf(observations, 'compile_failed'); + const connection = latestOf(observations, 'connection'); + const health: LocationHealth = !live.size + ? 'broken' + : failed && (!connection || failed.report.health.generation >= connection.report.health.generation) + ? 'falling_back' + : connection && !connection.report.health.healthy + ? 'degraded' + : 'healthy'; + return { health, liveInstances: live.size, lastSeenAt }; +} + +/** The connection check a location ran after publishing; the server keeps no message or duration. */ +export function connectionTestOf(observations: Wire['LocationHealthObservation'][]): ConnectionTest | null { + const latest = latestOf(observations, 'connection'); + if (!latest) return null; + const ok = latest.report.health.healthy; + return { outcome: ok ? 'ok' : 'failed', at: latest.received_at, durationMs: 0, trigger: 'publish', by: null, message: ok ? 'Connected after publishing' : 'Could not connect after publishing' }; +} + +/** `observations` is null when the person may not list the whole target. */ +export function toLocationSummary(location: Location, observations: Wire['LocationHealthObservation'][] | null, published: Wire['PublishedSnapshot'] | null, now: number): LocationSummary { + const known = observations ? healthOf(observations, now) : { health: null, liveInstances: 0, lastSeenAt: null }; + return { + id: location.id, + name: location.name, + target: location.target, + ...known, + brokenDeployPolicy: null, + failedDeploy: null, + connectionTest: observations ? connectionTestOf(observations) : null, + activeProblems: 0, + snapshot: published + ? { + id: published.snapshot_id, + generation: published.generation, + gitRevision: published.git_revision ?? '', + gitDirty: published.git_dirty, + gitAuthor: published.git_author ?? '', + publishedAt: published.published_at, + sqbVersion: published.sqb_version + } + : null + }; +} diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index bc1dc8c6c2..4a87a54ab7 100644 --- a/ui/src/lib/api/types/platform.ts +++ b/ui/src/lib/api/types/platform.ts @@ -163,7 +163,11 @@ export type LocationDetail = { connectionTest: ConnectionTest | null; }; -export type LocationSummary = Pick & { +export type LocationSummary = Pick & { + /** Null when the server can't report it to this person. */ + health: LocationHealth | null; + /** Null when the server doesn't report the policy. */ + brokenDeployPolicy: BrokenDeployPolicy | null; /** Unresolved platform events of this location. */ activeProblems: number; liveInstances: number; diff --git a/ui/src/lib/platform/labels.ts b/ui/src/lib/platform/labels.ts index 7d29b9b9a9..c6702a11f0 100644 --- a/ui/src/lib/platform/labels.ts +++ b/ui/src/lib/platform/labels.ts @@ -38,7 +38,7 @@ export function healthWords(location: Pick = { diff --git a/ui/src/routes/locations/+page.svelte b/ui/src/routes/locations/+page.svelte index 548b28e2bd..f7cc7c541e 100644 --- a/ui/src/routes/locations/+page.svelte +++ b/ui/src/routes/locations/+page.svelte @@ -19,14 +19,17 @@ let query = $state(''); const all = $derived(locations.data ?? []); const filtered = $derived(all.filter((l) => !query.trim() || l.name.toLowerCase().includes(query.trim().toLowerCase()))); - const unhealthy = $derived(all.filter((l) => l.health !== 'healthy' || l.activeProblems > 0).length); + const unhealthy = $derived(all.filter((l) => (l.health && l.health !== 'healthy') || l.activeProblems > 0).length); + const opensDetail = $derived(app.client.supports('getLocation')); + const showsServer = $derived(app.client.supports('platformHealth')); + const knowsPolicy = $derived(all.some((l) => l.brokenDeployPolicy)); - +{#if showsServer}{/if} Deployed version Warehouse Location processes - If a deploy fails + {#if knowsPolicy}If a deploy fails{/if} {#each filtered as l (l.id)} - goto(`/locations/${l.id}`)}> + opensDetail && goto(`/locations/${l.id}`)}>
- e.stopPropagation()}>{l.name} + {#if opensDetail} e.stopPropagation()}>{l.name}{:else}{l.name}{/if}
{targetWords(l.target)}
- + {#if l.health} + + {:else} + + {/if} {#if l.activeProblems} {/if} @@ -85,13 +92,19 @@
{l.liveInstances === 1 ? 'Running' : l.liveInstances === 0 ? 'Not running' : `${l.liveInstances} running`}
- + {#if l.lastSeenAt}{/if} - {l.brokenDeployPolicy === 'fallback' ? 'Keep running the previous version' : 'Hold scheduled runs until a deploy works'} + {#if knowsPolicy}{l.brokenDeployPolicy === 'fallback' ? 'Keep running the previous version' : l.brokenDeployPolicy ? 'Hold scheduled runs until a deploy works' : '—'}{/if} {:else} - + {/each}
+ + From 8dc27f1bcae7b339db4e59daeaba7be0954aa204 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:49:27 +0000 Subject: [PATCH 09/16] fix(server): limit a target's health read to its own observations The person health read filtered by target only after the location's newest 100 observations were taken across all targets, so a busier target could push out another target's compile or connection reports. The target filter now applies before each limit. --- .../src/platform/_helpers/recent_health.rs | 36 +++++++++++++----- .../src/platform/main/location_health.rs | 8 +++- .../main/read_person_location_health.rs | 7 +--- .../tests/api/test_location_health.rs | 38 +++++++++++++++++++ fensu.toml | 2 +- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs index b050c540b2..40e68baa26 100644 --- a/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs @@ -8,26 +8,38 @@ use crate::store::main::decode_row::decode_row; use crate::store::main::query::query; use crate::store::models::{ControlTransaction, Parameter}; -/// At most 100, newest first, from today's and yesterday's reports plus each instance's last check-in. +/// At most 100, newest first, from today's and yesterday's reports plus each instance's last check-in, of one target when given. pub(crate) async fn recent_health( transaction: ControlTransaction, location_id: Uuid, + target: Option<&str>, ) -> Result<(ControlTransaction, Vec), ControlError> { let today = transaction.now.date_naive(); let yesterday = today - chrono::TimeDelta::days(1); + let (event_target, instance_target) = match target { + Some(_) => (" AND payload->'report'->>'target'=$3", " AND target=$2"), + None => ("", ""), + }; + let target_parameter: Vec = target + .map(|target| Parameter::Text(target.to_owned())) + .into_iter() + .collect(); let (transaction, rows) = query( transaction, - "SELECT received_at, payload FROM (\ + &format!("SELECT received_at, payload FROM (\ SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ - WHERE invocation_id=$1 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) current_day \ + WHERE invocation_id=$1 AND event_type='location_health'{event_target} ORDER BY invocation_sequence DESC LIMIT 100) current_day \ UNION ALL \ SELECT * FROM (SELECT occurred_at AS received_at, invocation_sequence, payload FROM recall_events \ - WHERE invocation_id=$2 AND event_type='location_health' ORDER BY invocation_sequence DESC LIMIT 100) previous_day\ - ) observations ORDER BY received_at DESC, invocation_sequence DESC LIMIT 100", - vec![ + WHERE invocation_id=$2 AND event_type='location_health'{event_target} ORDER BY invocation_sequence DESC LIMIT 100) previous_day\ + ) observations ORDER BY received_at DESC, invocation_sequence DESC LIMIT 100"), + [ Parameter::Text(format!("location/{location_id}/{today}")), Parameter::Text(format!("location/{location_id}/{yesterday}")), - ], + ] + .into_iter() + .chain(target_parameter.clone()) + .collect(), ) .await?; let mut observations: Vec = rows @@ -43,9 +55,13 @@ pub(crate) async fn recent_health( .collect::>()?; let (transaction, instances) = query( transaction, - "SELECT last_seen_at AS received_at, health_json AS report FROM sqb_location_instances \ - WHERE location_id=$1 ORDER BY last_seen_at DESC LIMIT 100", - vec![Parameter::Id(location_id)], + &format!( + "SELECT last_seen_at AS received_at, health_json AS report FROM sqb_location_instances \ + WHERE location_id=$1{instance_target} ORDER BY last_seen_at DESC LIMIT 100" + ), + std::iter::once(Parameter::Id(location_id)) + .chain(target_parameter) + .collect(), ) .await?; observations.extend( diff --git a/crates/sqlbuild-orchestration/src/platform/main/location_health.rs b/crates/sqlbuild-orchestration/src/platform/main/location_health.rs index 49fce2a188..148e6fb77f 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/location_health.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/location_health.rs @@ -12,8 +12,12 @@ pub async fn location_health( store: &ControlStore, location_id: Uuid, ) -> Result, ControlError> { - let (transaction, observations) = - recent_health(begin(store, TransactionMode::Read).await?, location_id).await?; + let (transaction, observations) = recent_health( + begin(store, TransactionMode::Read).await?, + location_id, + None, + ) + .await?; commit(transaction).await?; Ok(observations) } diff --git a/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs index b8a559d466..91b9eeab0d 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs @@ -24,10 +24,7 @@ pub async fn read_person_location_health( default_role, ) .await?; - let (transaction, observations) = recent_health(transaction, location_id).await?; + let (transaction, observations) = recent_health(transaction, location_id, Some(target)).await?; commit(transaction).await?; - Ok(observations - .into_iter() - .filter(|observation| observation.report.target == target) - .collect()) + Ok(observations) } diff --git a/crates/sqlbuild-orchestration/tests/api/test_location_health.rs b/crates/sqlbuild-orchestration/tests/api/test_location_health.rs index 556d5669a9..928b2759ca 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_location_health.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_location_health.rs @@ -222,6 +222,44 @@ fn given_scoped_location_observations_when_retried_or_read_then_history_and_auth .all(|observation| observation.received_at >= before && observation.received_at <= Utc::now()) ); + let mut busy = snapshot(location_id); + busy.target = "another target".into(); + publish_snapshot(store, &busy) + .await + .expect("busy target generation"); + let busy_heartbeat = LocationHealthReport { + event_id: Uuid::now_v7(), + session_id: Uuid::now_v7(), + target: busy.target.clone(), + health: LocationHealth::Heartbeat {}, + }; + report_remote_location_health((&api.url, &token.bearer), location_id, &busy_heartbeat) + .await + .expect("busy target instance"); + for _ in 0..101 { + let busy_connection = LocationHealthReport { + event_id: Uuid::now_v7(), + health: LocationHealth::Connection { + generation: 1, + healthy: true, + }, + ..busy_heartbeat.clone() + }; + report_remote_location_health( + (&api.url, &token.bearer), + location_id, + &busy_connection, + ) + .await + .expect("busy target observation"); + } + assert_eq!( + person_read("preview #1") + .await + .expect("quiet target health"), + observations, + "a busier target's newer observations do not push out this target's" + ); stop_api(api).await; close_test_store(fixture).await; }); diff --git a/fensu.toml b/fensu.toml index 363656d382..51249679c6 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1381,7 +1381,7 @@ paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrator 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/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_jobs.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/platform/main/read_person_location_health.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"] +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_jobs.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/platform/main/read_person_location_health.rs", "crates/sqlbuild-orchestration/src/platform/main/location_health.rs", "crates/sqlbuild-orchestration/src/snapshots/main/current_snapshot_record.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]] From 1f04a8666e5ac2d4f17eace655f9b42dc305b6fd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:56:08 +0000 Subject: [PATCH 10/16] feat(server): let people read live platform problems Add GET /api/control/platform/problems for the UI's alert banner. It derives problems at read time with the watchdog's thresholds: whether the scheduler has a leader, location targets whose instances all missed two check-ins, and running schedules more than 30 seconds late. Broken locations and late ticks appear only for targets the person lists, and access is checked per target before that target's ticks are read. --- clients/typescript/src/schema.d.ts | 68 ++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/person_reads.rs | 16 +- .../sqlbuild-orchestration/src/api/models.rs | 1 + .../src/platform/main/mod.rs | 1 + .../src/platform/main/read_person_problems.rs | 108 ++++++++++++ .../src/platform/models.rs | 32 ++++ .../tests/platform/test_contracts.rs | 2 + .../tests/platform/test_problems.rs | 154 ++++++++++++++++++ .../tests/platform/test_types.rs | 6 + 10 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs create mode 100644 crates/sqlbuild-orchestration/tests/platform/test_problems.rs diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 4aba6e0688..34a6de6854 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -578,6 +578,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/platform/problems": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readPersonPlatformProblems"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/runs": { parameters: { query?: never; @@ -1010,6 +1026,14 @@ export interface components { * @enum {string} */ BatchState: "complete" | "gap" | "replay_required"; + /** @description Every instance of this location target has missed two check-ins. */ + BrokenLocation: { + /** Format: date-time */ + last_seen_at: string; + /** Format: uuid */ + location_id: string; + target: string; + }; /** @enum {string} */ BuiltinRole: "viewer" | "operator" | "developer" | "admin"; /** @description Browser cancellation cannot supply an actor, claim or lifecycle transition. */ @@ -1512,6 +1536,17 @@ export interface components { * @enum {string} */ OutputStream: "stdout" | "stderr"; + /** @description A running schedule whose next tick is more than 30 seconds late. */ + OverdueTick: { + /** Format: uuid */ + location_id: string; + name: string; + /** Format: date-time */ + scheduled_for: string; + target: string; + /** Format: uuid */ + trigger_id: string; + }; PasswordChangeRequest: { current_password: string; new_password: string; @@ -1576,6 +1611,12 @@ export interface components { }; /** @enum {string} */ PlatformEventKind: "heartbeat" | "scheduler_no_leader" | "tick_overdue" | "calendar_horizon_low" | "location_broken" | "launch_failed"; + /** @description Problems derived from live state when read; nothing here is stored. */ + PlatformProblems: { + broken_locations: components["schemas"]["BrokenLocation"][]; + overdue_ticks: components["schemas"]["OverdueTick"][]; + scheduler_leader: boolean; + }; /** @description A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. */ PublishedChange: { change_kind: string; @@ -3689,6 +3730,33 @@ export interface operations { }; }; }; + readPersonPlatformProblems: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Live scheduler state, and broken locations and late ticks of targets the person lists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformProblems"]; + }; + }; + /** @description Session rejected */ + 401: { + 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 bcc76af513..28ba723955 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -130,6 +130,10 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/health", get(crate::api::_helpers::resources::person_reads::health), ) + .route( + "/api/control/platform/problems", + get(crate::api::_helpers::resources::person_reads::problems), + ) .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/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs index 181d5833d1..1b85c7a490 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -12,7 +12,8 @@ use crate::declarations::main::list_person_jobs::list_person_jobs; use crate::declarations::main::list_person_triggers::list_person_triggers; use crate::declarations::models::{JobRecord, TriggerRecord}; use crate::platform::main::read_person_location_health::read_person_location_health; -use crate::platform::models::LocationHealthObservation; +use crate::platform::main::read_person_problems::read_person_problems; +use crate::platform::models::{LocationHealthObservation, PlatformProblems}; 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; @@ -185,6 +186,19 @@ pub(crate) async fn health( )) } +#[utoipa::path(get, path = "/api/control/platform/problems", operation_id = "readPersonPlatformProblems", + responses((status = 200, body = PlatformProblems, description = "Live scheduler state, and broken locations and late ticks of targets the person lists"), + (status = 401, description = "Session rejected")), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn problems( + State(store): State, + Extension(default_role): Extension, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result, ApiError> { + Ok(Json( + read_person_problems(&store, principal, default_role).await?, + )) +} + fn not_found(message: &str) -> ApiError { ApiError { status: StatusCode::NOT_FOUND, diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index c28dcbec2f..4868410cae 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -29,6 +29,7 @@ use crate::api::_helpers::resources::{ person_reads::ticks, person_reads::snapshots, person_reads::health, + person_reads::problems, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, diff --git a/crates/sqlbuild-orchestration/src/platform/main/mod.rs b/crates/sqlbuild-orchestration/src/platform/main/mod.rs index fe0e22b625..d1c062ba19 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/mod.rs @@ -6,6 +6,7 @@ pub mod location_work; pub(crate) mod read_location_work; pub mod read_person_connection_test; pub mod read_person_location_health; +pub mod read_person_problems; pub(crate) mod read_platform_events; pub mod report_location_health; pub mod report_remote_location_health; diff --git a/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs b/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs new file mode 100644 index 0000000000..cddec67364 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs @@ -0,0 +1,108 @@ +//! People see the scheduler's state, and broken locations and late ticks only for targets they list. + +use std::collections::BTreeSet; + +use chrono::TimeDelta; +use uuid::Uuid; + +use crate::authorization::main::person_lists_target::person_lists_target; +use crate::credentials::models::DefaultRole; +use crate::platform::models::{BrokenLocation, LateTarget, OverdueTick, PlatformProblems}; +use crate::store::errors::ControlError; +use crate::store::main::decode_row::decode_row; +use crate::store::main::query::query; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; + +/// Uses the watchdog's thresholds; access is checked per target before each target's ticks are read. +pub async fn read_person_problems( + store: &ControlStore, + principal_id: Uuid, + default_role: DefaultRole, +) -> Result { + let transaction = begin(store, TransactionMode::Read).await?; + let now = transaction.now; + let overdue_before = now - TimeDelta::seconds(30); + let (transaction, leases) = query( + transaction, + "SELECT name FROM sqb_scheduler_lease WHERE name='scheduler' AND expires_at>$1", + vec![Parameter::Time(now)], + ) + .await?; + let (transaction, locations) = query( + transaction, + "SELECT i.location_id, i.target, MAX(i.last_seen_at) AS last_seen_at \ + FROM sqb_location_instances i JOIN sqb_locations l ON l.location_id=i.location_id \ + WHERE l.archived_at IS NULL GROUP BY i.location_id,i.target \ + HAVING MAX(i.last_seen_at)<$1 ORDER BY i.location_id,i.target", + vec![Parameter::Time(now - TimeDelta::seconds(90))], + ) + .await?; + let (transaction, late_targets) = query( + transaction, + "SELECT DISTINCT t.location_id, t.target FROM sqb_triggers t \ + JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ + JOIN sqb_locations l ON l.location_id=t.location_id \ + WHERE t.kind='schedule' AND s.status='running' AND t.removed_in_snapshot_id IS NULL \ + AND l.archived_at IS NULL AND s.next_due_at<$1", + vec![Parameter::Time(overdue_before)], + ) + .await?; + commit(transaction).await?; + let locations: Vec = locations + .into_iter() + .map(decode_row) + .collect::>()?; + let late_targets: BTreeSet<(Uuid, String)> = late_targets + .into_iter() + .map(|row| { + let target: LateTarget = decode_row(row)?; + Ok((target.location_id, target.target)) + }) + .collect::>()?; + let mut listed: BTreeSet<(Uuid, String)> = BTreeSet::new(); + for (location_id, target) in locations + .iter() + .map(|location| (location.location_id, location.target.clone())) + .chain(late_targets.iter().cloned()) + .collect::>() + { + if person_lists_target(store, principal_id, (location_id, &target), default_role).await? { + listed.insert((location_id, target)); + } + } + let mut overdue_ticks: Vec = Vec::new(); + let mut transaction = begin(store, TransactionMode::Read).await?; + for (location_id, target) in late_targets.intersection(&listed) { + let (next, ticks) = query( + transaction, + "SELECT t.trigger_id, t.name, t.location_id, t.target, s.next_due_at AS scheduled_for \ + 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.kind='schedule' AND s.status='running' \ + AND t.removed_in_snapshot_id IS NULL AND s.next_due_at<$3 \ + ORDER BY s.next_due_at, t.name LIMIT 100", + vec![ + Parameter::Id(*location_id), + Parameter::Text(target.clone()), + Parameter::Time(overdue_before), + ], + ) + .await?; + transaction = next; + overdue_ticks.extend( + ticks + .into_iter() + .map(decode_row) + .collect::, _>>()?, + ); + } + commit(transaction).await?; + Ok(PlatformProblems { + scheduler_leader: !leases.is_empty(), + broken_locations: locations + .into_iter() + .filter(|location| listed.contains(&(location.location_id, location.target.clone()))) + .collect(), + overdue_ticks, + }) +} diff --git a/crates/sqlbuild-orchestration/src/platform/models.rs b/crates/sqlbuild-orchestration/src/platform/models.rs index 543aaeb420..b6d47ea0c9 100644 --- a/crates/sqlbuild-orchestration/src/platform/models.rs +++ b/crates/sqlbuild-orchestration/src/platform/models.rs @@ -250,3 +250,35 @@ pub struct PlatformEventEnvelope { pub struct PlatformEventPayload { pub platform: PlatformEvent, } + +/// Problems derived from live state when read; nothing here is stored. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PlatformProblems { + pub scheduler_leader: bool, + pub broken_locations: Vec, + pub overdue_ticks: Vec, +} + +/// Every instance of this location target has missed two check-ins. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BrokenLocation { + pub location_id: Uuid, + pub target: String, + pub last_seen_at: DateTime, +} + +/// A running schedule whose next tick is more than 30 seconds late. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct OverdueTick { + pub trigger_id: Uuid, + pub name: String, + pub location_id: Uuid, + pub target: String, + pub scheduled_for: DateTime, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LateTarget { + pub(crate) location_id: Uuid, + pub(crate) target: String, +} diff --git a/crates/sqlbuild-orchestration/tests/platform/test_contracts.rs b/crates/sqlbuild-orchestration/tests/platform/test_contracts.rs index 0f5c53a679..eb9b98bffe 100644 --- a/crates/sqlbuild-orchestration/tests/platform/test_contracts.rs +++ b/crates/sqlbuild-orchestration/tests/platform/test_contracts.rs @@ -2,6 +2,8 @@ #[path = "helpers.rs"] mod helpers; +#[path = "test_problems.rs"] +mod test_problems; #[path = "test_types.rs"] mod test_types; #[path = "test_watchdog.rs"] diff --git a/crates/sqlbuild-orchestration/tests/platform/test_problems.rs b/crates/sqlbuild-orchestration/tests/platform/test_problems.rs new file mode 100644 index 0000000000..62fd90b10a --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/platform/test_problems.rs @@ -0,0 +1,154 @@ +use crate::authorization::main::{ + assign_builtin_role::assign_builtin_role, bootstrap_user::bootstrap_user, +}; +use crate::credentials::main::identities::create_principal::create_principal; +use crate::credentials::models::{AuditContext, BuiltinRole, DefaultRole, RoleAssignmentRequest}; +use crate::platform::main::read_person_problems::read_person_problems; +use crate::platform::main::report_location_health::report_location_health; +use crate::platform::models::{LocationHealth, LocationHealthReport}; +use crate::scheduler::tests::contracts::helpers::acquire_lease; +use crate::snapshots::main::publish_snapshot::publish_snapshot; +use crate::store::main::begin::begin; +use crate::store::main::commit::commit; +use crate::store::main::migrate_store::migrate_store; +use crate::store::main::query::query; +use crate::store::models::{Parameter, TransactionMode}; +use chrono::TimeDelta; +use uuid::Uuid; + +use crate::credentials::tests::location_helpers::create_location; +use crate::platform::tests::helpers::snapshot; +use crate::platform::tests::test_types::ProblemsTestCase; +use crate::store::tests::store_helpers::{close_test_store, open_test_store, run}; +use crate::store::tests::store_test_types::Backend; + +#[test] +fn given_broken_locations_and_late_ticks_when_people_read_problems_then_only_listed_targets_appear() +{ + let test_cases = [ + ProblemsTestCase { + description: "SQLite without a leader", + backend: Backend::Sqlite, + leaders: 0, + }, + ProblemsTestCase { + description: "PostgreSQL with a leader", + backend: Backend::Postgres, + leaders: 1, + }, + ]; + for test_case in test_cases { + run(async { + let Some(fixture) = open_test_store(test_case.backend).await else { + return; + }; + let store = &fixture.store; + migrate_store(store).await.expect("migrate problems store"); + let context = AuditContext { + actor: "control-store-owner".into(), + granting_role: "control-store-owner".into(), + request_id: Uuid::now_v7(), + ip_address: None, + }; + let admin = bootstrap_user(store, &context, "orders-admin") + .await + .expect("administrator"); + let developer = create_principal(store, &context, "orders-developer") + .await + .expect("developer"); + let outsider = create_principal(store, &context, "orders-outsider") + .await + .expect("outsider"); + let mut locations = Vec::new(); + for name in ["orders", "inventory"] { + let location = create_location(store, name, "test-admin") + .await + .expect("location"); + publish_snapshot(store, &snapshot(location)) + .await + .expect("published triggers"); + report_location_health( + store, + location, + &LocationHealthReport { + event_id: Uuid::now_v7(), + session_id: Uuid::now_v7(), + target: "development".into(), + health: LocationHealth::Heartbeat {}, + }, + ) + .await + .expect("instance check-in"); + locations.push(location); + } + let transaction = begin(store, TransactionMode::Write) + .await + .expect("age check-ins"); + let stale = transaction.now - TimeDelta::minutes(5); + let (transaction, _) = query( + transaction, + "UPDATE sqb_location_instances SET last_seen_at=$1", + vec![Parameter::Time(stale)], + ) + .await + .expect("stale check-ins"); + commit(transaction).await.expect("aged check-ins"); + assign_builtin_role( + store, + (&context, admin), + &RoleAssignmentRequest { + principal_id: developer, + role: BuiltinRole::Developer.into(), + location_id: Some(locations[1]), + target: Some("development".into()), + revoke: false, + }, + ) + .await + .expect("developer on inventory only"); + for _ in 0..test_case.leaders { + acquire_lease(store, Uuid::now_v7()) + .await + .expect("acquire lease") + .expect("leader"); + } + let read = |principal: Uuid| read_person_problems(store, principal, DefaultRole::None); + let everything = read(admin).await.expect("administrator problems"); + assert_eq!( + everything.scheduler_leader, + test_case.leaders > 0, + "{}", + test_case.description + ); + assert_eq!(everything.broken_locations.len(), 2); + assert_eq!( + everything.overdue_ticks.len(), + 200, + "each listed target keeps its own 100 late ticks" + ); + let scoped = read(developer).await.expect("developer problems"); + assert_eq!(scoped.scheduler_leader, everything.scheduler_leader); + assert_eq!( + scoped + .broken_locations + .iter() + .map(|location| location.location_id) + .collect::>(), + vec![locations[1]], + "only the listed location is reported broken" + ); + assert_eq!(scoped.overdue_ticks.len(), 100); + assert!( + scoped + .overdue_ticks + .iter() + .all(|tick| tick.location_id == locations[1]), + "another location's late ticks stay hidden" + ); + let hidden = read(outsider).await.expect("outsider problems"); + assert_eq!(hidden.scheduler_leader, everything.scheduler_leader); + assert!(hidden.broken_locations.is_empty() && hidden.overdue_ticks.is_empty()); + close_test_store(fixture).await; + }); + } +} diff --git a/crates/sqlbuild-orchestration/tests/platform/test_types.rs b/crates/sqlbuild-orchestration/tests/platform/test_types.rs index 52ecb92e0c..003c26e766 100644 --- a/crates/sqlbuild-orchestration/tests/platform/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/platform/test_types.rs @@ -51,3 +51,9 @@ pub(crate) struct WebhookState { pub(crate) status: u16, pub(crate) requests: Vec, } + +pub(crate) struct ProblemsTestCase { + pub(crate) description: &'static str, + pub(crate) backend: Backend, + pub(crate) leaders: usize, +} From 8929fae8b131044355038be800640a02e6239b21 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:10:31 +0000 Subject: [PATCH 11/16] feat(ui): show live platform problems in the alert banner The banner reads the new problems endpoint every 30 seconds, because missed check-ins and late ticks come from time passing, not change notices. A stopped scheduler and each broken location are critical, and late schedules are grouped per location. The read now names each location and dates a stopped scheduler from its last lease. Links go to the Locations list until location detail pages are supported. --- clients/typescript/src/schema.d.ts | 7 ++ .../src/platform/_helpers/mod.rs | 1 + .../src/platform/_helpers/problems.rs | 97 +++++++++++++++++++ .../src/platform/main/read_person_problems.rs | 94 +++--------------- .../src/platform/models.rs | 18 ++++ .../tests/platform/test_problems.rs | 21 +++- .../tests/platform/test_types.rs | 1 + ui/src/lib/api/http-client.ts | 12 ++- ui/src/lib/api/http-problems.test.ts | 48 +++++++++ ui/src/lib/api/http-problems.ts | 56 +++++++++++ ui/src/lib/api/mock/fixtures.ts | 2 +- .../shell/platform-event-banner.svelte | 8 +- ui/src/lib/platform/labels.ts | 2 +- ui/src/lib/state/app.svelte.ts | 4 + 14 files changed, 284 insertions(+), 87 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/platform/_helpers/problems.rs create mode 100644 ui/src/lib/api/http-problems.test.ts create mode 100644 ui/src/lib/api/http-problems.ts diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 34a6de6854..4674ad9a07 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1032,6 +1032,7 @@ export interface components { last_seen_at: string; /** Format: uuid */ location_id: string; + location_name: string; target: string; }; /** @enum {string} */ @@ -1540,6 +1541,7 @@ export interface components { OverdueTick: { /** Format: uuid */ location_id: string; + location_name: string; name: string; /** Format: date-time */ scheduled_for: string; @@ -1616,6 +1618,11 @@ export interface components { broken_locations: components["schemas"]["BrokenLocation"][]; overdue_ticks: components["schemas"]["OverdueTick"][]; scheduler_leader: boolean; + /** + * Format: date-time + * @description When the last scheduler lease ran out, or will run out; absent until a server first leads. + */ + scheduler_lease_expires_at?: string | null; }; /** @description A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. */ PublishedChange: { diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs index 791b027cff..a302fe0aee 100644 --- a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod connection_tests; pub(crate) mod liveness; pub(crate) mod location_heartbeat; pub(crate) mod observations; +pub(crate) mod problems; pub(crate) mod recent_health; pub(crate) mod retention; pub(crate) mod watchdog; diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/problems.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/problems.rs new file mode 100644 index 0000000000..f8e57a9574 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/problems.rs @@ -0,0 +1,97 @@ +//! Live problem queries, with the watchdog's thresholds; callers decide who may see each target's. + +use chrono::{DateTime, TimeDelta, Utc}; +use uuid::Uuid; + +use crate::platform::models::{LateTarget, OverdueTick, ProblemScan}; +use crate::store::errors::ControlError; +use crate::store::main::decode_row::decode_row; +use crate::store::main::query::query; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; + +pub(crate) async fn scan_problems(store: &ControlStore) -> Result { + let transaction = begin(store, TransactionMode::Read).await?; + let now = transaction.now; + let (transaction, leases) = query( + transaction, + "SELECT expires_at FROM sqb_scheduler_lease WHERE name='scheduler'", + vec![], + ) + .await?; + let (transaction, locations) = query( + transaction, + "SELECT i.location_id, l.name AS location_name, i.target, MAX(i.last_seen_at) AS last_seen_at \ + FROM sqb_location_instances i JOIN sqb_locations l ON l.location_id=i.location_id \ + WHERE l.archived_at IS NULL GROUP BY i.location_id,l.name,i.target \ + HAVING MAX(i.last_seen_at)<$1 ORDER BY i.location_id,i.target", + vec![Parameter::Time(now - TimeDelta::seconds(90))], + ) + .await?; + let (transaction, late_targets) = query( + transaction, + "SELECT DISTINCT t.location_id, t.target FROM sqb_triggers t \ + JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ + JOIN sqb_locations l ON l.location_id=t.location_id \ + WHERE t.kind='schedule' AND s.status='running' AND t.removed_in_snapshot_id IS NULL \ + AND l.archived_at IS NULL AND s.next_due_at<$1", + vec![Parameter::Time(overdue_before(now))], + ) + .await?; + commit(transaction).await?; + Ok(ProblemScan { + now, + lease: leases.into_iter().next().map(decode_row).transpose()?, + broken: locations + .into_iter() + .map(decode_row) + .collect::>()?, + late_targets: late_targets + .into_iter() + .map(|row| { + let target: LateTarget = decode_row(row)?; + Ok((target.location_id, target.target)) + }) + .collect::>()?, + }) +} + +/// At most 100 late ticks per target, oldest first. +pub(crate) async fn late_ticks( + store: &ControlStore, + targets: impl IntoIterator, + now: DateTime, +) -> Result, ControlError> { + let mut overdue: Vec = Vec::new(); + let mut transaction = begin(store, TransactionMode::Read).await?; + for (location_id, target) in targets { + let (next, ticks) = query( + transaction, + "SELECT t.trigger_id, t.name, t.location_id, l.name AS location_name, t.target, \ + s.next_due_at AS scheduled_for FROM sqb_triggers t \ + JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ + JOIN sqb_locations l ON l.location_id=t.location_id WHERE t.location_id=$1 AND t.target=$2 \ + AND t.kind='schedule' AND s.status='running' AND t.removed_in_snapshot_id IS NULL \ + AND s.next_due_at<$3 ORDER BY s.next_due_at, t.name LIMIT 100", + vec![ + Parameter::Id(*location_id), + Parameter::Text(target.clone()), + Parameter::Time(overdue_before(now)), + ], + ) + .await?; + transaction = next; + overdue.extend( + ticks + .into_iter() + .map(decode_row) + .collect::, _>>()?, + ); + } + commit(transaction).await?; + Ok(overdue) +} + +fn overdue_before(now: DateTime) -> DateTime { + now - TimeDelta::seconds(30) +} diff --git a/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs b/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs index cddec67364..5644e4f32c 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs @@ -2,107 +2,45 @@ use std::collections::BTreeSet; -use chrono::TimeDelta; use uuid::Uuid; use crate::authorization::main::person_lists_target::person_lists_target; use crate::credentials::models::DefaultRole; -use crate::platform::models::{BrokenLocation, LateTarget, OverdueTick, PlatformProblems}; +use crate::platform::_helpers::problems::{late_ticks, scan_problems}; +use crate::platform::models::PlatformProblems; use crate::store::errors::ControlError; -use crate::store::main::decode_row::decode_row; -use crate::store::main::query::query; -use crate::store::main::{begin::begin, commit::commit}; -use crate::store::models::{ControlStore, Parameter, TransactionMode}; +use crate::store::models::ControlStore; -/// Uses the watchdog's thresholds; access is checked per target before each target's ticks are read. +/// Access is checked per target before that target's late ticks are read, so one target cannot crowd out another. pub async fn read_person_problems( store: &ControlStore, principal_id: Uuid, default_role: DefaultRole, ) -> Result { - let transaction = begin(store, TransactionMode::Read).await?; - let now = transaction.now; - let overdue_before = now - TimeDelta::seconds(30); - let (transaction, leases) = query( - transaction, - "SELECT name FROM sqb_scheduler_lease WHERE name='scheduler' AND expires_at>$1", - vec![Parameter::Time(now)], - ) - .await?; - let (transaction, locations) = query( - transaction, - "SELECT i.location_id, i.target, MAX(i.last_seen_at) AS last_seen_at \ - FROM sqb_location_instances i JOIN sqb_locations l ON l.location_id=i.location_id \ - WHERE l.archived_at IS NULL GROUP BY i.location_id,i.target \ - HAVING MAX(i.last_seen_at)<$1 ORDER BY i.location_id,i.target", - vec![Parameter::Time(now - TimeDelta::seconds(90))], - ) - .await?; - let (transaction, late_targets) = query( - transaction, - "SELECT DISTINCT t.location_id, t.target FROM sqb_triggers t \ - JOIN sqb_trigger_state s ON s.trigger_id=t.trigger_id \ - JOIN sqb_locations l ON l.location_id=t.location_id \ - WHERE t.kind='schedule' AND s.status='running' AND t.removed_in_snapshot_id IS NULL \ - AND l.archived_at IS NULL AND s.next_due_at<$1", - vec![Parameter::Time(overdue_before)], - ) - .await?; - commit(transaction).await?; - let locations: Vec = locations - .into_iter() - .map(decode_row) - .collect::>()?; - let late_targets: BTreeSet<(Uuid, String)> = late_targets - .into_iter() - .map(|row| { - let target: LateTarget = decode_row(row)?; - Ok((target.location_id, target.target)) - }) - .collect::>()?; + let scan = scan_problems(store).await?; let mut listed: BTreeSet<(Uuid, String)> = BTreeSet::new(); - for (location_id, target) in locations + for (location_id, target) in scan + .broken .iter() .map(|location| (location.location_id, location.target.clone())) - .chain(late_targets.iter().cloned()) + .chain(scan.late_targets.iter().cloned()) .collect::>() { if person_lists_target(store, principal_id, (location_id, &target), default_role).await? { listed.insert((location_id, target)); } } - let mut overdue_ticks: Vec = Vec::new(); - let mut transaction = begin(store, TransactionMode::Read).await?; - for (location_id, target) in late_targets.intersection(&listed) { - let (next, ticks) = query( - transaction, - "SELECT t.trigger_id, t.name, t.location_id, t.target, s.next_due_at AS scheduled_for \ - 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.kind='schedule' AND s.status='running' \ - AND t.removed_in_snapshot_id IS NULL AND s.next_due_at<$3 \ - ORDER BY s.next_due_at, t.name LIMIT 100", - vec![ - Parameter::Id(*location_id), - Parameter::Text(target.clone()), - Parameter::Time(overdue_before), - ], - ) - .await?; - transaction = next; - overdue_ticks.extend( - ticks - .into_iter() - .map(decode_row) - .collect::, _>>()?, - ); - } - commit(transaction).await?; Ok(PlatformProblems { - scheduler_leader: !leases.is_empty(), - broken_locations: locations + scheduler_leader: scan + .lease + .as_ref() + .is_some_and(|lease| lease.expires_at > scan.now), + scheduler_lease_expires_at: scan.lease.map(|lease| lease.expires_at), + overdue_ticks: late_ticks(store, scan.late_targets.intersection(&listed), scan.now).await?, + broken_locations: scan + .broken .into_iter() .filter(|location| listed.contains(&(location.location_id, location.target.clone()))) .collect(), - overdue_ticks, }) } diff --git a/crates/sqlbuild-orchestration/src/platform/models.rs b/crates/sqlbuild-orchestration/src/platform/models.rs index b6d47ea0c9..1bb5c963c8 100644 --- a/crates/sqlbuild-orchestration/src/platform/models.rs +++ b/crates/sqlbuild-orchestration/src/platform/models.rs @@ -255,6 +255,8 @@ pub struct PlatformEventPayload { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct PlatformProblems { pub scheduler_leader: bool, + /// When the last scheduler lease ran out, or will run out; absent until a server first leads. + pub scheduler_lease_expires_at: Option>, pub broken_locations: Vec, pub overdue_ticks: Vec, } @@ -263,6 +265,7 @@ pub struct PlatformProblems { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct BrokenLocation { pub location_id: Uuid, + pub location_name: String, pub target: String, pub last_seen_at: DateTime, } @@ -273,12 +276,27 @@ pub struct OverdueTick { pub trigger_id: Uuid, pub name: String, pub location_id: Uuid, + pub location_name: String, pub target: String, pub scheduled_for: DateTime, } +#[derive(Debug, Deserialize)] +pub(crate) struct SchedulerLease { + pub(crate) expires_at: DateTime, +} + #[derive(Debug, Deserialize)] pub(crate) struct LateTarget { pub(crate) location_id: Uuid, pub(crate) target: String, } + +/// Problems found at one instant: the scheduler lease, broken location targets and targets with late ticks. +#[derive(Debug)] +pub(crate) struct ProblemScan { + pub(crate) now: DateTime, + pub(crate) lease: Option, + pub(crate) broken: Vec, + pub(crate) late_targets: std::collections::BTreeSet<(Uuid, String)>, +} diff --git a/crates/sqlbuild-orchestration/tests/platform/test_problems.rs b/crates/sqlbuild-orchestration/tests/platform/test_problems.rs index 62fd90b10a..3ebb270633 100644 --- a/crates/sqlbuild-orchestration/tests/platform/test_problems.rs +++ b/crates/sqlbuild-orchestration/tests/platform/test_problems.rs @@ -30,11 +30,13 @@ fn given_broken_locations_and_late_ticks_when_people_read_problems_then_only_lis description: "SQLite without a leader", backend: Backend::Sqlite, leaders: 0, + expected_leader: false, }, ProblemsTestCase { description: "PostgreSQL with a leader", backend: Backend::Postgres, leaders: 1, + expected_leader: true, }, ]; for test_case in test_cases { @@ -115,12 +117,22 @@ fn given_broken_locations_and_late_ticks_when_people_read_problems_then_only_lis let read = |principal: Uuid| read_person_problems(store, principal, DefaultRole::None); let everything = read(admin).await.expect("administrator problems"); assert_eq!( - everything.scheduler_leader, - test_case.leaders > 0, + everything.scheduler_leader, test_case.expected_leader, "{}", test_case.description ); - assert_eq!(everything.broken_locations.len(), 2); + assert_eq!( + everything.scheduler_lease_expires_at.is_some(), + test_case.expected_leader + ); + assert_eq!( + everything + .broken_locations + .iter() + .map(|location| location.location_name.as_str()) + .collect::>(), + std::collections::BTreeSet::from(["inventory", "orders"]) + ); assert_eq!( everything.overdue_ticks.len(), 200, @@ -142,7 +154,8 @@ fn given_broken_locations_and_late_ticks_when_people_read_problems_then_only_lis scoped .overdue_ticks .iter() - .all(|tick| tick.location_id == locations[1]), + .all(|tick| tick.location_id == locations[1] + && tick.location_name == "inventory"), "another location's late ticks stay hidden" ); let hidden = read(outsider).await.expect("outsider problems"); diff --git a/crates/sqlbuild-orchestration/tests/platform/test_types.rs b/crates/sqlbuild-orchestration/tests/platform/test_types.rs index 003c26e766..8fa6dd4a47 100644 --- a/crates/sqlbuild-orchestration/tests/platform/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/platform/test_types.rs @@ -56,4 +56,5 @@ pub(crate) struct ProblemsTestCase { pub(crate) description: &'static str, pub(crate) backend: Backend, pub(crate) leaders: usize, + pub(crate) expected_leader: bool, } diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index b45308f9cb..1d7ce14601 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -7,10 +7,11 @@ import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; import { toLocationSummary } from './http-locations'; import { toAccount, toRoleAssignments } from './http-people'; +import { toPlatformEvents } from './http-problems'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; -import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; +import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope } from './types'; import type { Account, AuditEntry, AuditQuery, LocationSummary, RoleAssignment } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -46,7 +47,8 @@ const SUPPORTED: ReadonlySet = new Set([ 'listAuditEntries', 'listAccounts', 'listRoleAssignments', - 'listLocations' + 'listLocations', + 'listPlatformEvents' ]); /** The server's largest audit page, and how many the audit log reads before it stops looking. */ @@ -284,6 +286,12 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return found.slice(0, limit); }, /** Each location the person sees, with health only where they may list the whole target. */ + async listPlatformEvents(): Promise { + const { data, error, response } = await http.GET('/api/control/platform/problems'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return toPlatformEvents(data, Date.now()); + }, async listLocations(): Promise { const { data, error, response } = await http.GET('/api/control/bootstrap'); if (response.status === 401) onUnauthorized(); diff --git a/ui/src/lib/api/http-problems.test.ts b/ui/src/lib/api/http-problems.test.ts new file mode 100644 index 0000000000..9074446a06 --- /dev/null +++ b/ui/src/lib/api/http-problems.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { toPlatformEvents } from './http-problems'; + +const NOW = Date.parse('2026-11-08T12:00:00Z'); +const tick = (name: string, location: string, scheduledFor: string) => ({ + trigger_id: `t-${name}`, + name, + location_id: location, + location_name: location === 'l1' ? 'orders' : 'inventory', + target: 'development', + scheduled_for: scheduledFor +}); + +describe('toPlatformEvents', () => { + it('reports nothing while the scheduler leads and nothing is late', () => { + expect(toPlatformEvents({ scheduler_leader: true, scheduler_lease_expires_at: '2026-11-08T12:00:10Z', broken_locations: [], overdue_ticks: [] }, NOW)).toEqual([]); + }); + + it('dates a stopped scheduler from its lease expiry, and from now when no server has led', () => { + const [stopped] = toPlatformEvents({ scheduler_leader: false, scheduler_lease_expires_at: '2026-11-08T11:50:00Z', broken_locations: [], overdue_ticks: [] }, NOW); + expect(stopped).toMatchObject({ kind: 'scheduler_no_leader', severity: 'critical', locationId: null, occurredAt: '2026-11-08T11:50:00Z' }); + const [never] = toPlatformEvents({ scheduler_leader: false, broken_locations: [], overdue_ticks: [] }, NOW); + expect(never.occurredAt).toBe('2026-11-08T12:00:00.000Z'); + }); + + it('keys broken locations by location and target', () => { + const [broken] = toPlatformEvents( + { scheduler_leader: true, broken_locations: [{ location_id: 'l2', location_name: 'inventory', target: 'development', last_seen_at: '2026-11-08T11:55:00Z' }], overdue_ticks: [] }, + NOW + ); + expect(broken).toMatchObject({ kind: 'location_broken', locationId: 'l2:development', occurredAt: '2026-11-08T11:55:00Z', subject: 'inventory · development' }); + }); + + it('groups late ticks per location, dated and named by the oldest', () => { + const events = toPlatformEvents( + { + scheduler_leader: true, + broken_locations: [], + overdue_ticks: [tick('hourly_orders', 'l1', '2026-11-08T11:40:00Z'), tick('daily_orders', 'l1', '2026-11-08T11:10:00Z'), tick('stock_levels', 'l2', '2026-11-08T11:59:00Z')] + }, + NOW + ); + expect(events.map((e) => [e.locationId, e.occurredAt, e.message])).toEqual([ + ['l1:development', '2026-11-08T11:10:00Z', '2 schedules in orders · development are late, starting with daily_orders.'], + ['l2:development', '2026-11-08T11:59:00Z', 'stock_levels in inventory · development is late.'] + ]); + }); +}); diff --git a/ui/src/lib/api/http-problems.ts b/ui/src/lib/api/http-problems.ts new file mode 100644 index 0000000000..9b1e44dfb2 --- /dev/null +++ b/ui/src/lib/api/http-problems.ts @@ -0,0 +1,56 @@ +import type { components } from '$control/schema.d.ts'; +import { locationKey as keyOf } from './http-client'; +import type { PlatformEvent } from './types'; + +type Wire = components['schemas']; + +const placeOf = (name: string, target: string) => `${name} · ${target}`; + +/** Live problems as active banner events: a stopped scheduler, each broken location, and late schedules per location. */ +export function toPlatformEvents(problems: Wire['PlatformProblems'], now: number): PlatformEvent[] { + const events: PlatformEvent[] = []; + if (!problems.scheduler_leader) { + events.push({ + id: 'scheduler', + kind: 'scheduler_no_leader', + severity: 'critical', + occurredAt: problems.scheduler_lease_expires_at ?? new Date(now).toISOString(), + active: true, + locationId: null, + subject: null, + message: 'No server is running the scheduler, so schedules and sensors aren’t starting runs.' + }); + } + for (const location of problems.broken_locations) { + events.push({ + id: `broken:${keyOf(location.location_id, location.target)}`, + kind: 'location_broken', + severity: 'critical', + occurredAt: location.last_seen_at, + active: true, + locationId: keyOf(location.location_id, location.target), + subject: placeOf(location.location_name, location.target), + message: `${placeOf(location.location_name, location.target)} stopped checking in, so its scheduled runs aren’t starting.` + }); + } + const late = new Map(); + for (const tick of problems.overdue_ticks) { + const key = keyOf(tick.location_id, tick.target); + late.set(key, [...(late.get(key) ?? []), tick]); + } + for (const [key, ticks] of late) { + const oldest = ticks.reduce((a, b) => (a.scheduled_for <= b.scheduled_for ? a : b)); + const place = placeOf(oldest.location_name, oldest.target); + events.push({ + id: `late:${key}`, + kind: 'tick_overdue', + severity: 'warning', + occurredAt: oldest.scheduled_for, + active: true, + locationId: key, + subject: place, + message: ticks.length === 1 ? `${oldest.name} in ${place} is late.` : `${ticks.length} schedules in ${place} are late, starting with ${oldest.name}.` + }); + } + return events; +} diff --git a/ui/src/lib/api/mock/fixtures.ts b/ui/src/lib/api/mock/fixtures.ts index e4eea051b5..b56bb33124 100644 --- a/ui/src/lib/api/mock/fixtures.ts +++ b/ui/src/lib/api/mock/fixtures.ts @@ -82,7 +82,7 @@ export function createFixtures(now: number) { ]; const platformEvents: PlatformEvent[] = [ - { id: 'pe-1', kind: 'location_broken', severity: 'warning', occurredAt: ago(48), active: true, locationId: LOCATION_MARKETING, subject: 'marketing-analytics', message: 'The latest deploy failed to load; still running the previous version.' }, + { id: 'pe-1', kind: 'location_broken', severity: 'warning', occurredAt: ago(48), active: true, locationId: LOCATION_MARKETING, subject: 'marketing-analytics', message: 'marketing-analytics stopped checking in, so its scheduled runs aren’t starting.' }, { id: 'pe-2', kind: 'tick_overdue', severity: 'warning', occurredAt: ago(12), active: true, locationId: LOCATION_SHOP, subject: 'segments_hourly', message: 'The segments_hourly schedule should have started a run 12 minutes ago.' }, { id: 'pe-6', kind: 'warehouse_sync_failed', severity: 'warning', occurredAt: ago(95), active: true, locationId: LOCATION_SHOP_STAGING, subject: '_sqlbuild_janitor_events', message: 'Permission denied on _sqlbuild_janitor_events: the catalog connection’s role can’t use schema ARCHIVE, so cleanup history isn’t up to date.' }, { id: 'pe-3', kind: 'heartbeat', severity: 'info', occurredAt: ago(1), active: false, locationId: null, subject: null, message: 'Server heartbeat.' } diff --git a/ui/src/lib/components/shell/platform-event-banner.svelte b/ui/src/lib/components/shell/platform-event-banner.svelte index f376d06cbd..d1e7f2c484 100644 --- a/ui/src/lib/components/shell/platform-event-banner.svelte +++ b/ui/src/lib/components/shell/platform-event-banner.svelte @@ -11,6 +11,11 @@ app.platformEvents.filter((e) => e.active && e.severity !== 'info').sort((a, b) => rank(b) - rank(a)) ); const critical = $derived(events.some((e) => e.severity === 'critical')); + /** Location problems link to the location's page if it exists, else the Locations list; server problems only to server status. */ + function linkOf(event: PlatformEvent): { href: string; text: string } | null { + if (event.locationId) return { href: app.client.supports('getLocation') ? problemHref(event) : '/locations', text: 'View location →' }; + return app.client.supports('platformHealth') ? { href: problemHref(event), text: 'View server status →' } : null; + } {#if events.length} @@ -20,11 +25,12 @@ diff --git a/ui/src/lib/platform/labels.ts b/ui/src/lib/platform/labels.ts index c6702a11f0..9fe55a11fb 100644 --- a/ui/src/lib/platform/labels.ts +++ b/ui/src/lib/platform/labels.ts @@ -44,7 +44,7 @@ export function healthWords(location: Pick = { tick_overdue: 'Schedule overdue', scheduler_no_leader: 'Scheduler stopped', - location_broken: 'Deploy failed', + location_broken: 'Location not checking in', launch_failed: 'Couldn’t start runs', connection_test_failed: 'Connection test failed', warehouse_sync_failed: 'Warehouse sync failing', diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 336731ad38..2fa0a7cf9f 100644 --- a/ui/src/lib/state/app.svelte.ts +++ b/ui/src/lib/state/app.svelte.ts @@ -18,6 +18,8 @@ import { } from '$lib/api'; const SCOPE_KEY = 'sb-scope'; +/** How often the banner rereads live problems; locations check in every 30 seconds. */ +const PROBLEM_POLL_MS = 30_000; /** * App-wide state: the bootstrap, the selected location, and the @@ -88,6 +90,8 @@ class AppState { this.#restoreScope(); this.client.changes.follow(this.scope); void this.#loadPlatformEvents(); + // Problems are derived from time passing, such as a missed check-in, so no change notice announces them. + if (this.client.supports('listPlatformEvents')) setInterval(() => void this.#loadPlatformEvents(), PROBLEM_POLL_MS); this.#loadScoped(); } From 58de3d619e4468a053bd20f66aea7c407bd7cf13 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:16:17 +0000 Subject: [PATCH 12/16] docs(ui): keep the locations doc comment on listLocations --- ui/src/lib/api/http-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 1d7ce14601..cff0183d7e 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -285,13 +285,13 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt } return found.slice(0, limit); }, - /** Each location the person sees, with health only where they may list the whole target. */ async listPlatformEvents(): Promise { const { data, error, response } = await http.GET('/api/control/platform/problems'); if (response.status === 401) onUnauthorized(); if (!data) throw new ApiError(errorText(error, response), response.status); return toPlatformEvents(data, Date.now()); }, + /** Each location the person sees, with health only where they may list the whole target. */ async listLocations(): Promise { const { data, error, response } = await http.GET('/api/control/bootstrap'); if (response.status === 401) onUnauthorized(); From 35f7a3a3e6b9e1e26621a694ae9d113533b3a713 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:28:35 +0000 Subject: [PATCH 13/16] feat(server): let role administrators read a target's role definitions Add GET /api/admin/locations/{location_id}/targets/{target}/roles behind server-wide role.assign, like the role holdings read. It returns the built-in roles with their actions, then the target's published custom roles with their description, grants and the nodes each selector matched, and how many active people hold each role there. --- clients/typescript/src/schema.d.ts | 76 +++++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/administrators.rs | 17 +++ .../sqlbuild-orchestration/src/api/models.rs | 1 + .../authorization/_helpers/custom_roles.rs | 17 +++ .../src/authorization/_helpers/mod.rs | 1 + .../authorization/_helpers/person_grants.rs | 8 ++ .../authorization/_helpers/role_overview.rs | 119 ++++++++++++++++++ .../main/list_role_definitions.rs | 30 +++++ .../src/authorization/main/mod.rs | 1 + .../src/authorization/models.rs | 19 +++ .../src/credentials/models.rs | 21 ++++ .../tests/api/test_person_sessions.rs | 8 +- .../tests/credentials/test_project_roles.rs | 54 ++++++++ fensu.toml | 2 +- 15 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs create mode 100644 crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 4674ad9a07..872f6a05cb 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1,5 +1,21 @@ // Generated from sqb server --openapi. Run npm run generate; do not edit. export interface paths { + "/api/admin/locations/{location_id}/targets/{target}/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRoleDefinitions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/people": { parameters: { query?: never; @@ -1707,6 +1723,28 @@ export interface components { }; /** @description A built-in role by name, or a custom role a location publishes. */ RoleKey: components["schemas"]["BuiltinRole"] | string; + /** @description A role usable on one location target: built in, or published by the location's project. */ + RoleOverview: { + builtin: boolean; + /** @description The project's description; absent for built-in roles. */ + description?: string | null; + /** @description Built-in roles: one grant without selectors, meaning every node. Custom roles: their published grants. */ + grants: components["schemas"]["RoleOverviewGrant"][]; + /** + * Format: int64 + * @description Active people assigned the role here or everywhere; SSO group members are not counted. + */ + holders: number; + key: string; + }; + /** @description Node selectors, the actions they grant, and the nodes each selector matched when published. */ + RoleOverviewGrant: { + matched: { + [key: string]: string[]; + }; + nodes: string[]; + permissions: components["schemas"]["Action"][]; + }; /** @description This is a server-owned instruction, not an arbitrary status supplied by a worker. */ RunChange: { container_id?: string | null; @@ -2295,6 +2333,44 @@ export interface components { } export type $defs = Record; export interface operations { + listRoleDefinitions: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Built-in roles, then the target's published custom roles */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleOverview"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; listPeople: { 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 28ba723955..7aba640430 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -69,6 +69,10 @@ pub(crate) fn person_routes() -> Router { "/api/admin/people", get(crate::api::_helpers::resources::administrators::people), ) + .route( + "/api/admin/locations/{location_id}/targets/{target}/roles", + get(crate::api::_helpers::resources::administrators::definitions), + ) .route( "/api/admin/roles", post(crate::api::_helpers::resources::administrators::assign) diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs index 4031c8df16..627719a819 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs @@ -9,10 +9,12 @@ use crate::api::models::MutationResponse; use crate::authorization::main::assign_builtin_role::assign_builtin_role; use crate::authorization::main::change_administrator::change_administrator; use crate::authorization::main::list_people::list_people; +use crate::authorization::main::list_role_definitions::list_role_definitions; use crate::authorization::main::list_role_holdings::list_role_holdings; use crate::credentials::models::AuthenticatedPerson; use crate::credentials::models::{ AdministratorChange, AuditContext, PersonSummary, RoleAssignmentRequest, RoleHoldings, + RoleOverview, }; use crate::store::models::ControlStore; @@ -70,3 +72,18 @@ pub(crate) async fn holdings( list_role_holdings(&store, (&context, principal)).await?, )) } + +#[utoipa::path(get, path = "/api/admin/locations/{location_id}/targets/{target}/roles", operation_id = "listRoleDefinitions", + params(("location_id" = Uuid, Path), ("target" = String, Path)), + responses((status = 200, body = Vec, description = "Built-in roles, then the target's published custom roles"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn definitions( + (State(store), Extension(context)): (State, Extension), + Path((location_id, target)): Path<(Uuid, String)>, + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + list_role_definitions(&store, (&context, principal), (location_id, &target)).await?, + )) +} diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 4868410cae..574a1accc0 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -16,6 +16,7 @@ use crate::api::_helpers::resources::{ administrators::assign, administrators::people, administrators::holdings, + administrators::definitions, audit::read, person_operations::request_job, person_operations::cancel_run, diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs index e62b97fb00..f363a71880 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs @@ -35,6 +35,23 @@ fn valid_grant(grant: &RoleGrant) -> bool { !grant.nodes.is_empty() && !blank_selector && !grant.permissions.is_empty() && !administrative } +/// Each published role's description by key; the document was validated when it was published. +pub(crate) fn decode_roles_descriptions( + document: Option<&Value>, +) -> Result, ControlError> { + let roles: BTreeMap = document.map_or_else( + || Ok(BTreeMap::new()), + |document| { + serde_json::from_value(document.clone()) + .map_err(|error| ControlError::Invalid(format!("roles: {error}"))) + }, + )?; + Ok(roles + .into_iter() + .map(|(key, role)| (key, role.description)) + .collect()) +} + pub(crate) fn decode_roles( document: Option<&Value>, ) -> Result>, ControlError> { diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs index fd4b1031c4..2e33ac15ba 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs @@ -4,3 +4,4 @@ pub(crate) mod administrators; pub(crate) mod custom_roles; pub(crate) mod person_grants; pub(crate) mod principal_names; +pub(crate) mod role_overview; diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs index a6d05a8f58..fd06e7352e 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs @@ -58,6 +58,14 @@ fn role_allows(role: BuiltinRole, action: Action) -> bool { } } +/// A built-in role's actions on every node, in vocabulary order. +pub(crate) fn builtin_actions(role: BuiltinRole) -> Vec { + ACTIONS + .into_iter() + .filter(|action| role_allows(role, *action)) + .collect() +} + /// The assigned and group roles of an active person for `target`; `None` when the person is disabled or unknown. pub(crate) async fn person_role_rows( transaction: ControlTransaction, diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs new file mode 100644 index 0000000000..f5ae39f286 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs @@ -0,0 +1,119 @@ +//! The facts behind a target's role overview, read in the caller's authorized transaction. + +use std::collections::BTreeMap; + +use uuid::Uuid; + +use crate::authorization::_helpers::custom_roles::{decode_grants, decode_roles_descriptions}; +use crate::authorization::_helpers::person_grants::builtin_actions; +use crate::authorization::models::{CurrentSnapshotId, RoleFacts, RoleHolderCount}; +use crate::credentials::models::{BuiltinRole, RoleGrant, RoleOverview, RoleOverviewGrant}; +use crate::snapshots::main::read_snapshot_documents::read_snapshot_documents; +use crate::store::errors::ControlError; +use crate::store::main::{decode_row::decode_row, query::query}; +use crate::store::models::{ControlTransaction, Parameter}; + +const BUILTIN: [(BuiltinRole, &str); 4] = [ + (BuiltinRole::Viewer, "viewer"), + (BuiltinRole::Operator, "operator"), + (BuiltinRole::Developer, "developer"), + (BuiltinRole::Admin, "admin"), +]; + +/// Published custom roles, active holders per role key, and the current snapshot's role descriptions. +pub(crate) async fn read_role_facts( + transaction: ControlTransaction, + (location_id, target): (Uuid, &str), +) -> Result<(ControlTransaction, RoleFacts), ControlError> { + let scope = vec![Parameter::Id(location_id), Parameter::Text(target.into())]; + let (transaction, custom) = query( + transaction, + "SELECT role_key, resolved_grants_json FROM sqb_effective_roles \ + WHERE location_id=$1 AND target=$2 ORDER BY role_key", + scope.clone(), + ) + .await?; + let (transaction, holders) = query( + transaction, + "SELECT a.role_key, COUNT(DISTINCT a.principal_id) AS holders FROM sqb_role_assignments a \ + JOIN sqb_principals p ON p.principal_id=a.principal_id WHERE p.disabled_at IS NULL \ + AND (a.location_id IS NULL OR a.location_id=$1) AND (a.target IS NULL OR a.target=$2) \ + GROUP BY a.role_key", + scope.clone(), + ) + .await?; + let (transaction, current) = query( + transaction, + "SELECT snapshot_id FROM sqb_current_snapshots WHERE location_id=$1 AND target=$2", + scope, + ) + .await?; + let current: Option = + current.into_iter().next().map(decode_row).transpose()?; + let (transaction, documents) = match current { + Some(current) => { + read_snapshot_documents(transaction, current.snapshot_id, Some(&["roles"])).await? + } + None => (transaction, BTreeMap::new()), + }; + let holders: Vec = holders + .into_iter() + .map(decode_row) + .collect::>()?; + Ok(( + transaction, + RoleFacts { + custom, + holders: holders + .into_iter() + .map(|count| (count.role_key, count.holders)) + .collect(), + descriptions: decode_roles_descriptions(documents.get("roles"))?, + }, + )) +} + +/// Built-in roles first, then custom roles by key. +pub(crate) fn role_overviews(facts: RoleFacts) -> Result, ControlError> { + let holders_of = |key: &str| facts.holders.get(key).copied().unwrap_or_default(); + let mut roles: Vec = BUILTIN + .into_iter() + .map(|(role, key)| RoleOverview { + key: key.into(), + builtin: true, + description: None, + grants: vec![RoleOverviewGrant { + nodes: Vec::new(), + permissions: builtin_actions(role), + matched: BTreeMap::new(), + }], + holders: holders_of(key), + }) + .collect(); + for row in &facts.custom { + let key = row["role_key"].as_str().unwrap_or_default().to_owned(); + roles.push(RoleOverview { + holders: holders_of(&key), + description: facts.descriptions.get(&key).cloned(), + builtin: false, + grants: decode_grants(&row["resolved_grants_json"])? + .into_iter() + .map(overview_grant) + .collect(), + key, + }); + } + Ok(roles) +} + +fn overview_grant(grant: RoleGrant) -> RoleOverviewGrant { + let mut matched: BTreeMap> = BTreeMap::new(); + for (selector, nodes) in grant.resolved_nodes { + matched.insert(selector, nodes.into_iter().collect()); + } + RoleOverviewGrant { + nodes: grant.nodes.into_iter().collect(), + permissions: grant.permissions.into_iter().collect(), + matched, + } +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs b/crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs new file mode 100644 index 0000000000..a84bfa99b8 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs @@ -0,0 +1,30 @@ +//! Administrators who assign roles see what each role usable on a target grants and how many hold it. + +use uuid::Uuid; + +use crate::authorization::_helpers::role_overview::{read_role_facts, role_overviews}; +use crate::authorization::main::begin_authorized::begin_authorized; +use crate::credentials::models::{ + Action, AuditContext, DefaultRole, PermissionScope, RoleOverview, +}; +use crate::store::errors::ControlError; +use crate::store::main::commit::commit; +use crate::store::models::ControlStore; + +/// Built-in roles first, then custom roles by key; requires `role.assign` everywhere. +pub async fn list_role_definitions( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + (location_id, target): (Uuid, &str), +) -> Result, ControlError> { + let (transaction, _) = begin_authorized( + store, + (context, credentials), + &PermissionScope::action(Action::RoleAssign, None), + DefaultRole::None, + ) + .await?; + let (transaction, facts) = read_role_facts(transaction, (location_id, target)).await?; + commit(transaction).await?; + role_overviews(facts) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs index 408b92f28b..163b3b6f0c 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs @@ -8,6 +8,7 @@ pub mod compile_roles; pub(crate) mod deny_authorization; pub mod grant_admin; pub mod list_people; +pub mod list_role_definitions; pub mod list_role_holdings; pub(crate) mod person_lists_target; pub(crate) mod publish_custom_roles; diff --git a/crates/sqlbuild-orchestration/src/authorization/models.rs b/crates/sqlbuild-orchestration/src/authorization/models.rs index 2b2aeb7017..5e80963f3d 100644 --- a/crates/sqlbuild-orchestration/src/authorization/models.rs +++ b/crates/sqlbuild-orchestration/src/authorization/models.rs @@ -22,3 +22,22 @@ pub struct PersonAccess { pub actions: Vec, pub locations: Vec, } + +/// What a target's role overview is built from, read in one transaction. +#[derive(Debug)] +pub(crate) struct RoleFacts { + pub(crate) custom: Vec, + pub(crate) holders: std::collections::BTreeMap, + pub(crate) descriptions: std::collections::BTreeMap, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct RoleHolderCount { + pub(crate) role_key: String, + pub(crate) holders: u64, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct CurrentSnapshotId { + pub(crate) snapshot_id: Uuid, +} diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index cb9cb43fc1..e5a1ff1281 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -66,6 +66,27 @@ pub struct RoleHoldings { pub group_roles: Vec, } +/// A role usable on one location target: built in, or published by the location's project. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RoleOverview { + pub key: String, + pub builtin: bool, + /// The project's description; absent for built-in roles. + pub description: Option, + /// Built-in roles: one grant without selectors, meaning every node. Custom roles: their published grants. + pub grants: Vec, + /// Active people assigned the role here or everywhere; SSO group members are not counted. + pub holders: u64, +} + +/// Node selectors, the actions they grant, and the nodes each selector matched when published. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RoleOverviewGrant { + pub nodes: Vec, + pub permissions: Vec, + pub matched: std::collections::BTreeMap>, +} + /// A role assigned to one person, everywhere or on one location and target. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct PersonRole { diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index d1533549d9..b126e740e8 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -324,11 +324,15 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ } let granted = usize::from(role_case.expected_admin == StatusCode::OK.as_u16()); let mut admin_reads = Vec::new(); - for admin_read in ["/api/admin/people", "/api/admin/roles"] { + for admin_read in [ + "/api/admin/people".to_owned(), + "/api/admin/roles".to_owned(), + format!("/api/admin/locations/{location}/targets/development/roles"), + ] { let listed = request( &provider.client, session, - (Method::GET, admin_read), + (Method::GET, &admin_read), (&Value::Null, &session.csrf), ) .await; diff --git a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs index 387778bc48..166aed5c93 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::json; use crate::authorization::main::compile_roles::compile_roles; +use crate::authorization::main::list_role_definitions::list_role_definitions; use crate::credentials::models::{Action, RoleAssignmentRequest, RoleKey}; use crate::credentials::tests::role_helpers::{ SUPPORT_ROLE, assign, authorize_member, developer_grants, member_listings, @@ -15,6 +16,7 @@ use crate::credentials::tests::test_types::{ProjectRoleTestCase, RolesFileTestCa use crate::declarations::models::TriggerChange; use crate::runs::tests::helpers::snapshot; use crate::snapshots::main::publish_snapshot::publish_snapshot; +use crate::store::errors::ControlError; 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; @@ -91,6 +93,58 @@ fn given_published_selector_when_it_matches_more_nodes_then_coverage_grows_on_pu "{}: the new role, the dropped view grant and the grown coverage enter the change feed", test_case.description ); + let definitions = list_role_definitions( + store, + (&roles.context, roles.administrator.identity.principal_id), + (roles.location_id, "development"), + ) + .await + .expect("role definitions"); + assert_eq!( + definitions + .iter() + .map(|role| (role.key.as_str(), role.builtin, role.holders)) + .collect::>(), + [ + ("viewer", true, 0), + ("operator", true, 0), + ("developer", true, 0), + ("admin", true, 1), + (SUPPORT_ROLE, false, 1) + ], + "{}: built-in roles, then the published role, each with its active holders here", + test_case.description + ); + assert_eq!( + definitions[0].grants[0].permissions, + [Action::View], + "a viewer views every node" + ); + let support = &definitions[4]; + assert_eq!( + support.description.as_deref(), + Some("Answer order questions") + ); + assert_eq!( + support.grants[0].matched, + BTreeMap::from([( + "tag:orders".to_owned(), + vec!["customers".to_owned(), "orders".to_owned()] + )]), + "each selector shows the nodes it matched at the latest publish" + ); + assert!( + matches!( + list_role_definitions( + store, + (&roles.context, roles.member_id), + (roles.location_id, "development"), + ) + .await, + Err(ControlError::Denied { .. }) + ), + "people without role.assign everywhere cannot read role definitions" + ); close_test_store(fixture).await; }); } diff --git a/fensu.toml b/fensu.toml index 51249679c6..c7d29e389c 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1381,7 +1381,7 @@ paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrator 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/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_jobs.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/platform/main/read_person_location_health.rs", "crates/sqlbuild-orchestration/src/platform/main/location_health.rs", "crates/sqlbuild-orchestration/src/snapshots/main/current_snapshot_record.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"] +paths = ["crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs", "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_jobs.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/platform/main/read_person_location_health.rs", "crates/sqlbuild-orchestration/src/platform/main/location_health.rs", "crates/sqlbuild-orchestration/src/snapshots/main/current_snapshot_record.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]] From a49d34610017232a2c92cecbb8154e9d12243026 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:35:57 +0000 Subject: [PATCH 14/16] feat(ui): show role definitions on the Roles pages The Roles list and role pages read the new role definitions endpoint. Built-in roles show their actions on every node, and custom roles show their published grants, the nodes each selector matched, the published definition and who holds them on this location. Holder counts include SSO group members, counted once per person. The SSO mappings tab and the base-role comparison stay hidden until the server provides them. --- clients/typescript/src/schema.d.ts | 2 +- .../authorization/_helpers/role_overview.rs | 13 +++-- .../src/credentials/models.rs | 2 +- .../tests/credentials/test_project_roles.rs | 39 ++++++++++++- ui/src/lib/api/http-client.ts | 21 ++++++- ui/src/lib/api/http-roles.test.ts | 56 +++++++++++++++++++ ui/src/lib/api/http-roles.ts | 45 +++++++++++++++ ui/src/lib/api/types/platform.ts | 8 +-- ui/src/lib/platform/access.ts | 6 +- ui/src/routes/admin/roles/+page.svelte | 4 +- ui/src/routes/admin/roles/[name]/+page.svelte | 12 ++-- ui/src/routes/admin/roles/roles-tab.svelte | 4 +- ui/src/routes/admin/roles/state.svelte.ts | 2 +- 13 files changed, 187 insertions(+), 27 deletions(-) create mode 100644 ui/src/lib/api/http-roles.test.ts create mode 100644 ui/src/lib/api/http-roles.ts diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 872f6a05cb..d7144f9518 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1732,7 +1732,7 @@ export interface components { grants: components["schemas"]["RoleOverviewGrant"][]; /** * Format: int64 - * @description Active people assigned the role here or everywhere; SSO group members are not counted. + * @description Active people holding the role here or everywhere, assigned directly or through an SSO group. */ holders: number; key: string; diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs index f5ae39f286..dfd563abf1 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs @@ -35,10 +35,15 @@ pub(crate) async fn read_role_facts( .await?; let (transaction, holders) = query( transaction, - "SELECT a.role_key, COUNT(DISTINCT a.principal_id) AS holders FROM sqb_role_assignments a \ - JOIN sqb_principals p ON p.principal_id=a.principal_id WHERE p.disabled_at IS NULL \ - AND (a.location_id IS NULL OR a.location_id=$1) AND (a.target IS NULL OR a.target=$2) \ - GROUP BY a.role_key", + "SELECT h.role_key, COUNT(DISTINCT h.principal_id) AS holders FROM (\ + SELECT a.principal_id, a.role_key FROM sqb_role_assignments a \ + WHERE (a.location_id IS NULL OR a.location_id=$1) AND (a.target IS NULL OR a.target=$2) \ + UNION ALL \ + SELECT m.principal_id, g.role_key FROM sqb_group_roles g \ + JOIN sqb_person_groups m ON m.group_name=g.group_name \ + WHERE (g.location_id IS NULL OR g.location_id=$1) AND (g.target IS NULL OR g.target=$2)\ + ) h JOIN sqb_principals p ON p.principal_id=h.principal_id WHERE p.disabled_at IS NULL \ + GROUP BY h.role_key", scope.clone(), ) .await?; diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index e5a1ff1281..5020b8b587 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -75,7 +75,7 @@ pub struct RoleOverview { pub description: Option, /// Built-in roles: one grant without selectors, meaning every node. Custom roles: their published grants. pub grants: Vec, - /// Active people assigned the role here or everywhere; SSO group members are not counted. + /// Active people holding the role here or everywhere, assigned directly or through an SSO group. pub holders: u64, } diff --git a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs index 166aed5c93..543013707a 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs @@ -4,8 +4,11 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::json; +use crate::api::models::GroupRole; use crate::authorization::main::compile_roles::compile_roles; use crate::authorization::main::list_role_definitions::list_role_definitions; +use crate::authorization::main::publish_group_roles::publish_group_roles; +use crate::credentials::main::identities::create_principal::create_principal; use crate::credentials::models::{Action, RoleAssignmentRequest, RoleKey}; use crate::credentials::tests::role_helpers::{ SUPPORT_ROLE, assign, authorize_member, developer_grants, member_listings, @@ -18,6 +21,8 @@ use crate::runs::tests::helpers::snapshot; use crate::snapshots::main::publish_snapshot::publish_snapshot; use crate::store::errors::ControlError; use crate::store::main::migrate_store::migrate_store; +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{Parameter, TransactionMode}; use crate::store::tests::store_helpers::{close_test_store, open_test_store, run}; use crate::store::tests::store_test_types::Backend; @@ -93,6 +98,36 @@ fn given_published_selector_when_it_matches_more_nodes_then_coverage_grows_on_pu "{}: the new role, the dropped view grant and the grown coverage enter the change feed", test_case.description ); + publish_group_roles( + store, + &[GroupRole { + group: "support-team".into(), + role: RoleKey::Custom(SUPPORT_ROLE.into()), + location_id: Some(roles.location_id), + target: None, + }], + ) + .await + .expect("group mapping"); + let grouped = create_principal(store, &roles.context, "support-lead") + .await + .expect("group member"); + for principal in [grouped, roles.member_id] { + let transaction = begin(store, TransactionMode::Write) + .await + .expect("group membership"); + let (transaction, _) = query( + transaction, + "INSERT INTO sqb_person_groups (principal_id,group_name) VALUES ($1,$2)", + vec![ + Parameter::Id(principal), + Parameter::Text("support-team".into()), + ], + ) + .await + .expect("member of the group"); + commit(transaction).await.expect("membership committed"); + } let definitions = list_role_definitions( store, (&roles.context, roles.administrator.identity.principal_id), @@ -110,9 +145,9 @@ fn given_published_selector_when_it_matches_more_nodes_then_coverage_grows_on_pu ("operator", true, 0), ("developer", true, 0), ("admin", true, 1), - (SUPPORT_ROLE, false, 1) + (SUPPORT_ROLE, false, 2) ], - "{}: built-in roles, then the published role, each with its active holders here", + "{}: built-in roles, then the published role, each counting a person once whether assigned directly or through a group", test_case.description ); assert_eq!( diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index cff0183d7e..bb195ca1b3 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -8,11 +8,12 @@ import { toSnapshotChanges } from './http-changes'; import { toLocationSummary } from './http-locations'; import { toAccount, toRoleAssignments } from './http-people'; import { toPlatformEvents } from './http-problems'; +import { roleDetailOf, toRoleDefinition } from './http-roles'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope } from './types'; -import type { Account, AuditEntry, AuditQuery, LocationSummary, RoleAssignment } from './types/platform'; +import type { Account, AuditEntry, AuditQuery, LocationSummary, RoleAssignment, RoleDefinition, RoleDetail } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -48,7 +49,9 @@ const SUPPORTED: ReadonlySet = new Set([ 'listAccounts', 'listRoleAssignments', 'listLocations', - 'listPlatformEvents' + 'listPlatformEvents', + 'listRoles', + 'getRole' ]); /** The server's largest audit page, and how many the audit log reads before it stops looking. */ @@ -310,6 +313,12 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt ); }, listAccounts: async (): Promise => (await people()).map(toAccount), + listRoles: async (locationId: string): Promise => (await roleOverviews(locationId)).map(toRoleDefinition), + async getRole(locationId: string, name: string): Promise { + const [roles, assignments] = await Promise.all([roleOverviews(locationId), implemented.listRoleAssignments()]); + const role = roles.find((r) => r.key === name); + return role ? roleDetailOf(role, assignments, locationId) : null; + }, async listRoleAssignments(): Promise { const { data, error, response } = await http.GET('/api/admin/roles'); if (response.status === 401) onUnauthorized(); @@ -396,6 +405,14 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return data; } + async function roleOverviews(locationKey: string) { + const { locationId, target } = parseLocationKey(locationKey); + const { data, error, response } = await http.GET('/api/admin/locations/{location_id}/targets/{target}/roles', { params: { path: { location_id: locationId, target } } }); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return data; + } + async function people() { const { data, error, response } = await http.GET('/api/admin/people'); if (response.status === 401) onUnauthorized(); diff --git a/ui/src/lib/api/http-roles.test.ts b/ui/src/lib/api/http-roles.test.ts new file mode 100644 index 0000000000..189802e195 --- /dev/null +++ b/ui/src/lib/api/http-roles.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { roleDetailOf, toRoleDefinition } from './http-roles'; +import type { RoleAssignment } from './types/platform'; + +const support = { + key: 'support', + builtin: false, + description: 'Answer order questions', + grants: [{ nodes: ['tag:orders'], permissions: ['view' as const, 'view.sql' as const], matched: { 'tag:orders': ['customers', 'orders'] } }], + holders: 2 +}; +const assignment = (id: string, role: string, locationId: string | null): RoleAssignment => ({ + id, + principal: { kind: 'user', name: id, displayName: id }, + role, + scope: { locationId }, + source: 'manual', + ssoGroup: null, + definedIn: null, + assignedBy: null, + assignedAt: null, + stale: false +}); + +describe('toRoleDefinition', () => { + it('describes built-in roles itself and keeps a custom role published description', () => { + const viewer = toRoleDefinition({ key: 'viewer', builtin: true, grants: [{ nodes: [], permissions: ['view'], matched: {} }], holders: 0 }); + expect(viewer).toMatchObject({ source: 'built_in', extends: null, recentlyMatched: null }); + expect(viewer.description).toContain('Sees'); + expect(toRoleDefinition(support)).toMatchObject({ source: 'project', description: 'Answer order questions', holders: 2 }); + }); +}); + +describe('roleDetailOf', () => { + it('lists assignments everywhere, on the whole location, or on this target only', () => { + const detail = roleDetailOf( + support, + [ + assignment('everywhere', 'support', null), + assignment('location', 'support', 'l1'), + assignment('target', 'support', 'l1:development'), + assignment('other-target', 'support', 'l1:production'), + assignment('other-location', 'support', 'l2'), + assignment('other-role', 'viewer', null) + ], + 'l1:development' + ); + expect(detail.assignments.map((a) => a.id)).toEqual(['everywhere', 'location', 'target']); + expect(detail.selectors).toEqual([{ selector: 'tag:orders', nodes: ['customers', 'orders'], newSinceLastWeek: [] }]); + expect(JSON.parse(detail.definition ?? '')).toEqual({ description: 'Answer order questions', grants: [{ nodes: ['tag:orders'], permissions: ['view', 'view.sql'] }] }); + }); + + it('has no definition text for built-in roles', () => { + expect(roleDetailOf({ key: 'admin', builtin: true, grants: [], holders: 1 }, [], 'l1:development').definition).toBeNull(); + }); +}); diff --git a/ui/src/lib/api/http-roles.ts b/ui/src/lib/api/http-roles.ts new file mode 100644 index 0000000000..2abbca8945 --- /dev/null +++ b/ui/src/lib/api/http-roles.ts @@ -0,0 +1,45 @@ +import type { components } from '$control/schema.d.ts'; +import type { BuiltInRole, Capability } from './types'; +import type { RoleAssignment, RoleDefinition, RoleDetail } from './types/platform'; + +type Wire = components['schemas']; + +/** What each built-in role is for; the server owns their actions. */ +export const BUILT_IN_DESCRIPTION: Record = { + viewer: 'Sees runs, jobs, triggers, checks and the catalog.', + operator: 'Runs jobs, cancels and reruns runs, and pauses or resumes triggers.', + developer: 'Everything an operator does, plus SQL, ad-hoc runs and connection tests.', + admin: 'Every action, including people, roles and the audit log.' +}; + +/** A role as the Roles list shows it; where it's defined and how it grew aren't known to the server. */ +export function toRoleDefinition(wire: Wire['RoleOverview']): RoleDefinition { + return { + name: wire.key, + source: wire.builtin ? 'built_in' : 'project', + definedIn: null, + description: wire.builtin ? BUILT_IN_DESCRIPTION[wire.key as BuiltInRole] : (wire.description ?? ''), + extends: null, + grants: wire.grants.map((g) => ({ nodes: g.nodes, permissions: g.permissions as Capability[] })), + holders: wire.holders, + recentlyMatched: null + }; +} + +/** Whether an assignment applies to this location key: everywhere, the whole location, or this target. */ +function covers(assignment: RoleAssignment, locationKey: string): boolean { + const scope = assignment.scope.locationId; + return scope === null || scope === locationKey || locationKey.startsWith(`${scope}:`); +} + +/** One role with what each selector matched at the latest publish and who holds it here. */ +export function roleDetailOf(wire: Wire['RoleOverview'], assignments: RoleAssignment[], locationKey: string): RoleDetail { + const role = toRoleDefinition(wire); + return { + ...role, + baseGrants: [], + selectors: wire.grants.flatMap((g) => g.nodes.map((selector) => ({ selector, nodes: g.matched[selector] ?? [], newSinceLastWeek: [] }))), + assignments: assignments.filter((a) => a.role === wire.key && covers(a, locationKey)), + definition: wire.builtin ? null : JSON.stringify({ description: wire.description, grants: wire.grants.map(({ nodes, permissions }) => ({ nodes, permissions })) }, null, 2) + }; +} diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index 4a87a54ab7..caf3aad99c 100644 --- a/ui/src/lib/api/types/platform.ts +++ b/ui/src/lib/api/types/platform.ts @@ -332,8 +332,8 @@ export type RoleDefinition = { grants: RoleGrant[]; /** People holding the role somewhere. */ holders: number; - /** Nodes the role's selectors started matching in the last 7 days. */ - recentlyMatched: number; + /** Nodes the role's selectors started matching in the last 7 days; null when unknown. */ + recentlyMatched: number | null; }; /** What one selector of a role matches in the current snapshot. */ @@ -349,8 +349,8 @@ export type RoleDetail = RoleDefinition & { baseGrants: RoleGrant[]; selectors: SelectorMatch[]; assignments: RoleAssignment[]; - /** The role's definition as written, read-only. */ - definition: string; + /** The role's definition as published, read-only; null for built-in roles. */ + definition: string | null; }; /** `locationId: null` applies everywhere. */ diff --git a/ui/src/lib/platform/access.ts b/ui/src/lib/platform/access.ts index dd2e3fc644..87cd630ed3 100644 --- a/ui/src/lib/platform/access.ts +++ b/ui/src/lib/platform/access.ts @@ -23,11 +23,11 @@ export function roleSummary(role: Pick = { built_in: 'Built-in', server_config: 'Server config', project: 'Policy file' }; +export const roleSourceLabel: Record = { built_in: 'Built-in', server_config: 'Server config', project: 'Project' }; export const assignmentSourceLabel: Record = { manual: 'Manual', sso: 'SSO group', policy: 'Server config', built_in: 'Default for everyone' }; diff --git a/ui/src/routes/admin/roles/+page.svelte b/ui/src/routes/admin/roles/+page.svelte index ac8cb2ab20..1a4525ef32 100644 --- a/ui/src/routes/admin/roles/+page.svelte +++ b/ui/src/routes/admin/roles/+page.svelte @@ -33,11 +33,11 @@ bind:value={() => tab, select} tabs={[ { value: 'roles', label: 'Roles', count: rs.roles.data?.length }, - { value: 'sso', label: 'SSO mappings', count: rs.sso.data?.length } + ...(app.client.supports('listSsoMappings') ? [{ value: 'sso' as const, label: 'SSO mappings', count: rs.sso.data?.length }] : []) ]} />
- {#if tab === 'roles'} + {#if tab === 'roles' || !app.client.supports('listSsoMappings')} {:else} diff --git a/ui/src/routes/admin/roles/[name]/+page.svelte b/ui/src/routes/admin/roles/[name]/+page.svelte index 3d998169d2..cb34c4354a 100644 --- a/ui/src/routes/admin/roles/[name]/+page.svelte +++ b/ui/src/routes/admin/roles/[name]/+page.svelte @@ -69,7 +69,7 @@ {:else} {#snippet actions()} - + {#if r.extends}{/if} {/snippet} {#if r.extends}

@@ -77,7 +77,7 @@ inherit further than one built-in role.

{:else} -

Starts from nothing; only the additions below apply.

+

Starts from nothing; only the additions below apply, on top of any built-in role a person also holds.

{/if} {#if compare} @@ -160,9 +160,11 @@ {/if} {/if} - - - + {#if r.definition} + + + + {/if}
diff --git a/ui/src/routes/admin/roles/roles-tab.svelte b/ui/src/routes/admin/roles/roles-tab.svelte index 38e0b1a219..cb3e8e993e 100644 --- a/ui/src/routes/admin/roles/roles-tab.svelte +++ b/ui/src/routes/admin/roles/roles-tab.svelte @@ -12,8 +12,8 @@

- Roles in {app.location?.name}. Each custom role is a built-in role plus additions on chosen nodes. Roles are defined in code (the project's policy file or the server - config) and are read-only here. + Roles in {app.location?.name}. Built-in roles act on every node; custom roles add actions on chosen nodes. Custom roles come from the project's roles file and are + read-only here.

diff --git a/ui/src/routes/admin/roles/state.svelte.ts b/ui/src/routes/admin/roles/state.svelte.ts index 098666121a..f7865b5afd 100644 --- a/ui/src/routes/admin/roles/state.svelte.ts +++ b/ui/src/routes/admin/roles/state.svelte.ts @@ -6,7 +6,7 @@ import { createResource } from '$lib/state/resource.svelte'; export function createRolesState() { const can = () => app.can('role.assign'); const roles = createResource((client, scope) => (can() ? client.listRoles(scope.locationId) : Promise.resolve([] as RoleDefinition[]))); - const sso = createResource((client) => (can() ? client.listSsoMappings() : Promise.resolve([] as SsoMapping[]))); + const sso = createResource((client) => (can() && client.supports('listSsoMappings') ? client.listSsoMappings() : Promise.resolve([] as SsoMapping[]))); return { roles, From d220b215539ec766bc1636c133778947b5777eb0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:46:07 +0000 Subject: [PATCH 15/16] feat(ui): show warehouse sync status in the top bar The top bar reads the target's warehouse sync reports from the health read: when a table last synced cleanly, and which tables' newest sync failed and at what stage. It rereads every 30 seconds with the alert banner, because sync reports send no change notice. --- ui/src/lib/api/client.ts | 3 ++- ui/src/lib/api/http-client.ts | 14 +++++++++++--- ui/src/lib/api/http-locations.test.ts | 27 ++++++++++++++++++++++++++- ui/src/lib/api/http-locations.ts | 22 +++++++++++++++++++++- ui/src/lib/state/app.svelte.ts | 11 +++++++---- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 11d6581757..e0fb906e31 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -122,7 +122,8 @@ export interface ApiClient extends PlatformApi { listPlatformEvents(query: { activeOnly: boolean }): Promise; - getWarehouseSync(scope: Scope): Promise; + /** Null when the location has reported no recent sync. */ + getWarehouseSync(scope: Scope): Promise; /** Live "changed" signals (SSE in the server client); consumers refetch. */ changes: ChangeSubscription; diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index bb195ca1b3..b79d2c2035 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -5,14 +5,14 @@ import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-chec import { toCatalogNodes } from './http-catalog'; import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; -import { toLocationSummary } from './http-locations'; +import { toLocationSummary, warehouseSyncOf } from './http-locations'; import { toAccount, toRoleAssignments } from './http-people'; import { toPlatformEvents } from './http-problems'; import { roleDetailOf, toRoleDefinition } from './http-roles'; import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; -import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope } from './types'; +import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope, WarehouseSync } from './types'; import type { Account, AuditEntry, AuditQuery, LocationSummary, RoleAssignment, RoleDefinition, RoleDetail } from './types/platform'; import type { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; @@ -51,7 +51,8 @@ const SUPPORTED: ReadonlySet = new Set([ 'listLocations', 'listPlatformEvents', 'listRoles', - 'getRole' + 'getRole', + 'getWarehouseSync' ]); /** The server's largest audit page, and how many the audit log reads before it stops looking. */ @@ -288,6 +289,13 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt } return found.slice(0, limit); }, + async getWarehouseSync(scope: Scope): Promise { + const { locationId, target } = parseLocationKey(scope.locationId); + const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/health', { params: { path: { location_id: locationId, target } } }); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return warehouseSyncOf(data); + }, async listPlatformEvents(): Promise { const { data, error, response } = await http.GET('/api/control/platform/problems'); if (response.status === 401) onUnauthorized(); diff --git a/ui/src/lib/api/http-locations.test.ts b/ui/src/lib/api/http-locations.test.ts index 05d99ab2b8..6d07abda32 100644 --- a/ui/src/lib/api/http-locations.test.ts +++ b/ui/src/lib/api/http-locations.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { connectionTestOf, healthOf } from './http-locations'; +import { connectionTestOf, healthOf, warehouseSyncOf } from './http-locations'; const now = Date.parse('2026-11-08T12:00:00Z'); const at = (secondsAgo: number) => new Date(now - secondsAgo * 1000).toISOString(); @@ -31,3 +31,28 @@ describe('location health', () => { expect(connectionTestOf(observations)).toMatchObject({ outcome: 'failed', at: at(5) }); }); }); + +describe('warehouseSyncOf', () => { + const sync = (at: string, table: 'catalog' | 'statistics', failure: 'read' | 'delivery' | null) => ({ + received_at: at, + report: { event_id: at, session_id: 's1', target: 'development', health: { kind: 'warehouse_sync' as const, generation: 1, location: { schema: 'main' }, table, failure, records_sent: 0, elapsed_millis: 5 } } + }); + + it('is null without any sync report', () => { + expect(warehouseSyncOf([])).toBeNull(); + }); + + it('dates the newest clean sync and names only tables whose newest sync failed', () => { + const result = warehouseSyncOf([ + sync('2026-11-08T12:03:00Z', 'catalog', 'read'), + sync('2026-11-08T12:02:00Z', 'statistics', null), + sync('2026-11-08T12:01:00Z', 'catalog', null), + sync('2026-11-08T12:00:00Z', 'statistics', 'delivery') + ]); + expect(result).toEqual({ lastSyncedAt: '2026-11-08T12:02:00Z', lastError: 'catalog: failed while reading the warehouse' }); + }); + + it('clears the error once the failing table syncs again', () => { + expect(warehouseSyncOf([sync('2026-11-08T12:05:00Z', 'catalog', null), sync('2026-11-08T12:03:00Z', 'catalog', 'read')])?.lastError).toBeNull(); + }); +}); diff --git a/ui/src/lib/api/http-locations.ts b/ui/src/lib/api/http-locations.ts index d08a1dadb8..4136768b3e 100644 --- a/ui/src/lib/api/http-locations.ts +++ b/ui/src/lib/api/http-locations.ts @@ -1,5 +1,5 @@ import type { components } from '$control/schema.d.ts'; -import type { Location, LocationHealth } from './types'; +import type { Location, LocationHealth, WarehouseSync } from './types'; import type { ConnectionTest, LocationSummary } from './types/platform'; type Wire = components['schemas']; @@ -60,3 +60,23 @@ export function toLocationSummary(location: Location, observations: Wire['Locati : null }; } + +const SYNC_STAGE: Record = { + watermark: 'reading where the last sync stopped', + read: 'reading the warehouse', + delivery: 'sending results to the server' +}; + +/** When a table last synced cleanly, and which tables' newest sync failed; null without any sync report. */ +export function warehouseSyncOf(observations: Wire['LocationHealthObservation'][]): WarehouseSync | null { + type Sync = Wire['LocationHealthObservation'] & { report: { health: Extract } }; + const syncs = observations.filter((o): o is Sync => o.report.health.kind === 'warehouse_sync').sort((a, b) => b.received_at.localeCompare(a.received_at)); + if (!syncs.length) return null; + const newest = new Map(); + for (const sync of syncs) if (!newest.has(sync.report.health.table)) newest.set(sync.report.health.table, sync); + const failing = [...newest.values()].flatMap((s) => (s.report.health.failure ? [`${s.report.health.table}: failed while ${SYNC_STAGE[s.report.health.failure]}`] : [])); + return { + lastSyncedAt: syncs.find((s) => !s.report.health.failure)?.received_at ?? null, + lastError: failing.length ? failing.sort().join('\n') : null + }; +} diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 2fa0a7cf9f..74577ab37c 100644 --- a/ui/src/lib/state/app.svelte.ts +++ b/ui/src/lib/state/app.svelte.ts @@ -18,8 +18,8 @@ import { } from '$lib/api'; const SCOPE_KEY = 'sb-scope'; -/** How often the banner rereads live problems; locations check in every 30 seconds. */ -const PROBLEM_POLL_MS = 30_000; +/** How often the shell rereads live problems and warehouse sync; locations report every 30 seconds. */ +const POLL_MS = 30_000; /** * App-wide state: the bootstrap, the selected location, and the @@ -90,8 +90,11 @@ class AppState { this.#restoreScope(); this.client.changes.follow(this.scope); void this.#loadPlatformEvents(); - // Problems are derived from time passing, such as a missed check-in, so no change notice announces them. - if (this.client.supports('listPlatformEvents')) setInterval(() => void this.#loadPlatformEvents(), PROBLEM_POLL_MS); + // Problems and sync reports come from time passing and location reports, so no change notice announces them. + setInterval(() => { + void this.#loadPlatformEvents(); + void this.#loadWarehouseSync(); + }, POLL_MS); this.#loadScoped(); } From b12327f26e56fd8e4178cf409c3fb9c200bd45a8 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:53:41 +0000 Subject: [PATCH 16/16] fix(ui): stop rereading warehouse sync after a refused read Every refused health read writes an audit denial, so a person who sees a location but may not list all of its nodes would add one each poll. The shell now asks once per target per page load and keeps the indicator at its unknown state after a refusal. --- ui/src/lib/state/app.svelte.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 74577ab37c..48bf1990d1 100644 --- a/ui/src/lib/state/app.svelte.ts +++ b/ui/src/lib/state/app.svelte.ts @@ -46,6 +46,7 @@ class AppState { readonly capabilities = $derived([...(this.bootstrap?.capabilities ?? []), ...(this.location?.capabilities ?? [])]); #started = false; + #syncRefused = new Set(); constructor() { this.client = createClient(new URLSearchParams(browser ? window.location.search : ''), () => this.#signIn()); @@ -154,11 +155,17 @@ class AppState { if (!this.client.supports('getWarehouseSync')) return; const scope = this.scope; if (!scope) return; + // Each refusal is audited, so a target the person may not list is asked once per page load, not every poll. + if (this.#syncRefused.has(scope.locationId)) { + this.warehouseSync = null; + return; + } try { const sync = await this.client.getWarehouseSync(scope); if (this.scope === scope) this.warehouseSync = sync; - } catch { - this.warehouseSync = null; + } catch (error) { + if (error instanceof ApiError && error.status === 403) this.#syncRefused.add(scope.locationId); + if (this.scope === scope) this.warehouseSync = null; } }