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(®istry(), 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:#}");
}
}