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>
This commit is contained in:
Claude 2026-07-17 20:01:19 +00:00
parent 062dad4ead
commit 3eeab687d0
6 changed files with 288 additions and 64 deletions

View file

@ -415,11 +415,24 @@ holds nothing secret anyway: usernames, roles, and *public* keys.
| `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` |
| `box` | Incus **restricted** tier, no sudo | `incus` |
`box` carries a refusal with it: rig never installs Incus — box's `setup-host`
owns the daemon — so an absent `incus` group means that never ran, and apply
dies pointing at `box setup-host` rather than conjure a group the
(nonexistent) daemon would never consult. `incus-admin` is deliberately
**not** a role: that group is host-root-equivalent, break-glass by hand only.
**The honest limit of the `rig` role:** its sudo grant is binary-scoped, not
argument-scoped — it trusts its holder with every rig verb *except* identity
management. The `rig users` commands gate their **invoker**: run under sudo
by anyone outside `rig-admin`, they refuse. Without that gate, `sudo rig
users apply` against a file naming yourself admin would make the scoped grant
silently root-equivalent through the very tool it scopes. Direct root — a
bring-up shell, before any admin exists — proceeds.
`box` binds where VMs live, and a users file is fleet-wide — its box grants
are not. rig never installs Incus — box's `setup-host` owns the daemon — so
when the `incus` group is absent, the `host=` trait decides: on `host=yes`
apply dies pointing at `box setup-host` (a VM host missing Incus is a real
problem) rather than conjure a group the (nonexistent) daemon would never
consult; on `host=no` the box role is **skipped with a warning** and
everything else — admins included — still converges, because one box-role
user somewhere in the fleet must not stop apply everywhere VMs don't live.
`incus-admin` is deliberately **not** a role: that group is
host-root-equivalent, break-glass by hand only.
**All passwords stay locked, always** — created or found. The SSH key at the
door is the authentication, and NOPASSWD sudo does not weaken it: there was
@ -427,14 +440,21 @@ never a password to guess or rotate.
Convergence is exact. Membership in the three rig-managed groups is made to
match the file — added *and* removed — while every other group is left alone:
not rig's to converge. `authorized_keys` becomes exactly the file's keys. A
user dropped from the file is found via the `/etc/rig/users` ledger and
**locked, never deleted** — deletion frees the uid for reuse and orphans file
ownership, so attribution would rot; home stays for the same reason. And the
sudoers rules land in `/etc/sudoers.d/rig-roles` only after `visudo -c`
passes on the candidate — 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.
not rig's to converge. `authorized_keys` becomes exactly the file's keys, and
its ownership and mode (and `.ssh`'s) are converged on **every** run, not
just when content changes — sshd's `StrictModes` treats them as
load-bearing, so drifted perms are a broken login that "already converged"
would lie about. A user dropped from the file is found via the
`/etc/rig/users` ledger and **revoked, never deleted**: the account is
expired — the switch PAM actually enforces; a locked password alone still
lets a pubkey in under Debian's `UsePAM` — and `authorized_keys` is renamed
to `authorized_keys.revoked-by-rig`. Access revoked, data kept: deletion
frees the uid for reuse and orphans file ownership, so attribution would rot;
home stays for the same reason, and re-adding the user to the file brings
them back, fresh keys and all. And the sudoers rules land in
`/etc/sudoers.d/rig-roles` only after `visudo -c` passes on the candidate — 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.
### `rig users status`
@ -444,8 +464,12 @@ rig users status
Read-only truth: per rig-managed user, the roles derived from the groups the
user is **actually** in — not the ledger's memory of an apply — plus the
`authorized_keys` count and whether the account is locked or active. Reads the
box only; no network, no writes. Run as root (shadow is read).
`authorized_keys` count (`revoked` when only the `.revoked-by-rig` rename
remains) and the user's state, **active** or **revoked**. The state is the
ledger's word corroborated by the account's real expiry — the switch that
actually revokes — and a mismatch is flagged loudly as drift: a box someone
changed behind rig's back must never read as healthy. Reads the box only; no
network, no writes. Run as root (shadow is read).
### `rig users close-root`
@ -459,8 +483,13 @@ must say `class=human` — an absent marker refuses (never shut the root door
blind; re-run bootstrap so the box knows what it is), and `class=server`
refuses with no `--force`, because root there is the control plane's
automation identity and closing it severs fleet management. Then at least one
`rig-admin` member must hold a non-empty `authorized_keys` — never close the
only door.
`rig-admin` member must hold a login sshd would plausibly **accept** — a
non-empty `authorized_keys` alone proves a file, not a door: the gate checks
the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
user and not group/world-writable), a real login shell, and an unexpired
account, and its refusal names which check failed, per candidate. It proves
the door *should* open, not that it does — which is why the separate-session
verification below stays load-bearing. Never close the only door.
Before running it, prove the admin door in a **separate** session — `ssh
<admin>@<box>` while this one stays open. Root SSH is being welded shut; the

View file

@ -24,7 +24,9 @@
# Refusals: unknown role (the valid set is named), differing roles across one
# user's lines, root as username (root's keys are class policy's business, not
# this file's), malformed line (fewer than 3 fields, or a key field that does
# not start with an SSH key type), duplicate identical key line.
# not start with an SSH key type), invalid username (the charset below —
# '|' would corrupt this parser's own delimited stream, a leading '-' reads
# as a useradd flag), duplicate identical key line.
parse_users_file() {
local path="$1"
local -a errs=() out=() rlist=()
@ -44,6 +46,14 @@ parse_users_file() {
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
continue ;;
esac
# The username feeds this parser's own '|'-delimited stream and then
# useradd: 'fo|o' silently becomes user 'fo' with garbage keys, and a
# leading '-' reads as a useradd flag mid-convergence. One safe charset
# refuses both by construction (and ':', which would corrupt passwd).
if ! [[ "$u" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
errs+=("line $n: invalid username '$u' — must match ^[a-z_][a-z0-9_-]{0,31}\$ (lowercase letter or '_' first, then lowercase, digits, '_', '-'; max 32)")
continue
fi
if [ "$u" = "root" ]; then
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy")
continue

View file

@ -42,8 +42,12 @@ 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 locked and stripped of the rig groups — home kept, never
deleted. Run as root.
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
}
@ -76,6 +80,7 @@ PARSED="$(parse_users_file "$FILE")" \
declare -A USER_ROLES=() USER_KEYS=()
USERS=()
BOX_USERS=()
NEED_SUDO=0
NEED_INCUS=0
while IFS='|' read -r u r k; do
@ -83,6 +88,7 @@ while IFS='|' read -r u r k; do
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
@ -92,9 +98,19 @@ 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 /etc/rig/role)" in
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" ;;
@ -113,10 +129,22 @@ fi
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 — refuse with the pointer rather
# than conjure a group the (nonexistent) daemon would never consult.
if [ "$NEED_INCUS" -eq 1 ] && ! getent group incus >/dev/null; then
die "a user carries role box but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus"
# 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"; }
@ -129,8 +157,10 @@ for u in "${USERS[@]}"; do
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. Idempotent.
usermod -L "$u"
# 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
@ -139,7 +169,10 @@ for u in "${USERS[@]}"; do
want=""
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
case ",$roles," in *,rig,*) want="$want rig" ;; esac
case ",$roles," in *,box,*) want="$want incus" ;; 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 "*)
@ -157,43 +190,68 @@ for u in "${USERS[@]}"; do
esac
done
# authorized_keys becomes exactly the file's keys — cmp-guarded like every
# file rig converges, so an unchanged file is a clean no-op.
# 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
mkdir -p "$home/.ssh"
chmod 0700 "$home/.ssh"
chown "$u:$ugroup" "$home/.ssh"
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. Locked, not deleted:
# deleting frees the uid for reuse and orphans file ownership — attribution
# would rot. Home stays for the same reason.
# 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 IFS= read -r prev; do
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 "$prev"
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
warn "$prev is no longer in the file: locked and stripped of the rig groups (home kept — rig never deletes a user)"
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\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
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"

View file

@ -26,8 +26,11 @@ Human class ONLY. On class=server, root SSH is the control plane's (Coolify's)
automation identity — closing it severs fleet management — so close-root
refuses there, with no --force. It also refuses without a role marker (re-run
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
member holds a working authorized_keys (run rig users apply first; never close
the only door).
member holds a login sshd would plausibly accept — authorized_keys present
and non-empty, home/.ssh/keys owned by the user and not group/world-writable
(sshd's StrictModes rejects the key otherwise), a real login shell, account
not expired. The refusal names which check failed, per candidate. Run rig
users apply first; never close the only door.
Before running, verify your admin login in a SEPARATE session — `ssh
<admin>@<box>` while this one stays open. Root SSH is the door being welded
@ -48,6 +51,16 @@ done
# --- 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 reshape who enters this box as whom — the scoped grant silently
# root-equivalent through the users family. 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
# Marker gate — the policy lives in assert_marker_human (lib) so the harness
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER
# exists for the same reason: it keeps the command's own gate pointable at
@ -56,18 +69,69 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
die "$WHY"
fi
# Admin-door gate — never close the only door. Root SSH goes away below, so at
# least one rig-admin member must already hold a non-empty authorized_keys:
# "verified in a separate session" cannot be automated, but a key at the door
# can be, and its absence is proof enough to stop.
# Admin-door gate — never close the only door. Root SSH goes away below, so
# at least one rig-admin member must hold a login sshd would plausibly ACCEPT
# — a non-empty authorized_keys alone proves a file exists, not a door:
# StrictModes rejects keys behind wrongly-owned or group/world-writable
# paths, a nologin shell never logs in, and an expired account fails PAM
# before the key is read. So every candidate is checked for the StrictModes
# shape, and the refusal names, per candidate, WHICH check failed — an
# operator staring at a refusal must see the repair. Honestly: this proves
# the door SHOULD open per StrictModes, not that it does — the
# verify-in-a-separate-session advisory in --help stays load-bearing.
today=$(( $(date +%s) / 86400 ))
# path_strict <path> <uid> <label> — flag the two StrictModes complaints
flag() { bad="${bad:+$bad, }$1"; }
path_strict() {
local p="$1" uid="$2" l="$3" o m
o="$(stat -c '%u' "$p" 2>/dev/null)" || return 0
m="$(stat -c '%a' "$p" 2>/dev/null)"
if [ "$o" != "$uid" ]; then flag "$l not owned by the user"; fi
if [ $(( 8#$m & 8#022 )) -ne 0 ]; then flag "$l is group/world-writable (mode $m)"; fi
}
ADMIN_OK=0
CANDIDATES=0
DETAIL=""
while IFS= read -r a; do
[ -n "$a" ] || continue
h="$(getent passwd "$a" | cut -d: -f6)"
if [ -n "$h" ] && [ -s "$h/.ssh/authorized_keys" ]; then ADMIN_OK=1; break; fi
CANDIDATES=$((CANDIDATES + 1))
bad=""
uid="$(id -u "$a" 2>/dev/null)" || { DETAIL="$DETAIL; $a: no such user"; continue; }
ent="$(getent passwd "$a")"
h="$(printf '%s' "$ent" | cut -d: -f6)"
shell="$(printf '%s' "$ent" | cut -d: -f7)"
if [ ! -d "$h" ]; then
flag "home directory missing"
else
path_strict "$h" "$uid" "home"
if [ ! -d "$h/.ssh" ]; then
flag ".ssh directory missing"
else
path_strict "$h/.ssh" "$uid" ".ssh directory"
if [ ! -s "$h/.ssh/authorized_keys" ]; then
flag "authorized_keys missing or empty"
else
path_strict "$h/.ssh/authorized_keys" "$uid" "authorized_keys"
fi
fi
fi
case "$shell" in
*/nologin|*/false) flag "login shell $shell never logs in" ;;
esac
# shadow field 8 is the expiry in days-since-epoch; empty means never.
exp="$(getent shadow "$a" 2>/dev/null | cut -d: -f8)"
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
flag "account expired"
fi
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
DETAIL="$DETAIL; $a: $bad"
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')
[ "$ADMIN_OK" -eq 1 ] \
|| die "no admin user with a key on this box — run rig users apply first; never close the only door"
if [ "$ADMIN_OK" -ne 1 ]; then
if [ "$CANDIDATES" -eq 0 ]; then
die "no admin user with a key on this box — run rig users apply first; never close the only door"
fi
die "no rig-admin member would pass sshd's door${DETAIL} — repair, prove the login in a separate session, then re-run; never close the only door"
fi
# --- the drop-in --------------------------------------------------------------
# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each

View file

@ -1,8 +1,12 @@
#!/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, lock state
# from shadow. Reads only — no network, no writes.
# 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' "$*"; }
@ -15,8 +19,11 @@ 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, and whether the account is locked or active.
Reads the box only — no network, no writes. Run as root (shadow is read).
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
}
@ -36,11 +43,15 @@ if [ ! -r "$LEDGER" ]; then
exit 0
fi
while IFS= read -r u; do
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 locks. Say so rather than crash or silently skip.
# 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
@ -54,12 +65,24 @@ while IFS= read -r u; do
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
# Field 2 of `passwd -S` is the lock flag; locked is apply's resting state
# for a user dropped from the file, so it is the fact worth surfacing.
state=active
case "$(passwd -S "$u" 2>/dev/null | awk '{print $2}')" in
L|LK) state=locked ;;
esac
log "$u roles=$roles keys=$keys $state"
done < "$LEDGER"

View file

@ -314,6 +314,15 @@ printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
printf '%s\n' 'dan admin' > "$FIX_BAD"
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
# line and the rule, like every other parser refusal.
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
check "users parser: leading-dash username is refused" 1 "invalid username" parse "$FIX_BAD"
check "users parser: valid file emits dan (both keys' roles agree)" \
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
@ -347,6 +356,31 @@ sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-ap
check "users apply: visudo -c precedes the sudoers install" \
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
# --file <me-as-admin>` turns role rig root-equivalent through this very
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
# refusal in both identity-management commands (repo precedent: the
# staging/runner tag greps).
check "users apply: invoker gate refusal is present" 0 "" \
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
check "users close-root: invoker gate refusal is present" 0 "" \
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
# switch PAM actually honors, and the keys are renamed, never deleted
# (convergence never destroys). Needs root + real accounts, so grep both moves.
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
check "users apply: revoked keys are renamed, never deleted" 0 "" \
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
# A fleet-wide users file must not abort apply on a host=no box just because
# it names a box-role user somewhere in the fleet: the box role binds where
# VMs live, so on host=no it skips (with a warning) and everything else —
# admins included — still converges.
check "users apply: box role skips on a host=no box" 0 "" \
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
# --- users close-root: the human-class root-door shutter ---------------------
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
@ -367,6 +401,12 @@ sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-roo
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
check "users close-root: sshd -t precedes the ssh restart" \
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
# non-empty authorized_keys behind group/world-writable perms is a key sshd
# rejects — closing root behind it welds the only door shut. The full gate
# needs root + real accounts, so grep the load-bearing check's wording.
check "users close-root: gate checks the StrictModes shape" 0 "" \
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
# path sits behind the root check, so the gate is a pure lib function on
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command