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 (#99) — the refusal read "they already have the admin tier; there is
nothing tighter to grant", which is true about *permission* and silent nothing tighter to grant", which is true about *permission* and silent
about *provisioning*: the `incus` group is indeed a strict subset of what about *provisioning*: the `incus` group is indeed a strict subset of what
`incus-admin` opens, but the `user-<uid>` project, the boxnet narrowing, `incus-admin` opens **at the daemon API**, but the `user-<uid>` project, the
the snapshot and backup allowances, and the `box-net` profile installed boxnet narrowing, the snapshot and backup allowances, and the `box-net`
into that project are none of them permissions, and an `incus-admin` profile installed into that project are none of them permissions, and an
member had none of them — `box_tier()` resolves them to `admin`, so they `incus-admin` member had none of them — `box_tier()` resolves them to
worked in the shared default project next to root and every other admin, `admin`, so they worked in the shared default project next to root and every
with no world of their own and no supported way to get one. `box grant` other admin, with no world of their own and no supported way to get one.
now runs the full convergence for them: the group step is a **reported `box grant` now runs the full convergence for them.
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 The group step is part of that convergence, not an exception to it: an
run unchanged. The touch that provokes the lazy project is **pinned at `incus-admin` member is added to `incus` like anyone else. The subset
incus-user's socket**, which the fix turns out to require: the incus client argument holds for the API and **fails at the filesystem**, which is where
picks its socket by writability (`client/connection.go` — the daemon socket it matters here — the two sockets are two files with two owning groups
when writable, `unix.socket.user` only otherwise), so for an `incus-admin` (Debian 13 / Incus 6.0.4, measured):
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 | socket | group | mode |
reason, since an unqualified `profile show` would have answered from the | --- | --- | --- |
shared default project and proved nothing. On success the grant prints the | `/var/lib/incus/unix.socket` | `incus-admin` | 0660 |
caveat the hard exit was gesturing at, in the two forms it actually takes: | `/var/lib/incus/unix.socket.user` | `incus` | 0660 |
the restrictions are a **default placement, not a confinement** (admin
membership still wins at the socket — the default project and other users' `incus-admin` opens the first and not the second, and only the second
instances stay one flag away), and until `incus-admin` goes their own `box` provisions a `user-<uid>` project. Without the membership the provisioning
commands keep landing in the default project, at which point the provisioned touch takes `EACCES`, the swallowing `|| true` hides it, no project appears,
project becomes their home with no re-grant needed. The failure path knows and the grant dies blaming a perfectly healthy incus-user — the exact
the new case too: nothing was added, so nothing is rolled back, and the incus-admin-only user #99 is about, left no better off. So the membership is
backout says exactly that rather than going quiet. `box revoke` mirrors it — granted, and the grant says out loud why: it is the key to a file, not a new
a bare revoke of an `incus-admin` member is a named **no-op** (it makes no privilege (`box_tier()` still reads them as `admin`, both-groups → `admin`).
privileged call at all: there is no membership to drop), and `--purge`
unmakes the provisioning while refusing to call them "out", both naming 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. `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 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 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 fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo shims
shims — every assertion is made against what the run did, not what the — every assertion is made against what the run did, not what the source says
source says it would. 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 ## 0.7.0 — 2026-07-19

View file

@ -127,11 +127,13 @@ It had never worked.
## Rehearsal and CI ## Rehearsal and CI
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves `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, isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
injections (n). Real users, real grants, real mints, probes from inside; 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 `--container` for CI, VM mode on real hardware; cleanup deletes everything it
made. made.

View file

@ -30,6 +30,9 @@
# host-owned control — the scoped guarantee, measured (#75 review) # host-owned control — the scoped guarantee, measured (#75 review)
# n. a grant that fails is fail-closed: fresh user backed out (verified), # n. a grant that fails is fail-closed: fresh user backed out (verified),
# pre-existing member warned loudly, re-run converges (#75 review) # 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 # 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). # 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; } [ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
echo echo
echo "── cleanup" 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 id "$u" >/dev/null 2>&1 || continue
# A half-failed purge followed by userdel leaves a project owned by # A half-failed purge followed by userdel leaves a project owned by
# nobody — and doctor's leftover check keys on the USER existing. Keep # nobody — and doctor's leftover check keys on the USER existing. Keep
@ -469,6 +472,65 @@ else
fi fi
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge" 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 "════════════════════════════════════════════" echo "════════════════════════════════════════════"
echo " $pass passed, $fail failed" 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. # 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: # 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, # An incus-admin member goes in too, and NOT for privilege: incus-user's
# because the grant still owes them everything below — #99) # 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 # 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) # 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 — # 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 # 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 # so nothing here could restrict them. True — and beside the point, because it
# conflates the two separate things a grant hands over: # conflates the two separate things a grant hands over:
# · PERMISSION — the 'incus' group, i.e. socket access. They already hold # · PERMISSION — the 'incus' group. At the DAEMON API they already hold
# strictly more through incus-admin, so the group step below is a reported # strictly more through incus-admin, so this group adds no privilege. It
# no-op: adding them to 'incus' would grant nothing and would only mislead # is still required, because the two sockets are two FILES with two
# whoever reads the group list later. # 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 # · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
# snapshot and backup allowances, the box-net profile installed INTO that # snapshot and backup allowances, the box-net profile installed INTO that
# project. An incus-admin member has none of it: box_tier() resolves them # 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 # admin re-runs. Backing out the group closes that window completely for a
# fresh grant (their existing sessions predate the membership, so no process # 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 # 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 # take on a re-run's failure. An incus-admin member now takes those same two
# all — nothing to take back, which is not the same as nothing to say: their # paths (#101): the membership IS added for them and so IS backed out, with
# socket outlives the failure by a route this script never granted and must # one thing extra to say either way — the rollback closes incus-user's socket
# not pretend to control (the third branch). # 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 added_group=0; was_member=0
backout() { backout() {
if [ "$added_group" -eq 1 ]; then if [ "$added_group" -eq 1 ]; then
@ -124,6 +135,16 @@ backout() {
exit 1 exit 1
fi fi
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2 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 # The one window the database cannot close: a login STARTED between our
# usermod and this backout keeps the group in its session credentials. # 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 # 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 "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 echo " the group until it ends: sudo loginctl terminate-user $user" >&2
fi 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 elif [ "$was_member" -eq 1 ]; then
# A user who was ALREADY in the group keeps it — stripping a membership # 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. # 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 "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 " 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 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 fi
} }
trap backout EXIT trap backout EXIT
if [ "$admin_member" -eq 1 ]; then if id -nG "$user" | tr ' ' '\n' | grep -qx incus; 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 was_member=1
echo "group: $user already in 'incus'" echo "group: $user already in 'incus'"
else else
$SUDO usermod -aG incus "$user" $SUDO usermod -aG incus "$user"
added_group=1 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 fi
project="user-$uid" 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" incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
fi fi
user_socket="$incus_dir/unix.socket.user" 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; } || { 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 fi
@ -299,14 +328,22 @@ if [ "$admin_member" -eq 1 ]; then
# admins' until they either drop incus-admin — at which point this # admins' until they either drop incus-admin — at which point this
# project becomes their automatic home, no re-run needed — or pin # project becomes their automatic home, no re-run needed — or pin
# INCUS_SOCKET at incus-user by hand. # 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 "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 " 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 " DEFAULT PLACEMENT, not a confinement. They can reach the default project and"
echo " every other user's instances whenever they choose to." 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 " 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 " 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 " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so"
echo " 'box revoke $user' unwinds this provisioning; it cannot touch their admin access." 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 else
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet." 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)" 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
fi fi
# An incus-admin member is the mirror of grant's #99 case: 'box grant' gives # An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
# them the provisioning without the group (they already hold strictly more), # put them in 'incus' — not for privilege, but because incus-user's socket is
# so revoke has no group to take — and saying "revoked" at that would be the # a file owned by that group (#101) — so revoke has a real membership to take
# script claiming a lockout it did not perform. Recorded here, spoken below. # 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 admin_member=0
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
admin_member=1 admin_member=1
fi fi
# The group, first — access ends even if a purge step below trips. # 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 if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
$SUDO gpasswd -d "$user" incus >/dev/null $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 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 else
echo "group: $user was not in 'incus'" echo "group: $user was not in 'incus'"
fi 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)" echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
if [ "$admin_member" -eq 1 ]; then if [ "$admin_member" -eq 1 ]; then
# "restores access" would be the wrong promise here: no access was lost. # "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 else
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access" echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
fi fi
fi fi
if [ "$admin_member" -eq 1 ]; then if [ "$admin_member" -eq 1 ]; then
# Not "revoked": there was no tier of theirs to take. What a bare revoke # Not "revoked", still: what came back is the 'incus' membership grant
# did here is exactly nothing — the group was never theirs to lose and the # added for incus-user's socket, and their access to this host was never
# project is kept — so name the two real options instead of a summary that # riding on it. The project is kept. So say exactly what was taken and
# would read as a lockout. # name the two real options, instead of a summary that reads as a lockout.
echo "no-op: $user holds the admin tier via 'incus-admin', not the restricted tier — nothing was taken." 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 their access: gpasswd -d $user incus-admin"
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge" echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
exit 0 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 # shellcheck disable=SC2016 # the $-string is a literal in the target file
check "rehearsal: injects grant failures (criterion n)" 0 "" \ check "rehearsal: injects grant failures (criterion n)" 0 "" \
grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh" 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 # shellcheck disable=SC2016 # the $-strings are literals in the target file
check "revoke: purge deletes instances one at a time" 0 "" \ check "revoke: purge deletes instances one at a time" 0 "" \
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh" grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
@ -596,11 +605,14 @@ exit 0
SHIM SHIM
cat > "$GSHIM/sudo" <<'SHIM' cat > "$GSHIM/sudo" <<'SHIM'
#!/usr/bin/env bash #!/usr/bin/env bash
# Fake sudo: logs and swallows. 'sudo test' must answer honestly-absent — # Fake sudo: logs and swallows — EXCEPT 'sudo test', which is run for real.
# revoke's absence assert reads incus-user's state directory through it, and # Both scripts route filesystem probes through it on purpose (/var/lib/incus
# a blanket exit 0 would report the purge incomplete on a clean machine. # 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" [ -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 exit 0
SHIM SHIM
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent" 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" A="$W99/admin"
check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \ check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \
rungrant "users incus-admin" "$A" 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" 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" \ check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \
rungrant "users incus-admin" "$W99/a3" 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" \ 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" rungrant "users incus-admin" "$W99/a4"
# The logs: what the run actually did to the machine. # The logs: what the run actually did to the machine.
check "grant: the admin member is NOT added to 'incus' (nothing to add)" 1 "" \ # #101's decision, pinned at the seam that broke: an incus-admin member IS
grep -qF 'usermod -aG incus' "$A/sudo.log" # 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 "" \ check "grant: their project is still narrowed to boxnet" 0 "" \
grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log" grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log"
check "grant: their project still gets snapshots" 0 "" \ 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" 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 "" \ 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" 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 --------------------------- # --- the restricted user: unchanged, and unpinned ---------------------------
R="$W99/restricted" 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 "" \ check "grant: ...and their client is left to its own socket fallback" 1 "" \
grep -qF 'INCUS_SOCKET' "$R/sudo.log" 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" F="$W99/failed"
check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \ check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \
rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1 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" \ 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 rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1
check "grant: ...and rolls nothing back, because nothing was added" 1 "" \ # The membership IS this run's now, so the backout IS its business (#101).
grep -qF 'gpasswd -d' "$F/sudo.log" 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 ------------------ # --- revoke, the mirror: it cannot take what it never gave ------------------
# BOX_YES=1 throughout: --purge is destructive and refuses without a terminal # 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. # to confirm on, and this suite has none. It changes nothing for a bare revoke.
runrevoke() { # runrevoke <state-dir> [script args...] runrevoke() { # runrevoke <groups> <state-dir> [script args...]
local state="$1"; shift local groups="$1" state="$2"; shift 2
mkdir -p "$state" 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" \ 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 "$@" 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" V="$W99/revoke"
check "revoke: a bare revoke of an admin member is a named no-op, not 'revoked'" 0 "no-op:" \ check "revoke: a bare revoke of a granted admin member is 'partial', not 'revoked'" 0 "partial:" \
runrevoke "$V" runrevoke "$GRANTED" "$V"
check "revoke: ...and says their access is incus-admin's, untouched here" 0 "which this does not touch" \ check "revoke: ...and refuses to call it a lockout" 0 "is NOT locked out" \
runrevoke "$W99/v2" runrevoke "$GRANTED" "$W99/v2"
check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \ check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \
runrevoke "$W99/v3" runrevoke "$GRANTED" "$W99/v3"
# Stronger than "no gpasswd": a bare revoke of an admin member makes no # The mirror of grant's flip: there IS a privileged call now, and it is the
# privileged call whatsoever — the sudo log never comes into existence (the # membership grant added — asserted against the log, not the prose.
# same absence-of-the-log assertion the #80 refusals are held to). 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 "" \ 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" P="$W99/purge"
check "revoke --purge: still unmakes the provisioning" 0 "purged:" \ 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" \ 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 "" \ check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \
grep -qF 'project delete user-1000' "$P/incus.log" grep -qF 'project delete user-1000' "$P/incus.log"
rm -rf "$GSHIM" "$W99" rm -rf "$GSHIM" "$W99"