fix: grant the 'incus' membership to incus-admin members too (#101 review)

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-<uid> 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 <user> 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 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 16:50:42 +00:00
parent 4a06c3ac00
commit 8598cf96fe
6 changed files with 297 additions and 101 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <groups> <state-dir> [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 <state-dir> [script args...]
local state="$1"; shift
runrevoke() { # runrevoke <groups> <state-dir> [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"