crates/monetize-ui-server/src/guardian.rs
//! **THE ESTATE GUARDIAN** (5.4 resilience): every estate's repair cases, the
//! four buttons and the auto-fix opt-in, reshaped from monetize's `Guardian`
//! service (`fleet.proto`) for the browser.
//!
//! ```text
//! GET /api/guardian → {estates: [EstateJson]}
//! GET /api/guardian/<instance> → EstateJson
//! POST /api/guardian/<instance>/press {remedy, case_id} → CaseJson
//! POST /api/guardian/<instance>/auto-fix {on} → {on}
//! ```
//!
//! A button never reaches into an estate: monetize records the case and
//! queues a SIGNED order the estate pulls (or creates a box through its
//! cloud). What this side adds is the one thing only it knows: WHO pressed,
//! and whether that session is strong — an admin passkey, user-verified, with
//! a fresh `auth_time` ([`vouch`]). monetize refuses a weak press too; the
//! check here only words the refusal before the call.
use axum::extract::{Path, State};
use axum::{Extension, Json};
use monetize_proto as pb;
use serde::{Deserialize, Serialize};
use crate::api::ApiError;
use crate::app::AppState;
use crate::auth::Caller;
use crate::grpc::Fail;
/// How old a passkey login may be and still press a button (5 min) — the
/// console's one step-up window.
pub const STRONG_FRESH_SECS: i64 = crate::auth::STEP_UP_SECS;
/// The four remedies a human may press, in the order the screen shows them.
pub const REMEDIES: [&str; 4] = ["recharge_from_twin", "review_twin", "new_twin", "new_server"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoxJson {
pub role: String,
pub heard_unix_ms: u64,
pub silent: bool,
pub laid_ipv4: String,
pub health: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepJson {
pub seq: u64,
pub words: String,
pub state: String,
pub detail: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineJson {
pub at_unix_ms: u64,
pub words: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseJson {
pub id: String,
pub instance: String,
pub product: String,
pub state: String,
pub opened_unix_ms: u64,
pub updated_unix_ms: u64,
pub finding_code: String,
pub finding_words: String,
pub evidence: Vec<String>,
pub buttons: Vec<String>,
pub remedy: String,
pub approved_by: String,
pub approved_unix_ms: u64,
pub auto: bool,
pub epoch: u64,
pub steps: Vec<StepJson>,
pub log: Vec<LineJson>,
pub outcome: String,
pub receipt_json: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EstateJson {
pub instance: String,
pub product: String,
pub auto_fix: bool,
pub boxes: Vec<BoxJson>,
/// Newest first, as monetize answers.
pub cases: Vec<CaseJson>,
/// Its estate bearer (ST-13): until when it opens (0: none, or revoked).
#[serde(default)]
pub bearer_until_unix_ms: u64,
/// When the operator revoked it (0: never).
#[serde(default)]
pub bearer_revoked_unix_ms: u64,
/// PROD-4: the fleet did not answer within its bound; this is its state this many seconds old.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stale_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardianJson {
pub estates: Vec<EstateJson>,
/// PROD-4: the fleet did not answer within its bound; this is its state this many seconds old.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stale_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PressBody {
pub remedy: String,
#[serde(default)]
pub case_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AutoFixBody {
pub on: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AutoFixJson {
pub on: bool,
}
/// Pure: one `GuardianCase` for the browser.
pub fn case_json(c: &pb::GuardianCase) -> CaseJson {
CaseJson {
id: c.id.clone(),
instance: c.instance.clone(),
product: c.product.clone(),
state: c.state.clone(),
opened_unix_ms: c.opened_unix_ms,
updated_unix_ms: c.updated_unix_ms,
finding_code: c.finding_code.clone(),
finding_words: c.finding_words.clone(),
evidence: c.evidence.clone(),
buttons: c.buttons.clone(),
remedy: c.remedy.clone(),
approved_by: c.approved_by.clone(),
approved_unix_ms: c.approved_unix_ms,
auto: c.auto,
epoch: c.epoch,
steps: c.steps.iter().map(|s| StepJson { seq: s.seq, words: s.words.clone(), state: s.state.clone(), detail: s.detail.clone() }).collect(),
log: c.log.iter().map(|l| LineJson { at_unix_ms: l.at_unix_ms, words: l.words.clone() }).collect(),
outcome: c.outcome.clone(),
receipt_json: c.receipt_json.clone(),
}
}
/// Pure: one `GuardianEstate` for the browser.
pub fn estate_json(e: &pb::GuardianEstate) -> EstateJson {
EstateJson {
instance: e.instance.clone(),
product: e.product.clone(),
auto_fix: e.auto_fix,
boxes: e
.boxes
.iter()
.map(|b| BoxJson { role: b.role.clone(), heard_unix_ms: b.heard_unix_ms, silent: b.silent, laid_ipv4: b.laid_ipv4.clone(), health: b.health.iter().map(|(k, v)| (k.clone(), v.clone())).collect() })
.collect(),
cases: e.cases.iter().map(case_json).collect(),
bearer_until_unix_ms: e.bearer_until_unix_ms,
bearer_revoked_unix_ms: e.bearer_revoked_unix_ms,
stale_secs: None,
}
}
/// Pure: `Guardian.Cases`' answer.
pub fn guardian_json(r: &pb::GuardianCasesResponse) -> GuardianJson {
GuardianJson { estates: r.estates.iter().map(estate_json).collect(), stale_secs: None }
}
/// Strong enough to press a button: the console's one step-up rule.
pub use crate::auth::strong;
/// **Who is pressing, and is it strong** — what the BFF vouches for to
/// monetize. The break-glass bearer and an open loopback console name
/// themselves but are never strong: a repair button needs a person.
pub fn vouch(caller: Option<&Caller>, now_secs: i64) -> (String, bool) {
match caller {
Some(Caller::Operator(s)) => (s.user.clone(), strong(s.role, s.uv, s.auth_time, now_secs)),
Some(Caller::AdminToken) => ("admin-token".into(), false),
None => ("open-console".into(), false),
}
}
/// Pure: one of [`REMEDIES`], or a refusal naming them.
pub fn remedy_of(raw: &str) -> Result<String, Fail> {
let r = raw.trim();
if REMEDIES.contains(&r) {
return Ok(r.to_owned());
}
Err(Fail::invalid(format!("{r:?} is not a guardian button; the buttons are {}", REMEDIES.join(", "))))
}
/// `GET /api/guardian`.
pub async fn list(State(state): State<AppState>) -> Result<Json<GuardianJson>, ApiError> {
// PROD-4: bounded; past the bound, the last fleet with its age.
let r = crate::estates::cases(&state.monetize, "").await?;
Ok(Json(GuardianJson { stale_secs: r.stale_secs, ..guardian_json(&r.answer) }))
}
/// `GET /api/guardian/<instance>`.
pub async fn estate(State(state): State<AppState>, Path(instance): Path<String>) -> Result<Json<EstateJson>, ApiError> {
let r = crate::estates::cases(&state.monetize, &instance).await?;
let e = r.answer.estates.iter().find(|e| e.instance == instance).ok_or_else(|| ApiError(Fail { code: "notfound".into(), message: format!("no estate {instance:?} under the guardian") }))?;
Ok(Json(EstateJson { stale_secs: r.stale_secs, ..estate_json(e) }))
}
fn weak() -> ApiError {
ApiError(Fail {
code: "permissiondenied".into(),
message: format!("a repair button needs an admin passkey, user-verified, logged in within {} min — log in again and press", STRONG_FRESH_SECS / 60),
})
}
/// `POST /api/guardian/<instance>/press` `{remedy, case_id}`.
pub async fn press(State(state): State<AppState>, caller: Option<Extension<Caller>>, Path(instance): Path<String>, Json(body): Json<PressBody>) -> Result<Json<CaseJson>, ApiError> {
let remedy = remedy_of(&body.remedy).map_err(ApiError)?;
let (principal, strong) = vouch(caller.as_deref(), crate::auth::now_secs());
if !strong {
return Err(weak());
}
let c = state.monetize.guardian().press(pb::GuardianPressRequest { instance, remedy, case_id: body.case_id, principal, strong }).await?.into_inner();
Ok(Json(case_json(&c)))
}
/// `POST /api/guardian/<instance>/auto-fix` `{on}`.
pub async fn auto_fix(State(state): State<AppState>, caller: Option<Extension<Caller>>, Path(instance): Path<String>, Json(body): Json<AutoFixBody>) -> Result<Json<AutoFixJson>, ApiError> {
let (principal, strong) = vouch(caller.as_deref(), crate::auth::now_secs());
if !strong {
return Err(weak());
}
let r = state.monetize.guardian().set_auto_fix(pb::GuardianAutoFixRequest { instance, on: body.on, principal, strong }).await?.into_inner();
Ok(Json(AutoFixJson { on: r.on }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::role::Role;
fn case(id: &str, state: &str) -> pb::GuardianCase {
pb::GuardianCase {
id: id.into(),
instance: "acme".into(),
product: "gunnar".into(),
state: state.into(),
opened_unix_ms: 1_000,
updated_unix_ms: 2_000,
finding_code: "twin_silent".into(),
finding_words: "the twin has not called home for 4 min".into(),
evidence: vec!["twin heard 1970-01-01T00:00:01Z".into()],
buttons: vec!["new_twin".into(), "review_twin".into()],
remedy: "new_twin".into(),
approved_by: "rickard".into(),
approved_unix_ms: 1_500,
auto: false,
epoch: 3,
steps: vec![pb::GuardianStep { seq: 7, words: "fence the old twin".into(), state: "met".into(), detail: "destroyed".into() }],
log: vec![pb::GuardianLine { at_unix_ms: 1_000, words: "detected".into() }],
outcome: String::new(),
receipt_json: "{\"verb\":\"adopt_twin\"}".into(),
}
}
/// ★ **A case reaches the browser whole**: state, finding, evidence, the
/// buttons monetize recommends, who approved, the steps with their order
/// seq, the log and the receipt; the estate carries its boxes (last heard,
/// silent) and the auto-fix switch; cases stay newest first.
#[test]
fn an_estate_and_its_cases_reach_the_browser_whole() {
let r = pb::GuardianCasesResponse {
estates: vec![pb::GuardianEstate {
instance: "acme".into(),
product: "gunnar".into(),
auto_fix: true,
boxes: vec![
pb::GuardianBox { role: "main".into(), heard_unix_ms: 9_000, silent: false, health: [("twin".to_string(), "ok".to_string())].into(), laid_ipv4: "10.0.0.2".into() },
pb::GuardianBox { role: "twin".into(), heard_unix_ms: 1_000, silent: true, health: Default::default(), laid_ipv4: "10.0.0.3".into() },
],
cases: vec![case("c2", "running"), case("c1", "done")],
bearer_until_unix_ms: 77,
bearer_revoked_unix_ms: 0,
}],
};
let g = guardian_json(&r);
assert_eq!(g.estates.len(), 1);
let e = &g.estates[0];
assert_eq!((e.instance.as_str(), e.product.as_str(), e.auto_fix), ("acme", "gunnar", true));
assert_eq!((e.bearer_until_unix_ms, e.bearer_revoked_unix_ms), (77, 0), "the estate bearer is listed (ST-13)");
assert_eq!(e.boxes.iter().map(|b| (b.role.as_str(), b.silent, b.heard_unix_ms)).collect::<Vec<_>>(), [("main", false, 9_000), ("twin", true, 1_000)]);
assert_eq!(e.boxes[0].health.get("twin").map(String::as_str), Some("ok"));
assert_eq!(e.boxes[1].laid_ipv4, "10.0.0.3");
assert_eq!(e.cases.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["c2", "c1"], "newest first, as monetize answers");
let c = &e.cases[0];
assert_eq!((c.state.as_str(), c.finding_code.as_str(), c.remedy.as_str(), c.approved_by.as_str()), ("running", "twin_silent", "new_twin", "rickard"));
assert_eq!((c.opened_unix_ms, c.updated_unix_ms, c.approved_unix_ms, c.epoch, c.auto), (1_000, 2_000, 1_500, 3, false));
assert_eq!(c.buttons, ["new_twin", "review_twin"]);
assert_eq!(c.evidence.len(), 1);
assert_eq!(c.steps, [StepJson { seq: 7, words: "fence the old twin".into(), state: "met".into(), detail: "destroyed".into() }]);
assert_eq!(c.log, [LineJson { at_unix_ms: 1_000, words: "detected".into() }]);
assert_eq!(c.receipt_json, "{\"verb\":\"adopt_twin\"}");
assert_eq!(estate_json(&r.estates[0]), *e);
assert_eq!(case_json(&r.estates[0].cases[1]).state, "done");
}
/// ★ **Strong = an ADMIN passkey, user-verified, logged in within 5 min.**
/// Anything less is weak: an old login, no user verification, a viewer.
#[test]
fn only_a_fresh_user_verified_admin_session_is_strong() {
let now = 1_000_000;
assert!(strong(Role::Admin, true, now - 10, now));
assert!(strong(Role::Admin, true, now - STRONG_FRESH_SECS, now), "the bound is inclusive");
assert!(!strong(Role::Admin, true, now - STRONG_FRESH_SECS - 1, now), "a login older than 5 min must log in again");
assert!(!strong(Role::Admin, false, now - 10, now), "no user verification: a key tap is not a person");
assert!(!strong(Role::Operator, true, now - 10, now), "an operator reads; a repair is an admin's");
assert!(!strong(Role::Viewer, true, now - 10, now));
assert!(!strong(Role::Admin, true, now + 120, now), "an auth_time in the future is not fresh, it is wrong");
}
/// ★ **The break-glass bearer and an open console are never strong**: a
/// repair button needs a person, and the principal says who it was.
#[test]
fn the_break_glass_bearer_and_an_open_console_name_themselves_and_are_weak() {
assert_eq!(vouch(Some(&Caller::AdminToken), 1_000), ("admin-token".to_string(), false));
assert_eq!(vouch(None, 1_000), ("open-console".to_string(), false));
}
/// Only the four remedies pass; anything else is refused naming them.
#[test]
fn only_the_four_remedies_are_buttons() {
for r in REMEDIES {
assert_eq!(remedy_of(r).unwrap(), r);
}
assert_eq!(remedy_of(" new_twin ").unwrap(), "new_twin");
let e = remedy_of("rm_rf").unwrap_err();
assert_eq!(e.code, "invalidargument");
assert!(REMEDIES.iter().all(|r| e.message.contains(r)), "{}", e.message);
}
}