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)"