feat: users apply grants the box tier, not just the socket #53

Merged
dan-claude-bot merged 1 commit from feat/users-apply-grants-tier into main 2026-07-19 19:12:10 +00:00
4 changed files with 233 additions and 5 deletions

View file

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

View file

@ -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 <user>` for every box-role user — the `incus` group is only
the first of the five steps that grant performs (the `user-<uid>` 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

View file

@ -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 <user>', which is the tier: the
group is only its socket, the user-<uid> 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-<uid> 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-<uid>
# 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-<uid> 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-<uid> 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

View file

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