Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
    }
}