From a9505698327ccebf59ef10a6ebe6b7ead0876147 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:15:55 +0000 Subject: [PATCH] feat: users apply grants the box tier, not just the socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role `box` resolved to exactly one action, `usermod -aG incus`. That is the socket — step 1 of the five `box grant` performs. Without the other four (the user- project, its narrowing to boxnet and only boxnet, the snapshot and backup allowances clone and `box export` ride, and the shipped box-net profile installed into that project) the user's first `box new` refuses for want of a box-net profile, so apply's promise — the users file is the fleet's source of truth — was not kept for this role. Worse, until an admin arrived by hand the user held an `incus` membership with no converged project, and incus-user would lazily hand them a stock unhardened NAT bridge: a state box's own contract forbids. On host=yes apply now calls `box grant ` per box-role user. rig calls box's grant rather than reimplementing four fifths of it — the "rig never installs Incus" boundary is about installation, not invocation, and grant is already script-callable: idempotent, root-or-sudo, stdin-pinned, with its own run-as-the-user touch. Three decisions the code carries in comment form: - Ordering. The call sits after `useradd` (grant opens with a getent passwd and refuses an unknown account) and after the other groups, so a user whose grant fails still lands with everything rig owns outright. - Failure granularity, split the way the host= guard beside it already splits. A missing box CLI on host=yes dies, like the missing incus group: a broken VM host, not a per-user accident. A per-user grant failure warns and continues — one box-role user somewhere in the fleet must not stop apply everywhere VMs don't live. host=no and marker-less boxes keep their existing skip-with-warning untouched. - The group ADD is deferred to grant, while `incus` stays in the wanted set so the exact-convergence loop never strips a box-role user's socket. Grant's rollback only reaches a membership that run added, so rig opening the socket first would leave a failed grant unable to close it. And grant is the authority on whether the group belongs at all: for an incus-admin member it deliberately does not add `incus`. An incus-admin member is warned, never fatal: box grant refuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed. Closes #49 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 20 +++++++ README.md | 18 +++++- commands/users-apply.sh | 120 ++++++++++++++++++++++++++++++++++++++-- test/cli.sh | 80 +++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f77e10a..4458365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ on the way to cutting its first release, and this file starts there. ## Unreleased +### Added + +- **`users apply` grants the box *tier*, not just its socket** (#49) — role + `box` resolved to exactly one action, `usermod -aG incus`. That is the + socket; it is step 1 of the five `box grant` performs, so every box-role + user still needed an admin to run `box grant ` by hand before their + first `box new` would do anything but refuse ("your project has no box-net + profile"), and until that admin arrived they held an `incus` membership + with no converged project — incus-user would lazily hand them a stock + unhardened NAT bridge, which is worse than no grant at all. On `host=yes` + apply now calls `box grant` per box-role user, after `useradd` (grant + refuses an unknown account) and with the group ADD deferred to grant, so a + grant that fails partway can take the socket back with it. Failures split + the way the `host=` guard beside them already splits: a missing `box` CLI + on `host=yes` dies (a broken VM host), a per-user grant failure warns and + continues (one box-role user must not stop apply for the fleet). `host=no` + and marker-less boxes keep their existing skip-with-warning. An + `incus-admin` member is warned, not fatal — `box grant` refuses them today, + which heavy-duty/box#99 fixes box-side with no rig change needed. + ### Fixed - **A `host=no` box with an `incus` group no longer hands out the bare diff --git a/README.md b/README.md index 5618d31..8e655d1 100644 --- a/README.md +++ b/README.md @@ -750,7 +750,7 @@ holds nothing secret anyway: usernames, roles, and *public* keys. |---------|----------------------------------------------|-------------| | `admin` | full NOPASSWD sudo | `rig-admin` | | `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` | -| `box` | Incus **restricted** tier, no sudo | `incus` | +| `box` | Incus **restricted** tier, no sudo | `incus` + `box grant` | **The honest limit of the `rig` role:** its sudo grant is binary-scoped, not argument-scoped — it trusts its holder with every rig verb *except* identity @@ -784,6 +784,22 @@ apply dies pointing at `box setup-host` rather than conjure a group nothing would consult. `incus-admin` is deliberately **not** a role: that group is host-root-equivalent, break-glass by hand only. +**The group is the socket; the tier is `box grant`.** On `host=yes`, apply +calls `box grant ` for every box-role user — the `incus` group is only +the first of the five steps that grant performs (the `user-` project, +its narrowing to `boxnet` and *only* `boxnet`, the snapshot and backup +allowances `box clone` and `box export` ride, and the shipped `box-net` +profile installed into that project). rig calls box's own grant rather than +reimplementing four fifths of it: the "rig never installs Incus" boundary is +about installation, not invocation, and deferring here respects it harder +than a rig-side copy would. The group ADD is left to grant too, so a grant +that fails partway can take the socket back with it — rig opening the socket +first would leave a user with live access to an *un-narrowed* project, which +is worse than no grant at all. A grant that fails **warns and continues**: +one box-role user's project must not stop apply for the fleet. A missing +`box` CLI on `host=yes` **dies**, like the missing `incus` group — that is a +broken VM host, not a per-user accident. + Losing the `box` role goes back through box, too. `rig-admin` and `rig` are rig's groups and a `gpasswd -d` is the whole story for them; `incus` is box's, and `box revoke` does more with it than remove a membership — it says out loud diff --git a/commands/users-apply.sh b/commands/users-apply.sh index e0c1449..9042e1f 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -46,11 +46,16 @@ follows it to the user. Apply dies if root has no authorized_keys to seed. roles: admin group rig-admin — full NOPASSWD sudo rig group rig — NOPASSWD sudo for /usr/local/bin/rig only - box group incus — Incus restricted tier, no sudo (box's setup-host - owns the Incus install; rig only asserts it). - Dropping this role hands the group back through + box group incus — Incus restricted tier, no sudo. On host=yes apply + calls 'box grant ', which is the tier: the + group is only its socket, the user- project, + the boxnet-only narrowing, snapshots, backups and + the box-net profile are the rest of it. Dropping + the role hands the group back through 'box revoke' — never --purge: convergence removes - access, never someone's running boxes. + access, never someone's running boxes. box's + setup-host owns the Incus install; rig only + asserts it, and defers BOTH directions to box. All passwords stay locked, always — the SSH key at the door is the authentication, and NOPASSWD sudo does not weaken it. Convergent: membership @@ -223,6 +228,40 @@ if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; 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 +# --- the box TIER, not just its socket (#49) --------------------------------- +# Group incus is step 1 of the five 'box grant' performs: the group is the +# SOCKET, while the tier is the per-user convergence behind it — the +# user- project, its narrowing to boxnet and only boxnet, the snapshot +# and backup allowances clone and 'box export' ride, and the shipped box-net +# profile installed into that project. rig doing step 1 alone left every +# box-role user's first 'box new' refusing ("your project has no box-net +# profile"), so apply's promise — the users file is the fleet's source of +# truth — was not kept for this role. It is kept by CALLING box's own grant +# rather than reimplementing four fifths of it: 'box grant' is idempotent, +# root-or-sudo (which apply already is), stdin-pinned so no incus client can +# wedge waiting on an answer, and does its own run-as-the-user touch, so the +# granted user never has to log in first for the lazy project to exist. +# +# Only on host=yes. The box role binds where VMs live, and the two other +# arms of the host= decision above are deliberately untouched: host=no keeps +# its skip-with-warning, and a marker with no host= trait keeps its own. +# Granting a tier on a box that does not host VMs would be converging policy +# into a daemon that is not there to enforce it. +# +# The box CLI's ABSENCE is a host-level fact, so it dies here, in the same +# register and for the same reason as the missing-incus-group die above: a +# host=yes box carrying box-role users but no box CLI is a broken VM host, +# not a per-user accident, and no amount of continuing converges it. +BOX_GRANT=0 +if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then + case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in + *host=yes*) + command -v box >/dev/null 2>&1 \ + || die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI (rig bootstrap does it on host=yes) and re-run" + BOX_GRANT=1 ;; + esac +fi + in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } # --- dropping role box: give the group back to the tool that owns it (#50) --- @@ -313,6 +352,30 @@ for u in "${USERS[@]}"; do for g in rig-admin rig incus; do case " $want " in *" $g "*) + # incus stays in the WANTED set — that is what keeps the exact + # convergence below from stripping a box-role user's socket — but on + # a host where we are about to run 'box grant', the ADD is deferred to + # grant itself. Two reasons, both load-bearing: + # + # Grant's rollback only reaches a membership THAT RUN added: a user it + # finds already in incus keeps it when a later step trips + # (box/host/grant-user.sh's was_member branch), by design — stripping + # a membership it did not add could break a working user over a failed + # re-run. So if rig opens the socket first, grant can no longer close + # it, and a grant that fails midway leaves the user holding live + # access to an UN-narrowed project: the stock unhardened incusbr- + # one --network flag away. That state is strictly worse than no grant + # at all, which is the sharp end of #49. Deferring makes the socket and + # the tier land together or fail together. + # + # And grant is the authority on whether the group belongs at all: for + # an incus-admin member it deliberately does NOT add incus (admin + # membership already wins at the socket, so the row would only mislead + # whoever reads the group list later). rig adding it here would put a + # membership on that user which box has decided against — not a loop + # that oscillates, but rig overruling box on box's own tier, which is + # exactly the boundary this whole change is respecting. + if [ "$g" = incus ] && [ "$BOX_GRANT" -eq 1 ]; then continue; fi if ! in_group "$u" "$g"; then usermod -aG "$g" "$u" log "added $u to $g" @@ -331,6 +394,55 @@ for u in "${USERS[@]}"; do esac done + # The tier itself. AFTER the account exists — 'box grant' opens with a + # getent passwd and refuses an unknown user — and after the other groups, + # so a user whose grant fails still lands with everything rig owns outright. + # + # Per-user failures WARN and continue, matching the governing call the + # host= split above already makes: one box-role user somewhere in the fleet + # must not stop apply everywhere VMs don't live, and a project that would + # not converge is exactly that shape of local fact. Host-level facts still + # die, but they die before this loop — the missing group and the missing + # CLI both. What is left here (project creation refused, an instance still + # parked on the private bridge blocking the narrowing, an incus-user socket + # that will not answer) is per-user by construction. + if [ "$BOX_GRANT" -eq 1 ]; then + case ",$roles," in + *,box,*) + had_socket=0 + if in_group "$u" incus; then had_socket=1; fi + log "granting $u the box restricted tier (box grant $u)" + grant_rc=0 + box grant "$u" || grant_rc=$? + if [ "$grant_rc" -eq 0 ]; then + # CHANGED is a claim about what apply DID, and the only part of the + # grant rig can observe from out here is the group transition — + # every other step converges inside incus's own state, with no cheap + # "already converged?" probe short of redoing the work. So report + # the transition when it happens and stay silent otherwise: a + # convergent call that changed nothing must not turn "already + # converged; no changes" into a permanent lie on every host=yes box. + if [ "$had_socket" -eq 0 ] && in_group "$u" incus; then + log "granted $u the restricted tier (incus group, user-$(id -u "$u") project, boxnet-only, box-net profile)" + CHANGED=1 + fi + elif in_group "$u" incus-admin; then + # Today 'box grant' refuses incus-admin members outright — it reads + # their admin membership as "there is nothing tighter to grant", + # which conflates permission with provisioning: they hold the full + # socket but have no project of their own. heavy-duty/box#99 makes + # grant provision them instead of refusing, at which point this + # branch simply stops being reached — no rig change needed, because + # rig asks for the tier and lets box decide what that means. Until + # then the honest report is that nothing was lost: they still hold + # strictly more access than the tier would give them. + warn "box grant $u exited $grant_rc and $u is in incus-admin, which box refuses to grant today — they keep the full socket (incus-admin is strictly stronger than the tier), but they get no user- project of their own and land in the shared default project alongside every other admin. Blocked on heavy-duty/box#99; everything else converged, and a later apply picks the tier up once box stops refusing" + else + warn "box grant $u exited $grant_rc: $u has their account, keys and rig groups, but NOT the box restricted tier — no user- project, no boxnet narrowing, no box-net profile, so their 'box new' will refuse. box's own output above names the cause; fix it and re-run apply (or 'box grant $u' by hand). Every other user still converged — one box-role user must not stop apply for the fleet" + fi ;; + esac + fi + # authorized_keys becomes exactly the file's keys — only the content WRITE # is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode # converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing diff --git a/test/cli.sh b/test/cli.sh index 49c96d4..6b6703a 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -964,6 +964,86 @@ check "drop_incus: an absent pgrep warns rather than guessing" 0 "" \ in_out "$DROP_NOPGREP" "loginctl terminate-user dan" rm -rf "$NOPGREP_D" +# --- the box role grants the TIER, not just the socket (#49) ----------------- +# Group incus is step 1 of the five 'box grant' performs; without the rest the +# user's first 'box new' refuses for want of a box-net profile. Running the +# real thing needs root, an Incus daemon and real accounts, so these assert the +# CALL and its guard rails in the source — the same way every other root-only +# refusal in this harness is pinned. +# The $-refs below are literals we grep FOR in the script — single quotes +# are the point, as in the bootstrap ordering checks above. +# shellcheck disable=SC2016 +check "users apply: box role calls 'box grant', not just usermod" 0 "" \ + grep -qE '^[[:space:]]*box grant "\$u"' "$ROOT/commands/users-apply.sh" +# Ordering is the safety property (repo precedent: bootstrap's marker-then-box +# assert): 'box grant' opens with a getent passwd and refuses an unknown user, +# so the call must come AFTER useradd, never before. Defaults fail closed. +useradd_at="$(grep -nE '^[[:space:]]*useradd -m' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +# shellcheck disable=SC2016 +grant_at="$(grep -nE '^[[:space:]]*box grant "\$u"' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +check "users apply: 'box grant' runs after useradd (grant refuses unknown users)" \ + 0 "" test "${useradd_at:-999999}" -lt "${grant_at:-0}" +# Failure granularity, both halves. A HOST-level fact — box-role users on a +# host=yes box with no box CLI — dies, like the missing-incus-group die beside +# it. A PER-USER grant failure warns and continues, because one box-role user +# somewhere in the fleet must not stop apply everywhere VMs don't live. +check "users apply: a missing box CLI on host=yes is a die, not a warning" 0 "" \ + grep -qF 'die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH' \ + "$ROOT/commands/users-apply.sh" +# shellcheck disable=SC2016 +check "users apply: a per-user grant failure warns and continues" 0 "" \ + grep -qF 'warn "box grant $u exited $grant_rc:' "$ROOT/commands/users-apply.sh" +# The grant is host=yes only: a tier converged into a daemon that is not there +# to enforce it is not policy. Read the guard's own block — BOX_GRANT=0 up to +# the line that sets it to 1 — rather than the file at large, so a host=yes +# match borrowed from the die above cannot pass this for free. +# shellcheck disable=SC2016 # $1 resolves inside the inner bash -c +check "users apply: the grant is gated on host=yes" 0 "" \ + bash -c 'awk "/^BOX_GRANT=0\$/,/BOX_GRANT=1 ;;/" "$1" | grep -q "[*]host=yes[*])"' \ + _ "$ROOT/commands/users-apply.sh" +# The incus-admin case is blocked on heavy-duty/box#99: grant refuses those +# members today, and rig must NOT turn that refusal into a failed apply. The +# branch names the blocker so whoever reads the warning can find the fix. +# shellcheck disable=SC2016 +check "users apply: an incus-admin grant refusal is warned, not fatal" 0 "" \ + grep -q 'elif in_group "$u" incus-admin; then' "$ROOT/commands/users-apply.sh" +check "users apply: the incus-admin warning cites the box-side blocker" 0 "" \ + grep -q "heavy-duty/box#99" "$ROOT/commands/users-apply.sh" +# The group ADD is deferred to grant so a failed grant can take the socket back +# with it (grant only rolls back a membership THAT RUN added). But incus must +# stay in the WANTED set, or the exact-convergence loop's other arm would strip +# a box-role user's socket on the very run that granted it — assert both, since +# either alone is a bug. +# shellcheck disable=SC2016 +check "users apply: the incus group add is deferred to 'box grant'" 0 "" \ + grep -qF 'if [ "$g" = incus ] && [ "$BOX_GRANT" -eq 1 ]; then continue; fi' \ + "$ROOT/commands/users-apply.sh" +# The wanted-set arm gained #58's BOX_ROLE_OK gate on rebase, and the two +# conditions answer different questions: BOX_ROLE_OK is "does the box role +# apply on this box at all" (the marker's call), INCUS_OK is "is the group +# there to converge". Both must hold, and `incus` must still ENTER the wanted +# set when they do — otherwise the exact-convergence else-arm below would +# strip a box-role user's socket on the very run that granted it, which is +# the hazard this PR exists to remove. Pinned as the composed line so a +# regression in either operand fails here. +# shellcheck disable=SC2016 +check "users apply: role box still puts incus in the wanted set" 0 "" \ + grep -qF 'case ",$roles," in *,box,*) if [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac' \ + "$ROOT/commands/users-apply.sh" +# The deferral must be an ADD-side skip only. Landing it on the removal arm +# would leave a de-roled user's socket open forever, so prove the guard sits +# above the usermod -aG and below the wanted-set case, not in the else branch. +# shellcheck disable=SC2016 +defer_at="$(grep -nF 'if [ "$g" = incus ] && [ "$BOX_GRANT" -eq 1 ]; then continue; fi' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +# shellcheck disable=SC2016 +strip_at="$(grep -nE '^[[:space:]]*gpasswd -d "\$u" "\$g"' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +check "users apply: the deferral sits on the add arm, not the removal arm" \ + 0 "" test "${defer_at:-999999}" -lt "${strip_at:-0}" +# rig still never installs Incus (the #12/#25 design law, asserted for +# bootstrap above): calling box's grant is invocation, not installation. +check "users apply: never apt-installs incus (box owns the daemon)" 1 "" \ + grep -nE 'apt-get install.* incus' "$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