rig/commands/users-status.sh
Claude 3eeab687d0 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

88 lines
3.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# rig users status — what this box's operator accounts actually are, read from
# the machine itself: roles derived from REAL group membership (not the
# ledger's memory of an apply), key counts from authorized_keys, and the
# active/revoked state from the ledger CORROBORATED by the account's actual
# expiry — apply locks every password always, so the lock flag says nothing;
# expiry is the switch that actually revokes, and a mismatch between ledger
# and expiry is drift worth shouting about. Reads only — no network, no
# writes.
set -euo pipefail
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 status
Per rig-managed user (the /etc/rig/users ledger): roles derived from the
groups the user is ACTUALLY in (rig-admin -> admin, rig -> rig, incus -> box),
the authorized_keys count ('revoked' when only the .revoked-by-rig rename
remains), and whether the user is active or revoked — the ledger's word,
checked against the account's real expiry, with a loud warning when the two
disagree (a drifted box must never read as healthy). Reads the box only — no
network, no writes. Run as root (shadow is read).
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
[ "$(id -u)" -eq 0 ] || die "must run as root"
LEDGER=/etc/rig/users
if [ ! -r "$LEDGER" ]; then
log "no rig-managed users (no $LEDGER yet — rig users apply creates it)"
exit 0
fi
today=$(( $(date +%s) / 86400 ))
while read -r u lstate _; do
[ -n "$u" ] || continue
# Ledger lines are 'name active' / 'name revoked'; a legacy bare name (a
# ledger written before states existed) reads as active.
[ -n "${lstate:-}" ] || lstate=active
if ! id -u "$u" >/dev/null 2>&1; then
# In the ledger but off the box: someone deleted by hand what rig only
# ever revokes. Say so rather than crash or silently skip.
warn "$u: in the ledger but not on the box (rig never deletes — removed by hand?)"
continue
fi
groups=" $(id -nG "$u") "
roles=""
case "$groups" in *" rig-admin "*) roles="admin" ;; esac
case "$groups" in *" rig "*) roles="${roles:+$roles,}rig" ;; esac
case "$groups" in *" incus "*) roles="${roles:+$roles,}box" ;; esac
[ -n "$roles" ] || roles="none"
home="$(getent passwd "$u" | cut -d: -f6)"
keys=0
if [ -r "$home/.ssh/authorized_keys" ]; then
keys="$(grep -c . "$home/.ssh/authorized_keys" || true)"
elif [ -e "$home/.ssh/authorized_keys.revoked-by-rig" ]; then
# Only the rename apply's revocation performed remains: access revoked,
# data kept.
keys=revoked
fi
# Corroborate the ledger against the switch that actually revokes: shadow
# field 8 is the expiry (days since epoch, empty = never). The ledger is
# apply's memory; the expiry is the machine's present tense — when they
# disagree, someone changed the account behind rig's back.
exp="$(getent shadow "$u" 2>/dev/null | cut -d: -f8)"
actual=active
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
actual=revoked
fi
if [ "$actual" != "$lstate" ]; then
warn "$u: DRIFT — ledger says $lstate but the account's expiry says $actual; re-run rig users apply"
log "$u roles=$roles keys=$keys $lstate (DRIFT: expiry says $actual)"
else
log "$u roles=$roles keys=$keys $lstate"
fi
done < "$LEDGER"