#!/usr/bin/env bash # box revoke [--purge] — take the restricted tier back (#74). # # Two strengths, deliberately: # · bare revoke removes the user from the 'incus' group. That closes the # socket — the only path their certificate can travel — so access ends at # their next login, while their project and boxes stay intact (and their # boxes stay RUNNING: revoking a person does not kill their workloads). # 'box grant' restores everything untouched. # · --purge also deletes what the tier created: their boxes, their images, # their project, the private bridge, the trust-store certificate, the # incus-user state. Irreversible, so it asks first. set -euo pipefail usage() { echo "usage: box revoke [--purge]" >&2; exit 2; } user=""; purge=0 for a in "$@"; do case "$a" in --purge) purge=1 ;; -*) usage ;; *) [ -z "$user" ] || usage; user="$a" ;; esac done [ -n "$user" ] || usage if [ "$(id -u)" -eq 0 ]; then SUDO="" elif command -v sudo >/dev/null 2>&1; then SUDO="sudo" else echo "ERROR: box revoke needs root and 'sudo' was not found." >&2 exit 1 fi getent passwd "$user" >/dev/null || { echo "box revoke: no such user: $user" >&2; exit 1; } uid="$(id -u "$user")" project="user-$uid" # incus-user's own naming rule, mirrored exactly: the bridge is incusbr- # unless that would not fit in an interface name (15 chars), then user-. bridge="incusbr-$uid" [ "${#bridge}" -gt 15 ] && bridge="user-$uid" if [ "$purge" -eq 1 ]; then # Destructive and irreversible: a TTY to ask on, or BOX_YES=1, or refuse — # the same non-interactive contract as install.sh. if [ -z "${BOX_YES:-}" ]; then if [ -t 0 ]; then printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project" # EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read' # returns non-zero and errexit ends the run right here — before the # 'case' below can name the abort, so the most destructive prompt box # has would go silent at the moment it asked (#111). read -r reply || { echo "box revoke: aborted." >&2; exit 1; } case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac else echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2 exit 2 fi fi fi # An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES # put them in 'incus' — not for privilege, but because incus-user's socket is # a file owned by that group (#101) — so revoke has a real membership to take # back here. What it does not have is a lockout: 'incus-admin' opens the # daemon and is not this script's to remove, so "revoked" would still be the # script claiming something it did not perform. Recorded here, spoken below. admin_member=0 if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then admin_member=1 fi # The group, first — access ends even if a purge step below trips. dropped_group=0 if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then $SUDO gpasswd -d "$user" incus >/dev/null dropped_group=1 if [ "$admin_member" -eq 1 ]; then echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which" echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here" else echo "group: removed $user from 'incus'" fi elif [ "$admin_member" -eq 1 ]; then echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch" else echo "group: $user was not in 'incus'" fi # 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 # --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 2>&1 /dev/null) while IFS=, read -r fp _; do [ -n "$fp" ] || continue incus --project "$project" image delete "$fp" /dev/null) incus --project "$project" profile delete box-net >/dev/null 2>&1 &2; exit 1; } echo "purge: project $project removed" fi if incus network delete "$bridge" >/dev/null 2>&1 . while IFS=, read -r name fp _; do [ "$name" = "incus-user-$uid" ] || continue incus config trust remove "$fp" /dev/null) # incus-user's per-user client state (their key pair). Removed so a future # re-grant starts clean instead of trusting a key the purge revoked. # $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a # non-root admin, so an unprivileged stat answers "absent" for a directory # that is very much there — the same lie the absence assert below must dodge. if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; then $SUDO rm -rf "/var/lib/incus/users/$uid" echo "purge: incus-user state for uid $uid removed" 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. The # incus-user state directory too — it was purged for releases without being # re-checked, which is exactly the gap this block exists to close. # The trust store is read into a capture rather than piped into a reader that # stops at its first match — #102's shape, and this file is `set -euo pipefail` # already, so unlike drill/wipe.sh (#107) nothing but the writer's size is # holding it. A reader that exits early SIGPIPEs incus mid-table and the # pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it # would read as "no leftover cert" on a host that still trusts the revoked # user's certificate and the purge would report success. Fail-open, on the # cleanup path whose entire job is to prove access is gone. # Un-racy in practice today — the trust store is small and likely one write — # so this is defensive, not a live defect. Captured so it cannot become one. trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)" leftover="" incus project show "$project" >/dev/null 2>&1 /dev/null 2>&1 /dev/null \ && leftover="$leftover /var/lib/incus/users/$uid" if [ -n "$leftover" ]; then echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2 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