diff --git a/docs/deployment-authority.md b/docs/deployment-authority.md index 4722734..048e784 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -318,6 +318,57 @@ 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: 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 `route-continuity-cut.md`, "An older Idunn refuses the v5 store"), take a diff --git a/src/brake.rs b/src/brake.rs index d44cd96..2dbee13 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,72 @@ 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 { + 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_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(NamedStop { + label, + proven_writer: Some(steward.user.clone()), + stop, + }); + } + stops +} + #[cfg(unix)] pub(crate) fn process_uid() -> u32 { // SAFETY: geteuid has no failure mode and no arguments. @@ -140,7 +208,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 +221,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") } @@ -237,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))), } } @@ -383,6 +462,115 @@ 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: &[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()?; + 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].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].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)?; + 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") { + 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].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(()) + } + + 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].stop, 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..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, process_uid, read_stop}; +use crate::brake::{NamedStop, read_stops}; #[cfg(test)] use crate::brake::StopState; use crate::deployment::{ @@ -9345,27 +9345,26 @@ 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)? + .iter() + .filter_map(NamedStop::note) + .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 +9376,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(|named| named.stop.admits())) } /// The deployment a continuity gives way to: one on its target that awaits @@ -26298,6 +26298,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 `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,13 +26318,24 @@ 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 = \"{}/steward/brake.cc\"\n\n[rollout]", + site.display() + ), + ), + } }, Arc::new(InstallingWorkload), )?; 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() })); @@ -26451,6 +26469,61 @@ 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 { + 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("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: 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..265e59f 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,39 @@ 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" + ); + // 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 { WorkloadBinding::HostActuator(workload) => { @@ -1462,7 +1509,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 +1581,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 +3116,65 @@ 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}"); + } + // 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]")) + .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).