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 2aebecfe52..8d34d3475c 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1,5 +1,37 @@ // 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; + 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 +55,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["listRoleHoldings"]; put?: never; post: operations["assignBuiltinRole"]; delete?: never; @@ -354,6 +386,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; @@ -546,6 +594,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; @@ -978,6 +1042,15 @@ 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; + location_name: string; + target: string; + }; /** @enum {string} */ BuiltinRole: "viewer" | "operator" | "developer" | "admin"; /** @description Browser cancellation cannot supply an actor, claim or lifecycle transition. */ @@ -1197,6 +1270,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; }; @@ -1472,6 +1553,18 @@ 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; + location_name: string; + name: string; + /** Format: date-time */ + scheduled_for: string; + target: string; + /** Format: uuid */ + trigger_id: string; + }; PasswordChangeRequest: { current_password: string; new_password: string; @@ -1492,6 +1585,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; none while the person is disabled. + */ + 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"]; @@ -1504,6 +1629,17 @@ 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; + /** + * 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` (`role:` for those who can't assign roles). */ PublishedChange: { change_kind: string; @@ -1580,8 +1716,35 @@ 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 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 holding the role here or everywhere, assigned directly or through an SSO group. + */ + 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; @@ -1802,6 +1965,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 */ @@ -2168,6 +2333,78 @@ 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; + 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; @@ -2212,6 +2449,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; @@ -2540,6 +2811,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; @@ -2996,6 +3269,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; @@ -3502,6 +3813,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 5bce2d5b43..7aba640430 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,18 @@ 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/locations/{location_id}/targets/{target}/roles", + get(crate::api::_helpers::resources::administrators::definitions), + ) .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", @@ -121,6 +130,14 @@ 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/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/administrators.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs index 89fbada6a9..72c7bc9589 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/administrators.rs @@ -8,8 +8,14 @@ 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_role_definitions::list_role_definitions; +use crate::authorization::main::list_role_holdings::list_role_holdings; +use crate::credentials::main::identities::list_people::list_people; use crate::credentials::models::AuthenticatedPerson; -use crate::credentials::models::{AdministratorChange, AuditContext, RoleAssignmentRequest}; +use crate::credentials::models::{ + AdministratorChange, AuditContext, PersonSummary, RoleAssignmentRequest, RoleHoldings, + RoleOverview, +}; use crate::store::models::ControlStore; #[utoipa::path(post, path = "/api/admin/people/{principal_id}", operation_id = "changeAdministrator", @@ -42,3 +48,42 @@ 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?, + )) +} + +#[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/_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/_helpers/resources/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs index aa10e0c867..1b85c7a490 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,9 @@ 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::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; @@ -161,6 +164,41 @@ 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?, + )) +} + +#[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 9571345a46..574a1accc0 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -14,6 +14,9 @@ use crate::api::_helpers::resources::{ #[openapi(paths( administrators::change, administrators::assign, + administrators::people, + administrators::holdings, + administrators::definitions, audit::read, person_operations::request_job, person_operations::cancel_run, @@ -26,6 +29,8 @@ use crate::api::_helpers::resources::{ person_reads::triggers, person_reads::ticks, person_reads::snapshots, + person_reads::health, + person_reads::problems, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, @@ -87,6 +92,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/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 831d2d7b1e..2e33ac15ba 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs @@ -3,3 +3,5 @@ pub(crate) mod administrator_roles; 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/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/_helpers/role_overview.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs new file mode 100644 index 0000000000..dfd563abf1 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/role_overview.rs @@ -0,0 +1,124 @@ +//! 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 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?; + 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/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 03b7ff29e1..a38eebf512 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_role_definitions; +pub mod list_role_holdings; pub(crate) mod person_assigns_roles; pub(crate) mod person_lists_target; pub(crate) mod publish_custom_roles; 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/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/main/identities/list_people.rs b/crates/sqlbuild-orchestration/src/credentials/main/identities/list_people.rs new file mode 100644 index 0000000000..de67a734f9 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/credentials/main/identities/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 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?; + 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/credentials/main/identities/mod.rs b/crates/sqlbuild-orchestration/src/credentials/main/identities/mod.rs index 54451634e7..59b28fe23a 100644 --- a/crates/sqlbuild-orchestration/src/credentials/main/identities/mod.rs +++ b/crates/sqlbuild-orchestration/src/credentials/main/identities/mod.rs @@ -1,2 +1,3 @@ pub mod create_principal; pub(crate) mod insert_principal; +pub mod list_people; diff --git a/crates/sqlbuild-orchestration/src/credentials/models.rs b/crates/sqlbuild-orchestration/src/credentials/models.rs index fc0a74344e..5020b8b587 100644 --- a/crates/sqlbuild-orchestration/src/credentials/models.rs +++ b/crates/sqlbuild-orchestration/src/credentials/models.rs @@ -39,6 +39,72 @@ 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, +} + +/// 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; none while the person is disabled. + 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 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 holding the role here or everywhere, assigned directly or through an SSO group. + 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 { + 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. diff --git a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs index 0c2b80f5ca..a302fe0aee 100644 --- a/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/mod.rs @@ -2,6 +2,8 @@ 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; pub(crate) mod work_client; 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/_helpers/recent_health.rs b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs new file mode 100644 index 0000000000..40e68baa26 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/_helpers/recent_health.rs @@ -0,0 +1,76 @@ +//! 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, 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, + &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'{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'{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 + .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, + &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( + 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..148e6fb77f 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/location_health.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/location_health.rs @@ -2,62 +2,22 @@ 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}")), - ], + let (transaction, observations) = recent_health( + begin(store, TransactionMode::Read).await?, + location_id, + None, ) .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); 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..d1c062ba19 100644 --- a/crates/sqlbuild-orchestration/src/platform/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/platform/main/mod.rs @@ -5,6 +5,8 @@ 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 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_location_health.rs b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs new file mode 100644 index 0000000000..91b9eeab0d --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs @@ -0,0 +1,30 @@ +//! 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, Some(target)).await?; + commit(transaction).await?; + Ok(observations) +} 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..5644e4f32c --- /dev/null +++ b/crates/sqlbuild-orchestration/src/platform/main/read_person_problems.rs @@ -0,0 +1,46 @@ +//! People see the scheduler's state, and broken locations and late ticks only for targets they list. + +use std::collections::BTreeSet; + +use uuid::Uuid; + +use crate::authorization::main::person_lists_target::person_lists_target; +use crate::credentials::models::DefaultRole; +use crate::platform::_helpers::problems::{late_ticks, scan_problems}; +use crate::platform::models::PlatformProblems; +use crate::store::errors::ControlError; +use crate::store::models::ControlStore; + +/// 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 scan = scan_problems(store).await?; + let mut listed: BTreeSet<(Uuid, String)> = BTreeSet::new(); + for (location_id, target) in scan + .broken + .iter() + .map(|location| (location.location_id, location.target.clone())) + .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)); + } + } + Ok(PlatformProblems { + 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(), + }) +} diff --git a/crates/sqlbuild-orchestration/src/platform/models.rs b/crates/sqlbuild-orchestration/src/platform/models.rs index 543aaeb420..1bb5c963c8 100644 --- a/crates/sqlbuild-orchestration/src/platform/models.rs +++ b/crates/sqlbuild-orchestration/src/platform/models.rs @@ -250,3 +250,53 @@ 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, + /// 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, +} + +/// 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 location_name: String, + 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 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/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/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_location_health.rs b/crates/sqlbuild-orchestration/tests/api/test_location_health.rs index db5ebfe4fa..928b2759ca 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"); @@ -191,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/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/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index cc253368c7..b126e740e8 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 @@ -321,6 +322,69 @@ 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".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), + (&Value::Null, &session.csrf), + ) + .await; + assert_eq!( + listed.status().as_u16(), + role_case.expected_admin, + "{admin_read}" + ); + 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("person") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + 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/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() diff --git a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs index 387778bc48..543013707a 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs @@ -4,7 +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, @@ -15,7 +19,10 @@ 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::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; @@ -91,6 +98,88 @@ 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), + (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, 2) + ], + "{}: built-in roles, then the published role, each counting a person once whether assigned directly or through a group", + 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/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..3ebb270633 --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/platform/test_problems.rs @@ -0,0 +1,167 @@ +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, + expected_leader: false, + }, + ProblemsTestCase { + description: "PostgreSQL with a leader", + backend: Backend::Postgres, + leaders: 1, + expected_leader: true, + }, + ]; + 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.expected_leader, + "{}", + test_case.description + ); + 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, + "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] + && tick.location_name == "inventory"), + "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..8fa6dd4a47 100644 --- a/crates/sqlbuild-orchestration/tests/platform/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/platform/test_types.rs @@ -51,3 +51,10 @@ 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, + pub(crate) expected_leader: bool, +} diff --git a/fensu.toml b/fensu.toml index 7c1f8e7b53..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/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]] 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-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..b79d2c2035 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -3,11 +3,17 @@ 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 { 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, 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'; @@ -38,9 +44,21 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheckSql', 'getCheckEvidence', 'listCatalogNodes', - 'listSnapshotChanges' + 'listSnapshotChanges', + 'listAuditEntries', + 'listAccounts', + 'listRoleAssignments', + 'listLocations', + 'listPlatformEvents', + 'listRoles', + 'getRole', + 'getWarehouseSync' ]); +/** 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 +274,66 @@ 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); + }, + 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(); + 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(); + 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), + 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(); + 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 { @@ -335,6 +413,21 @@ 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(); + if (!data) throw new ApiError(errorText(error, response), response.status); + 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); 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..6d07abda32 --- /dev/null +++ b/ui/src/lib/api/http-locations.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +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(); +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) }); + }); +}); + +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 new file mode 100644 index 0000000000..4136768b3e --- /dev/null +++ b/ui/src/lib/api/http-locations.ts @@ -0,0 +1,82 @@ +import type { components } from '$control/schema.d.ts'; +import type { Location, LocationHealth, WarehouseSync } 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 + }; +} + +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/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/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/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/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/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index 4c130b04f8..caf3aad99c 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; @@ -296,7 +300,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; @@ -327,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. */ @@ -344,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. */ @@ -369,7 +374,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; }; @@ -395,7 +400,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/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/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/lib/platform/labels.ts b/ui/src/lib/platform/labels.ts index 7d29b9b9a9..9fe55a11fb 100644 --- a/ui/src/lib/platform/labels.ts +++ b/ui/src/lib/platform/labels.ts @@ -38,13 +38,13 @@ 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/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/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/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 336731ad38..48bf1990d1 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 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 @@ -44,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()); @@ -88,6 +91,11 @@ class AppState { this.#restoreScope(); this.client.changes.follow(this.scope); void this.#loadPlatformEvents(); + // 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(); } @@ -147,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; } } diff --git a/ui/src/routes/admin/audit/+page.svelte b/ui/src/routes/admin/audit/+page.svelte index fe37712c67..b1412ebbb0 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 ?? []); + const linksPeople = $derived(app.client.supports('effectiveAccess')); + const checksDecisions = $derived(app.client.supports('lookupDecision')); @@ -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 checksDecisions}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; 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 @@ 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, 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 - + {#if knowsPolicy}{/if} {#each filtered as l (l.id)} - goto(`/locations/${l.id}`)}> + opensDetail && goto(`/locations/${l.id}`)}> - + {#if knowsPolicy}{/if} {:else} - + {/each}
Warehouse Location processesIf a deploy failsIf a deploy fails
- 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'}{l.brokenDeployPolicy === 'fallback' ? 'Keep running the previous version' : l.brokenDeployPolicy ? 'Hold scheduled runs until a deploy works' : '—'}
+ +