rig/commands/users-apply.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

294 lines
13 KiB
Bash
Executable file

#!/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
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
owns the Incus install; rig only asserts it)
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
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=()
USERS=()
BOX_USERS=()
NEED_SUDO=0
NEED_INCUS=0
while IFS='|' read -r u r k; do
[ -n "$u" ] || continue
if [ -z "${USER_ROLES[$u]:-}" ]; then
USERS+=("$u")
USER_ROLES[$u]="$r"
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
fi
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
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"
# 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
# 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.
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. An
# absent incus group means that never ran — but what that MEANS is the host=
# trait's call. The box role binds where VMs live; a users file is fleet-wide,
# its box grants are not. So on host=yes an absent group is a broken VM host
# (refuse, point at setup-host), while on host=no it is simply not this box's
# role to converge — skip it, never abort the admins the file also carries.
INCUS_OK=0
if getent group incus >/dev/null; then INCUS_OK=1; fi
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
*host=yes*)
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" ;;
*host=no*)
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
*)
warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;;
esac
fi
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
# --- 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
# 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 group exists (host=no boxes
# skipped it above): converging membership in a conjured group would hand
# the daemon's arrival an audience it never granted.
case ",$roles," in *,box,*) if [ "$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
gpasswd -d "$u" "$g" >/dev/null
log "removed $u from $g"
CHANGED=1
fi ;;
esac
done
# 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")"
mkdir -p "$home/.ssh"
AK_TMP="$(mktemp)"
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"
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 --------------------------
# 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
REVOKED=()
if [ -r "$LEDGER" ]; then
while read -r prev pstate _; do
[ -n "$prev" ] || continue
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
id -u "$prev" >/dev/null 2>&1 || continue
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
for g in rig-admin rig incus; do
if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi
done
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)"
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