rig/commands/users-apply.sh

455 lines
22 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# rig users apply — converge named operator accounts from a declarative users
# file, on every class. Humans always enter as themselves and elevate via
# sudo: a shared root login is unattributable, so operators belong on servers
# too — class never gates this command, it only decides root SSH's fate AFTER
# users exist (close-root on human, kept as the control plane's automation
# door on server). Convergent: a second identical run changes nothing and
# says so.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh"
log() { printf 'rig-users: %s\n' "$*"; }
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig users apply --file <path>
--file <path> users file (required; '-' reads it from stdin)
The file is line-based and bash-parseable on purpose — a rig box has no YAML
parser and no jq, and gets neither for this. Whitespace-separated: user,
comma-joined roles, then the SSH public key (the rest of the line). '#'
comments and blank lines are fine. Repeated username lines add authorized
keys; the roles must be identical on every line of one user.
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop
maria rig,box ssh-ed25519 AAAA... maria@mac
The key field may also be the literal token '@root': this user's
authorized_keys is seeded from root's CURRENT /root/.ssh/authorized_keys.
You provably hold a root private key — you SSHed in with it to run apply at
all — so the seeded key is the one key that cannot lock you out. '@root'
mixes with literal key lines: seeded keys land first, literal keys are
appended after them, and every re-run re-seeds from root's then-current file
(convergent to it — a seeded key you remove from the admin by hand returns;
switch the line to literal keys to pin them). Root's key lines are copied
verbatim, options included — a from= or command= restriction on a root key
follows it to the user. Apply dies if root has no authorized_keys to seed.
roles:
admin group rig-admin — full NOPASSWD sudo
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
box group incus — Incus restricted tier, no sudo (box's setup-host
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
owns the Incus install; rig only asserts it).
Dropping this role hands the group back through
'box revoke' — never --purge: convergence removes
access, never someone's running boxes.
All passwords stay locked, always — the SSH key at the door is the
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
in the three rig-managed groups is made exact (other groups are never
touched), authorized_keys becomes exactly the file's keys, and a user dropped
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
from the file is REVOKED: account expired (which blocks SSH keys too, not
just the password), authorized_keys renamed to authorized_keys.revoked-by-rig,
rig groups stripped — home kept, nothing deleted, and re-adding the user
brings them back. Run as root; under sudo, only rig-admin members may — the
users family changes who holds root, so role rig's scoped sudo does not reach
it.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--file)
[ $# -ge 2 ] || die "--file needs a value" 2
FILE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
[ -n "$FILE" ] || die "--file <path> is required" 2
# stdin is read ONCE into a temp: the file is parsed for validation and then
# walked again to converge, and a pipe only plays once.
if [ "$FILE" = "-" ]; then
STDIN_TMP="$(mktemp)"
cat > "$STDIN_TMP"
FILE="$STDIN_TMP"
fi
[ -r "$FILE" ] || die "cannot read users file: $FILE" 2
# File parsing is argument validation: every error in the file is reported in
# one pass, exit 2, still before the root check.
PARSED="$(parse_users_file "$FILE")" \
|| die "invalid users file: $FILE — every error is listed above; nothing was changed" 2
declare -A USER_ROLES=() USER_KEYS=() USER_SEED=()
USERS=()
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
BOX_USERS=()
NEED_SUDO=0
NEED_INCUS=0
NEED_SEED=0
while IFS='|' read -r u r k; do
[ -n "$u" ] || continue
if [ -z "${USER_ROLES[$u]:-}" ]; then
USERS+=("$u")
USER_ROLES[$u]="$r"
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
fi
# '@root' is a key SOURCE, not a key: remember who seeds and resolve the
# actual lines after the root check — /root/.ssh is unreadable before it.
if [ "$k" = "@root" ]; then
USER_SEED[$u]=1
NEED_SEED=1
else
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
fi
case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
case ",$r," in *,box,*) NEED_INCUS=1 ;; esac
done <<< "$PARSED"
# --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
# is binary-scoped but not argument-scoped, so without this gate a rig-role
# user could run `sudo rig users apply --file <me-as-admin>` — the scoped
# grant silently root-equivalent through this very command. Direct root (no
# SUDO_USER: bring-up, a root shell) proceeds.
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
fi
# --- @root seed source (#17) -------------------------------------------------
# The lockout-avoidance move: the operator SSHed in as root to run this at
# all, so root's CURRENT authorized_keys provably contains a key they hold —
# the one claim no local check can make about a pasted literal. Resolved ONCE
# here (post-root-check: /root/.ssh needs root) and copied verbatim, options
# included: a from=/command= restriction on a root key line follows it to the
# user, which is honest — rig will not silently widen what a key can do.
# Comments and blanks are dropped so the seeded block is exactly key lines;
# an empty result is a hard stop, because seeding nothing would converge the
# admin's authorized_keys to empty and close-root would then refuse — better
# to name the real problem now.
ROOT_SEED_KEYS=""
if [ "$NEED_SEED" -eq 1 ]; then
ROOT_SEED_KEYS="$(grep -Ev '^[[:space:]]*(#|$)' /root/.ssh/authorized_keys 2>/dev/null || true)"
[ -n "$ROOT_SEED_KEYS" ] \
|| die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead"
fi
# Class is a note, never a refusal: #26's call is that operators belong on
# EVERY class — what differs is root SSH's fate once they exist.
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;;
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;;
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;;
esac
CHANGED=0
# --- sudo (only when some role actually grants through it) -------------------
if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
log "installing sudo (admin/rig grants go through it)"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
CHANGED=1
fi
# --- groups ------------------------------------------------------------------
groupadd -f rig-admin
groupadd -f rig
# rig NEVER installs Incus: box's setup-host owns the daemon and its group.
#
# Whether the box role applies AT ALL is the host= trait's call, decided here,
# once, from the marker alone — never from what groups happen to exist on the
# machine (#58). The box role binds where VMs live; a users file is fleet-wide,
# its box grants are not. Apply used to ask the trait only when group incus was
# ABSENT, which meant a host=no or marker-less box that nonetheless carried the
# group (setup-host ran, then the box was re-bootstrapped with other traits)
# handed box-role users a bare `usermod -aG incus` — the socket with no tier
# behind it, and incus-user answers a socket it is given by lazily creating an
# UNHARDENED project under whoever opens it. The trait now decides the same way
# in both directions: BOX_ROLE_OK is the single gate, and the group's presence
# only ever answers the narrower question of whether a box that DOES claim to
# host VMs is ready to.
#
# The rejected alternative was letting the machine overrule the marker — treat
# a real incus group as evidence the box hosts VMs and converge anyway, warning
# that the marker disagrees. It reads reasonable, but it inverts what the rest
# of this family does with host=: the marker is the box's declared identity, and
# bootstrap is the one thing that writes it. Provisioning a VM-host tier onto a
# box that does not claim to be a VM host is rig deciding it knows better than
# the declaration, on evidence (a leftover group) that survives exactly the
# repurposing that makes the marker right and the group stale. The cost of
# choosing the marker is a genuine VM host mislabelled host=no that stops
# provisioning — so this does not do it SILENTLY: the skip warning below names
# the contradiction and names `rig bootstrap` as the one-line repair.
#
# Consequence worth stating plainly: on such a box the exact-membership
# convergence below will now STRIP box-role users out of incus rather than
# leave them there. That is the same call, not a second one. A membership
# inherited from a previous life is the identical half-grant state as one
# freshly added — socket, no tier — and rig's promise for its three managed
# groups is exactness, not "exact except where drift got there first".
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
INCUS_OK=0
if getent group incus >/dev/null; then INCUS_OK=1; fi
BOX_ROLE_OK=0
BOX_ROLE_WHY=""
if BOX_ROLE_WHY="$(assert_marker_hosts_vms "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
BOX_ROLE_OK=1
fi
if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 0 ]; then
# The group being present while the trait says otherwise is the marker/reality
# mismatch — the case that used to slip through — so it gets its own sentence
# rather than the generic skip. Never a die: a fleet-wide users file naming a
# box-role user somewhere must not abort the admins it also carries here.
if [ "$INCUS_OK" -eq 1 ]; then
warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY — yet group incus EXISTS here, so this box's marker and this box's reality disagree. rig believes the marker and grants nothing (the group alone is only the socket; without the tier behind it incus-user would lazily build an unhardened project under whoever opens it). If this machine really does host VMs, re-run rig bootstrap with --host yes and apply again; everything else converges"
else
warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY; everything else converges"
fi
fi
if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus"
fi
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
# --- dropping role box: give the group back to the tool that owns it (#50) ---
# rig-admin and rig are rig's own groups, and `gpasswd -d` is the whole story
# for them. incus is not rig's: box's setup-host creates it, `box grant` hands
# it out, and `box revoke` takes it back — doing strictly MORE than removing
# the membership. It says out loud what removing the membership does not do:
# supplementary groups are read at LOGIN, so a session the dropped operator
# already holds keeps the Incus socket until that session dies, and the remedy
# is `loginctl terminate-user <user>`. rig's bare `gpasswd -d` logged "removed
# <user> from incus" and left it there, so an operator who dropped someone from
# the users file and watched apply succeed believed the VM access was gone —
# and was wrong for as long as that user held a session.
#
# So box takes its own group back. The fallback below exists for the host where
# box is not installed (someone else built the Incus stack, or box was removed
# from under it), and it carries the session warning ITSELF: the silence is the
# bug being fixed, not the gpasswd call.
#
# NEVER --purge from here. A bare revoke ends access and leaves the user's
# project and boxes intact — still RUNNING, because revoking a person does not
# kill their workloads. Deleting them is irreversible and is not a convergence
# step: an edit to the users file must never destroy someone's machines behind
# an admin's back. `box revoke <user> --purge`, run deliberately, is where that
# lives.
#
# The absent-group case needs no guard of its own: `id -nG` cannot report a
# group that does not exist, so every caller's `in_group` test is already false
# on a host=no box or one where `box setup-host` never ran — there is nothing
# to revoke, and apply says nothing and moves on.
drop_incus() { # drop_incus <user> — hand group 'incus' back to box
local u="$1"
if command -v box >/dev/null 2>&1; then
# Don't trust the exit code — prove the effective state (the #12 lesson,
# the same one bootstrap applies to box's installer). A revoke that exits
# 0 having left the membership in place would otherwise be reported as a
# removal; the membership is what closes the socket, so it gets checked.
if box revoke "$u" && ! in_group "$u" incus; then
log "removed $u from incus (via 'box revoke' — box owns that group)"
return 0
fi
warn "'box revoke $u' did not remove the incus group — removing it directly; check box on this host"
fi
if in_group "$u" incus; then
gpasswd -d "$u" incus >/dev/null
log "removed $u from incus"
fi
# box's warning, in rig's voice, because rig is the one that took the group
# here. pgrep decides whether it applies; if pgrep is absent rig cannot tell,
# and an unnecessary warning costs an operator one command while a missing
# one costs them a wrong belief about who can reach the daemon.
if ! command -v pgrep >/dev/null 2>&1 || pgrep -u "$u" >/dev/null 2>&1; then
warn "$u may hold live sessions, and group membership is read at login — those sessions keep the Incus socket until they end. To end them now: loginctl terminate-user $u"
fi
}
# --- converge each user ------------------------------------------------------
for u in "${USERS[@]}"; do
if ! id -u "$u" >/dev/null 2>&1; then
useradd -m -s /bin/bash "$u"
log "created user $u"
CHANGED=1
fi
# Locked always, created or found: no password ever exists to guess or
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# rotate — the SSH key at the door is the authentication. The expiry is
# cleared just as idempotently: revocation below IS an expiry date, so a
# user dropped once and re-added comes back to life on this line.
usermod -L -e '' "$u"
# Membership in the three rig-managed groups is made EXACT — added and
# removed to match the file. Other groups are never touched: they are not
# rig's to converge.
roles="${USER_ROLES[$u]}"
want=""
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
case ",$roles," in *,rig,*) want="$want rig" ;; esac
# incus joins the wanted set only when the box role APPLIES to this box —
# the host= trait's call, made once above — and only when the group is
# actually there. Deliberately a gate on whether the role applies, not on
# the add: it is the same question no matter who performs the add, so it
# keeps answering correctly if the add itself later moves elsewhere (#53
# defers it to `box grant`). BOX_ROLE_OK=1 already implies the group exists
# (the arm above dies otherwise), but INCUS_OK is kept in the condition
# because converging membership in a conjured group would hand the daemon's
# arrival an audience it never granted, and that must not depend on a
# neighbouring branch staying fatal.
case ",$roles," in *,box,*) if [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
for g in rig-admin rig incus; do
case " $want " in
*" $g "*)
if ! in_group "$u" "$g"; then
usermod -aG "$g" "$u"
log "added $u to $g"
CHANGED=1
fi ;;
*)
if in_group "$u" "$g"; then
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
if [ "$g" = incus ]; then
drop_incus "$u"
else
gpasswd -d "$u" "$g" >/dev/null
log "removed $u from $g"
fi
CHANGED=1
fi ;;
esac
done
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# authorized_keys becomes exactly the file's keys — only the content WRITE
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
# (a group-writable .ssh is a rejected key), so drifted perms behind
# matching content would otherwise stay broken while apply logs "already
# converged". Perms are part of the converged state.
home="$(getent passwd "$u" | cut -d: -f6)"
ugroup="$(id -gn "$u")"
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
mkdir -p "$home/.ssh"
# Seeded (@root) keys land FIRST, literal lines append after — fixed order
# so the cmp-guard sees identical bytes on identical state and re-runs
# converge to root's then-current keys plus the literals (#17). A literal
# that duplicates a seeded key writes twice; sshd does not mind and the
# bytes stay deterministic.
AK_TMP="$(mktemp)"
{
if [ -n "${USER_SEED[$u]:-}" ]; then printf '%s\n' "$ROOT_SEED_KEYS"; fi
printf '%s' "${USER_KEYS[$u]:-}"
} > "$AK_TMP"
if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then
install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys"
log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)"
CHANGED=1
fi
rm -f "$AK_TMP"
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
chmod 0700 "$home/.ssh"
chown "$u:$ugroup" "$home/.ssh"
chmod 0600 "$home/.ssh/authorized_keys"
chown "$u:$ugroup" "$home/.ssh/authorized_keys"
done
# --- previously managed users no longer in the file --------------------------
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# The ledger is what lets a REMOVED user be found at all — so it must REMEMBER
# them: two-field lines, 'name active' / 'name revoked' (a legacy bare name
# reads as active). Revoked, not deleted: deleting frees the uid for reuse and
# orphans file ownership — attribution would rot. Home stays for the same
# reason. But revoked must actually mean revoked: a '!'-locked password is not
# a closed door under UsePAM — Debian sshd still honors the pubkey — so the
# lock alone left a dropped operator with working SSH. Account expiry (a date
# in the past) is the switch PAM actually enforces, against every auth method
# including keys; the keys themselves are renamed, never deleted — access
# revoked, data kept, convergence never destroys.
LEDGER=/etc/rig/users
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
REVOKED=()
if [ -r "$LEDGER" ]; then
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
while read -r prev pstate _; do
[ -n "$prev" ] || continue
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
id -u "$prev" >/dev/null 2>&1 || continue
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
usermod -L -e 1 "$prev"
prevhome="$(getent passwd "$prev" | cut -d: -f6)"
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
fi
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
# incus goes back through box here too — a user dropped from the file
# entirely is exactly the offboarding this warning was written for, and it
# would be perverse for the full revocation to be the quiet one. It fires
# once, on the transition: the membership is gone on the next run, so
# in_group is false and an already-revoked user stays a clean no-op.
for g in rig-admin rig incus; do
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
if in_group "$prev" "$g"; then
if [ "$g" = incus ]; then
drop_incus "$prev"
else
gpasswd -d "$prev" "$g" >/dev/null
fi
fi
done
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
REVOKED+=("$prev")
# Warn on the TRANSITION only: an already-revoked user is converged above
# (quietly — repairing drift, not announcing news) so a second identical
# run stays a clean no-op.
if [ "${pstate:-active}" != "revoked" ]; then
warn "$prev is no longer in the file: account expired (blocks SSH keys too, not just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, rig groups stripped (home kept — rig never deletes a user)"
CHANGED=1
fi
done < "$LEDGER"
fi
LEDGER_TMP="$(mktemp)"
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s active\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
if [ "${#REVOKED[@]}" -gt 0 ]; then printf '%s revoked\n' "${REVOKED[@]}" >> "$LEDGER_TMP"; fi
if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then
mkdir -p /etc/rig
install -m 0644 "$LEDGER_TMP" "$LEDGER"
CHANGED=1
fi
rm -f "$LEDGER_TMP"
# --- sudoers -----------------------------------------------------------------
# Both group rules ship in one drop-in whether or not both roles are in use:
# the groups exist and the rules are inert without members. visudo gates the
# install because a bad file under /etc/sudoers.d can take down ALL of sudo —
# locking every admin out of the very escalation path apply just granted.
SUDOERS_TMP="$(mktemp)"
cat > "$SUDOERS_TMP" <<'EOF'
# Managed by `rig users apply` — do not edit; the next apply converges it.
%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL
%rig ALL=(root) NOPASSWD: /usr/local/bin/rig
EOF
if command -v visudo >/dev/null 2>&1; then
visudo -c -f "$SUDOERS_TMP" >/dev/null \
|| die "sudoers candidate failed validation — /etc/sudoers.d untouched; candidate kept at $SUDOERS_TMP for inspection"
if ! cmp -s "$SUDOERS_TMP" /etc/sudoers.d/rig-roles 2>/dev/null; then
install -m 0440 "$SUDOERS_TMP" /etc/sudoers.d/rig-roles
log "sudoers role rules installed (/etc/sudoers.d/rig-roles)"
CHANGED=1
fi
rm -f "$SUDOERS_TMP"
else
# No sudo on the box means no role needed it (the install above would have
# run otherwise): rules for a binary that is not there can wait for the
# apply that brings a sudo-bearing role.
rm -f "$SUDOERS_TMP"
log "sudo not installed and no role needs it; skipping the sudoers drop-in"
fi
if [ "$CHANGED" -eq 0 ]; then
log "already converged; no changes"
else
log "converged ${#USERS[@]} user(s)"
fi