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:
Eureka 2026-11-13 18:09:41 +00:00
commit c13e175bbf
4 changed files with 174 additions and 47 deletions

View file

@ -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

View file

@ -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(())

View file

@ -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(&current.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")?;

View file

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