fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2f57cca7f
commit
4a06c3ac00
5 changed files with 358 additions and 18 deletions
41
CHANGELOG.md
41
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-<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 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 <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.
|
||||
|
||||
## 0.7.0 — 2026-07-19
|
||||
|
||||
### Added
|
||||
|
|
|
|||
12
bin/box
12
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 <user> 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
|
||||
|
|
|
|||
|
|
@ -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-<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
|
||||
# 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-<uid>).
|
||||
# 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; then
|
||||
echo "project: touching incus-user as $user to create $project..."
|
||||
run_as "$user" timeout 60 incus project list >/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 </dev/null \
|
||||
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&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
|
||||
|
|
|
|||
|
|
@ -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 </dev/null; then
|
||||
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
||||
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
||||
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"
|
||||
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."
|
||||
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
|
||||
fi
|
||||
echo "revoked: $user no longer has the restricted tier."
|
||||
exit 0
|
||||
|
|
@ -160,4 +194,13 @@ if [ -n "$leftover" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
echo "revoked: $user is out, and everything the tier created is gone."
|
||||
if [ "$admin_member" -eq 1 ]; then
|
||||
# "is out" would be false: the purge unmade the provisioning, and their
|
||||
# incus-admin membership — untouched by design, it is not this script's to
|
||||
# take — still opens every project on the host, including a fresh one they
|
||||
# can build by hand a second from now.
|
||||
echo "purged: everything 'box grant' created for $user is gone — but $user is NOT out."
|
||||
echo " 'incus-admin' still gives them the whole daemon: gpasswd -d $user incus-admin"
|
||||
else
|
||||
echo "revoked: $user is out, and everything the tier created is gone."
|
||||
fi
|
||||
|
|
|
|||
149
test/cli.sh
149
test/cli.sh
|
|
@ -512,8 +512,8 @@ check "grant: installs the SHIPPED profile into the project" 0 "" \
|
|||
grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh"
|
||||
check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \
|
||||
grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh"
|
||||
check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \
|
||||
grep -qF 'incus-admin' "$ROOT/host/grant-user.sh"
|
||||
check "grant: an incus-admin member is provisioned, not refused (#99)" 1 "" \
|
||||
grep -qF 'there is nothing tighter to grant' "$ROOT/host/grant-user.sh"
|
||||
check "revoke: group removal is the lockout" 0 "" \
|
||||
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
||||
# Group membership is read at login: purge must terminate live sessions (a
|
||||
|
|
@ -554,6 +554,151 @@ check "revoke: purge deletes instances one at a time" 0 "" \
|
|||
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
||||
check "revoke: purge removes the trust-store certificate" 0 "" \
|
||||
grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #99: an incus-admin member is PROVISIONED, not refused. The distinction the
|
||||
# old refusal missed is permission (the 'incus' group — theirs already, and
|
||||
# stronger) versus provisioning (the user-<uid> 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 <groups> <state-dir> [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 <state-dir> [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)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue