From 37622db430583312b744bb0b61cffb231e6c005a Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 17:46:36 +0000 Subject: [PATCH 1/3] Deployment brake: a steward-owned second stop per target, named by the binding BrakeBinding.steward = { user, store }. read_stops reads the operator store as before and the steward store with the steward user's uid as the expected owner, so the file owner proves who wrote it. Deploys admit iff every stop admits; the wait note names each holding stop. Binding is stored as text in the plan, so no persisted record changes. deployment-brake-status gains --owner-user for the operator to read a steward's store. Co-Authored-By: Claude Sonnet 5.5 --- src/brake.rs | 128 +++++++++++++++++++++++++++++++++++++++++- src/control_plane.rs | 117 +++++++++++++++++++++++++++++++------- src/deployment.rs | 73 +++++++++++++++++++++++- src/provisioning.rs | 16 +++--- src/steward_orders.rs | 20 ++++--- 5 files changed, 318 insertions(+), 36 deletions(-) diff --git a/src/brake.rs b/src/brake.rs index d44cd96..003748b 100644 --- a/src/brake.rs +++ b/src/brake.rs @@ -11,6 +11,8 @@ use std::path::Path; +use crate::deployment::BrakeBinding; + use anyhow::{Context, Result, anyhow, bail, ensure}; use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; use serde::{Deserialize, Serialize}; @@ -128,6 +130,31 @@ impl Stop { } } +/// Every stop a binding names, each with the words that identify its store in +/// a wait note: the operator's, then the steward's when the binding names one. +/// Deployments admit iff every one admits. The operator's store is read as +/// owned by this process's uid; the steward's as owned by the steward user's +/// uid, so the file system proves who wrote it. A steward that does not +/// resolve to a non-root uid is an unreadable stop, never an absent one. +pub(crate) fn read_stops(brakes: &BrakeBinding, runtime_id: &str) -> Vec<(String, Stop)> { + let mut stops = vec![( + "deployment brake".to_string(), + read_stop(&brakes.deployment_store, runtime_id, process_uid()), + )]; + if let Some(steward) = &brakes.steward { + let label = format!("steward {} deployment brake", steward.user); + let stop = match crate::steward_orders::user_uid(&steward.user) { + Ok(uid) => read_stop(&steward.store, runtime_id, uid), + Err(why) => Stop::Unreadable(format!( + "{why:#}; the binding's steward user {} must be a non-root login", + steward.user + )), + }; + stops.push((label, stop)); + } + stops +} + #[cfg(unix)] pub(crate) fn process_uid() -> u32 { // SAFETY: geteuid has no failure mode and no arguments. @@ -140,7 +167,7 @@ pub(crate) fn process_uid() -> u32 { } #[cfg(unix)] -fn login_name(uid: u32) -> Result { +pub(crate) fn login_name(uid: u32) -> Result { // SAFETY: getpwuid returns null or a pointer into libc's static buffer, // copied before any other call; the verbs are single threaded. unsafe { @@ -153,7 +180,7 @@ fn login_name(uid: u32) -> Result { } #[cfg(not(unix))] -fn login_name(_: u32) -> Result { +pub(crate) fn login_name(_: u32) -> Result { std::env::var("USERNAME").context("USERNAME is unset") } @@ -383,6 +410,103 @@ mod tests { Ok(()) } + /// A binding whose steward is the user running the test, so the steward's + /// store can really be owned by the steward. None when run as root, which + /// no steward may be. + fn binding_with_steward(dir: &Path, user: &str) -> BrakeBinding { + BrakeBinding { + deployment_store: dir.join("operator.cc"), + lifecycle_store: dir.join("lifecycle.cc"), + steward: Some(crate::deployment::StewardBrakeBinding { + user: user.into(), + store: dir.join("steward.cc"), + }), + } + } + + fn admits_all(stops: &[(String, Stop)]) -> bool { + stops.iter().all(|(_, stop)| stop.admits()) + } + + #[test] + fn the_steward_store_is_a_second_stop_and_the_operator_store_is_one_it_cannot_clear() -> Result<()> { + let me = process_uid(); + if me == 0 { + return Ok(()); + } + let dir = tempfile::tempdir()?; + let binding = binding_with_steward(dir.path(), &login_name(me)?); + let (operator, steward) = (&binding.deployment_store, &binding.steward.as_ref().unwrap().store); + // Both absent: admits. + assert!(admits_all(&read_stops(&binding, "runtime"))); + // Steward engaged alone: halts, and the stop names the steward's store. + replace_stop(steward, "runtime", StopState::Engaged, "steward freeze", 10)?; + let stops = read_stops(&binding, "runtime"); + assert!(!admits_all(&stops)); + assert!(matches!(&stops[1], (label, Stop::Engaged(held)) + if label.starts_with("steward ") && held.reason == "steward freeze" && held.by == login_name(me)?)); + // Both engaged, then the steward clears its own: the operator's holds. + replace_stop(operator, "runtime", StopState::Engaged, "operator hold", 11)?; + replace_stop(steward, "runtime", StopState::Clear, "steward thaw", 12)?; + let stops = read_stops(&binding, "runtime"); + assert!(matches!(&stops[0].1, Stop::Engaged(_)) && matches!(&stops[1].1, Stop::Clear(_))); + assert!(!admits_all(&stops)); + // The other order: operator clear, steward engaged still halts. + replace_stop(operator, "runtime", StopState::Clear, "operator thaw", 13)?; + replace_stop(steward, "runtime", StopState::Engaged, "steward again", 14)?; + assert!(!admits_all(&read_stops(&binding, "runtime"))); + // Both clear: admits. + replace_stop(steward, "runtime", StopState::Clear, "steward thaw", 15)?; + assert!(admits_all(&read_stops(&binding, "runtime"))); + Ok(()) + } + + #[test] + fn steward_store_owned_by_another_uid_stops() -> Result<()> { + let me = process_uid(); + let dir = tempfile::tempdir()?; + // Steward is "nobody" (a real non-root user that is not the writer); + // the file is written by this process, so even a Clear record fails. + if me == 65534 || user_missing("nobody") { + return Ok(()); + } + let binding = binding_with_steward(dir.path(), "nobody"); + let steward = &binding.steward.as_ref().unwrap().store; + replace_stop(steward, "runtime", StopState::Clear, "forged clear", 10)?; + let stops = read_stops(&binding, "runtime"); + assert!(matches!(&stops[1].1, Stop::Unreadable(why) if why.contains("is owned by uid") + && why.contains("idunn-provision deployment-brake-clear"))); + assert!(!admits_all(&stops)); + Ok(()) + } + + fn user_missing(name: &str) -> bool { + crate::steward_orders::user_uid(name).is_err() + } + + #[test] + fn an_unresolvable_or_root_steward_user_is_an_unreadable_stop_not_an_absent_one() -> Result<()> { + let dir = tempfile::tempdir()?; + for user in ["no-such-login-zz", "root"] { + let binding = binding_with_steward(dir.path(), user); + let stops = read_stops(&binding, "runtime"); + assert!(matches!(&stops[1].1, Stop::Unreadable(_)), "{user}"); + assert!(!admits_all(&stops)); + } + Ok(()) + } + + #[test] + fn a_binding_without_a_steward_reads_only_the_operator_store() -> Result<()> { + let dir = tempfile::tempdir()?; + let mut binding = binding_with_steward(dir.path(), "nobody"); + binding.steward = None; + let stops = read_stops(&binding, "runtime"); + assert_eq!(stops.len(), 1); + assert!(admits_all(&stops)); + Ok(()) + } + fn store_entry(path: &Path) -> Result { let mut entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?; entries.pop().context("no entry") diff --git a/src/control_plane.rs b/src/control_plane.rs index 5b06543..a210943 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::brake::{Stop, process_uid, read_stop}; +use crate::brake::{Stop, read_stops}; #[cfg(test)] use crate::brake::StopState; use crate::deployment::{ @@ -9345,27 +9345,34 @@ impl Engine { /// Read live at each point that is still reversible: Sealing, the static /// flip intent and the entry into Fencing. fn stop_note(&self, current: &Stored) -> Result> { - Ok(match self.deployment_stop(current)? { - Stop::Absent | Stop::Clear(_) => None, - Stop::Engaged(held) => Some(format!( - "deployment brake engaged by {}: {}", - held.by, held.reason - )), - Stop::Unreadable(why) => Some(format!( - "deployment brake is unreadable and fails closed: {why}" - )), - }) + let notes: Vec = self + .deployment_stops(current)? + .into_iter() + .filter_map(|(label, stop)| match stop { + Stop::Absent | Stop::Clear(_) => None, + Stop::Engaged(held) => { + Some(format!("{label} engaged by {}: {}", held.by, held.reason)) + } + Stop::Unreadable(why) => { + Some(format!("{label} is unreadable and fails closed: {why}")) + } + }) + .collect(); + Ok((!notes.is_empty()).then(|| notes.join("; "))) } - /// What the binding's deployment stop says about this transaction's - /// runtime. Read live; never cached, never consumed. - fn deployment_stop(&self, current: &Stored) -> Result { + /// What the binding's stops say about this transaction's runtime: the + /// operator's store and, when the binding names one, the steward's. Read + /// live; never cached, never consumed. + fn deployment_stops( + &self, + current: &Stored, + ) -> Result> { let plan = required(¤t.value.plan, "transaction plan")?; let binding = plan.parsed_inputs()?.1; - Ok(read_stop( - &binding.brakes.deployment_store, + Ok(read_stops( + &binding.brakes, &binding.runtime_identity.runtime_id, - process_uid(), )) } @@ -9377,7 +9384,8 @@ impl Engine { /// hold is a reason to leave a serving or retrying body alone. fn deploy_can_move(&self, deploy: &Stored) -> bool { deploy.value.deployment_admitted() - || self.deployment_stop(deploy).is_ok_and(|stop| stop.admits()) + || self.deployment_stops(deploy) + .is_ok_and(|stops| stops.iter().all(|(_, stop)| stop.admits())) } /// The deployment a continuity gives way to: one on its target that awaits @@ -26298,6 +26306,13 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide /// that seals it, and a stand-in site beside it. #[cfg(unix)] fn static_world() -> Result { + static_world_with_steward(None) + } + + /// The static world, its binding naming `steward` (a login) as a second + /// stop whose store is `authority/steward-brake.cc`. + #[cfg(unix)] + fn static_world_with_steward(steward: Option<&str>) -> Result { use crate::deployment::tests::{STATIC_BINDING, STATIC_RECIPE}; let tools = TempDir::new()?; let tree = tools.path().join("tree"); @@ -26311,7 +26326,17 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide STATIC_RECIPE, |root| { site = root.join("site"); - STATIC_BINDING.replace("@SITE@", &site.display().to_string()) + let text = STATIC_BINDING.replace("@SITE@", &site.display().to_string()); + match steward { + None => text, + Some(user) => text.replace( + "[rollout]", + &format!( + "[brakes.steward]\nuser = \"{user}\"\nstore = \"{}/authority/steward-brake.cc\"\n\n[rollout]", + site.display() + ), + ), + } }, Arc::new(InstallingWorkload), )?; @@ -26451,6 +26476,60 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide Ok(()) } + #[cfg(unix)] + #[test] + fn a_steward_stop_holds_a_deploy_beside_the_operators_and_names_who() -> Result<()> { + let me = crate::brake::process_uid(); + if me == 0 { + return Ok(()); + } + let user = crate::brake::login_name(me)?; + let w = static_world_with_steward(Some(&user))?; + let steward_store = w.site.join("authority/steward-brake.cc"); + let op_store = w.site.join("authority/deployment-brake.cc"); + let stop = |store: &Path, state, reason: &str| -> Result<()> { + crate::brake::replace_stop(store, "site-yggdrasil", state, reason, now_millis()?)?; + Ok(()) + }; + stop(&steward_store, StopState::Engaged, "steward hold")?; + let id = w.transaction_of("up-service")?; + w.tick_until(&id, "the sealed release", |t| t.installed_release.is_some())?; + let settle = |w: &StaticWorld| -> Result { + for _ in 0..2 { + w.world.engine.resume_backoff.lock().unwrap().clear(); + let _ = w.world.engine.run_scheduler_tick(); + } + latest_id(&w.world, &id) + }; + let held = settle(&w)?; + assert_eq!(held.phase, DeploymentPhase::Sealing); + assert_eq!( + held.last_error.as_deref(), + Some(format!("steward {user} deployment brake engaged by {user}: steward hold").as_str()) + ); + // The operator engages too; the steward clears its own. The operator's holds. + stop(&op_store, StopState::Engaged, "operator hold")?; + stop(&steward_store, StopState::Clear, "steward thaw")?; + let held = settle(&w)?; + assert!(!held.deployment_admitted()); + assert_eq!( + held.last_error.as_deref(), + Some(format!("deployment brake engaged by {user}: operator hold").as_str()) + ); + // Both engaged: both named. + stop(&steward_store, StopState::Engaged, "steward again")?; + let held = settle(&w)?; + assert!(held.last_error.as_deref().is_some_and(|note| note.contains("operator hold") + && note.contains("steward again"))); + // The operator clears, the steward still holds; then both clear. + stop(&op_store, StopState::Clear, "operator thaw")?; + assert!(!settle(&w)?.deployment_admitted()); + stop(&steward_store, StopState::Clear, "steward thaw")?; + w.tick_until(&id, "the end", |t| t.is_terminal())?; + assert!(latest_id(&w.world, &id)?.deployment_admitted()); + Ok(()) + } + #[cfg(unix)] #[test] fn one_clear_admits_many_deploys_and_an_engaged_stop_names_who() -> Result<()> { diff --git a/src/deployment.rs b/src/deployment.rs index 21cf234..a21c6cf 100644 --- a/src/deployment.rs +++ b/src/deployment.rs @@ -753,6 +753,20 @@ impl ProcessWriteLeaseBinding { pub struct BrakeBinding { pub deployment_store: PathBuf, pub lifecycle_store: PathBuf, + /// A second deployment stop, written by the target's steward in a + /// directory only the steward can write. Absent means the operator's store + /// is the only stop. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steward: Option, +} + +/// The steward's own stop: the file's owner uid proves who wrote it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StewardBrakeBinding { + /// Login name; must resolve to a non-root uid when the store is read. + pub user: String, + pub store: PathBuf, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1430,6 +1444,18 @@ impl OperatorBinding { self.brakes.deployment_store != self.brakes.lifecycle_store, "deployment and lifecycle brakes are identical" ); + if let Some(steward) = &self.brakes.steward { + require_absolute_path(&steward.store, "steward deployment brake")?; + ensure!( + crate::steward_orders::login_grammar_ok(&steward.user) && steward.user != "root", + "steward brake user is invalid or root" + ); + ensure!( + steward.store != self.brakes.deployment_store + && steward.store != self.brakes.lifecycle_store, + "steward brake store is identical to another brake store" + ); + } self.workload.validate_launch_environment()?; let workload = match &self.workload { WorkloadBinding::HostActuator(workload) => { @@ -1462,7 +1488,10 @@ impl OperatorBinding { &self.brakes.deployment_store, &self.brakes.lifecycle_store, &self.runtime_identity.trust_anchor_store, - ] { + ] + .into_iter() + .chain(self.brakes.steward.as_ref().map(|steward| &steward.store)) + { ensure!( !paths_overlap(authority, &workload.release_root) && !paths_overlap(authority, &workload.current_link), @@ -1531,6 +1560,7 @@ impl OperatorBinding { self.brakes.deployment_store.clone(), self.brakes.lifecycle_store.clone(), ]; + authority_paths.extend(self.brakes.steward.as_ref().map(|steward| steward.store.clone())); if let Some(write_lease) = &self.process_write_lease { authority_paths.push(write_lease.record_path.clone()); authority_paths.push(write_lease.lock_path()); @@ -3065,6 +3095,47 @@ nodes = ["yggdrasil"] text } + const STEWARD: &str = "\n[brakes.steward]\nuser = \"mind-streampixels\"\nstore = \"/var/lib/gamecult/idunn-steward/streampixels/deployment-brake.cc\"\n"; + + #[test] + fn binding_without_steward_parses_and_admits_on_operator_store_only() { + for text in [BINDING.to_string(), static_binding(&[])] { + assert_eq!(OperatorBinding::parse(&text).unwrap().brakes.steward, None); + } + let steward = OperatorBinding::parse(&BINDING.replace("\n[rollout]", &format!("{STEWARD}\n[rollout]"))) + .unwrap() + .brakes + .steward + .unwrap(); + assert_eq!(steward.user, "mind-streampixels"); + } + + #[test] + fn a_steward_brake_must_be_a_distinct_absolute_non_root_login_and_clear_of_the_workload() { + let with = |steward: &str| BINDING.replace("\n[rollout]", &format!("{steward}\n[rollout]")); + let store = "/var/lib/gamecult/idunn-steward/x/deployment-brake.cc"; + for bad in [ + format!("\n[brakes.steward]\nuser = \"root\"\nstore = \"{store}\"\n"), + format!("\n[brakes.steward]\nuser = \"Bad User\"\nstore = \"{store}\"\n"), + "\n[brakes.steward]\nuser = \"mind\"\nstore = \"relative.cc\"\n".into(), + "\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/idunn-authority/test-service-deployment-brake.cc\"\n".into(), + "\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/idunn-authority/test-service-lifecycle-brake.cc\"\n".into(), + format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{store}\"\nextra = 1\n"), + ] { + assert!(OperatorBinding::parse(&with(&bad)).is_err(), "{bad}"); + } + // steward_store_overlap_with_workload_path_refused + let overlapping = BINDING + .replace("\n[rollout]", &format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{store}\"\n\n[rollout]")) + .replace( + "cpu_quota_percent = 200\n\n[workload.argument_bindings]", + "cpu_quota_percent = 200\nread_write_paths = [\"/var/lib/gamecult/idunn-steward/x\"]\n\n[workload.argument_bindings]", + ); + assert!(OperatorBinding::parse(&overlapping).is_err()); + let static_overlap = static_binding(&[("[rollout]", "[brakes.steward]\nuser = \"mind\"\nstore = \"/srv/site/releases/x/brake.cc\"\n\n[rollout]")]); + assert!(OperatorBinding::parse(&static_overlap).is_err()); + } + #[test] fn static_recipe_and_binding_admit() { let recipe = TargetDeclaration::parse(STATIC_RECIPE).unwrap(); diff --git a/src/provisioning.rs b/src/provisioning.rs index 56e104f..5af3903 100644 --- a/src/provisioning.rs +++ b/src/provisioning.rs @@ -176,12 +176,14 @@ fn deployment_brake_write(options: &BTreeMap, state: StopState) /// Print the stop and exit nonzero unless deployments admit, so a script can /// gate on it as it could on the retired exact-actuation validation. fn deployment_brake_status(options: &BTreeMap) -> Result<()> { - require_only(options, &["store", "runtime-id"])?; - let stop = read_stop( - &path(options, "store")?, - required(options, "runtime-id")?, - process_uid(), - ); + allow_only(options, &["store", "runtime-id", "owner-user"])?; + // A steward's store is owned by the steward: the operator names that user + // to read it, and the owner check is the same one Idunn makes. + let owner = match options.get("owner-user") { + Some(user) => crate::steward_orders::user_uid(user)?, + None => process_uid(), + }; + let stop = read_stop(&path(options, "store")?, required(options, "runtime-id")?, owner); match &stop { Stop::Absent => println!("absent: deployments admit"), Stop::Unreadable(why) => println!("unreadable: deployments wait ({why})"), @@ -958,7 +960,7 @@ fn normalized(path: &Path) -> Result { } fn usage() -> &'static str { - "Usage: idunn-provision enroll-provider-health-identity --private-store \n idunn-provision provider-health-public-key --private-store \n idunn-provision export-provider-health-public-anchor --private-store --public-anchor \n idunn-provision enroll-idunn-identity --private-store \n idunn-provision export-idunn-public-anchor --private-store --public-anchor \n idunn-provision enroll-odin-topology-identity --private-store \n idunn-provision export-odin-topology-public-anchor --private-store --public-anchor \n idunn-provision deployment-brake-engage --store --runtime-id --reason \n idunn-provision deployment-brake-clear --store --runtime-id --reason \n idunn-provision deployment-brake-status --store --runtime-id \n idunn-provision lifecycle-brake-engage --store --runtime-id --target --reason --updated-at-unix-millis \n idunn-provision lifecycle-brake-release --store --runtime-id --target --reason --updated-at-unix-millis [--released-until-unix-millis ]\n idunn-provision lifecycle-brake-status --store --runtime-id --target --now-unix-millis \n idunn-provision create-daemon-health-trust-binding --output --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex --bound-at-unix-millis --release-binding-required \n idunn-provision add-daemon-health-trust-binding --output --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex --bound-at-unix-millis --release-binding-required \n idunn-provision require-daemon-health-release-binding --store --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex \n idunn-provision rotate-daemon-health-trust-signer --output --binding-id --signer-public-key-hex --bound-at-unix-millis \n idunn-provision validate-daemon-health-trust-binding --input \n idunn-provision create-provider-projection-trust-anchor --output --trust-anchor-id --runtime-id --idunn-public-anchor --bound-at-unix-millis --expires-at-unix-millis \n idunn-provision validate-provider-projection-trust-anchor --input --idunn-public-anchor " + "Usage: idunn-provision enroll-provider-health-identity --private-store \n idunn-provision provider-health-public-key --private-store \n idunn-provision export-provider-health-public-anchor --private-store --public-anchor \n idunn-provision enroll-idunn-identity --private-store \n idunn-provision export-idunn-public-anchor --private-store --public-anchor \n idunn-provision enroll-odin-topology-identity --private-store \n idunn-provision export-odin-topology-public-anchor --private-store --public-anchor \n idunn-provision deployment-brake-engage --store --runtime-id --reason \n idunn-provision deployment-brake-clear --store --runtime-id --reason \n idunn-provision deployment-brake-status --store --runtime-id [--owner-user ]\n idunn-provision lifecycle-brake-engage --store --runtime-id --target --reason --updated-at-unix-millis \n idunn-provision lifecycle-brake-release --store --runtime-id --target --reason --updated-at-unix-millis [--released-until-unix-millis ]\n idunn-provision lifecycle-brake-status --store --runtime-id --target --now-unix-millis \n idunn-provision create-daemon-health-trust-binding --output --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex --bound-at-unix-millis --release-binding-required \n idunn-provision add-daemon-health-trust-binding --output --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex --bound-at-unix-millis --release-binding-required \n idunn-provision require-daemon-health-release-binding --store --binding-id --daemon --health-contract --source-runtime --signer-public-key-hex \n idunn-provision rotate-daemon-health-trust-signer --output --binding-id --signer-public-key-hex --bound-at-unix-millis \n idunn-provision validate-daemon-health-trust-binding --input \n idunn-provision create-provider-projection-trust-anchor --output --trust-anchor-id --runtime-id --idunn-public-anchor --bound-at-unix-millis --expires-at-unix-millis \n idunn-provision validate-provider-projection-trust-anchor --input --idunn-public-anchor " } #[cfg(test)] diff --git a/src/steward_orders.rs b/src/steward_orders.rs index dba9a02..379e09c 100644 --- a/src/steward_orders.rs +++ b/src/steward_orders.rs @@ -1528,12 +1528,7 @@ impl StewardBinding { !self.principal.starts_with('-') && !self.principal.contains('/'), "steward principal is invalid" ); - let user_ok = !self.user.is_empty() - && self.user.len() <= 32 - && self.user.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') - }) - && !self.user.starts_with(|first: char| first.is_ascii_digit() || first == '-'); + let user_ok = login_grammar_ok(&self.user); ensure!(user_ok && self.user != "root", "steward user is invalid or root"); ensure!( self.mind_control.is_absolute(), @@ -1581,7 +1576,18 @@ fn load_binding(path: &Path) -> Result { StewardBinding::parse(&text) } -fn user_uid(name: &str) -> Result { +/// A login name Idunn will resolve: the one grammar for every binding that names +/// a user. +pub(crate) fn login_grammar_ok(user: &str) -> bool { + !user.is_empty() + && user.len() <= 32 + && user.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) + && !user.starts_with(|first: char| first.is_ascii_digit() || first == '-') +} + +pub(crate) fn user_uid(name: &str) -> Result { let name = std::ffi::CString::new(name)?; // SAFETY: `passwd` is plain data and getpwnam_r fills it, with strings in // `buffer`, which outlives every read of them below (none are read). -- 2.47.3 From c5a0ce3d239a6255752924e5cad36824188e3f25 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 17:47:48 +0000 Subject: [PATCH 2/3] Document the steward stop in deployment-authority Co-Authored-By: Claude Sonnet 5.5 --- docs/deployment-authority.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/deployment-authority.md b/docs/deployment-authority.md index 4722734..964bb86 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -318,6 +318,27 @@ runtime's record, another owner, an unsearchable or non-directory parent) reads as unreadable and fails closed; the wait note carries the `idunn-provision deployment-brake-clear` command that replaces it. +A binding may name a second stop, the target's steward: + +```toml +[brakes.steward] +user = "mind-streampixels" # a login, never root +store = "/var/lib/gamecult/idunn-steward/streampixels/deployment-brake.cc" +``` + +Deploys admit only when every stop admits. The steward's store is read as owned +by that user's uid, so the file owner proves who wrote it (`by` is also the +uid's login name, written by the verb). Idunn never writes it and no key, +group or ACL is involved: the steward needs write access to its store +directory only, and engages or clears with the same `idunn-provision +deployment-brake-engage|clear --store `. The operator's store is a +separate stop the steward cannot clear; the wait note names each holding +stop. A steward store owned by another uid, or a user that does not resolve to +a non-root uid, is unreadable and fails closed; the steward replaces its own +store with `deployment-brake-clear`. The operator reads a steward's store with +`deployment-brake-status --owner-user `. The binding is stored in the +plan, so editing the stanza applies to deploys planned afterwards. + Record v6 (`deployment_admitted_at_unix_millis`, key 38) is forward-only: an Idunn that predates v6 cannot read a migrated `control.cc`, so, as for v5 (see `route-continuity-cut.md`, "An older Idunn refuses the v5 store"), take a -- 2.47.3 From c13e175bbf9208c0a512dcb7d0ce3a053e4179c3 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 18:09:41 +0000 Subject: [PATCH 3/3] Steward brake: refuse a shared steward directory, name the proven owner, steward-only remedy Binding validation refuses a steward store whose directory is shared with, inside or around any other Idunn authority directory, since the steward owns its directory and could unlink a root-owned store. The wait note names a steward stop by the binding's steward user, not the record's claimed by. The Unreadable hint for a steward store says only the owner uid can repair it. Root-run tests skip with a message. Docs gain a provisioning runbook and the sealed-binding recovery caveat. Co-Authored-By: Claude Sonnet 5.5 --- docs/deployment-authority.md | 52 ++++++++++++++---- src/brake.rs | 102 ++++++++++++++++++++++++++++------- src/control_plane.rs | 28 ++++------ src/deployment.rs | 39 ++++++++++++++ 4 files changed, 174 insertions(+), 47 deletions(-) diff --git a/docs/deployment-authority.md b/docs/deployment-authority.md index 964bb86..048e784 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -327,17 +327,47 @@ store = "/var/lib/gamecult/idunn-steward/streampixels/deployment-brake.cc" ``` Deploys admit only when every stop admits. The steward's store is read as owned -by that user's uid, so the file owner proves who wrote it (`by` is also the -uid's login name, written by the verb). Idunn never writes it and no key, -group or ACL is involved: the steward needs write access to its store -directory only, and engages or clears with the same `idunn-provision -deployment-brake-engage|clear --store `. The operator's store is a -separate stop the steward cannot clear; the wait note names each holding -stop. A steward store owned by another uid, or a user that does not resolve to -a non-root uid, is unreadable and fails closed; the steward replaces its own -store with `deployment-brake-clear`. The operator reads a steward's store with -`deployment-brake-status --owner-user `. The binding is stored in the -plan, so editing the stanza applies to deploys planned afterwards. +by that user's uid: the owner uid proves who wrote the file, and the wait note +names the stop by the binding's steward user, never by the record's `by` (which +is the verb's own text). Idunn never writes the steward directory and no key, +group or ACL is involved. The operator's store is a separate stop the steward +cannot clear, and the wait note names each holding stop. + +The steward owns its directory, so it could unlink any file in it. Binding +validation therefore refuses a steward store whose directory is shared with, +inside or around the directory of any other Idunn authority store (deployment +and lifecycle brakes, trust anchor, write-lease record, release authority). +Validation cannot see the file system: the root who provisions the directory +makes it contain the steward's store and nothing else. + +A steward store owned by another uid, or a steward user that does not resolve +to a non-root uid, is unreadable and fails closed. Only the steward (the +owner uid) can repair it: a clear run as root leaves a root-owned file that is +still unreadable. + +### Provisioning a steward (root, once per target) + +1. Create the store directory, owned by the steward and holding nothing else: + `install -d -o mind-streampixels -g mind-streampixels -m 0755 + /var/lib/gamecult/idunn-steward/streampixels`. It must not be, contain or sit + inside any other authority directory, and no workload `ReadWritePaths` may + cover it. Idunn (root) reads it without further grants; no group or ACL. +2. Add the stanza above to the target's operator binding, after `[brakes]`, + with the steward's real login. Deploys planned afterwards see it. +3. The steward engages and clears as itself, never through root: + `idunn-provision deployment-brake-engage --store --runtime-id + --reason ` and `deployment-brake-clear` with the same flags. +4. The operator reads it with `idunn-provision deployment-brake-status --store + --runtime-id --owner-user mind-streampixels`. + +The binding is sealed into each plan. A deploy already planned keeps the +binding it was sealed with: editing the stanza does not apply to it. If the +steward's login is deleted or its directory is lost, that deploy stays +unreadable-held at Sealing (or the static flip intent). `idunn cancel` withdraws +only a deploy held for a readiness disagreement before Fencing, and `idunn +expire` only one in Warming through Committing, so neither frees a deploy held +by the stop. Recovery is to restore the steward's login or recreate the +directory owned by it (and have the steward clear its store). Record v6 (`deployment_admitted_at_unix_millis`, key 38) is forward-only: an Idunn that predates v6 cannot read a migrated `control.cc`, so, as for v5 (see diff --git a/src/brake.rs b/src/brake.rs index 003748b..2dbee13 100644 --- a/src/brake.rs +++ b/src/brake.rs @@ -130,27 +130,68 @@ impl Stop { } } +/// One stop a binding names, with how to name it in a wait note. +pub(crate) struct NamedStop { + pub(crate) label: String, + /// The login the file system proves wrote the store: the steward user, whose + /// uid owns the file. None for the operator's store, whose record carries + /// only the verb's own claim of `by`. + pub(crate) proven_writer: Option, + pub(crate) stop: Stop, +} + +impl NamedStop { + /// The wait-note line for a stop that does not admit; None when it admits. + /// A steward stop is named by the proven owner, never by the record's `by`. + pub(crate) fn note(&self) -> Option { + let label = &self.label; + match &self.stop { + Stop::Absent | Stop::Clear(_) => None, + Stop::Engaged(held) => Some(match &self.proven_writer { + Some(_) => format!("{label} engaged: {}", held.reason), + None => format!("{label} engaged by {}: {}", held.by, held.reason), + }), + Stop::Unreadable(why) => Some(format!("{label} is unreadable and fails closed: {why}")), + } + } +} + /// Every stop a binding names, each with the words that identify its store in /// a wait note: the operator's, then the steward's when the binding names one. /// Deployments admit iff every one admits. The operator's store is read as /// owned by this process's uid; the steward's as owned by the steward user's /// uid, so the file system proves who wrote it. A steward that does not /// resolve to a non-root uid is an unreadable stop, never an absent one. -pub(crate) fn read_stops(brakes: &BrakeBinding, runtime_id: &str) -> Vec<(String, Stop)> { - let mut stops = vec![( - "deployment brake".to_string(), - read_stop(&brakes.deployment_store, runtime_id, process_uid()), - )]; +pub(crate) fn read_stops(brakes: &BrakeBinding, runtime_id: &str) -> Vec { + let mut stops = vec![NamedStop { + label: "deployment brake".to_string(), + proven_writer: None, + stop: read_stop(&brakes.deployment_store, runtime_id, process_uid()), + }]; if let Some(steward) = &brakes.steward { let label = format!("steward {} deployment brake", steward.user); let stop = match crate::steward_orders::user_uid(&steward.user) { - Ok(uid) => read_stop(&steward.store, runtime_id, uid), + Ok(uid) => read_stop_with_remedy(&steward.store, runtime_id, uid, |path| { + // Only the owner uid can mend it: a clear run as root leaves a + // root-owned file, which this reader refuses again. + format!( + "only {} (the store's owner uid) can repair it, by running as that user: \ + idunn-provision deployment-brake-clear --store {} --runtime-id {runtime_id} \ + --reason ; a clear run as root stays unreadable", + steward.user, + path.display() + ) + }), Err(why) => Stop::Unreadable(format!( "{why:#}; the binding's steward user {} must be a non-root login", steward.user )), }; - stops.push((label, stop)); + stops.push(NamedStop { + label, + proven_writer: Some(steward.user.clone()), + stop, + }); } stops } @@ -264,15 +305,26 @@ fn read_checked(path: &Path, runtime_id: &str, owner: u32) -> Result Stop { + read_stop_with_remedy(path, runtime_id, expected_owner_uid, |path| { + format!( + "to replace it run: idunn-provision deployment-brake-clear \ + --store {} --runtime-id {runtime_id} --reason (or deployment-brake-engage)", + path.display() + ) + }) +} + +fn read_stop_with_remedy( + path: &Path, + runtime_id: &str, + expected_owner_uid: u32, + remedy: impl FnOnce(&Path) -> String, +) -> Stop { match read_checked(path, runtime_id, expected_owner_uid) { Ok(None) => Stop::Absent, Ok(Some(record)) if record.state == StopState::Clear => Stop::Clear(record), Ok(Some(record)) => Stop::Engaged(record), - Err(error) => Stop::Unreadable(format!( - "{error:#}; to replace it run: idunn-provision deployment-brake-clear \ - --store {} --runtime-id {runtime_id} --reason (or deployment-brake-engage)", - path.display() - )), + Err(error) => Stop::Unreadable(format!("{error:#}; {}", remedy(path))), } } @@ -424,14 +476,15 @@ mod tests { } } - fn admits_all(stops: &[(String, Stop)]) -> bool { - stops.iter().all(|(_, stop)| stop.admits()) + fn admits_all(stops: &[NamedStop]) -> bool { + stops.iter().all(|named| named.stop.admits()) } #[test] fn the_steward_store_is_a_second_stop_and_the_operator_store_is_one_it_cannot_clear() -> Result<()> { let me = process_uid(); if me == 0 { + eprintln!("SKIPPED: a steward is never root, and this run is root"); return Ok(()); } let dir = tempfile::tempdir()?; @@ -443,13 +496,17 @@ mod tests { replace_stop(steward, "runtime", StopState::Engaged, "steward freeze", 10)?; let stops = read_stops(&binding, "runtime"); assert!(!admits_all(&stops)); - assert!(matches!(&stops[1], (label, Stop::Engaged(held)) - if label.starts_with("steward ") && held.reason == "steward freeze" && held.by == login_name(me)?)); + assert!(matches!(&stops[1].stop, Stop::Engaged(held) if held.reason == "steward freeze")); + // The note names the proven owner, and never the record's claimed `by`. + assert_eq!( + stops[1].note().unwrap(), + format!("steward {} deployment brake engaged: steward freeze", login_name(me)?) + ); // Both engaged, then the steward clears its own: the operator's holds. replace_stop(operator, "runtime", StopState::Engaged, "operator hold", 11)?; replace_stop(steward, "runtime", StopState::Clear, "steward thaw", 12)?; let stops = read_stops(&binding, "runtime"); - assert!(matches!(&stops[0].1, Stop::Engaged(_)) && matches!(&stops[1].1, Stop::Clear(_))); + assert!(matches!(&stops[0].stop, Stop::Engaged(_)) && matches!(&stops[1].stop, Stop::Clear(_))); assert!(!admits_all(&stops)); // The other order: operator clear, steward engaged still halts. replace_stop(operator, "runtime", StopState::Clear, "operator thaw", 13)?; @@ -468,14 +525,21 @@ mod tests { // Steward is "nobody" (a real non-root user that is not the writer); // the file is written by this process, so even a Clear record fails. if me == 65534 || user_missing("nobody") { + eprintln!("SKIPPED: needs a user `nobody` other than the caller"); return Ok(()); } let binding = binding_with_steward(dir.path(), "nobody"); let steward = &binding.steward.as_ref().unwrap().store; replace_stop(steward, "runtime", StopState::Clear, "forged clear", 10)?; let stops = read_stops(&binding, "runtime"); - assert!(matches!(&stops[1].1, Stop::Unreadable(why) if why.contains("is owned by uid") + assert!(matches!(&stops[1].stop, Stop::Unreadable(why) if why.contains("is owned by uid") + && why.contains("only nobody (the store's owner uid) can repair it") + && why.contains("stays unreadable") && why.contains("idunn-provision deployment-brake-clear"))); + // The operator's stop keeps the operator's remedy. + replace_stop(&binding.deployment_store, "other-runtime", StopState::Clear, "x", 11)?; + assert!(matches!(&read_stops(&binding, "runtime")[0].stop, Stop::Unreadable(why) + if why.contains("to replace it run:") && !why.contains("only "))); assert!(!admits_all(&stops)); Ok(()) } @@ -490,7 +554,7 @@ mod tests { for user in ["no-such-login-zz", "root"] { let binding = binding_with_steward(dir.path(), user); let stops = read_stops(&binding, "runtime"); - assert!(matches!(&stops[1].1, Stop::Unreadable(_)), "{user}"); + assert!(matches!(&stops[1].stop, Stop::Unreadable(_)), "{user}"); assert!(!admits_all(&stops)); } Ok(()) diff --git a/src/control_plane.rs b/src/control_plane.rs index a210943..34d1b87 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::brake::{Stop, read_stops}; +use crate::brake::{NamedStop, read_stops}; #[cfg(test)] use crate::brake::StopState; use crate::deployment::{ @@ -9347,16 +9347,8 @@ impl Engine { fn stop_note(&self, current: &Stored) -> Result> { let notes: Vec = self .deployment_stops(current)? - .into_iter() - .filter_map(|(label, stop)| match stop { - Stop::Absent | Stop::Clear(_) => None, - Stop::Engaged(held) => { - Some(format!("{label} engaged by {}: {}", held.by, held.reason)) - } - Stop::Unreadable(why) => { - Some(format!("{label} is unreadable and fails closed: {why}")) - } - }) + .iter() + .filter_map(NamedStop::note) .collect(); Ok((!notes.is_empty()).then(|| notes.join("; "))) } @@ -9367,7 +9359,7 @@ impl Engine { fn deployment_stops( &self, current: &Stored, - ) -> Result> { + ) -> Result> { let plan = required(¤t.value.plan, "transaction plan")?; let binding = plan.parsed_inputs()?.1; Ok(read_stops( @@ -9385,7 +9377,7 @@ impl Engine { fn deploy_can_move(&self, deploy: &Stored) -> bool { deploy.value.deployment_admitted() || self.deployment_stops(deploy) - .is_ok_and(|stops| stops.iter().all(|(_, stop)| stop.admits())) + .is_ok_and(|stops| stops.iter().all(|named| named.stop.admits())) } /// The deployment a continuity gives way to: one on its target that awaits @@ -26310,7 +26302,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide } /// The static world, its binding naming `steward` (a login) as a second - /// stop whose store is `authority/steward-brake.cc`. + /// stop whose store is `steward/brake.cc`. #[cfg(unix)] fn static_world_with_steward(steward: Option<&str>) -> Result { use crate::deployment::tests::{STATIC_BINDING, STATIC_RECIPE}; @@ -26332,7 +26324,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide Some(user) => text.replace( "[rollout]", &format!( - "[brakes.steward]\nuser = \"{user}\"\nstore = \"{}/authority/steward-brake.cc\"\n\n[rollout]", + "[brakes.steward]\nuser = \"{user}\"\nstore = \"{}/steward/brake.cc\"\n\n[rollout]", site.display() ), ), @@ -26343,6 +26335,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide std::fs::create_dir_all(site.join("www"))?; std::fs::create_dir_all(site.join("config"))?; std::fs::create_dir_all(site.join("authority"))?; + std::fs::create_dir_all(site.join("steward"))?; std::fs::write(site.join("config/site.json"), SITE_CONFIG_ONE)?; *world.engine.runner_stand_in.lock().unwrap() = Some(Arc::new(SealingRunner { tree: tree.clone() })); @@ -26481,11 +26474,12 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide fn a_steward_stop_holds_a_deploy_beside_the_operators_and_names_who() -> Result<()> { let me = crate::brake::process_uid(); if me == 0 { + eprintln!("SKIPPED: a steward is never root, and this run is root"); return Ok(()); } let user = crate::brake::login_name(me)?; let w = static_world_with_steward(Some(&user))?; - let steward_store = w.site.join("authority/steward-brake.cc"); + let steward_store = w.site.join("steward/brake.cc"); let op_store = w.site.join("authority/deployment-brake.cc"); let stop = |store: &Path, state, reason: &str| -> Result<()> { crate::brake::replace_stop(store, "site-yggdrasil", state, reason, now_millis()?)?; @@ -26505,7 +26499,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide assert_eq!(held.phase, DeploymentPhase::Sealing); assert_eq!( held.last_error.as_deref(), - Some(format!("steward {user} deployment brake engaged by {user}: steward hold").as_str()) + Some(format!("steward {user} deployment brake engaged: steward hold").as_str()) ); // The operator engages too; the steward clears its own. The operator's holds. stop(&op_store, StopState::Engaged, "operator hold")?; diff --git a/src/deployment.rs b/src/deployment.rs index a21c6cf..265e59f 100644 --- a/src/deployment.rs +++ b/src/deployment.rs @@ -1455,6 +1455,27 @@ impl OperatorBinding { && steward.store != self.brakes.lifecycle_store, "steward brake store is identical to another brake store" ); + // The steward owns its store's directory, so it could unlink any + // file there. Every other authority store must therefore live + // outside that directory, and the steward's directory must be + // clear of every directory an operator store lives in. + let steward_dir = steward.store.parent().unwrap_or(&steward.store); + let others = [ + Some(&self.brakes.deployment_store), + Some(&self.brakes.lifecycle_store), + Some(&self.runtime_identity.trust_anchor_store), + self.process_write_lease.as_ref().map(|lease| &lease.record_path), + self.repository.release_authority_store.as_ref(), + ]; + for other in others.into_iter().flatten() { + let other_dir = other.parent().unwrap_or(other); + ensure!( + !paths_overlap(steward_dir, other_dir), + "steward brake directory {} is shared with or nested in the directory of Idunn authority {}: the steward owns its directory and could remove that file", + steward_dir.display(), + other.display() + ); + } } self.workload.validate_launch_environment()?; let workload = match &self.workload { @@ -3124,6 +3145,24 @@ nodes = ["yggdrasil"] ] { assert!(OperatorBinding::parse(&with(&bad)).is_err(), "{bad}"); } + // A steward directory shared with, or enclosing, an operator store + // lets the steward unlink a root-owned file: refused. + for (deployment, steward_store) in [ + ("/var/lib/gamecult/steward-x/op.cc", "/var/lib/gamecult/steward-x/s.cc"), + ("/var/lib/gamecult/steward-x/sub/op.cc", "/var/lib/gamecult/steward-x/s.cc"), + ("/var/lib/gamecult/op.cc", "/var/lib/gamecult/steward-x/s.cc"), + ] { + let shared = BINDING + .replace("/var/lib/gamecult/idunn-authority/test-service-deployment-brake.cc", deployment) + .replace("\n[rollout]", &format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{steward_store}\"\n\n[rollout]")); + assert!(OperatorBinding::parse(&shared).is_err(), "{deployment} {steward_store}"); + } + // Disjoint directories are the one admitted shape. + let disjoint = BINDING.replace( + "\n[rollout]", + "\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/steward-x/s.cc\"\n\n[rollout]", + ); + OperatorBinding::parse(&disjoint).unwrap(); // steward_store_overlap_with_workload_path_refused let overlapping = BINDING .replace("\n[rollout]", &format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{store}\"\n\n[rollout]")) -- 2.47.3