fix: box grant provisions incus-admin members instead of refusing them #101
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.
|
||||
# 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
|
||||
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,8 +114,23 @@ 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)"
|
||||
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
|
||||
fi
|
||||
|
|
@ -160,4 +194,13 @@ if [ -n "$leftover" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
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