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]"))