revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3f9b38ac96
commit
ec5e898094
7 changed files with 155 additions and 33 deletions
|
|
@ -123,8 +123,10 @@ An admin hands the tier out per user, and takes it back:
|
|||
|
||||
```sh
|
||||
box grant dev1 # dev1 can now: box new / list / shell / snapshot / rm — their boxes only
|
||||
box revoke dev1 # locked out; their boxes survive (grant again restores)
|
||||
box revoke dev1 --purge # ...or delete everything they had
|
||||
box revoke dev1 # tier removed; their boxes survive (grant again restores).
|
||||
# a session they already hold keeps the socket until it
|
||||
# ends — revoke warns and names the loginctl command
|
||||
box revoke dev1 --purge # ...or end their sessions and delete everything they had
|
||||
```
|
||||
|
||||
`grant` is an idempotent convergence, not a flag flip, because incus-user's
|
||||
|
|
|
|||
25
bin/box
25
bin/box
|
|
@ -448,14 +448,18 @@ existing box-to-box drop means even their instances cannot reach a sibling.
|
|||
EOF
|
||||
;;
|
||||
revoke) cat <<'EOF'
|
||||
Take the restricted tier back from a user. Without --purge, this only removes
|
||||
them from the 'incus' group: they lose the socket, but their project and
|
||||
boxes stay (still running!) and 'box grant' restores access untouched. With
|
||||
--purge, their boxes, images, project, private bridge and trust-store
|
||||
certificate are removed too — irreversible, so it asks first.
|
||||
Take the restricted tier back from a user. Without --purge, this removes
|
||||
them from the 'incus' group: their project and boxes stay (still running!)
|
||||
and 'box grant' restores access untouched. Group membership is read at
|
||||
LOGIN, so a session they already hold keeps the socket until it ends —
|
||||
revoke says so and names the loginctl command when it happens. With
|
||||
--purge, their sessions are terminated first (a stale session could quietly
|
||||
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.
|
||||
|
||||
box revoke dev1 # lock them out, keep their boxes
|
||||
box revoke dev1 --purge # ...and delete everything they had
|
||||
box revoke dev1 # take the tier; their boxes keep running
|
||||
box revoke dev1 --purge # ...or end their sessions and delete everything
|
||||
EOF
|
||||
;;
|
||||
teardown-host) cat <<'EOF'
|
||||
|
|
@ -754,7 +758,12 @@ cmd_new() {
|
|||
# for an admin (setup-host builds it), in YOUR project for a restricted user
|
||||
# (box grant converges it). Its absence has a different fix per tier, and
|
||||
# incus's own "Profile not found" at launch time names neither.
|
||||
if [ -z "$remote" ] && ! incus profile show box-net >/dev/null 2>&1 </dev/null; then
|
||||
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
|
||||
# A missing profile and a daemon that is not answering are different
|
||||
# faults with different fixes — "run setup-host" at a wedged daemon
|
||||
# (the #26 shape) is wrong advice. Separate them before diagnosing.
|
||||
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|
||||
|| die "the incus daemon is not answering — diagnose it: box doctor"
|
||||
if [ "$(box_tier)" = restricted ]; then
|
||||
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -169,11 +169,17 @@ in their own project — which is why the nft bridge drop, which they cannot
|
|||
touch, exists as the second, host-owned layer. Defense in depth, both layers
|
||||
measured (`drill/multiuser.sh`, criteria a–l).
|
||||
|
||||
`box revoke` is two strengths: bare, it removes the group — the socket
|
||||
closes, their boxes keep *running* (revoking a person does not kill their
|
||||
workloads) and `grant` restores everything; `--purge` deletes their world
|
||||
(boxes, images, project, private bridge, trust-store certificate) and asserts
|
||||
the absence afterwards.
|
||||
`box revoke` is two strengths: bare, it removes the group — their boxes keep
|
||||
*running* (revoking a person does not kill their workloads), `grant` restores
|
||||
everything, and because supplementary groups are read at login, revoke warns
|
||||
when live sessions keep the socket until they end (and names the `loginctl`
|
||||
command). `--purge` terminates those sessions *first* — a stale-group process
|
||||
could otherwise touch incus-user after the purge and lazily recreate the
|
||||
project with stock, unhardened defaults, undoing the grant's whole point —
|
||||
then deletes their world (boxes, images, project, private bridge, trust-store
|
||||
certificate) and asserts the absence afterwards. A failed `grant` backs its
|
||||
own group-add out on exit for the same reason: no half-granted user holding
|
||||
an un-narrowed socket.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
|
|
|||
|
|
@ -79,10 +79,13 @@ as_u() { local u="$1"; shift; runuser -u "$u" -- "$@" </dev/null; }
|
|||
# down): "refused" means a packet ARRIVED and was answered — which, for an
|
||||
# isolation probe, is a failure wearing polite clothes. Silence is the pass.
|
||||
probe_from() { # probe_from <user> <box> <url>
|
||||
local u="$1" b="$2" url="$3" out
|
||||
out="$(as_u "$u" timeout -k 5 30 incus exec "$b" -- curl -sS -m 15 -o /dev/null "$url" 2>&1)"
|
||||
case "$out" in
|
||||
"") echo reachable ;;
|
||||
local u="$1" b="$2" url="$3" out rc
|
||||
out="$(as_u "$u" timeout -k 5 30 incus exec "$b" -- curl -sS -m 15 -o /dev/null "$url" 2>&1)"; rc=$?
|
||||
# Silence + success is the only 'reachable': when the OUTER timeout kills a
|
||||
# wedged exec (the #26 shape), curl never spoke — empty output with rc 124
|
||||
# must not read as an open door.
|
||||
case "$rc:$out" in
|
||||
0:) echo reachable ;;
|
||||
*Connection\ refused*) echo refused ;;
|
||||
*) echo dropped ;;
|
||||
esac
|
||||
|
|
@ -104,9 +107,15 @@ cleanup() {
|
|||
echo "── cleanup"
|
||||
for u in "$U1" "$U2"; do
|
||||
id "$u" >/dev/null 2>&1 || continue
|
||||
BOX_YES=1 box revoke "$u" --purge >/dev/null 2>&1
|
||||
# A half-failed purge followed by userdel leaves a project owned by
|
||||
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
||||
# the user when the purge fails, and name what survived.
|
||||
if BOX_YES=1 box revoke "$u" --purge >/dev/null 2>&1; then
|
||||
userdel -r "$u" >/dev/null 2>&1
|
||||
id "$u" >/dev/null 2>&1 && echo " WARNING: user $u still exists" || echo " removed $u (tier, boxes, account)"
|
||||
else
|
||||
echo " WARNING: purge FAILED for $u — kept the account so 'box doctor' can name it; project user-$(id -u "$u") may survive"
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
|
@ -238,15 +247,29 @@ v6="$(as_u "$U1" timeout -k 5 20 incus exec mine -- sh -c 'ip -6 addr show dev e
|
|||
|| no "(g) the box holds a global IPv6 address — an uncovered egress path"
|
||||
|
||||
phase "h. the escape hatches, tried and refused"
|
||||
as_u "$U1" incus launch images:debian/13 esc --network "incusbr-$uid1" >/dev/null 2>&1 \
|
||||
&& { no "(h) $U1 attached the unhardened private bridge"; as_u "$U1" incus delete -f esc >/dev/null 2>&1; } \
|
||||
|| ok "(h) attaching the private incusbr-$uid1 is refused (not in restricted.networks.access)"
|
||||
as_u "$U1" incus project set "$p1" restricted.networks.access "boxnet,incusbr-$uid1" >/dev/null 2>&1 \
|
||||
&& no "(h) $U1 widened their OWN project's network access" \
|
||||
|| ok "(h) a restricted certificate cannot widen its own project"
|
||||
as_u "$U1" incus network set boxnet dns.mode=managed >/dev/null 2>&1 \
|
||||
&& no "(h) $U1 edited boxnet itself" \
|
||||
|| ok "(h) boxnet's config refuses a restricted certificate"
|
||||
# Each probe asserts the refusal's REASON, not just a nonzero exit — an image
|
||||
# server hiccup or a name collision also exits nonzero, and reading that as
|
||||
# "the escape is closed" is a false verdict wearing a green light (the drill
|
||||
# has relearned this enough times to earn a rule).
|
||||
out="$(as_u "$U1" incus launch images:debian/13 esc --network "incusbr-$uid1" 2>&1)"; rc=$?
|
||||
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE 'not found|not allowed'; then
|
||||
ok "(h) attaching the private incusbr-$uid1 is refused (not in restricted.networks.access)"
|
||||
else
|
||||
no "(h) private-bridge attach: rc=$rc, said: $(printf '%s' "$out" | head -1)"
|
||||
as_u "$U1" incus delete -f esc >/dev/null 2>&1
|
||||
fi
|
||||
out="$(as_u "$U1" incus project set "$p1" restricted.networks.access "boxnet,incusbr-$uid1" 2>&1)"; rc=$?
|
||||
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qi 'restricted'; then
|
||||
ok "(h) a restricted certificate cannot widen its own project"
|
||||
else
|
||||
no "(h) project-widen attempt: rc=$rc, said: $(printf '%s' "$out" | head -1)"
|
||||
fi
|
||||
out="$(as_u "$U1" incus network set boxnet dns.mode=managed 2>&1)"; rc=$?
|
||||
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE 'permission|restricted'; then
|
||||
ok "(h) boxnet's config refuses a restricted certificate"
|
||||
else
|
||||
no "(h) boxnet edit attempt: rc=$rc, said: $(printf '%s' "$out" | head -1)"
|
||||
fi
|
||||
|
||||
phase "e/f. the honest refusals — expose, setup-host, doctor"
|
||||
out="$(as_u "$U1" box expose mine 3000 2>&1)"; rc=$?
|
||||
|
|
@ -275,7 +298,16 @@ acc="$(incus project get "$p1" restricted.networks.access 2>/dev/null)"
|
|||
aud "k. incus-user re-sync: convergence intact (matches its source: setup runs only at project creation)"
|
||||
|
||||
phase "l. revoke — one user out, the other untouched"
|
||||
# Revocation's hard case is a user who is LOGGED IN: groups are read at
|
||||
# login, so a held session keeps the socket — and after a purge it could
|
||||
# touch incus-user and recreate the project with stock, unhardened defaults.
|
||||
# Hold a session open across the purge and demand it dies with the tier.
|
||||
runuser -u "$U2" -- sleep 300 </dev/null >/dev/null 2>&1 &
|
||||
sleep 1
|
||||
BOX_YES=1 box revoke "$U2" --purge >/dev/null 2>&1 && ok "(l) box revoke $U2 --purge exits 0" || no "(l) revoke failed"
|
||||
pgrep -u "$U2" >/dev/null 2>&1 \
|
||||
&& no "(l) $U2 still has live processes after the purge — a stale session could recreate their project, unhardened" \
|
||||
|| ok "(l) the purge terminated $U2's held session (no stale-group path back in)"
|
||||
as_u "$U2" incus list >/dev/null 2>&1 \
|
||||
&& no "(l) $U2 still reaches the daemon after revoke" \
|
||||
|| ok "(l) $U2 is locked out"
|
||||
|
|
|
|||
|
|
@ -85,10 +85,28 @@ fi
|
|||
# 1. The group. 'incus' is the restricted socket; membership takes effect at
|
||||
# the user's next login, but run_as below starts a fresh process with the
|
||||
# database's groups, so the grant itself never waits on a re-login.
|
||||
#
|
||||
# If THIS run granted the group and a later step fails, take it back on the
|
||||
# way out: a half-granted user would otherwise hold live socket access to an
|
||||
# UN-NARROWED project — the stock unhardened bridge attachable — until an
|
||||
# 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.
|
||||
added_group=0
|
||||
backout() {
|
||||
if [ "$added_group" -eq 1 ]; then
|
||||
$SUDO gpasswd -d "$user" incus >/dev/null 2>&1 || true
|
||||
echo "box grant: FAILED — removed $user from 'incus' again (no half-granted access left behind); fix the cause and re-run" >&2
|
||||
fi
|
||||
}
|
||||
trap backout EXIT
|
||||
|
||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||
echo "group: $user already in 'incus'"
|
||||
else
|
||||
$SUDO usermod -aG incus "$user"
|
||||
added_group=1
|
||||
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
||||
fi
|
||||
|
||||
|
|
@ -129,7 +147,12 @@ if ! err="$(incus project set "$project" restricted.networks.access boxnet 2>&1
|
|||
echo " (an instance still on the private bridge blocks this — move or delete it, then re-run)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "network: $project restricted to boxnet (the private incusbr-$uid is unreferenced and unreachable)"
|
||||
# The private bridge's name follows incus-user's own rule (revoke-user.sh
|
||||
# mirrors it too): incusbr-<uid>, or user-<uid> when that would not fit an
|
||||
# interface name — naming the wrong one here would be a true claim with the
|
||||
# wrong noun on big-uid (SSSD/AD) hosts.
|
||||
bridge="incusbr-$uid"; [ "${#bridge}" -gt 15 ] && bridge="user-$uid"
|
||||
echo "network: $project restricted to boxnet (the private $bridge is unreferenced and unreachable)"
|
||||
|
||||
# 5. Snapshots. incus-user projects block them by default, and box's whole
|
||||
# reuse story — log in once, snapshot, clone forever — is snapshots.
|
||||
|
|
@ -150,5 +173,6 @@ echo "profile: box-net installed in $project"
|
|||
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; }
|
||||
|
||||
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)"
|
||||
|
|
|
|||
|
|
@ -59,11 +59,39 @@ 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' — the socket closes with their next login"
|
||||
echo "group: removed $user from 'incus'"
|
||||
else
|
||||
echo "group: $user was not in 'incus'"
|
||||
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
|
||||
# --purge it is a hole: a stale-group process can touch incus-user AFTER the
|
||||
# purge and lazily recreate the project with incus-user's stock defaults —
|
||||
# the unhardened NAT bridge, un-narrowed — which is strictly worse than the
|
||||
# granted state this script is unwinding. So --purge terminates the user's
|
||||
# sessions first (it is already the destructive, confirmed path), and a bare
|
||||
# revoke says out loud what it did not do.
|
||||
if pgrep -u "$user" >/dev/null 2>&1; then
|
||||
if [ "$purge" -eq 1 ]; then
|
||||
echo "sessions: $user has live processes — terminating them (a stale session could recreate the project, unhardened, after the purge)"
|
||||
$SUDO loginctl terminate-user "$user" 2>/dev/null || true
|
||||
$SUDO pkill -u "$user" 2>/dev/null || true
|
||||
sleep 1
|
||||
$SUDO pkill -9 -u "$user" 2>/dev/null || true
|
||||
if pgrep -u "$user" >/dev/null 2>&1; then
|
||||
echo "box revoke: could not terminate $user's processes — refusing to purge under them" >&2
|
||||
echo " (they retain the socket until those sessions end, and could recreate the project)" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: $user has live sessions, and group membership is read at login —"
|
||||
echo " those sessions keep the socket until they end. To end them now:"
|
||||
echo " sudo loginctl terminate-user $user"
|
||||
fi
|
||||
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)"
|
||||
|
|
@ -113,9 +141,13 @@ if [ -d "/var/lib/incus/users/$uid" ]; then
|
|||
fi
|
||||
|
||||
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
|
||||
# The certificate included: its removal above is set -e-exempt (left of &&),
|
||||
# and a promise the header makes is a promise this block checks.
|
||||
leftover=""
|
||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
||||
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
||||
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
||||
&& leftover="$leftover cert:incus-user-$uid"
|
||||
if [ -n "$leftover" ]; then
|
||||
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
||||
exit 1
|
||||
|
|
|
|||
17
test/cli.sh
17
test/cli.sh
|
|
@ -217,6 +217,23 @@ check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \
|
|||
grep -qF 'incus-admin' "$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
|
||||
# stale-group process could recreate the project unhardened AFTER the purge),
|
||||
# and a bare revoke must say the socket survives in held sessions.
|
||||
check "revoke: purge terminates live sessions first" 0 "" \
|
||||
grep -qF 'loginctl terminate-user' "$ROOT/host/revoke-user.sh"
|
||||
check "revoke: purge refuses under unkillable sessions" 0 "" \
|
||||
grep -qF 'refusing to purge under them' "$ROOT/host/revoke-user.sh"
|
||||
check "revoke: bare revoke warns about held sessions" 0 "" \
|
||||
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
|
||||
check "revoke: the purge asserts the certificate's absence too" 0 "" \
|
||||
bash -c 'grep -A6 "Assert absence" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
||||
# A failed grant must not leave a half-granted user: if THIS run added the
|
||||
# group, the exit path takes it back (and the trap disarms only on success).
|
||||
check "grant: backs out its own group-add on failure" 0 "" \
|
||||
grep -qF 'trap backout EXIT' "$ROOT/host/grant-user.sh"
|
||||
check "grant: the back-out disarms on success" 0 "" \
|
||||
grep -qF 'trap - EXIT' "$ROOT/host/grant-user.sh"
|
||||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||
check "revoke: purge deletes instances one at a time" 0 "" \
|
||||
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
||||
|
|
|
|||
Loading…
Reference in a new issue