fix(users): the host= marker gates the box role, not the incus group
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 <noreply@anthropic.com>
This commit is contained in:
parent
5902f61da4
commit
b8e8e79b87
4 changed files with 181 additions and 19 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -8,6 +8,26 @@ on the way to cutting its first release, and this file starts there.
|
||||||
|
|
||||||
### Fixed
|
### 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-<uid>`, 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) —
|
- **The release suite accepts the ceremony's own tree** (#44) —
|
||||||
`test/release.sh` demanded a literal `## Unreleased` heading in the real
|
`test/release.sh` demanded a literal `## Unreleased` heading in the real
|
||||||
`CHANGELOG.md`, extracting non-empty and containing `#32`. All three are
|
`CHANGELOG.md`, extracting non-empty and containing `#32`. All three are
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,55 @@ assert_marker_human() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# assert_marker_hosts_vms <marker_path> — 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-<uid>, 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 <user> <denyusers token...>
|
# deny_verdict <user> <denyusers token...>
|
||||||
#
|
#
|
||||||
# Judge sshd's effective DenyUsers list against ONE candidate, fail closed.
|
# Judge sshd's effective DenyUsers list against ONE candidate, fail closed.
|
||||||
|
|
|
||||||
|
|
@ -165,23 +165,59 @@ fi
|
||||||
# --- groups ------------------------------------------------------------------
|
# --- groups ------------------------------------------------------------------
|
||||||
groupadd -f rig-admin
|
groupadd -f rig-admin
|
||||||
groupadd -f rig
|
groupadd -f rig
|
||||||
# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An
|
# rig NEVER installs Incus: box's setup-host owns the daemon and its group.
|
||||||
# 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,
|
# Whether the box role applies AT ALL is the host= trait's call, decided here,
|
||||||
# its box grants are not. So on host=yes an absent group is a broken VM host
|
# once, from the marker alone — never from what groups happen to exist on the
|
||||||
# (refuse, point at setup-host), while on host=no it is simply not this box's
|
# machine (#58). The box role binds where VMs live; a users file is fleet-wide,
|
||||||
# role to converge — skip it, never abort the admins the file also carries.
|
# 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
|
INCUS_OK=0
|
||||||
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
||||||
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
|
BOX_ROLE_OK=0
|
||||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
BOX_ROLE_WHY=""
|
||||||
*host=yes*)
|
if BOX_ROLE_WHY="$(assert_marker_hosts_vms "${RIG_ROLE_MARKER:-/etc/rig/role}")"; 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" ;;
|
BOX_ROLE_OK=1
|
||||||
*host=no*)
|
fi
|
||||||
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
|
if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 0 ]; then
|
||||||
*)
|
# The group being present while the trait says otherwise is the marker/reality
|
||||||
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" ;;
|
# mismatch — the case that used to slip through — so it gets its own sentence
|
||||||
esac
|
# 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
|
fi
|
||||||
|
|
||||||
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
||||||
|
|
@ -206,10 +242,17 @@ for u in "${USERS[@]}"; do
|
||||||
want=""
|
want=""
|
||||||
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
||||||
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
||||||
# incus joins the wanted set only when the group exists (host=no boxes
|
# incus joins the wanted set only when the box role APPLIES to this box —
|
||||||
# skipped it above): converging membership in a conjured group would hand
|
# the host= trait's call, made once above — and only when the group is
|
||||||
# the daemon's arrival an audience it never granted.
|
# actually there. Deliberately a gate on whether the role applies, not on
|
||||||
case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
|
# 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
|
for g in rig-admin rig incus; do
|
||||||
case " $want " in
|
case " $want " in
|
||||||
*" $g "*)
|
*" $g "*)
|
||||||
|
|
|
||||||
50
test/cli.sh
50
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 "" \
|
check "users apply: box role skips on a host=no box" 0 "" \
|
||||||
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
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 <marker_path>
|
||||||
|
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 ---------------------
|
# --- 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: --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
|
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue