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 <noreply@anthropic.com>
This commit is contained in:
parent
c5a0ce3d23
commit
c13e175bbf
4 changed files with 174 additions and 47 deletions
|
|
@ -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 <path>`. 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 <login>`. 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 <store> --runtime-id <id>
|
||||
--reason <text>` and `deployment-brake-clear` with the same flags.
|
||||
4. The operator reads it with `idunn-provision deployment-brake-status --store
|
||||
<store> --runtime-id <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
|
||||
|
|
|
|||
102
src/brake.rs
102
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<String>,
|
||||
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<String> {
|
||||
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<NamedStop> {
|
||||
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 <text>; 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<Option<Depl
|
|||
}
|
||||
|
||||
pub(crate) fn read_stop(path: &Path, runtime_id: &str, expected_owner_uid: u32) -> 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 <text> (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 <text> (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(())
|
||||
|
|
|
|||
|
|
@ -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<DeploymentTransaction>) -> Result<Option<String>> {
|
||||
let notes: Vec<String> = 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<DeploymentTransaction>,
|
||||
) -> Result<Vec<(String, Stop)>> {
|
||||
) -> Result<Vec<NamedStop>> {
|
||||
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<DeploymentTransaction>) -> 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<StaticWorld> {
|
||||
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")?;
|
||||
|
|
|
|||
|
|
@ -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]"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue