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

nordisk/Skidbladnir

src/promote/guard.rs

//! ⛔ The **prod guard** — the fence on `(namespace, host)`, with a two-key
//! override.
//!
//! LAW: never touch a guarded slot unless a human engages **both** keys in the
//! moment — a flag on the command line *and* an environment variable. One key is
//! a stray flag in a script; two are a decision.
//!
//! # Four holes this design closes
//!
//! **1. The config cannot delete the fence.** See
//! [`SlotRegistry::load_over`](super::SlotRegistry::load_over). The guard asks the
//! registry, and the registry cannot be talked out of a guarded slot.
//!
//! **2. Nothing is inferred from a name or a host shape.** The fence reads the
//! declared [`Slot::guarded`](super::schema::Slot::guarded) field from the ladder
//! schema. There is no rule anywhere that concludes "this is production because
//! it is called prod" or "this is safe because it is a subdomain".
//!
//! **3. There is no raw string that means "somewhere".** The guard takes a
//! [`TargetRef`], and [`SlotRegistry::resolve`](super::SlotRegistry::resolve)
//! matches it against every identity a slot has. `--slot prod` cannot clear a
//! fence keyed on `njord`, because they are the same slot.
//!
//! **4. An unknown target is fenced, not allowed.** [`UnknownPolicy::Fence`] is
//! the default. A tool that acts on whatever string it was handed is a tool that
//! acts on a typo, and the blast radius of a typo in this module is a namespace
//! full of somebody's data. Opting out is [`Guard::allow_unknown`] — explicit,
//! greppable, and documented as read-only territory.
//!
//! # The clearance is a value, not a promise
//!
//! [`Guard::assert`] returns a [`Cleared`] token, and every mutating operation in
//! [`release`](super::release) takes `&Cleared`. There is no way to construct one
//! outside this module. That turns "remember to call the guard first" — a
//! convention every op has to keep, and the one an op forgets — into a signature
//! the compiler enforces. The token also remembers *what* it cleared, so a
//! clearance for one slot cannot be spent on another.

use anyhow::Result;

use super::registry::{Resolution, SlotRegistry, TargetRef};

/// The default second key. A caller with its own history may name a different one
/// via [`Override::with_env_key`].
pub const DEFAULT_OVERRIDE_ENV: &str = "PROMOTE_ALLOW_PROD";

/// The value the second key must hold. Not "any non-empty value": an env var left
/// over as `PROMOTE_ALLOW_PROD=0` or `=false` in a shell profile must not arm it.
pub const OVERRIDE_VALUE: &str = "1";

/// The two-key override. Both keys, or no override.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Override {
    /// Key one: the explicit flag the human typed on this invocation.
    pub flag: bool,
    /// Key two: the name of the environment variable that must equal `1`.
    pub env_key: String,
}

impl Default for Override {
    fn default() -> Self {
        Override::none()
    }
}

impl Override {
    /// No override — the posture every op has until a human says otherwise.
    pub fn none() -> Self {
        Override {
            flag: false,
            env_key: DEFAULT_OVERRIDE_ENV.to_string(),
        }
    }

    /// Key one engaged (e.g. `--allow-prod` was passed). Still needs key two.
    pub fn flagged() -> Self {
        Override {
            flag: true,
            env_key: DEFAULT_OVERRIDE_ENV.to_string(),
        }
    }

    /// Use a different environment variable as the second key.
    pub fn with_env_key(mut self, key: &str) -> Self {
        self.env_key = key.to_string();
        self
    }
}

/// Where the second key is read from.
///
/// Injectable because a guard test that mutates the process environment is a test
/// that races every other test in the binary — the tool this generalises needed a
/// `Mutex` around its own test suite for exactly that reason. Here the tests hand
/// the guard a value and there is nothing global to serialise on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvSource {
    /// Read the real process environment.
    Process,
    /// Use this value (`None` = the variable is unset). Tests, and servers that
    /// carry their own environment.
    Value(Option<String>),
}

impl EnvSource {
    fn get(&self, key: &str) -> Option<String> {
        match self {
            EnvSource::Process => std::env::var(key).ok(),
            EnvSource::Value(v) => v.clone(),
        }
    }
}

/// What to do with an identifier no slot claims.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UnknownPolicy {
    /// Refuse. The default: an unrecognised target is far more often a typo than
    /// a slot somebody forgot to register, and the cost of the two mistakes is
    /// not symmetric.
    #[default]
    Fence,
    /// Allow. For genuinely read-only verbs (list, inspect, diff) where acting on
    /// the wrong namespace costs nothing.
    Allow,
}

/// Why a target was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
    /// It is a guarded slot, and the two keys were not both turned.
    Guarded,
    /// No slot in the registry claims this identifier.
    UnknownSlot,
}

/// A refusal that names the target, the reason, and the way forward.
///
/// No refusal is a dead end: [`Refusal::remedy`] always says what would make the
/// operation legal, even when the answer is "nothing you should do today".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
    /// The target, safe to print (a URL has lost its credentials).
    pub target: String,
    /// What kind of identifier it was.
    pub kind: &'static str,
    pub reason: Reason,
    /// The concrete next step.
    pub remedy: String,
    /// Anything the registry wants said alongside — notably a configuration file
    /// that tried to lower this very fence.
    pub notes: Vec<String>,
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.reason {
            Reason::Guarded => write!(
                f,
                "⛔ refusing to act on a GUARDED slot — {} '{}'.\n   {}",
                self.kind, self.target, self.remedy
            )?,
            Reason::UnknownSlot => write!(
                f,
                "⛔ refusing: no slot is registered for {} '{}'.\n   {}",
                self.kind, self.target, self.remedy
            )?,
        }
        for n in &self.notes {
            write!(f, "\n   {n}")?;
        }
        Ok(())
    }
}

impl std::error::Error for Refusal {}

/// **Proof that the guard cleared this target.** Constructible only by
/// [`Guard::assert`] / [`Guard::check`].
///
/// Every mutating operation takes one by reference, so "did anyone check?" is
/// answered by the type system instead of by reading each call site. It carries
/// what it cleared so it cannot be spent somewhere else.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cleared {
    slot: Option<String>,
    namespace: Option<String>,
    target: String,
    via_override: bool,
    guarded: bool,
}

impl Cleared {
    /// The slot name, when the target resolved to a registered slot.
    pub fn slot(&self) -> Option<&str> {
        self.slot.as_deref()
    }
    /// The namespace this clearance is for.
    pub fn namespace(&self) -> Option<&str> {
        self.namespace.as_deref()
    }
    /// The identifier the human actually gave.
    pub fn target(&self) -> &str {
        &self.target
    }
    /// True when the two keys were turned — the caller should say so in its log.
    pub fn via_override(&self) -> bool {
        self.via_override
    }
    /// True when the cleared slot is a guarded one.
    pub fn is_guarded(&self) -> bool {
        self.guarded
    }

    /// Does this clearance cover `namespace`?
    ///
    /// The slot's own namespace, or any **generation** of it
    /// (`test1-20260721-1432`) — raising a generation is still acting on the slot
    /// that was cleared. Anything else is a clearance being spent on a target it
    /// was not granted for, which is exactly the mistake worth catching.
    pub fn covers(&self, namespace: &str) -> bool {
        let ns = namespace.trim();
        if self
            .namespace
            .as_deref()
            .is_some_and(|n| n.eq_ignore_ascii_case(ns))
        {
            return true;
        }
        let of =
            |base: Option<&str>| base.is_some_and(|b| super::generation::Generation::is_of(b, ns));
        of(self.namespace.as_deref()) || of(self.slot.as_deref())
    }

    /// Refuse to spend this clearance on a namespace it was not granted for.
    pub fn require_covers(&self, namespace: &str) -> Result<()> {
        if self.covers(namespace) {
            return Ok(());
        }
        anyhow::bail!(
            "clearance mismatch: the guard cleared '{}' (slot {:?}), not namespace '{namespace}'.\n   \
             Clear the namespace you are about to act on — a clearance is not transferable.",
            self.target,
            self.slot
        )
    }

    /// The line an operator should see when a clearance came from the override.
    pub fn banner(&self) -> Option<String> {
        self.via_override.then(|| {
            format!(
                "⚠  PROD OVERRIDE ACTIVE — acting on the GUARDED slot '{}'. You asserted this.",
                self.slot.as_deref().unwrap_or(&self.target)
            )
        })
    }
}

/// The verdict for one target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
    Cleared(Cleared),
    Refused(Refusal),
}

impl Verdict {
    pub fn cleared(&self) -> Option<&Cleared> {
        match self {
            Verdict::Cleared(c) => Some(c),
            Verdict::Refused(_) => None,
        }
    }
    pub fn is_cleared(&self) -> bool {
        matches!(self, Verdict::Cleared(_))
    }
}

/// The fence, bound to a registry.
#[derive(Debug, Clone)]
pub struct Guard<'r> {
    registry: &'r SlotRegistry,
    over: Override,
    unknown: UnknownPolicy,
    env: EnvSource,
}

impl<'r> Guard<'r> {
    /// A guard over `registry`, with no override and unknown targets fenced.
    pub fn new(registry: &'r SlotRegistry) -> Self {
        Guard {
            registry,
            over: Override::none(),
            unknown: UnknownPolicy::Fence,
            env: EnvSource::Process,
        }
    }

    /// Supply key one (and, optionally, a non-default name for key two).
    pub fn with_override(mut self, over: Override) -> Self {
        self.over = over;
        self
    }

    /// Read key two from somewhere other than the process environment.
    pub fn with_env(mut self, env: EnvSource) -> Self {
        self.env = env;
        self
    }

    /// Let unregistered targets through — **read-only verbs only**.
    pub fn allow_unknown(mut self) -> Self {
        self.unknown = UnknownPolicy::Allow;
        self
    }

    /// The registry behind this guard.
    pub fn registry(&self) -> &'r SlotRegistry {
        self.registry
    }

    /// Are both keys turned?
    pub fn override_engaged(&self) -> bool {
        self.over.flag && self.env.get(&self.over.env_key).as_deref() == Some(OVERRIDE_VALUE)
    }

    /// The verdict, without raising an error.
    pub fn check(&self, target: &TargetRef<'_>) -> Verdict {
        let shown = target.shown();
        let notes: Vec<String> = self
            .registry
            .demotions()
            .iter()
            .map(super::registry::Demotion::line)
            .collect();

        match self.registry.resolve(target) {
            Resolution::Slot(slot) if slot.is_guarded() => {
                if self.override_engaged() {
                    Verdict::Cleared(Cleared {
                        slot: Some(slot.name.clone()),
                        namespace: Some(slot.namespace.clone()),
                        target: shown,
                        via_override: true,
                        guarded: true,
                    })
                } else {
                    Verdict::Refused(Refusal {
                        target: shown,
                        kind: target.kind(),
                        reason: Reason::Guarded,
                        remedy: format!(
                            "'{}' is declared `guarded` (role {}, namespace '{}', hosts {:?}).\n   \
                             To act on it anyway — a human decision, in the moment — turn BOTH keys: \
                             pass the allow-prod flag AND set {}={}.\n   \
                             {}",
                            slot.name,
                            slot.role.as_str(),
                            slot.namespace,
                            slot.hosts,
                            self.over.env_key,
                            OVERRIDE_VALUE,
                            if self.over.flag {
                                format!("(the flag is set; {} is not)", self.over.env_key)
                            } else if self.env.get(&self.over.env_key).as_deref()
                                == Some(OVERRIDE_VALUE)
                            {
                                format!("({} is set; the flag is not)", self.over.env_key)
                            } else {
                                "(neither key is turned)".to_string()
                            },
                        ),
                        notes,
                    })
                }
            }
            Resolution::Slot(slot) => Verdict::Cleared(Cleared {
                slot: Some(slot.name.clone()),
                namespace: Some(slot.namespace.clone()),
                target: shown,
                via_override: false,
                guarded: false,
            }),
            Resolution::Unknown => match self.unknown {
                UnknownPolicy::Allow => Verdict::Cleared(Cleared {
                    slot: None,
                    namespace: Some(target.needle()),
                    target: shown,
                    via_override: false,
                    guarded: false,
                }),
                UnknownPolicy::Fence => Verdict::Refused(Refusal {
                    target: shown,
                    kind: target.kind(),
                    reason: Reason::UnknownSlot,
                    remedy: format!(
                        "Registered slots: {}.\n   \
                         Either you meant one of those, or this slot belongs in the ladder \
                         file — add it there (with its `role` and `guarded`) and it becomes \
                         usable. A read-only verb may call Guard::allow_unknown().",
                        self.registry.known_identifiers().join(", ")
                    ),
                    notes,
                }),
            },
        }
    }

    /// The verdict as a `Result` — the form every operation calls.
    pub fn assert(&self, target: &TargetRef<'_>) -> Result<Cleared> {
        match self.check(target) {
            Verdict::Cleared(c) => Ok(c),
            Verdict::Refused(r) => Err(anyhow::Error::new(r)),
        }
    }

    /// Clear **every** target of an operation at once, returning the clearance for
    /// the first one.
    ///
    /// A cross-slot operation has more than one target — a namespace *and* a
    /// database URL *and* an object-store endpoint — and clearing only the first
    /// is how a lab-guarded restore ends up writing to a production database. All
    /// or nothing.
    pub fn assert_all(&self, targets: &[TargetRef<'_>]) -> Result<Cleared> {
        let mut first: Option<Cleared> = None;
        for t in targets {
            let c = self.assert(t)?;
            if first.is_none() {
                first = Some(c);
            }
        }
        first.ok_or_else(|| anyhow::anyhow!("guard: no target given — refusing to clear nothing"))
    }

    /// Clear a **code promotion** `from` → `to`: both endpoints pass the fence,
    /// and the hop is legal on the ladder.
    ///
    /// Returns the rungs that were skipped (empty for a single-edge hop). A skip
    /// is only possible with `allow_skip`, and even then the skipped rungs come
    /// back so the caller reports them — the override never makes the fact go
    /// away.
    pub fn assert_promotion(
        &self,
        from: &str,
        to: &str,
        allow_skip: bool,
    ) -> Result<(Cleared, Vec<String>)> {
        let skipped = self
            .registry
            .check_promotion(from, to)
            .require(from, to, allow_skip)?;
        self.assert(&TargetRef::Slot(from))?;
        let cleared = self.assert(&TargetRef::Slot(to))?;
        Ok((cleared, skipped))
    }

    /// Clear a **data copy** `from` → `to`. Data flows *down* the ladder; the
    /// reverse replaces one slot's rows with another's and is refused outright.
    pub fn assert_data_flow(&self, from: &str, to: &str) -> Result<Cleared> {
        self.registry
            .check_data_flow(from, to)
            .require(from, to, false)?;
        self.assert(&TargetRef::Slot(from))?;
        self.assert(&TargetRef::Slot(to))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::promote::registry::SlotRegistry;
    #[cfg(feature = "promote")]
    use crate::promote::registry::DemotionPolicy;
    use crate::promote::schema::{Slot, SlotRole};
    #[cfg(feature = "promote")]
    use crate::promote::schema::Ladder;

    fn registry() -> SlotRegistry {
        SlotRegistry::from_slots(vec![
            Slot::new("prod", "njord", SlotRole::Production, true)
                .host("g11.se")
                .host("www.g11.se"),
            Slot::new("preprod", "njord-lab", SlotRole::PreProduction, false)
                .host("lab.g11.se")
                .promotes_to("prod"),
            Slot::new("test1", "test1", SlotRole::Test, false)
                .host("test1.g11.se")
                .promotes_to("preprod"),
            Slot::new("test2", "test2", SlotRole::Test, false)
                .host("test2.g11.se")
                .promotes_to("test1"),
        ])
        .unwrap()
    }

    fn armed(r: &SlotRegistry) -> Guard<'_> {
        Guard::new(r)
            .with_override(Override::flagged())
            .with_env(EnvSource::Value(Some("1".into())))
    }

    #[test]
    fn unguarded_slots_are_free() {
        let r = registry();
        let g = Guard::new(&r);
        for t in [
            TargetRef::Slot("preprod"),
            TargetRef::Namespace("test1"),
            TargetRef::Host("lab.g11.se"),
        ] {
            assert!(g.assert(&t).is_ok(), "{t:?} must be allowed");
        }
    }

    #[test]
    fn a_guarded_slot_is_refused_by_default() {
        let r = registry();
        let g = Guard::new(&r).with_env(EnvSource::Value(None));
        assert!(g.assert(&TargetRef::Namespace("njord")).is_err());
        assert!(g.assert(&TargetRef::Host("g11.se")).is_err());
        assert!(
            g.assert(&TargetRef::Url("postgres://u:p@www.g11.se/app"))
                .is_err()
        );
    }

    #[test]
    fn one_key_is_never_enough() {
        let r = registry();
        let g = Guard::new(&r)
            .with_override(Override::flagged())
            .with_env(EnvSource::Value(None));
        assert!(
            g.assert(&TargetRef::Slot("prod")).is_err(),
            "flag alone must not suffice"
        );
        let g = Guard::new(&r).with_env(EnvSource::Value(Some("1".into())));
        assert!(
            g.assert(&TargetRef::Slot("prod")).is_err(),
            "env alone must not suffice"
        );
        for v in ["0", "true", "yes", ""] {
            let g = Guard::new(&r)
                .with_override(Override::flagged())
                .with_env(EnvSource::Value(Some(v.into())));
            assert!(
                g.assert(&TargetRef::Slot("prod")).is_err(),
                "env='{v}' must not arm the override"
            );
        }
    }

    #[test]
    fn both_keys_clear_a_guarded_slot_and_the_clearance_says_so() {
        let r = registry();
        let c = armed(&r)
            .assert(&TargetRef::Slot("prod"))
            .expect("two keys must clear");
        assert!(c.via_override() && c.is_guarded());
        assert!(c.banner().unwrap().contains("PROD OVERRIDE ACTIVE"));
    }

    /// The `--slot prod` bypass, as a guard test rather than a registry test.
    #[test]
    fn the_guarded_slots_name_does_not_clear_the_fence() {
        let r = registry();
        let g = Guard::new(&r).with_env(EnvSource::Value(None));
        let err = g
            .assert(&TargetRef::Slot("prod"))
            .expect_err("`--slot prod` must be fenced");
        assert!(format!("{err}").contains("GUARDED"), "{err}");
    }

    /// A guard whose registry was handed a lying config must still fence — and
    /// must say that the config tried.
    #[test]
    #[cfg(feature = "promote")]
    fn a_config_that_lowered_the_fence_cannot_clear_it_and_is_reported() {
        let cfg = Ladder::from_yaml(
            "lying.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: prod, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n",
        )
        .unwrap();
        let r =
            SlotRegistry::load_over(&registry(), cfg, DemotionPolicy::ReportAndKeepFenced).unwrap();
        let g = Guard::new(&r).with_env(EnvSource::Value(None));
        let err = g
            .assert(&TargetRef::Namespace("njord"))
            .expect_err("still fenced");
        let msg = format!("{err}");
        assert!(msg.contains("GUARDED"), "{msg}");
        assert!(
            msg.contains("IGNORED"),
            "the refusal must report the config's attempt: {msg}"
        );
    }

    #[test]
    fn an_unknown_target_is_fenced_by_default_and_the_refusal_names_the_way_out() {
        let r = registry();
        let g = Guard::new(&r);
        let err = g
            .assert(&TargetRef::Namespace("typo-slot"))
            .expect_err("unknown must be fenced");
        let msg = format!("{err}");
        assert!(msg.contains("no slot is registered"), "{msg}");
        assert!(
            msg.contains("njord-lab"),
            "the refusal must list what IS registered: {msg}"
        );
        assert!(msg.contains("ladder file"), "…and how to add it: {msg}");
        assert!(
            Guard::new(&r)
                .allow_unknown()
                .assert(&TargetRef::Namespace("typo-slot"))
                .is_ok()
        );
    }

    #[test]
    fn a_clearance_covers_its_slot_and_its_generations_but_nothing_else() {
        let r = registry();
        let c = Guard::new(&r).assert(&TargetRef::Slot("test1")).unwrap();
        assert!(c.covers("test1"));
        assert!(
            c.covers("test1-20260721-1432"),
            "a generation of the cleared slot"
        );
        assert!(
            !c.covers("njord"),
            "a clearance for test1 must not cover a guarded slot"
        );
        assert!(!c.covers("test2"));
        assert!(c.require_covers("njord").is_err());
    }

    #[test]
    fn assert_all_refuses_when_any_target_is_guarded() {
        let r = registry();
        let g = Guard::new(&r).with_env(EnvSource::Value(None));
        let err = g
            .assert_all(&[
                TargetRef::Namespace("njord-lab"),
                TargetRef::Url("postgres://u:secret@g11.se:5432/app"),
            ])
            .expect_err("the second target is guarded");
        let msg = format!("{err}");
        assert!(msg.contains("GUARDED"), "{msg}");
        assert!(
            !msg.contains("secret"),
            "a refusal must never echo a credential: {msg}"
        );
    }

    #[test]
    fn assert_all_refuses_an_empty_target_list() {
        let r = registry();
        assert!(
            Guard::new(&r).assert_all(&[]).is_err(),
            "clearing nothing is not a clearance"
        );
    }

    // ── the ladder is enforced, not documented ───────────────────────────────

    #[test]
    fn a_legal_single_rung_promotion_clears() {
        let r = registry();
        let (c, skipped) = Guard::new(&r)
            .assert_promotion("test2", "test1", false)
            .unwrap();
        assert_eq!(c.slot(), Some("test1"));
        assert!(skipped.is_empty());
    }

    #[test]
    fn skipping_rungs_is_refused_and_the_override_still_reports_them() {
        let r = registry();
        let g = armed(&r);
        assert!(
            g.assert_promotion("test2", "prod", false).is_err(),
            "must not skip silently"
        );
        let (_, skipped) = g.assert_promotion("test2", "prod", true).unwrap();
        assert_eq!(skipped, vec!["test1".to_string(), "preprod".to_string()]);
    }

    /// The ladder check runs BEFORE the fence, so an illegal hop into production
    /// is refused as an illegal hop even when both keys are turned.
    #[test]
    fn promoting_to_a_guarded_slot_still_needs_both_keys() {
        let r = registry();
        let g = Guard::new(&r).with_env(EnvSource::Value(None));
        assert!(g.assert_promotion("preprod", "prod", false).is_err());
        assert!(armed(&r).assert_promotion("preprod", "prod", false).is_ok());
    }

    #[test]
    fn data_never_flows_upward() {
        let r = registry();
        let g = armed(&r);
        assert!(
            g.assert_data_flow("prod", "test1").is_ok(),
            "prod → test1 is the normal copy"
        );
        let err = g
            .assert_data_flow("test1", "prod")
            .expect_err("upward copy must be refused");
        assert!(format!("{err:#}").contains("wrong way"), "{err:#}");
    }
}