From 4a06c3ac001ae86b70bff64542b6c530d97948ed Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:18:46 +0000 Subject: [PATCH 1/3] fix: box grant provisions incus-admin members instead of refusing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user- project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 41 ++++++++++++ bin/box | 12 ++++ host/grant-user.sh | 127 ++++++++++++++++++++++++++++++++----- host/revoke-user.sh | 47 +++++++++++++- test/cli.sh | 149 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 358 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f238a23..0531763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,47 @@ which records not just what changed but what each drill run proved. verdict, the single `on.push` key, and the same-job tag+publish+re-arm in the same daemon-free, fail-closed style. +### Fixed + +- **`box grant` provisions an `incus-admin` member instead of refusing them** + (#99) — the refusal read "they already have the admin tier; there is + nothing tighter to grant", which is true about *permission* and silent + about *provisioning*: the `incus` group is indeed a strict subset of what + `incus-admin` opens, but the `user-` project, the boxnet narrowing, + the snapshot and backup allowances, and the `box-net` profile installed + into that project are none of them permissions, and an `incus-admin` + member had none of them — `box_tier()` resolves them to `admin`, so they + worked in the shared default project next to root and every other admin, + with no world of their own and no supported way to get one. `box grant` + now runs the full convergence for them: the group step is a **reported + no-op** (nothing is added — adding `incus` would grant nothing and leave a + group list implying a restriction that was never in force), and steps 2–5 + run unchanged. The touch that provokes the lazy project is **pinned at + incus-user's socket**, which the fix turns out to require: the incus client + picks its socket by writability (`client/connection.go` — the daemon socket + when writable, `unix.socket.user` only otherwise), so for an `incus-admin` + member an unpinned touch sails past incus-user and provisions nothing; the + user-side proof that closes the grant names their project for the same + reason, since an unqualified `profile show` would have answered from the + shared default project and proved nothing. On success the grant prints the + caveat the hard exit was gesturing at, in the two forms it actually takes: + the restrictions are a **default placement, not a confinement** (admin + membership still wins at the socket — the default project and other users' + instances stay one flag away), and until `incus-admin` goes their own `box` + commands keep landing in the default project, at which point the provisioned + project becomes their home with no re-grant needed. The failure path knows + the new case too: nothing was added, so nothing is rolled back, and the + backout says exactly that rather than going quiet. `box revoke` mirrors it — + a bare revoke of an `incus-admin` member is a named **no-op** (it makes no + privileged call at all: there is no membership to drop), and `--purge` + unmakes the provisioning while refusing to call them "out", both naming + `gpasswd -d incus-admin` as the only thing that ends their access. + Unblocks rig's `users apply` (heavy-duty/rig#49), which had to call `box + grant` for a user who is both `incus-admin` by hand and role `box` in the + fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo + shims — every assertion is made against what the run did, not what the + source says it would. + ## 0.7.0 — 2026-07-19 ### Added diff --git a/bin/box b/bin/box index ad26a7a..3150444 100755 --- a/bin/box +++ b/bin/box @@ -542,6 +542,14 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades): rides snapshots, 'box export' rides backups — #70) · installs the box-net profile into their project +An incus-admin member is provisioned too, not refused (#99): the group step +is a reported no-op — they already hold more — and everything else converges, +so they finally have a project of their own. What it is not is a confinement: +incus-admin wins at the socket, so the restrictions are a default placement +they can step outside at will, and their own client keeps resolving to the +admin socket (and the default project) until incus-admin is taken away. The +grant says all of that out loud when it lands. + The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/ import/rm on their own boxes. Not theirs: expose (edits daemon-global state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the @@ -561,6 +569,10 @@ recreate the project, unhardened, afterwards — measured, not theoretical), then their boxes, images, project, private bridge and trust-store certificate are removed — irreversible, so it asks first. +On an incus-admin member there is no membership to drop, so a bare revoke is +a named no-op and --purge unmakes the provisioning without ending any access: +only 'gpasswd -d incus-admin' does that, and revoke says so. + box revoke dev1 # take the tier; their boxes keep running box revoke dev1 --purge # ...or end their sessions and delete everything EOF diff --git a/host/grant-user.sh b/host/grant-user.sh index 01b79c5..4591388 100644 --- a/host/grant-user.sh +++ b/host/grant-user.sh @@ -9,7 +9,9 @@ # Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md. # # So granting is a per-user CONVERGENCE, and it must be run by an admin: -# 1. put the user in the 'incus' group (not incus-admin — that is the tier) +# 1. put the user in the 'incus' group (not incus-admin — that is the tier; +# and for someone already in incus-admin this step is a reported no-op, +# because the grant still owes them everything below — #99) # 2. touch incus-user AS the user, so the lazy project exists to converge # 3. unpin the private bridge (drop eth0 from the project's default profile) # 4. restrict the project's network access to boxnet and ONLY boxnet — @@ -63,12 +65,25 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2; uid="$(id -u "$user")" [ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; } -# An incus-admin member already holds the full socket; "granting" them the -# restricted tier would not restrict anything (admin membership wins at the -# socket), it would only mislead whoever reads the group list later. +# An incus-admin member gets the full convergence anyway (#99). This used to +# be a hard refusal, on the reasoning that admin membership wins at the socket +# so nothing here could restrict them. True — and beside the point, because it +# conflates the two separate things a grant hands over: +# · PERMISSION — the 'incus' group, i.e. socket access. They already hold +# strictly more through incus-admin, so the group step below is a reported +# no-op: adding them to 'incus' would grant nothing and would only mislead +# whoever reads the group list later. +# · PROVISIONING — the user- project, the boxnet narrowing, the +# snapshot and backup allowances, the box-net profile installed INTO that +# project. An incus-admin member has none of it: box_tier() resolves them +# to 'admin' (bin/box), so they work in the SHARED default project next to +# root and every other admin, with no world of their own. This script is +# the only thing that provisions one, and refusing left them unable to get +# it without first being taken out of incus-admin. +# So provision, and say plainly at the end what the provisioning does not do. +admin_member=0 if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then - echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2 - exit 1 + admin_member=1 fi # The stack the tier converges ONTO must exist first. Checked via the daemon, @@ -93,7 +108,10 @@ fi # admin re-runs. Backing out the group closes that window completely for a # fresh grant (their existing sessions predate the membership, so no process # holds it yet). A user who was already in the group keeps it: not ours to -# take on a re-run's failure. +# take on a re-run's failure. And an incus-admin member had nothing added at +# all — nothing to take back, which is not the same as nothing to say: their +# socket outlives the failure by a route this script never granted and must +# not pretend to control (the third branch). added_group=0; was_member=0 backout() { if [ "$added_group" -eq 1 ]; then @@ -114,6 +132,14 @@ backout() { echo "box grant: NOTE — $user has live processes; a session begun during this grant would still hold" >&2 echo " the group until it ends: sudo loginctl terminate-user $user" >&2 fi + elif [ "$admin_member" -eq 1 ]; then + # Nothing was added, so nothing comes back — but a failed grant is still a + # failure, and silence would read as success. Their socket is untouched + # here in both directions: this run never gave it, and 'box revoke' cannot + # take it (that is gpasswd -d incus, and their access is incus-admin's). + echo "box grant: FAILED for $user, who keeps full admin socket access throughout (via 'incus-admin' — this run neither granted nor removed it)." >&2 + echo " nothing was rolled back because nothing was added; their project may be part-converged, and a re-run converges the rest." >&2 + echo " to close their access you must take incus-admin itself: gpasswd -d $user incus-admin" >&2 elif [ "$was_member" -eq 1 ]; then # A user who was ALREADY in the group keeps it — stripping a membership # this run did not add could break a working user over a failed re-run. @@ -126,7 +152,13 @@ backout() { } trap backout EXIT -if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then +if [ "$admin_member" -eq 1 ]; then + # Deliberately NOT usermod -aG incus: 'incus' is a strict subset of what + # incus-admin already opens, so adding it changes no access and leaves a + # group list implying a restriction that was never in force. Report the + # no-op and move on to the part of the grant that does something. + echo "group: $user is in 'incus-admin' — socket access is already theirs, and stronger; leaving the group list alone" +elif id -nG "$user" | tr ' ' '\n' | grep -qx incus; then was_member=1 echo "group: $user already in 'incus'" else @@ -137,12 +169,44 @@ fi project="user-$uid" +# The incus CLI picks its socket by WRITABILITY, not by intent: with no +# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and +# only falls back to unix.socket.user when it is not (client/connection.go, +# stable-6.0 — the same branch that then defaults the project to user-). +# For a plain 'incus' member the fallback fires and every command below lands +# where we want it. For an incus-admin member the daemon socket IS writable, +# so an unpinned client sails straight past incus-user — the touch would not +# provision anything and the grant would die claiming incus-user was +# unhealthy. Pin the socket for them, by incus's own directory rule. +# INCUS_DIR first, then /run/incus if the daemon socket lives there, else +# /var/lib/incus: incus's resolution order, not an approximation of it — the +# pinned path has to name the same directory the client would have chosen. +user_socket="" +if [ "$admin_member" -eq 1 ]; then + incus_dir="${INCUS_DIR:-}" + if [ -z "$incus_dir" ]; then + incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus" + fi + user_socket="$incus_dir/unix.socket.user" + [ -e "$user_socket" ] \ + || { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; } +fi + +# Run the incus CLI as the granted user, on the socket that will actually +# serve them: pinned to incus-user for an admin member, left to the CLI's own +# resolution for everyone else (whose fallback already gets it right). +run_as_incus() { + if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@" + else run_as "$user" "$@" + fi +} + # 2. The project is created LAZILY, on the user's first contact with # incus-user — an admin cannot pre-create it (incus-user would fight over # it), so make that first contact happen now, as the user. if ! incus project show "$project" >/dev/null 2>&1 /dev/null 2>&1 || true + run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true incus project show "$project" >/dev/null 2>&1 &2; exit 1; } echo "project: $project created" @@ -205,10 +269,45 @@ echo "profile: box-net installed in $project" # Prove the grant from the USER's side of the socket — the only side that # matters. This catches the failure the steps above cannot see one at a time: -# a converged project the user still cannot reach. -run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \ - || { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; } +# a converged project the user still cannot reach. For an admin member the +# project is named explicitly: an unqualified profile show over the pinned +# incus-user socket asks about 'default', and over their own admin socket it +# would answer from the shared default project — a green that proves the +# convergence nothing at all. +if [ -n "$user_socket" ]; then + run_as_incus timeout 30 incus --project "$project" profile show box-net >/dev/null 2>&1 \ + || { echo "box grant: converged, but $user cannot reach $project's box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; } +else + run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \ + || { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; } +fi trap - EXIT # converged and verified: the grant stands -echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet." -echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)" +if [ "$admin_member" -eq 1 ]; then + # The honest version of what the old refusal was gesturing at. The project + # is real, converged and theirs — that is what they were missing and what + # this run supplied. What it is NOT is confinement, in two distinct ways + # that both come from incus-admin winning at the socket, and both belong in + # the output rather than in a hard exit: + # · nothing here binds them. Every restriction converged above describes + # project $project; the default project and every other user's instances + # stay one flag away, and no setting inside $project can say otherwise + # while they hold that group. + # · nothing here even routes them, yet. Their unpinned CLI resolves to the + # writable daemon socket and so to the DEFAULT project (the socket rule + # cited at step 2), so their 'box new' still lands beside the other + # admins' until they either drop incus-admin — at which point this + # project becomes their automatic home, no re-run needed — or pin + # INCUS_SOCKET at incus-user by hand. + echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)." + echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a" + echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and" + echo " every other user's instances whenever they choose to." + echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT" + echo " project — the admin socket is the one their client picks. To make $project theirs" + echo " for real: gpasswd -d $user incus-admin (no re-grant needed; the project is ready)." + echo " 'box revoke $user' unwinds this provisioning; it cannot touch their admin access." +else + echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet." + echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)" +fi diff --git a/host/revoke-user.sh b/host/revoke-user.sh index 5a86203..05484d2 100644 --- a/host/revoke-user.sh +++ b/host/revoke-user.sh @@ -56,14 +56,33 @@ if [ "$purge" -eq 1 ]; then fi fi +# An incus-admin member is the mirror of grant's #99 case: 'box grant' gives +# them the provisioning without the group (they already hold strictly more), +# so revoke has no group to take — and saying "revoked" at that would be the +# script claiming a lockout it did not perform. Recorded here, spoken below. +admin_member=0 +if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then + admin_member=1 +fi + # The group, first — access ends even if a purge step below trips. if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then $SUDO gpasswd -d "$user" incus >/dev/null echo "group: removed $user from 'incus'" +elif [ "$admin_member" -eq 1 ]; then + echo "group: $user was never in 'incus' — their socket access is 'incus-admin', which this does not touch" else echo "group: $user was not in 'incus'" fi +# Say it once, plainly, before either path's summary: for an admin member the +# group step above closed nothing, so nothing this script does — including +# --purge — ends their access to the daemon. Only the admin group itself does. +if [ "$admin_member" -eq 1 ]; then + echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host" + echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin" +fi + # Supplementary groups are fixed AT LOGIN: the database change above does # nothing to a session the user already holds — a leftover tmux keeps the # socket until it dies. For a bare revoke that is an honest warning. For @@ -95,7 +114,22 @@ fi if [ "$purge" -eq 0 ]; then if incus project show "$project" >/dev/null 2>&1 project, the boxnet narrowing, +# snapshots, backups, the box-net profile — theirs not at all). Grepping the +# new prose would prove only that the prose exists, so both tier scripts are +# DRIVEN end to end under shims, the same seam setup-host is driven through: +# every incus and sudo call is logged, and the assertions are made against +# those logs — what the run did, not what the source says it would do. +# --------------------------------------------------------------------------- +GSHIM="$(mktemp -d)"; W99="$(mktemp -d)" +cat > "$GSHIM/incus" <<'SHIM' +#!/usr/bin/env bash +# Fake incus for the driven grant/revoke: logs every call, answers the +# existence probes from FAKE_*, and models the two state changes the scripts +# depend on — the project appearing after the incus-user touch, and +# disappearing after a purge deletes it. +[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG" +case "$*" in *"profile edit"*) cat >/dev/null ;; esac +case "$*" in + "network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;; + "project show "*) + [ -e "$FAKE_STATE/deleted" ] && exit 1 + if [ -n "${FAKE_PROJECT_LAZY:-}" ]; then + # Lazy creation: absent on the first look, present afterwards — i.e. + # the touch worked. n counts the looks this run has taken. + n=0; [ -e "$FAKE_STATE/looks" ] && n="$(cat "$FAKE_STATE/looks")" + printf '%s\n' "$((n + 1))" > "$FAKE_STATE/looks" + [ "$n" -ge 1 ] || exit 1 + else + [ -n "${FAKE_HAVE_PROJECT:-}" ] || exit 1 + fi ;; + "project delete "*) : > "$FAKE_STATE/deleted" ;; + *"restricted.networks.access"*) + [ -z "${FAKE_FAIL_NARROW:-}" ] || { echo 'Instance "old" is on incusbr-1000' >&2; exit 1; } ;; + *"network show "*) exit 1 ;; # the private bridge: never there in these runs +esac +exit 0 +SHIM +cat > "$GSHIM/sudo" <<'SHIM' +#!/usr/bin/env bash +# Fake sudo: logs and swallows. 'sudo test' must answer honestly-absent — +# revoke's absence assert reads incus-user's state directory through it, and +# a blanket exit 0 would report the purge incomplete on a clean machine. +[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG" +case "${1:-}" in test) exit 1 ;; esac +exit 0 +SHIM +printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent" +printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/systemctl" +printf '#!/usr/bin/env bash\nexit 1\n' > "$GSHIM/pgrep" +chmod +x "$GSHIM/incus" "$GSHIM/sudo" "$GSHIM/getent" "$GSHIM/systemctl" "$GSHIM/pgrep" + +# The pinned incus-user socket. box grant resolves it through INCUS_DIR (the +# client's own first choice), so a directory here is the whole seam. +mkdir -p "$W99/incusdir"; : > "$W99/incusdir/unix.socket.user" + +rungrant() { # rungrant [VAR=val ...] — the real grant, shimmed + local groups="$1" state="$2"; shift 2 + mkdir -p "$state" + env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" \ + FAKE_HAVE_BOXNET=1 FAKE_PROJECT_LAZY=1 INCUS_DIR="$W99/incusdir" \ + FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \ + PATH="$GSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/grant-user.sh" dev1 +} + +# --- the admin member: full convergence, no group change, honest caveat ----- +A="$W99/admin" +check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \ + rungrant "users incus-admin" "$A" +check "grant: ...and the group step is a reported no-op" 0 "leaving the group list alone" \ + rungrant "users incus-admin" "$W99/a2" +check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \ + rungrant "users incus-admin" "$W99/a3" +check "grant: ...and names the group that has to go for it to bind" 0 "gpasswd -d dev1 incus-admin" \ + rungrant "users incus-admin" "$W99/a4" +# The logs: what the run actually did to the machine. +check "grant: the admin member is NOT added to 'incus' (nothing to add)" 1 "" \ + grep -qF 'usermod -aG incus' "$A/sudo.log" +check "grant: their project is still narrowed to boxnet" 0 "" \ + grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log" +check "grant: their project still gets snapshots" 0 "" \ + grep -qF 'project set user-1000 restricted.snapshots allow' "$A/incus.log" +check "grant: their project still gets backups" 0 "" \ + grep -qF 'project set user-1000 restricted.backups allow' "$A/incus.log" +check "grant: box-net is still installed INTO their project" 0 "" \ + grep -qF -- '--project user-1000 profile edit box-net' "$A/incus.log" +# The socket pin (#99's teeth): incus's client takes the DAEMON socket when it +# is writable, and only falls back to unix.socket.user when it is not — so for +# an incus-admin member an unpinned touch never reaches incus-user at all, and +# the project it was supposed to create never appears. +check "grant: the touch is pinned at incus-user's socket (the admin socket would win)" 0 "" \ + grep -qF "INCUS_SOCKET=$W99/incusdir/unix.socket.user" "$A/sudo.log" +check "grant: the user-side proof names their project (an unqualified show proves nothing)" 0 "" \ + grep -qF -- '--project user-1000 profile show box-net' "$A/sudo.log" + +# --- the restricted user: unchanged, and unpinned --------------------------- +R="$W99/restricted" +check "grant: a plain user is still added to 'incus'" 0 "added dev1 to 'incus'" \ + rungrant "users" "$R" +check "grant: ...via usermod (the log, not the prose)" 0 "" \ + grep -qF 'usermod -aG incus dev1' "$R/sudo.log" +check "grant: ...and their client is left to its own socket fallback" 1 "" \ + grep -qF 'INCUS_SOCKET' "$R/sudo.log" + +# --- the failure path: nothing was added, so nothing comes back — loudly ---- +F="$W99/failed" +check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \ + rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1 +check "grant: ...says their admin socket was neither granted nor removed here" 1 "neither granted nor removed" \ + rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1 +check "grant: ...and rolls nothing back, because nothing was added" 1 "" \ + grep -qF 'gpasswd -d' "$F/sudo.log" + +# --- revoke, the mirror: it cannot take what it never gave ------------------ +# BOX_YES=1 throughout: --purge is destructive and refuses without a terminal +# to confirm on, and this suite has none. It changes nothing for a bare revoke. +runrevoke() { # runrevoke [script args...] + local state="$1"; shift + mkdir -p "$state" + env FAKE_UID=1000 FAKE_GROUPS="users incus-admin" FAKE_STATE="$state" BOX_YES=1 \ + FAKE_HAVE_PROJECT=1 FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \ + PATH="$GSHIM:$SHIMDIR:$PATH" bash "$ROOT/host/revoke-user.sh" dev1 "$@" +} +V="$W99/revoke" +check "revoke: a bare revoke of an admin member is a named no-op, not 'revoked'" 0 "no-op:" \ + runrevoke "$V" +check "revoke: ...and says their access is incus-admin's, untouched here" 0 "which this does not touch" \ + runrevoke "$W99/v2" +check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \ + runrevoke "$W99/v3" +# Stronger than "no gpasswd": a bare revoke of an admin member makes no +# privileged call whatsoever — the sudo log never comes into existence (the +# same absence-of-the-log assertion the #80 refusals are held to). +check "revoke: ...having made NO privileged call at all (no membership to drop)" 1 "" \ + test -e "$V/sudo.log" +P="$W99/purge" +check "revoke --purge: still unmakes the provisioning" 0 "purged:" \ + runrevoke "$P" --purge +check "revoke --purge: ...and refuses to call an admin member 'out'" 0 "is NOT out" \ + runrevoke "$W99/p2" --purge +check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \ + grep -qF 'project delete user-1000' "$P/incus.log" +rm -rf "$GSHIM" "$W99" # shellcheck disable=SC2016 # the $-strings are literals in the target file check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c ' gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" From 8598cf96fed4d8695959641fc06b79b2e5c01dce Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:50:42 +0000 Subject: [PATCH 2/3] fix: grant the 'incus' membership to incus-admin members too (#101 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user- project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 97 ++++++++++++++++-------- docs/plans/2026-07-18-restricted-tier.md | 8 +- drill/multiuser.sh | 64 +++++++++++++++- host/grant-user.sh | 97 ++++++++++++++++-------- host/revoke-user.sh | 38 +++++++--- test/cli.sh | 94 +++++++++++++++++------ 6 files changed, 297 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0531763..5579bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,40 +38,75 @@ which records not just what changed but what each drill run proved. (#99) — the refusal read "they already have the admin tier; there is nothing tighter to grant", which is true about *permission* and silent about *provisioning*: the `incus` group is indeed a strict subset of what - `incus-admin` opens, but the `user-` project, the boxnet narrowing, - the snapshot and backup allowances, and the `box-net` profile installed - into that project are none of them permissions, and an `incus-admin` - member had none of them — `box_tier()` resolves them to `admin`, so they - worked in the shared default project next to root and every other admin, - with no world of their own and no supported way to get one. `box grant` - now runs the full convergence for them: the group step is a **reported - no-op** (nothing is added — adding `incus` would grant nothing and leave a - group list implying a restriction that was never in force), and steps 2–5 - run unchanged. The touch that provokes the lazy project is **pinned at - incus-user's socket**, which the fix turns out to require: the incus client - picks its socket by writability (`client/connection.go` — the daemon socket - when writable, `unix.socket.user` only otherwise), so for an `incus-admin` - member an unpinned touch sails past incus-user and provisions nothing; the - user-side proof that closes the grant names their project for the same - reason, since an unqualified `profile show` would have answered from the - shared default project and proved nothing. On success the grant prints the - caveat the hard exit was gesturing at, in the two forms it actually takes: - the restrictions are a **default placement, not a confinement** (admin - membership still wins at the socket — the default project and other users' - instances stay one flag away), and until `incus-admin` goes their own `box` - commands keep landing in the default project, at which point the provisioned - project becomes their home with no re-grant needed. The failure path knows - the new case too: nothing was added, so nothing is rolled back, and the - backout says exactly that rather than going quiet. `box revoke` mirrors it — - a bare revoke of an `incus-admin` member is a named **no-op** (it makes no - privileged call at all: there is no membership to drop), and `--purge` - unmakes the provisioning while refusing to call them "out", both naming + `incus-admin` opens **at the daemon API**, but the `user-` project, the + boxnet narrowing, the snapshot and backup allowances, and the `box-net` + profile installed into that project are none of them permissions, and an + `incus-admin` member had none of them — `box_tier()` resolves them to + `admin`, so they worked in the shared default project next to root and every + other admin, with no world of their own and no supported way to get one. + `box grant` now runs the full convergence for them. + + The group step is part of that convergence, not an exception to it: an + `incus-admin` member is added to `incus` like anyone else. The subset + argument holds for the API and **fails at the filesystem**, which is where + it matters here — the two sockets are two files with two owning groups + (Debian 13 / Incus 6.0.4, measured): + + | socket | group | mode | + | --- | --- | --- | + | `/var/lib/incus/unix.socket` | `incus-admin` | 0660 | + | `/var/lib/incus/unix.socket.user` | `incus` | 0660 | + + `incus-admin` opens the first and not the second, and only the second + provisions a `user-` project. Without the membership the provisioning + touch takes `EACCES`, the swallowing `|| true` hides it, no project appears, + and the grant dies blaming a perfectly healthy incus-user — the exact + incus-admin-only user #99 is about, left no better off. So the membership is + granted, and the grant says out loud why: it is the key to a file, not a new + privilege (`box_tier()` still reads them as `admin`, both-groups → `admin`). + + The touch itself is **pinned at incus-user's socket**: the incus client picks + by writability (`client/connection.go` — the daemon socket when writable, + `unix.socket.user` only otherwise), so for an `incus-admin` member an + unpinned touch sails past incus-user and provisions nothing. The user-side + proof that closes the grant names their project for the same reason, since an + unqualified `profile show` would have answered from the shared default + project and proved nothing. The socket's existence is probed through `$SUDO`, + not a bare `[ -e ]` — `/var/lib/incus` is not traversable by a non-root + admin, so an unprivileged stat reports a present socket as absent, and this + probe exits on absent (the discipline `box revoke` already documents). + + On success the grant prints the caveat the hard exit was gesturing at, in the + two forms it actually takes: the restrictions are a **default placement, not + a confinement** (admin membership still wins at the socket — the default + project and other users' instances stay one flag away), and until + `incus-admin` goes their own `box` commands keep landing in the default + project. Dropping `incus-admin` then lands them in their ready project with + **no re-grant** — a promise that is only true because they keep `incus`; + without it that drop would leave them in neither group, `box_tier()` `none`, + and a converged project they could not open. The failure path follows: the + membership this run added is rolled back and verified, while the backout + refuses to call that a lockout — `incus-admin` is untouched and still opens + every project. + + `box revoke` mirrors it. A bare revoke of a granted `incus-admin` member now + takes the `incus` membership back and reports **`partial:`** — the socket key + `box grant` added is gone, their project is kept, and they are explicitly + **not** locked out. An `incus-admin` member who was never granted is still a + named **no-op** that makes no privileged call at all. `--purge` unmakes the + provisioning while refusing to call them "out". Every path names `gpasswd -d incus-admin` as the only thing that ends their access. + Unblocks rig's `users apply` (heavy-duty/rig#49), which had to call `box grant` for a user who is both `incus-admin` by hand and role `box` in the - fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo - shims — every assertion is made against what the run did, not what the - source says it would. + fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo shims + — every assertion is made against what the run did, not what the source says + it would — and, because those shims model neither `INCUS_SOCKET` nor file + permissions and so cannot reproduce the `EACCES`, measured on real Incus in + CI by a new `drill/multiuser.sh` criterion (o): an `incus-admin`-only member + is granted, the membership lands, the project appears, `unix.socket.user` + opens as them, and dropping `incus-admin` leaves them in their own project + with no re-grant. ## 0.7.0 — 2026-07-19 diff --git a/docs/plans/2026-07-18-restricted-tier.md b/docs/plans/2026-07-18-restricted-tier.md index 8fbec3d..042ebdc 100644 --- a/docs/plans/2026-07-18-restricted-tier.md +++ b/docs/plans/2026-07-18-restricted-tier.md @@ -127,11 +127,13 @@ It had never worked. ## Rehearsal and CI `drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves -criteria (a)–(f) from #74 plus the measured extensions (g)–(n): the in-box +criteria (a)–(f) from #74 plus the measured extensions (g)–(o): the in-box isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop, name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and -scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure -injections (n). Real users, real grants, real mints, probes from inside; +scoped revoke, the raw-attach scoped guarantee (m), the grant-failure +injections (n) and the `incus-admin`-only grant (o) — the last of these +because the shim suite models neither `INCUS_SOCKET` nor socket permissions, +so the group gate on `unix.socket.user` can only be measured live (#99, #101). Real users, real grants, real mints, probes from inside; `--container` for CI, VM mode on real hardware; cleanup deletes everything it made. diff --git a/drill/multiuser.sh b/drill/multiuser.sh index 9de9616..3f2dd7c 100644 --- a/drill/multiuser.sh +++ b/drill/multiuser.sh @@ -30,6 +30,9 @@ # host-owned control — the scoped guarantee, measured (#75 review) # n. a grant that fails is fail-closed: fresh user backed out (verified), # pre-existing member warned loudly, re-run converges (#75 review) +# o. an incus-admin-ONLY member is provisioned for real: the group step +# opens incus-user's socket, the lazy project appears, and dropping +# incus-admin lands them in it with no re-grant (#99, #101 review) # # ok/no/note return 0 by design — the 'A && ok || no' idiom below is the # same one drill.sh is built on (and the reason for the SC2015 disable). @@ -114,7 +117,7 @@ cleanup() { [ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; } echo echo "── cleanup" - for u in "$U1" "$U2" boxdrill3 boxdrill4; do + for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do id "$u" >/dev/null 2>&1 || continue # A half-failed purge followed by userdel leaves a project owned by # nobody — and doctor's leftover check keys on the USER existing. Keep @@ -469,6 +472,65 @@ else fi aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge" +phase "o. an incus-admin-ONLY member — #99's canonical user, on real Incus" +# The case the shim suite structurally cannot reach: the fake 'incus' in +# test/cli.sh ignores INCUS_SOCKET and file permissions, so a grant that could +# never connect() still logged a clean run there. This is the same path over +# the real daemon, where the socket is a real file with a real owning group. +# +# The blocker it exists to catch (#101 review): incus-user's socket is +# /var/lib/incus/unix.socket.user, group 'incus', mode 0660. incus-admin opens +# the ADMIN socket and not that one, so an incus-admin-only member without an +# 'incus' membership takes EACCES on grant's pinned touch — swallowed by its +# '|| true' — no project is created, and the grant dies blaming a perfectly +# healthy incus-user. Every assertion below is dead under that implementation. +U5=boxdrill5 +useradd -m -s /bin/bash "$U5" 2>/dev/null +usermod -aG incus-admin "$U5" +gpasswd -d "$U5" incus >/dev/null 2>&1 || true # stage the ONLY, exactly +uid5="$(id -u "$U5")"; p5="user-$uid5" +id -nG "$U5" | tr ' ' '\n' | grep -qx incus \ + && no "(o) $U5 is already in 'incus' — the admin-ONLY precondition is not staged, so this phase proves nothing" \ + || ok "(o) $U5 staged in 'incus-admin' only (the precondition the blocker needed)" + +out="$(box grant "$U5" 2>&1)"; rc=$? +if [ "$rc" -eq 0 ]; then + ok "(o) box grant converges an incus-admin-only member (rc=0)" +else + no "(o) box grant FAILED for the admin-only member (rc=$rc) — #99 is still closed:" + printf '%s\n' "$out" | tail -4 | sed 's/^/ /' +fi +id -nG "$U5" | tr ' ' '\n' | grep -qx incus \ + && ok "(o) the grant put them in 'incus' — the group that owns unix.socket.user" \ + || no "(o) still not in 'incus': the pinned touch cannot connect() to incus-user's socket" +incus project show "$p5" >/dev/null 2>&1 \ + && ok "(o) $p5 exists — the lazy touch really reached incus-user AS them" \ + || no "(o) $p5 was never created — the touch never reached incus-user (the EACCES this phase is for)" + +# The socket, directly: the connect() that used to fail, measured as them. +# Resolved by incus's own directory rule, not hardcoded. +sockdir=/var/lib/incus; [ -e /run/incus/unix.socket ] && sockdir=/run/incus +as_u "$U5" env INCUS_SOCKET="$sockdir/unix.socket.user" incus --project "$p5" profile show box-net >/dev/null 2>&1 \ + && ok "(o) they can open unix.socket.user and read $p5's box-net profile" \ + || no "(o) EACCES/unreachable on $sockdir/unix.socket.user — the #101 blocker is back" +acc5="$(incus project get "$p5" restricted.networks.access 2>/dev/null)" +[ "$acc5" = boxnet ] \ + && ok "(o) $p5 is narrowed to boxnet like any other granted project" \ + || no "(o) $p5 restricted.networks.access = '$acc5' — the admin-only grant converged half a project" + +# Grant's own closing promise, measured: "gpasswd -d incus-admin (no +# re-grant needed; the project is ready)". True only because they were left in +# 'incus' — under the old no-op this drop left them in NEITHER group, box_tier +# 'none', and a ready project they could not open. So drop it and look. +gpasswd -d "$U5" incus-admin >/dev/null 2>&1 +projects5="$(as_u "$U5" incus project list --format csv 2>/dev/null | cut -d, -f1)" +if [ "$(printf '%s\n' "$projects5" | grep -c .)" = 1 ] && printf '%s' "$projects5" | grep -q "$p5"; then + ok "(o) dropping incus-admin lands them in $p5 with NO re-grant — the promise holds" +else + no "(o) after dropping incus-admin they see: '$(printf '%s' "$projects5" | tr '\n' ' ')' — grant's no-re-grant promise is false" +fi +aud "o. incus-admin-only grant: in-'incus'=$(id -nG "$U5" 2>/dev/null | tr ' ' '\n' | grep -cx incus), project '$p5' access='$acc5', post-drop projects='$(printf '%s' "$projects5" | tr '\n' ' ')'" + echo echo "════════════════════════════════════════════" echo " $pass passed, $fail failed" diff --git a/host/grant-user.sh b/host/grant-user.sh index 4591388..c8bd708 100644 --- a/host/grant-user.sh +++ b/host/grant-user.sh @@ -9,9 +9,10 @@ # Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md. # # So granting is a per-user CONVERGENCE, and it must be run by an admin: -# 1. put the user in the 'incus' group (not incus-admin — that is the tier; -# and for someone already in incus-admin this step is a reported no-op, -# because the grant still owes them everything below — #99) +# 1. put the user in the 'incus' group (not incus-admin — that is the tier). +# An incus-admin member goes in too, and NOT for privilege: incus-user's +# socket is a FILE, group 'incus', mode 0660, so the membership is the +# only thing that lets step 2 connect() at all (#99, #101 review) # 2. touch incus-user AS the user, so the lazy project exists to converge # 3. unpin the private bridge (drop eth0 from the project's default profile) # 4. restrict the project's network access to boxnet and ONLY boxnet — @@ -69,10 +70,19 @@ uid="$(id -u "$user")" # be a hard refusal, on the reasoning that admin membership wins at the socket # so nothing here could restrict them. True — and beside the point, because it # conflates the two separate things a grant hands over: -# · PERMISSION — the 'incus' group, i.e. socket access. They already hold -# strictly more through incus-admin, so the group step below is a reported -# no-op: adding them to 'incus' would grant nothing and would only mislead -# whoever reads the group list later. +# · PERMISSION — the 'incus' group. At the DAEMON API they already hold +# strictly more through incus-admin, so this group adds no privilege. It +# is still required, because the two sockets are two FILES with two +# different owning groups (Debian 13 / Incus 6.0.4, measured): +# /var/lib/incus/unix.socket group incus-admin 0660 +# /var/lib/incus/unix.socket.user group incus 0660 +# incus-admin opens the first and not the second, and the second is the +# only one that provisions a user- project. An earlier revision of +# this script skipped the usermod for an admin member, reasoning that +# 'incus' is a subset of incus-admin — true of the API, false of the +# filesystem: the pinned touch below took EACCES, the '|| true' swallowed +# it, no project appeared, and the grant died blaming a healthy +# incus-user. So the group step is a real convergence for everyone. # · PROVISIONING — the user- project, the boxnet narrowing, the # snapshot and backup allowances, the box-net profile installed INTO that # project. An incus-admin member has none of it: box_tier() resolves them @@ -108,10 +118,11 @@ fi # admin re-runs. Backing out the group closes that window completely for a # fresh grant (their existing sessions predate the membership, so no process # holds it yet). A user who was already in the group keeps it: not ours to -# take on a re-run's failure. And an incus-admin member had nothing added at -# all — nothing to take back, which is not the same as nothing to say: their -# socket outlives the failure by a route this script never granted and must -# not pretend to control (the third branch). +# take on a re-run's failure. An incus-admin member now takes those same two +# paths (#101): the membership IS added for them and so IS backed out, with +# one thing extra to say either way — the rollback closes incus-user's socket +# and never their daemon access, which outlives this failure by a route the +# script never granted and must not pretend to control. added_group=0; was_member=0 backout() { if [ "$added_group" -eq 1 ]; then @@ -124,6 +135,16 @@ backout() { exit 1 fi echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2 + if [ "$admin_member" -eq 1 ]; then + # The rollback is real and verified, but for an admin member it is not a + # lockout and must not read as one: what came back was incus-user's + # socket key, not the daemon. Say what survives, and what would end it. + echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2 + echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2 + echo " neither granted nor removed, so every project on this host stays open to them." >&2 + echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2 + echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2 + fi # The one window the database cannot close: a login STARTED between our # usermod and this backout keeps the group in its session credentials. # For a fresh grant that is a rare race, but rare is not never — name it @@ -132,14 +153,6 @@ backout() { echo "box grant: NOTE — $user has live processes; a session begun during this grant would still hold" >&2 echo " the group until it ends: sudo loginctl terminate-user $user" >&2 fi - elif [ "$admin_member" -eq 1 ]; then - # Nothing was added, so nothing comes back — but a failed grant is still a - # failure, and silence would read as success. Their socket is untouched - # here in both directions: this run never gave it, and 'box revoke' cannot - # take it (that is gpasswd -d incus, and their access is incus-admin's). - echo "box grant: FAILED for $user, who keeps full admin socket access throughout (via 'incus-admin' — this run neither granted nor removed it)." >&2 - echo " nothing was rolled back because nothing was added; their project may be part-converged, and a re-run converges the rest." >&2 - echo " to close their access you must take incus-admin itself: gpasswd -d $user incus-admin" >&2 elif [ "$was_member" -eq 1 ]; then # A user who was ALREADY in the group keeps it — stripping a membership # this run did not add could break a working user over a failed re-run. @@ -148,23 +161,35 @@ backout() { echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2 echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2 echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2 + if [ "$admin_member" -eq 1 ]; then + # Same correction as above, for the member who was in BOTH groups before + # this run: 'box revoke' takes the 'incus' key back, and still leaves + # them the whole daemon. + echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2 + echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2 + echo " access: gpasswd -d $user incus-admin" >&2 + fi fi } trap backout EXIT -if [ "$admin_member" -eq 1 ]; then - # Deliberately NOT usermod -aG incus: 'incus' is a strict subset of what - # incus-admin already opens, so adding it changes no access and leaves a - # group list implying a restriction that was never in force. Report the - # no-op and move on to the part of the grant that does something. - echo "group: $user is in 'incus-admin' — socket access is already theirs, and stronger; leaving the group list alone" -elif id -nG "$user" | tr ' ' '\n' | grep -qx incus; then +if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then was_member=1 echo "group: $user already in 'incus'" else $SUDO usermod -aG incus "$user" added_group=1 - echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)" + if [ "$admin_member" -eq 1 ]; then + # Say why, because the group list alone would imply a restriction that is + # not in force — the concern the old no-op was built around. It was a + # cosmetic concern and this is where it gets carried: in output, not in a + # skipped mutation that broke the mechanism. + echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon," + echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is" + echo " group 'incus' mode 0660, and nothing below can provision $user without it" + else + echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)" + fi fi project="user-$uid" @@ -188,7 +213,11 @@ if [ "$admin_member" -eq 1 ]; then incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus" fi user_socket="$incus_dir/unix.socket.user" - [ -e "$user_socket" ] \ + # $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement + # that /var/lib/incus is not traversable by a non-root admin, so an + # unprivileged stat answers "absent" for a socket that is very much there — + # and this check EXITS on absent. Same discipline, same reason (#101 review). + $SUDO test -e "$user_socket" \ || { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; } fi @@ -299,14 +328,22 @@ if [ "$admin_member" -eq 1 ]; then # admins' until they either drop incus-admin — at which point this # project becomes their automatic home, no re-run needed — or pin # INCUS_SOCKET at incus-user by hand. + # That "no re-run needed" is a real promise only because the group step + # above put them in 'incus' (#101): dropping incus-admin leaves them a + # plain 'incus' member, which is exactly the tier whose client falls back + # to unix.socket.user and lands in $project. Under the old no-op they would + # have been left in NEITHER group — box_tier 'none', no socket at all, and + # a converged project they could not open. echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)." echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a" echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and" echo " every other user's instances whenever they choose to." echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT" echo " project — the admin socket is the one their client picks. To make $project theirs" - echo " for real: gpasswd -d $user incus-admin (no re-grant needed; the project is ready)." - echo " 'box revoke $user' unwinds this provisioning; it cannot touch their admin access." + echo " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so" + echo " their client falls straight back to incus-user and $project is already ready)." + echo " 'box revoke $user' unwinds this provisioning and takes the 'incus' membership back;" + echo " it cannot touch their admin access." else echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet." echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)" diff --git a/host/revoke-user.sh b/host/revoke-user.sh index 05484d2..147a05f 100644 --- a/host/revoke-user.sh +++ b/host/revoke-user.sh @@ -56,21 +56,30 @@ if [ "$purge" -eq 1 ]; then fi fi -# An incus-admin member is the mirror of grant's #99 case: 'box grant' gives -# them the provisioning without the group (they already hold strictly more), -# so revoke has no group to take — and saying "revoked" at that would be the -# script claiming a lockout it did not perform. Recorded here, spoken below. +# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES +# put them in 'incus' — not for privilege, but because incus-user's socket is +# a file owned by that group (#101) — so revoke has a real membership to take +# back here. What it does not have is a lockout: 'incus-admin' opens the +# daemon and is not this script's to remove, so "revoked" would still be the +# script claiming something it did not perform. Recorded here, spoken below. admin_member=0 if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then admin_member=1 fi # The group, first — access ends even if a purge step below trips. +dropped_group=0 if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then $SUDO gpasswd -d "$user" incus >/dev/null - echo "group: removed $user from 'incus'" + dropped_group=1 + if [ "$admin_member" -eq 1 ]; then + echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which" + echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here" + else + echo "group: removed $user from 'incus'" + fi elif [ "$admin_member" -eq 1 ]; then - echo "group: $user was never in 'incus' — their socket access is 'incus-admin', which this does not touch" + echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch" else echo "group: $user was not in 'incus'" fi @@ -116,17 +125,22 @@ if [ "$purge" -eq 0 ]; then echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)" if [ "$admin_member" -eq 1 ]; then # "restores access" would be the wrong promise here: no access was lost. - echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project" + echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership" else echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access" fi fi if [ "$admin_member" -eq 1 ]; then - # Not "revoked": there was no tier of theirs to take. What a bare revoke - # did here is exactly nothing — the group was never theirs to lose and the - # project is kept — so name the two real options instead of a summary that - # would read as a lockout. - echo "no-op: $user holds the admin tier via 'incus-admin', not the restricted tier — nothing was taken." + # Not "revoked", still: what came back is the 'incus' membership grant + # added for incus-user's socket, and their access to this host was never + # riding on it. The project is kept. So say exactly what was taken and + # name the two real options, instead of a summary that reads as a lockout. + if [ "$dropped_group" -eq 1 ]; then + echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added." + echo " $user is NOT locked out: 'incus-admin' still opens every project on this host." + else + echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken." + fi echo " to remove their access: gpasswd -d $user incus-admin" echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge" exit 0 diff --git a/test/cli.sh b/test/cli.sh index 8b641ba..d4e2a73 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -549,6 +549,15 @@ check "rehearsal: measures the raw boxnet attach (criterion m)" 0 "" \ # shellcheck disable=SC2016 # the $-string is a literal in the target file check "rehearsal: injects grant failures (criterion n)" 0 "" \ grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh" +# Criterion o is the real-Incus half of #101: the shim cannot model an EACCES +# on the user socket, so the admin-only grant is measured where the socket has +# a real owning group. Pinned so it cannot quietly leave the rehearsal. +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "rehearsal: grants an incus-admin-ONLY member on real Incus (criterion o)" 0 "" \ + grep -qF 'usermod -aG incus-admin "$U5"' "$ROOT/drill/multiuser.sh" +# shellcheck disable=SC2016 # ditto +check "rehearsal: ...and opens the user socket as them, not just the daemon" 0 "" \ + grep -qF 'INCUS_SOCKET="$sockdir/unix.socket.user"' "$ROOT/drill/multiuser.sh" # shellcheck disable=SC2016 # the $-strings are literals in the target file check "revoke: purge deletes instances one at a time" 0 "" \ grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh" @@ -596,11 +605,14 @@ exit 0 SHIM cat > "$GSHIM/sudo" <<'SHIM' #!/usr/bin/env bash -# Fake sudo: logs and swallows. 'sudo test' must answer honestly-absent — -# revoke's absence assert reads incus-user's state directory through it, and -# a blanket exit 0 would report the purge incomplete on a clean machine. +# Fake sudo: logs and swallows — EXCEPT 'sudo test', which is run for real. +# Both scripts route filesystem probes through it on purpose (/var/lib/incus +# is not traversable by a non-root admin, so an unprivileged stat lies), and +# both directions matter here: revoke's absence assert must see incus-user's +# state directory as genuinely absent on a clean machine, and grant's socket +# check must see the shimmed unix.socket.user as genuinely present. [ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG" -case "${1:-}" in test) exit 1 ;; esac +case "${1:-}" in test) shift; test "$@"; exit $? ;; esac exit 0 SHIM printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent" @@ -625,15 +637,24 @@ rungrant() { # rungrant [VAR=val ...] — the real grant, s A="$W99/admin" check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \ rungrant "users incus-admin" "$A" -check "grant: ...and the group step is a reported no-op" 0 "leaving the group list alone" \ +check "grant: ...and the group step is a real convergence, named as one" 0 "added dev1 to 'incus'" \ rungrant "users incus-admin" "$W99/a2" +check "grant: ...saying WHY (the socket is a file, group 'incus', not a privilege)" 0 "mode 0660" \ + rungrant "users incus-admin" "$W99/a2b" check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \ rungrant "users incus-admin" "$W99/a3" check "grant: ...and names the group that has to go for it to bind" 0 "gpasswd -d dev1 incus-admin" \ rungrant "users incus-admin" "$W99/a4" # The logs: what the run actually did to the machine. -check "grant: the admin member is NOT added to 'incus' (nothing to add)" 1 "" \ - grep -qF 'usermod -aG incus' "$A/sudo.log" +# #101's decision, pinned at the seam that broke: an incus-admin member IS +# usermod'ed into 'incus'. It buys them no API privilege they lack — but +# incus-user's socket is a FILE, group 'incus' mode 0660, and without the +# membership the pinned touch below takes EACCES, the '|| true' eats it, and +# the grant dies blaming a healthy incus-user. The shim cannot model that +# EACCES (it ignores INCUS_SOCKET and permissions entirely), so the decision +# is pinned here and MEASURED on real Incus in drill/multiuser.sh criterion o. +check "grant: the admin member IS added to 'incus' — the user socket's group (#101)" 0 "" \ + grep -qF 'usermod -aG incus dev1' "$A/sudo.log" check "grant: their project is still narrowed to boxnet" 0 "" \ grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log" check "grant: their project still gets snapshots" 0 "" \ @@ -650,6 +671,11 @@ check "grant: the touch is pinned at incus-user's socket (the admin socket would grep -qF "INCUS_SOCKET=$W99/incusdir/unix.socket.user" "$A/sudo.log" check "grant: the user-side proof names their project (an unqualified show proves nothing)" 0 "" \ grep -qF -- '--project user-1000 profile show box-net' "$A/sudo.log" +# The socket existence probe rides $SUDO, like revoke's: /var/lib/incus is not +# traversable by a non-root admin, and a bare [ -e ] there false-fails into an +# exit that blames incus-user for a socket that is present (#101 review). +check "grant: the socket probe goes through sudo, not a bare [ -e ]" 0 "" \ + grep -qF "test -e $W99/incusdir/unix.socket.user" "$A/sudo.log" # --- the restricted user: unchanged, and unpinned --------------------------- R="$W99/restricted" @@ -660,42 +686,62 @@ check "grant: ...via usermod (the log, not the prose)" 0 "" \ check "grant: ...and their client is left to its own socket fallback" 1 "" \ grep -qF 'INCUS_SOCKET' "$R/sudo.log" -# --- the failure path: nothing was added, so nothing comes back — loudly ---- +# --- the failure path: what this run added comes back, and says what didn't -- F="$W99/failed" check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \ rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1 check "grant: ...says their admin socket was neither granted nor removed here" 1 "neither granted nor removed" \ rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1 -check "grant: ...and rolls nothing back, because nothing was added" 1 "" \ - grep -qF 'gpasswd -d' "$F/sudo.log" +# The membership IS this run's now, so the backout IS its business (#101). +check "grant: ...and DOES roll the 'incus' membership back (this run added it)" 0 "" \ + grep -qF 'gpasswd -d dev1 incus' "$F/sudo.log" +check "grant: ...while refusing to call that rollback a lockout" 1 "closed incus-user's socket, NOT" \ + rungrant "users incus-admin" "$W99/f3" FAKE_FAIL_NARROW=1 # --- revoke, the mirror: it cannot take what it never gave ------------------ # BOX_YES=1 throughout: --purge is destructive and refuses without a terminal # to confirm on, and this suite has none. It changes nothing for a bare revoke. -runrevoke() { # runrevoke [script args...] - local state="$1"; shift +runrevoke() { # runrevoke [script args...] + local groups="$1" state="$2"; shift 2 mkdir -p "$state" - env FAKE_UID=1000 FAKE_GROUPS="users incus-admin" FAKE_STATE="$state" BOX_YES=1 \ + env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" BOX_YES=1 \ FAKE_HAVE_PROJECT=1 FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \ PATH="$GSHIM:$SHIMDIR:$PATH" bash "$ROOT/host/revoke-user.sh" dev1 "$@" } +# The granted admin member is in BOTH groups — that is what 'box grant' leaves +# behind now (#101) — so revoke has a real membership to take back. It takes +# it, and still refuses to call the result a lockout: 'incus-admin' holds the +# daemon and is not this script's to remove. +GRANTED="users incus incus-admin" V="$W99/revoke" -check "revoke: a bare revoke of an admin member is a named no-op, not 'revoked'" 0 "no-op:" \ - runrevoke "$V" -check "revoke: ...and says their access is incus-admin's, untouched here" 0 "which this does not touch" \ - runrevoke "$W99/v2" +check "revoke: a bare revoke of a granted admin member is 'partial', not 'revoked'" 0 "partial:" \ + runrevoke "$GRANTED" "$V" +check "revoke: ...and refuses to call it a lockout" 0 "is NOT locked out" \ + runrevoke "$GRANTED" "$W99/v2" check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \ - runrevoke "$W99/v3" -# Stronger than "no gpasswd": a bare revoke of an admin member makes no -# privileged call whatsoever — the sudo log never comes into existence (the -# same absence-of-the-log assertion the #80 refusals are held to). + runrevoke "$GRANTED" "$W99/v3" +# The mirror of grant's flip: there IS a privileged call now, and it is the +# membership grant added — asserted against the log, not the prose. +check "revoke: ...having actually dropped the 'incus' membership (the log)" 0 "" \ + grep -qF 'gpasswd -d dev1 incus' "$V/sudo.log" +check "revoke: ...calling that key incus-user's, not their daemon access" 0 "NOT their daemon access" \ + runrevoke "$GRANTED" "$W99/v4" +# An admin member who was never granted: nothing to take, and it still says so +# rather than reporting a revocation it did not perform. +N="$W99/revoke-ungranted" +check "revoke: an UNgranted admin member is still a named no-op" 0 "no-op:" \ + runrevoke "users incus-admin" "$N" +check "revoke: ...saying their access is incus-admin's, untouched here" 0 "which this does not touch" \ + runrevoke "users incus-admin" "$W99/n2" +# Absence of the LOG, not of a line in it: an ungranted admin member's bare +# revoke makes no privileged call whatsoever, so the file is never created. check "revoke: ...having made NO privileged call at all (no membership to drop)" 1 "" \ - test -e "$V/sudo.log" + test -e "$N/sudo.log" P="$W99/purge" check "revoke --purge: still unmakes the provisioning" 0 "purged:" \ - runrevoke "$P" --purge + runrevoke "$GRANTED" "$P" --purge check "revoke --purge: ...and refuses to call an admin member 'out'" 0 "is NOT out" \ - runrevoke "$W99/p2" --purge + runrevoke "$GRANTED" "$W99/p2" --purge check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \ grep -qF 'project delete user-1000' "$P/incus.log" rm -rf "$GSHIM" "$W99" From 7067fb03b0fb44d010a08f7ab268ea042209223c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 17:13:24 +0000 Subject: [PATCH 3/3] docs: box help matches what grant/revoke now mutate (#101 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 changed the incus-admin path from a skipped group step to a real `usermod -aG incus`, but `bin/box` help still described the superseded design — telling operators the group step is a no-op that will not happen (it does) and that a bare revoke has no membership to drop (it drops one). The help is the pre-run contract: it is what an operator reads to decide whether to run the command at all, so prose that denies a mutation the command performs is the same class of defect the rest of this PR exists to remove. Both sections now describe the current behavior, and `help revoke` carries the consequence the operator would otherwise discover: once `incus` is gone, a later `gpasswd -d incus-admin` lands them in NEITHER group, so grant's "no re-grant needed" holds only while they hold `incus`. Pinned in both directions — the current sentence must be present and the superseded one absent — so the prose cannot drift from the scripts again. Co-Authored-By: Claude Opus 4.8 --- bin/box | 20 ++++++++++++++------ test/cli.sh | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/bin/box b/bin/box index 3150444..0a24edd 100755 --- a/bin/box +++ b/bin/box @@ -542,9 +542,12 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades): rides snapshots, 'box export' rides backups — #70) · installs the box-net profile into their project -An incus-admin member is provisioned too, not refused (#99): the group step -is a reported no-op — they already hold more — and everything else converges, -so they finally have a project of their own. What it is not is a confinement: +An incus-admin member is provisioned too, not refused (#99): they are added +to 'incus' like anyone else — not a new privilege, since incus-admin already +opens the daemon, but the key to a FILE, because incus-user's socket is group +'incus' mode 0660 and nothing below can provision them without it. Everything +else converges, so they finally have a project of their own. What it is not +is a confinement: incus-admin wins at the socket, so the restrictions are a default placement they can step outside at will, and their own client keeps resolving to the admin socket (and the default project) until incus-admin is taken away. The @@ -569,9 +572,14 @@ recreate the project, unhardened, afterwards — measured, not theoretical), then their boxes, images, project, private bridge and trust-store certificate are removed — irreversible, so it asks first. -On an incus-admin member there is no membership to drop, so a bare revoke is -a named no-op and --purge unmakes the provisioning without ending any access: -only 'gpasswd -d incus-admin' does that, and revoke says so. +On an incus-admin member a bare revoke takes back the 'incus' membership that +grant added — reported as 'partial:', because it ends no access: incus-admin +still opens every project on this host. (One who was never granted is a named +no-op instead.) Mind what that leaves behind: with 'incus' gone, a later +'gpasswd -d incus-admin' drops them into NEITHER group and their ready +project becomes unreachable — grant's "no re-grant needed" holds only while +they still hold 'incus'. --purge unmakes the provisioning the same way. Only +'gpasswd -d incus-admin' ends their access, and revoke says so. box revoke dev1 # take the tier; their boxes keep running box revoke dev1 --purge # ...or end their sessions and delete everything diff --git a/test/cli.sh b/test/cli.sh index d4e2a73..e4f38c5 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -479,6 +479,22 @@ check "box revoke with no user exits 2 (via the CLI table)" 2 "usage: box revoke check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant check "help revoke names --purge" 0 "purge" "$BOX" help revoke +# The help is the PRE-RUN CONTRACT: an operator reads it to decide whether to +# run the command at all, so it must not promise a mutation that will not +# happen (or deny one that will). Round 1 of #101 changed what grant/revoke +# mutate for an incus-admin member and left this prose describing the +# superseded design — these pins are why that cannot happen silently again. +# Both directions: the current sentence must be present, and the superseded +# one must be gone. +check "help grant: the admin member's group step is a real add, not a no-op" \ + 0 "like anyone else" "$BOX" help grant +check "help revoke: a bare revoke of a granted admin member is 'partial:'" \ + 0 "partial:" "$BOX" help revoke +check "help grant no longer calls the admin group step a no-op" 0 "" \ + bash -c '! "'"$BOX"'" help grant | grep -q "reported no-op"' +check "help revoke no longer claims there is no membership to drop" 0 "" \ + bash -c '! "'"$BOX"'" help revoke | grep -q "no membership to drop"' + # Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted # guard cannot ship green (the house test discipline). # The expose guard must fire before ANY incus call in cmd_expose: line order.