From 9bdc4db57596290e248c0eb8d4a57462873063b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:23:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(users):=20declarative=20operators=20?= =?UTF-8?q?=E2=80=94=20apply/status=20over=20a=20users=20file,=20every=20c?= =?UTF-8?q?lass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators become a declared fact, not an accumulation of adduser runs: a line-based, bash-parseable users file (no YAML, no jq — a rig box has neither) names each user, their roles, and their keys, and apply converges the box to exactly that. Roles map to groups (admin→rig-admin with full NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no sudo — box's setup-host owns Incus, rig only asserts the group). Every password stays locked always; the SSH key at the door is the authentication. A user dropped from the file is found via the /etc/rig/users ledger and locked, never deleted — deleting frees the uid and rots attribution. The sudoers drop-in lands only after visudo -c passes, because a bad file under sudoers.d takes down all of sudo. Class never gates apply (#26: a shared root login is unattributable, so operators belong on every class); the marker only colors what root SSH does next. The whole file is validated in one pass before the root check, every error named with its line, so refusals are provable in the non-root harness through the sourced parser. Co-Authored-By: Claude Fable 5 --- bin/rig | 34 +++++ commands/lib/users-config.sh | 86 +++++++++++++ commands/users-apply.sh | 236 +++++++++++++++++++++++++++++++++++ commands/users-status.sh | 65 ++++++++++ test/cli.sh | 69 ++++++++++ 5 files changed, 490 insertions(+) create mode 100644 commands/lib/users-config.sh create mode 100755 commands/users-apply.sh create mode 100755 commands/users-status.sh diff --git a/bin/rig b/bin/rig index 3dc2103..1fe8f95 100755 --- a/bin/rig +++ b/bin/rig @@ -42,6 +42,18 @@ commands: re-register, reusing the binary already on the box. Needs a removal token for the old repo and a registration token for the new one. Run as root. + users apply --file + Converge named operator accounts from a declarative users file, on + every class: groups by role (admin/rig/box), passwords locked always, + authorized_keys made exact, visudo-gated sudoers rules. Users dropped + from the file are locked, never deleted. '-' reads stdin. Run as root. + users status + Roles (derived from actual group membership), key counts and lock + state for the rig-managed users. Reads the box only. Run as root. + users close-root + Shut root SSH on a class=human box once an admin key works. Refuses + on class=server — root there is the control plane's automation door — + and while no admin holds a key. Run as root. install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash @@ -103,6 +115,28 @@ case "$cmd" in ;; esac ;; + users) + shift + sub="${1:-}" + case "$sub" in + apply) + shift + exec "$ROOT/commands/users-apply.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/users-status.sh" "$@" + ;; + close-root) + shift + exec "$ROOT/commands/users-close-root.sh" "$@" + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; -h|--help|help) usage exit 0 diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh new file mode 100644 index 0000000..a468758 --- /dev/null +++ b/commands/lib/users-config.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Shared parsing for the rig users family. Sourced by the users-* commands and +# by the test harness against fixture files; never executed on its own. + +# The users file is line-based and whitespace-separated on purpose: a +# rig-bootstrapped box has no YAML parser and no jq, and `read` parses this +# shape for free — same jq-free reason runner-config.sh greps JSON. One line +# per key: +# +# # user roles ssh public key +# dan admin,box ssh-ed25519 AAAA... dan@laptop +# +# Repeated username lines are additional authorized keys; the roles field must +# be IDENTICAL on each — a repeated line means "another key", never a quiet +# role edit hiding mid-file. '#' comments and blank lines are skipped. + +# parse_users_file +# +# Emits one normalized 'user|roles|key' line per key line on stdout. On ANY +# validation error: EVERY error goes to stderr, each with its line number, no +# stdout, return 1. All errors in one pass because a bad file should cost one +# fix cycle, not one round-trip per line. +# +# 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. +parse_users_file() { + local path="$1" + local -a errs=() out=() rlist=() + local -A first_roles=() seen=() + local line u r k role ok n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + if [[ "$line" =~ ^[[:space:]]*(#|$) ]]; then continue; fi + read -r u r k <<< "$line" + if [ -z "${k:-}" ]; then + errs+=("line $n: malformed — expected 'user roles ssh-public-key' (3+ whitespace-separated fields)") + continue + fi + case "$k" in + ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;; + *) + errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)") + continue ;; + esac + 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 + fi + ok=1 + IFS=',' read -ra rlist <<< "$r" + for role in "${rlist[@]}"; do + case "$role" in + admin|rig|box) ;; + *) errs+=("line $n: unknown role '$role' for $u (valid roles: admin rig box)"); ok=0 ;; + esac + done + if [ -n "${first_roles[$u]:-}" ] && [ "${first_roles[$u]}" != "$r" ]; then + errs+=("line $n: $u has roles '$r' here but '${first_roles[$u]}' earlier — repeated lines add keys, roles must be identical") + ok=0 + fi + if [ -z "${first_roles[$u]:-}" ]; then first_roles[$u]="$r"; fi + if [ -n "${seen[$u|$k]:-}" ]; then + errs+=("line $n: duplicate key line for $u (same key already on line ${seen[$u|$k]})") + continue + fi + seen[$u|$k]="$n" + if [ "$ok" -eq 1 ]; then out+=("$u|$r|$k"); fi + done < "$path" + if [ "${#errs[@]}" -gt 0 ]; then + printf '%s\n' "${errs[@]}" >&2 + return 1 + fi + if [ "${#out[@]}" -gt 0 ]; then printf '%s\n' "${out[@]}"; fi + return 0 +} + +# read_role_marker — the marker line bootstrap wrote +# (`role=... class=... host=... join=...`), or nothing when absent. NO policy +# here: what an absent marker or a given class MEANS is each caller's call +# (apply notes it, close-root refuses on it) — the lib only reads. +read_role_marker() { + [ -r "$1" ] || return 0 + head -n1 "$1" +} diff --git a/commands/users-apply.sh b/commands/users-apply.sh new file mode 100755 index 0000000..36977d2 --- /dev/null +++ b/commands/users-apply.sh @@ -0,0 +1,236 @@ +#!/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 + + --file 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 locked and stripped of the rig groups — home kept, never +deleted. Run as root. +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 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=() +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" + 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" + +# 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 + *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 — 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" +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. Idempotent. + usermod -L "$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 + case ",$roles," in *,box,*) want="$want incus" ;; 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 — cmp-guarded like every + # file rig converges, so an unchanged file is a clean no-op. + home="$(getent passwd "$u" | cut -d: -f6)" + ugroup="$(id -gn "$u")" + 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" +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. +LEDGER=/etc/rig/users +if [ -r "$LEDGER" ]; then + while IFS= read -r prev; do + [ -n "$prev" ] || continue + case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac + id -u "$prev" >/dev/null 2>&1 || continue + usermod -L "$prev" + 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)" + CHANGED=1 + done < "$LEDGER" +fi +LEDGER_TMP="$(mktemp)" +if [ "${#USERS[@]}" -gt 0 ]; then printf '%s\n' "${USERS[@]}" > "$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 diff --git a/commands/users-status.sh b/commands/users-status.sh new file mode 100755 index 0000000..8234bab --- /dev/null +++ b/commands/users-status.sh @@ -0,0 +1,65 @@ +#!/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. +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, and whether the account is locked or active. +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 + +while IFS= read -r u; do + [ -n "$u" ] || continue + 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. + 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)" + 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" diff --git a/test/cli.sh b/test/cli.sh index a6ae065..143b4b0 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -266,6 +266,75 @@ else echo "skip: runner status/remove/repoint non-root refusals (running as root)" fi +check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users +check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate + +check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help +check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh" +check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file +check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users +check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope +check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help + +# --- users file refusal matrix, through the sourced parser ------------------- +# Reaching the parser via the CLI stops at the root check; it is pure and +# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array), +# so the refusals are proven here against fixtures, non-root and network-free. +parse() { # parse — the users-file parser, exactly as apply runs it + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + parse_users_file "$2"' _ "$ROOT" "$1" +} +FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line +FIX_BAD="$(mktemp)" # rewritten per refusal below +cat > "$FIX_OK" <<'USERS' +# fleet operators +dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop +dan admin,box ssh-ed25519 AAAAC3second dan@desk + +maria rig ssh-ed25519 AAAAC3fixture maria@mac +USERS +printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD" +check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD" +printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD" +check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD" +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" +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" +# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error. +# A single invocation, both messages asserted from its one stderr. +printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD" +MULTI_ERRS="$(mktemp)" +parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$? +check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1 +check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS" +check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS" +rm -f "$MULTI_ERRS" + +if [ "$(id -u)" -ne 0 ]; then + # A VALID fixture proves the whole file-validation pass sits before the + # root check — a parse failure here would exit 2, not 1. + check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK" + check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh" +else + echo "skip: users non-root refusals (running as root)" +fi +rm -f "$FIX_OK" "$FIX_BAD" + +# Validate-then-apply: `visudo -c` must pass before anything lands in +# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin +# out of the escalation path apply just granted. Assert the order in the file, +# matching the calls rather than comments (repo precedent: the runner-install +# repo-guard ordering check). Defaults fail closed. +visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +check "users apply: visudo -c precedes the sudoers install" \ + 0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}" + # The dump script ships to control-plane boxes as an embedded heredoc. A syntax # error in it would be invisible here and would first surface at 04:00 on a live # control plane. Extract it and syntax-check what actually gets written.