From b8e8e79b87cb7250336351ad25f44d4ade98d604 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:29:20 +0000 Subject: [PATCH] fix(users): the host= marker gates the box role, not the incus group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit users apply consulted the host= trait only when group incus was ABSENT, so a host=no or marker-less box that nonetheless carried the group handed box-role users a bare `usermod -aG incus` — the socket with no tier, which incus-user answers by lazily building an unhardened project under whoever opens it. The marker now decides in both directions through one pure gate, assert_marker_hosts_vms, so the verdict is identical whether or not the group exists. The marker wins over the machine deliberately — it is the box's declared identity and every other host= decision already treats it as authoritative — but not silently: when the group exists and the trait disagrees, the skip names the contradiction and rig bootstrap as the fix. Closes #58 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 20 +++++++++ commands/lib/users-config.sh | 49 ++++++++++++++++++++++ commands/users-apply.sh | 81 +++++++++++++++++++++++++++--------- test/cli.sh | 50 ++++++++++++++++++++++ 4 files changed, 181 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27383db..50a1def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ on the way to cutting its first release, and this file starts there. ### Fixed +- **A `host=no` box with an `incus` group no longer hands out the bare + socket** (#58) — `users apply` consulted the `host=` trait only when group + `incus` was ABSENT (die on `host=yes`, skip on `host=no`). When the group + was PRESENT the trait was never asked, so a `host=no` or marker-less box + that nonetheless carried the group — `box setup-host` ran, then the box was + re-bootstrapped with other traits — gave every box-role user a bare + `usermod -aG incus`: the socket with no tier behind it, which `incus-user` + answers by lazily building an UNHARDENED project under whoever opens it + (`incusbr-`, NAT on v4 and v6, no ACL, no `dns.mode=none`, no port + isolation). The marker now decides in BOTH directions, through one new pure + gate (`assert_marker_hosts_vms`, testable against fixture markers non-root + like `assert_marker_human`): the box role applies only where the box CLAIMS + to host VMs, so the verdict is identical whether or not the group exists. + The machine deliberately does not overrule the marker — but the skip is not + silent either: when the group exists and the trait disagrees, the warning + names the contradiction and `rig bootstrap` as the repair. On such a box + exact-membership convergence now strips box-role users out of `incus`, on + the same reasoning: a membership inherited from a previous life is the same + half-grant as a freshly added one. + - **The release suite accepts the ceremony's own tree** (#44) — `test/release.sh` demanded a literal `## Unreleased` heading in the real `CHANGELOG.md`, extracting non-empty and containing `#32`. All three are diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index 81c9c15..ca62a17 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -146,6 +146,55 @@ assert_marker_human() { esac } +# assert_marker_hosts_vms — the box role's gate: return 0, +# silently, only when the marker says host=yes; otherwise print the reason on +# stdout and return 1 (the caller decides whether that is a warn or a die). +# Same shape and same reason as assert_marker_human above: the policy is a +# pure marker->verdict function so the harness can prove every arm against +# fixture markers, non-root, while the CLI path sits behind the root check. +# +# The MARKER decides, not the machine (#58). Apply used to consult host= only +# when group incus was ABSENT — so a box whose marker said host=no but which +# happened to carry the group (box's setup-host ran, then the box was +# re-bootstrapped with different traits, or given --host no) handed box-role +# users a bare `usermod -aG incus`: the socket with no tier behind it. That is +# the worst of the three states, because incus-user answers a socket it is +# given by lazily creating an UNHARDENED project for whoever opens it — +# incusbr-, NAT on v4 and v6, no ACL, no dns.mode=none, no port +# isolation. The alternative considered was to let the group's presence win +# and converge anyway with a warning, on the theory that a real incus install +# is evidence the machine really does host VMs. It was rejected: the marker is +# what this box CLAIMS to be, and every other host= decision in the family +# already treats it as authoritative rather than as a hint to be second- +# guessed by probing the machine. A box that lies about itself gets its lie +# taken seriously and gets told, loudly, to re-run bootstrap — which is a +# cheap repair — instead of rig quietly provisioning a VM-host tier on a box +# that does not claim to be one. Deciding from the marker alone also means the +# verdict is the SAME whether or not the group exists, which is the property +# that was missing. +# +# No marker, and a marker with no host= trait, both land here as "not a VM +# host" for the same fail-closed reason: rig cannot tell an unbootstrapped box +# from a repurposed one, and the safe error is withholding VM access that can +# be granted by a re-run, not granting VM access that cannot be un-granted +# once a project exists under it. +assert_marker_hosts_vms() { + local marker + marker="$(read_role_marker "$1")" + case "$marker" in + *host=yes*) return 0 ;; + *host=no*) + printf '%s\n' "this box does not host VMs (host=no)" + return 1 ;; + "") + printf '%s\n' "no /etc/rig/role marker, so this box names no host= trait — re-run rig bootstrap so it knows whether it hosts VMs" + return 1 ;; + *) + printf '%s\n' "the role marker names no host= trait (${marker}) — re-run rig bootstrap so this box knows whether it hosts VMs" + return 1 ;; + esac +} + # deny_verdict # # Judge sshd's effective DenyUsers list against ONE candidate, fail closed. diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 97f0539..d0a1838 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -165,23 +165,59 @@ fi # --- groups ------------------------------------------------------------------ groupadd -f rig-admin groupadd -f rig -# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An -# absent incus group means that never ran — but what that MEANS is the host= -# trait's call. The box role binds where VMs live; a users file is fleet-wide, -# its box grants are not. So on host=yes an absent group is a broken VM host -# (refuse, point at setup-host), while on host=no it is simply not this box's -# role to converge — skip it, never abort the admins the file also carries. +# rig NEVER installs Incus: box's setup-host owns the daemon and its group. +# +# Whether the box role applies AT ALL is the host= trait's call, decided here, +# once, from the marker alone — never from what groups happen to exist on the +# machine (#58). The box role binds where VMs live; a users file is fleet-wide, +# its box grants are not. Apply used to ask the trait only when group incus was +# ABSENT, which meant a host=no or marker-less box that nonetheless carried the +# group (setup-host ran, then the box was re-bootstrapped with other traits) +# handed box-role users a bare `usermod -aG incus` — the socket with no tier +# behind it, and incus-user answers a socket it is given by lazily creating an +# UNHARDENED project under whoever opens it. The trait now decides the same way +# in both directions: BOX_ROLE_OK is the single gate, and the group's presence +# only ever answers the narrower question of whether a box that DOES claim to +# host VMs is ready to. +# +# The rejected alternative was letting the machine overrule the marker — treat +# a real incus group as evidence the box hosts VMs and converge anyway, warning +# that the marker disagrees. It reads reasonable, but it inverts what the rest +# of this family does with host=: the marker is the box's declared identity, and +# bootstrap is the one thing that writes it. Provisioning a VM-host tier onto a +# box that does not claim to be a VM host is rig deciding it knows better than +# the declaration, on evidence (a leftover group) that survives exactly the +# repurposing that makes the marker right and the group stale. The cost of +# choosing the marker is a genuine VM host mislabelled host=no that stops +# provisioning — so this does not do it SILENTLY: the skip warning below names +# the contradiction and names `rig bootstrap` as the one-line repair. +# +# Consequence worth stating plainly: on such a box the exact-membership +# convergence below will now STRIP box-role users out of incus rather than +# leave them there. That is the same call, not a second one. A membership +# inherited from a previous life is the identical half-grant state as one +# freshly added — socket, no tier — and rig's promise for its three managed +# groups is exactness, not "exact except where drift got there first". INCUS_OK=0 if getent group incus >/dev/null; then INCUS_OK=1; fi -if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then - case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in - *host=yes*) - die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;; - *host=no*) - warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;; - *) - warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;; - esac +BOX_ROLE_OK=0 +BOX_ROLE_WHY="" +if BOX_ROLE_WHY="$(assert_marker_hosts_vms "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then + BOX_ROLE_OK=1 +fi +if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 0 ]; then + # The group being present while the trait says otherwise is the marker/reality + # mismatch — the case that used to slip through — so it gets its own sentence + # rather than the generic skip. Never a die: a fleet-wide users file naming a + # box-role user somewhere must not abort the admins it also carries here. + if [ "$INCUS_OK" -eq 1 ]; then + warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY — yet group incus EXISTS here, so this box's marker and this box's reality disagree. rig believes the marker and grants nothing (the group alone is only the socket; without the tier behind it incus-user would lazily build an unhardened project under whoever opens it). If this machine really does host VMs, re-run rig bootstrap with --host yes and apply again; everything else converges" + else + warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY; everything else converges" + fi +fi +if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then + die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" fi in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } @@ -206,10 +242,17 @@ for u in "${USERS[@]}"; do want="" case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac case ",$roles," in *,rig,*) want="$want rig" ;; esac - # incus joins the wanted set only when the group exists (host=no boxes - # skipped it above): converging membership in a conjured group would hand - # the daemon's arrival an audience it never granted. - case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac + # incus joins the wanted set only when the box role APPLIES to this box — + # the host= trait's call, made once above — and only when the group is + # actually there. Deliberately a gate on whether the role applies, not on + # the add: it is the same question no matter who performs the add, so it + # keeps answering correctly if the add itself later moves elsewhere (#53 + # defers it to `box grant`). BOX_ROLE_OK=1 already implies the group exists + # (the arm above dies otherwise), but INCUS_OK is kept in the condition + # because converging membership in a conjured group would hand the daemon's + # arrival an audience it never granted, and that must not depend on a + # neighbouring branch staying fatal. + case ",$roles," in *,box,*) if [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac for g in rig-admin rig incus; do case " $want " in *" $g "*) diff --git a/test/cli.sh b/test/cli.sh index 07d18ab..1184f9c 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -750,6 +750,56 @@ check "users apply: revoked keys are renamed, never deleted" 0 "" \ check "users apply: box role skips on a host=no box" 0 "" \ grep -q "box role skipped" "$ROOT/commands/users-apply.sh" +# --- the box role's host= gate (#58) ----------------------------------------- +# The gate is a pure marker->verdict lib function for the same reason +# assert_marker_human is: apply's box arm sits behind the root check, so every +# arm is proven HERE against fixture markers, non-root. +# +# What #58 fixed: the trait used to be consulted only when group incus was +# ABSENT, so a host=no (or marker-less) box that happened to CARRY the group +# handed box-role users a bare `usermod -aG incus` — the socket with no tier, +# which incus-user answers by lazily building an unhardened project under +# whoever opens it. The load-bearing property below is that the verdict comes +# from the marker ALONE and is therefore identical whether or not the group +# exists; the group only decides whether a box that does claim host=yes is +# ready to serve the role. +hostvm_gate() { # hostvm_gate + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" +} +HOSTVM_FIX="$(mktemp -d)" +printf 'role=dev class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" +printf 'role=workload class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" +# A marker that predates the host= trait (or was hand-edited): present, but it +# names no host=. Distinct from an ABSENT marker and it must not read as yes. +printf 'role=workload class=server join=authkey\n' > "$HOSTVM_FIX/traitless" +check "users apply: host=yes passes the box-role gate" \ + 0 "" hostvm_gate "$HOSTVM_FIX/yes" +check "users apply: host=no fails the box-role gate" \ + 1 "does not host VMs" hostvm_gate "$HOSTVM_FIX/no" +# The marker-less case gets its own answer rather than falling through to +# either yes or no: rig cannot tell an unbootstrapped box from a repurposed +# one, so it withholds (recoverable by a re-run) and names the repair. +check "users apply: an absent marker fails the box-role gate, names bootstrap" \ + 1 "re-run rig bootstrap" hostvm_gate "$HOSTVM_FIX/absent" +check "users apply: a marker with no host= trait fails the gate, names bootstrap" \ + 1 "re-run rig bootstrap" hostvm_gate "$HOSTVM_FIX/traitless" +rm -rf "$HOSTVM_FIX" +# The gate must actually be WIRED to the wanted-groups decision, not merely +# exist: this is the line #58 reported, where the box arm used to test group +# presence alone. Pin both operands on that arm — a revert to the INCUS_OK-only +# test must not ship green. It is a gate on whether the ROLE APPLIES, kept +# separate from the mechanism of the add on purpose, so it survives #53 moving +# the add itself into `box grant`. +check "users apply: the incus want is gated on the host= verdict, not just the group" 0 "" \ + grep -qE '\*,box,\*\).*BOX_ROLE_OK.*INCUS_OK.*want incus' "$ROOT/commands/users-apply.sh" +# Marker says no, machine says yes: the skip must NAME the contradiction and +# the one-line repair. The cost of believing the marker is a genuine VM host +# that stops provisioning, and that is only acceptable while it is loud. +check "users apply: a marker/reality mismatch warns and names the repair" 0 "" \ + grep -q "marker and this box's reality disagree" "$ROOT/commands/users-apply.sh" + # --- users close-root: the human-class root-door shutter --------------------- check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope