Merge pull request #30 from dan-claude-bot/feat/close-root-reachability
users: finish #17 — close-root proves the door (sudo -n, per-user sshd -T), @root key seeding, runner row owned
This commit is contained in:
commit
539dee064a
6 changed files with 563 additions and 23 deletions
42
README.md
42
README.md
|
|
@ -243,6 +243,15 @@ On `class=server` it stays open — key-only, as bootstrap left it — because
|
|||
root there is the **automation** identity the control plane (Coolify) SSHes
|
||||
in as. It is a machine door, never a human one.
|
||||
|
||||
**Where this diverges from #17's original table:** that table let the
|
||||
`runner` role close root ("no Coolify involved"). The class model supersedes
|
||||
the per-role call: runner is `class=server` — an automation identity, not a
|
||||
person's box — and on every server-class machine root SSH is the management
|
||||
plane rig itself converges through, so `close-root` refuses there
|
||||
deliberately, runner included. A CI box you mean to administer like a human
|
||||
machine is `--class human` at bootstrap, not an exception carved out of the
|
||||
gate.
|
||||
|
||||
**The detection side benefit:** once humans never use root, any root login
|
||||
that is not the control plane is anomalous *by definition* — a cheap,
|
||||
high-signal alert that a shared root identity makes impossible to write.
|
||||
|
|
@ -554,6 +563,20 @@ refused as a username: this file names operators; root's fate is class policy.
|
|||
`--file -` reads stdin. A bad file exits 2 with **every** error listed at
|
||||
once, before anything changes — one fix cycle, not one round-trip per line.
|
||||
|
||||
**`@root` — seed keys from the door you came in through (#17).** A key field
|
||||
of exactly `@root` means "this user's `authorized_keys` becomes root's
|
||||
CURRENT `/root/.ssh/authorized_keys`". The point is lockout-avoidance: 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 rig can *know* opens for you; any pasted
|
||||
literal can be a key you do not hold. `@root` mixes with literal lines
|
||||
(seeded keys land first, literals append after), re-runs re-seed from root's
|
||||
then-current file — convergent to it, so a seeded key you hand-remove from
|
||||
the admin returns until you switch the line to literal keys — and apply dies
|
||||
if root has no `authorized_keys` to seed. Root's key lines are copied
|
||||
verbatim, options included: a `from=`/`command=` restriction follows the key,
|
||||
and on a Coolify-managed box root's file also carries *Coolify's* key — on
|
||||
`class=server`, prefer literal keys.
|
||||
|
||||
**Public tool, private state, here too.** The users file lives in *your*
|
||||
private infra repo and is passed per invocation — rig never persists it. It
|
||||
holds nothing secret anyway: usernames, roles, and *public* keys.
|
||||
|
|
@ -636,9 +659,22 @@ automation identity and closing it severs fleet management. Then at least one
|
|||
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.
|
||||
account — and then two **reachability** proofs (#17): `sudo -n true` under
|
||||
`runuser` must answer, so NOPASSWD sudo is effective rather than merely
|
||||
written, and `sshd -T -C user=<admin>` must resolve a per-user effective
|
||||
config that accepts the login (`pubkeyauthentication yes`, no `DenyUsers`
|
||||
hit — where any pattern or `USER@HOST` entry counts as a hit, fail closed,
|
||||
since `DenyUsers dan*` really denies admin `dan` and rig will not re-implement
|
||||
sshd's pattern engine to prove a miss — `AllowUsers`, if set, names them
|
||||
literally, and the same fail-closed pair for `DenyGroups`/`AllowGroups`
|
||||
judged against the admin's actual groups), so a `Match` block elsewhere
|
||||
cannot quietly exclude the admin
|
||||
while every file looks right. The refusal names which check failed, per
|
||||
candidate. What no local check can prove: that you *hold* the private key,
|
||||
and how a `Match Address` rule treats your real client address (the probe
|
||||
resolves against a synthetic `addr=127.0.0.1`) — 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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@
|
|||
# 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.
|
||||
#
|
||||
# The key field may also be the literal token '@root' (#17): "this user's
|
||||
# authorized_keys becomes root's CURRENT /root/.ssh/authorized_keys at apply
|
||||
# time". The operator provably holds a root private key — they SSHed in with
|
||||
# it to run apply at all — so seeding it is the one key source that cannot
|
||||
# lock them out; any pasted literal can be a key they do not hold. '@root'
|
||||
# mixes with literal key lines: seeded keys come first, literal keys are
|
||||
# APPENDED after them, and re-runs converge to root's then-current keys plus
|
||||
# the literals. The parser only owns the token's shape — reading root's file
|
||||
# needs root and is apply's business.
|
||||
|
||||
# parse_users_file <path>
|
||||
#
|
||||
|
|
@ -24,9 +34,11 @@
|
|||
# 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), invalid username (the charset below —
|
||||
# '|' would corrupt this parser's own delimited stream, a leading '-' reads
|
||||
# as a useradd flag), duplicate identical key line.
|
||||
# not start with an SSH key type and is not exactly '@root'), '@root' with
|
||||
# trailing material (the token IS the whole field), invalid username (the
|
||||
# charset below — '|' would corrupt this parser's own delimited stream, a
|
||||
# leading '-' reads as a useradd flag), duplicate identical key line (a
|
||||
# second '@root' for one user counts — the seen[] map catches it for free).
|
||||
parse_users_file() {
|
||||
local path="$1"
|
||||
local -a errs=() out=() rlist=()
|
||||
|
|
@ -41,9 +53,13 @@ parse_users_file() {
|
|||
continue
|
||||
fi
|
||||
case "$k" in
|
||||
@root) ;; # seed token — apply reads root's authorized_keys (#17)
|
||||
@root*)
|
||||
errs+=("line $n: '@root' is the whole key field — it names root's authorized_keys as this user's key source and takes no trailing material")
|
||||
continue ;;
|
||||
ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;;
|
||||
*)
|
||||
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
|
||||
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...) or be the literal '@root'")
|
||||
continue ;;
|
||||
esac
|
||||
# The username feeds this parser's own '|'-delimited stream and then
|
||||
|
|
@ -115,10 +131,91 @@ assert_marker_human() {
|
|||
*class=server*)
|
||||
# Root SSH on a server IS the control plane's (Coolify's) automation
|
||||
# identity — closing it severs fleet management. No --force exists.
|
||||
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management"
|
||||
# Deliberately per-CLASS, not per-role: #17's original table let the
|
||||
# runner role close root ("no Coolify involved"), but the class model
|
||||
# (#26) supersedes that — every server-class box, runner included, is
|
||||
# an automation identity whose management plane is root SSH, and rig
|
||||
# itself converges through that door. A CI box someone administers
|
||||
# like a human machine is class=human at bootstrap, not an exception
|
||||
# carved out here.
|
||||
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here"
|
||||
return 1 ;;
|
||||
*)
|
||||
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||
return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# deny_verdict <user> <denyusers token...>
|
||||
#
|
||||
# Judge sshd's effective DenyUsers list against ONE candidate, fail closed.
|
||||
# Empty output = every token is PROVABLY irrelevant to <user>: literal (no
|
||||
# sshd pattern metacharacters, no host qualifier) and not this username.
|
||||
# Anything else prints the reason and the caller flags the candidate:
|
||||
#
|
||||
# - a literal hit — DenyUsers really names them;
|
||||
# - ANY pattern token (* or ?) — 'DenyUsers dan*' genuinely denies admin
|
||||
# 'dan', and this side of sshd cannot re-implement its pattern engine
|
||||
# just to prove a miss, so an unprovable token counts as a hit;
|
||||
# - ANY host-qualified token (USER@HOST) — whether it bites depends on the
|
||||
# client's address, which no local probe knows.
|
||||
#
|
||||
# The asymmetry with AllowUsers is deliberate and points the same direction:
|
||||
# AllowUsers must name the admin literally (a pattern that WOULD admit them
|
||||
# still refuses — over-refusing is safe), DenyUsers refuses on anything it
|
||||
# cannot prove misses. Both errors close toward "repair first", never toward
|
||||
# a welded-shut root door. Pure text→text, sourced by the harness.
|
||||
deny_verdict() {
|
||||
local u="$1" tok; shift
|
||||
for tok in "$@"; do
|
||||
case "$tok" in
|
||||
"$u") printf 'sshd DenyUsers names this user'; return 0 ;;
|
||||
*[*?]*) printf "sshd DenyUsers has pattern entry '%s' — cannot prove it misses this user; make it literal or remove it, then re-run" "$tok"; return 0 ;;
|
||||
*@*) printf "sshd DenyUsers has host-qualified entry '%s' — whether it bites depends on the client address, which no local check can prove; make it literal or remove it, then re-run" "$tok"; return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# group_deny_verdict <space-separated groups> <denygroups token...>
|
||||
#
|
||||
# deny_verdict's sibling for sshd's DenyGroups, judged against the
|
||||
# candidate's ACTUAL group membership (id -Gn), same fail-closed rule:
|
||||
# empty output = every token is provably irrelevant — literal and naming
|
||||
# none of the candidate's groups. A literal token naming a group they are
|
||||
# in flags, and so does any pattern or host-qualified token, because a
|
||||
# token this side of sshd cannot prove irrelevant may be the one that
|
||||
# denies. Pure text→text, sourced by the harness.
|
||||
group_deny_verdict() {
|
||||
local groups="$1" tok g; shift
|
||||
for tok in "$@"; do
|
||||
case "$tok" in
|
||||
*[*?]*) printf "sshd DenyGroups has pattern entry '%s' — cannot prove it misses this user's groups; make it literal or remove it, then re-run" "$tok"; return 0 ;;
|
||||
*@*) printf "sshd DenyGroups has host-qualified entry '%s' — whether it bites depends on the client address, which no local check can prove; make it literal or remove it, then re-run" "$tok"; return 0 ;;
|
||||
*) for g in $groups; do
|
||||
if [ "$tok" = "$g" ]; then
|
||||
printf "sshd DenyGroups names '%s' — a group this user is in" "$g"; return 0
|
||||
fi
|
||||
done ;;
|
||||
esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# group_allow_verdict <space-separated groups> <allowgroups token...>
|
||||
#
|
||||
# AllowGroups' direction: when the directive is set, sshd admits only
|
||||
# members of a matching group, so the proof must be a LITERAL token
|
||||
# naming a group the candidate is in. A pattern that would in fact admit
|
||||
# them proves nothing here (same stance as AllowUsers: over-refusing is
|
||||
# the safe error), so no literal hit → flag. Pure text→text.
|
||||
group_allow_verdict() {
|
||||
local groups="$1" tok g; shift
|
||||
for tok in "$@"; do
|
||||
for g in $groups; do
|
||||
[ "$tok" = "$g" ] && return 0
|
||||
done
|
||||
done
|
||||
printf "sshd AllowGroups is set and no entry literally names a group this user is in — add their group (or them to a named group), then re-run"
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ keys; the roles must be identical on every line of one user.
|
|||
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
|
||||
|
|
@ -78,11 +89,12 @@ fi
|
|||
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=()
|
||||
declare -A USER_ROLES=() USER_KEYS=() USER_SEED=()
|
||||
USERS=()
|
||||
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
|
||||
|
|
@ -90,7 +102,14 @@ while IFS='|' read -r u r k; do
|
|||
USER_ROLES[$u]="$r"
|
||||
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
|
||||
fi
|
||||
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
|
||||
# '@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"
|
||||
|
|
@ -108,6 +127,24 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
|||
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.
|
||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||
|
|
@ -199,8 +236,16 @@ for u in "${USERS[@]}"; do
|
|||
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||
ugroup="$(id -gn "$u")"
|
||||
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)"
|
||||
printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP"
|
||||
{
|
||||
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)"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|||
. "$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() {
|
||||
|
|
@ -26,15 +27,25 @@ 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 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.
|
||||
member holds a login this box would actually honor. Per candidate, in order:
|
||||
the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
|
||||
owned by the user and not group/world-writable, a real login shell, account
|
||||
not expired), then two reachability proofs (#17) — `sudo -n true` under
|
||||
runuser must answer (NOPASSWD sudo is effective, not merely written), and
|
||||
`sshd -T -C user=...` must resolve a per-user effective config that accepts
|
||||
the login (pubkeyauthentication yes, no DenyUsers hit — where any pattern or
|
||||
host-qualified Deny entry counts as a hit, fail closed — AllowUsers, if set,
|
||||
names them literally, and the same pair of rules for DenyGroups/AllowGroups
|
||||
judged against the admin's actual groups from id -Gn). 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
|
||||
shut; the admin door must be proven, not presumed.
|
||||
shut; the admin door must be proven, not presumed. This is not ceremony: the
|
||||
local probe resolves Match blocks against a synthetic loopback client
|
||||
(addr=127.0.0.1), so a `Match Address` rule that treats real inbound clients
|
||||
differently is invisible to it — only a real login proves the real door.
|
||||
|
||||
Run as root. Convergent: once root is closed, a re-run is a clean no-op.
|
||||
EOF
|
||||
|
|
@ -70,16 +81,33 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
|||
fi
|
||||
|
||||
# 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:
|
||||
# at least one rig-admin member must hold a login this box would actually
|
||||
# HONOR — 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.
|
||||
# shape, and then for REACHABILITY (#17): the shape checks prove the door
|
||||
# SHOULD open, these prove what can be proven from inside — that NOPASSWD
|
||||
# sudo actually answers (`sudo -n true` under runuser; a sudoers drop-in
|
||||
# that never landed is a shape the file checks cannot see), and that sshd's
|
||||
# per-user EFFECTIVE config would accept the login (`sshd -T -C user=...` —
|
||||
# an AllowUsers or Match block elsewhere can quietly exclude the admin while
|
||||
# every file looks right). The refusal names, per candidate, WHICH check
|
||||
# failed — an operator staring at a refusal must see the repair. Honestly:
|
||||
# the one thing no local check can prove is that the operator HOLDS the
|
||||
# private key — the verify-in-a-separate-session advisory in --help stays
|
||||
# load-bearing.
|
||||
today=$(( $(date +%s) / 86400 ))
|
||||
# runuser ships in util-linux on Debian — rig's target — but the gate must
|
||||
# not die on a box without it: skip the live sudo proof with a loud warning
|
||||
# rather than block close-root on a missing prover. Warned once, not per
|
||||
# candidate.
|
||||
HAVE_RUNUSER=0
|
||||
if command -v runuser >/dev/null 2>&1; then
|
||||
HAVE_RUNUSER=1
|
||||
else
|
||||
warn "runuser not found; skipping the live NOPASSWD-sudo proof — verify 'sudo -n true' as your admin by hand before trusting the closed door"
|
||||
fi
|
||||
# path_strict <path> <uid> <label> — flag the two StrictModes complaints
|
||||
flag() { bad="${bad:+$bad, }$1"; }
|
||||
path_strict() {
|
||||
|
|
@ -123,6 +151,62 @@ while IFS= read -r a; do
|
|||
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
|
||||
flag "account expired"
|
||||
fi
|
||||
# Reachability proof 1 — NOPASSWD sudo answers for real. `sudo -n` never
|
||||
# prompts: with the %rig-admin NOPASSWD rule effective it exits 0, and a
|
||||
# sudoers drop-in that failed to land (or a sudo that is simply absent)
|
||||
# exits non-zero right here instead of after root is welded shut.
|
||||
if [ "$HAVE_RUNUSER" -eq 1 ]; then
|
||||
if ! runuser -u "$a" -- sudo -n true >/dev/null 2>&1; then
|
||||
flag "sudo -n true fails as this user (NOPASSWD sudo not effective — re-run rig users apply)"
|
||||
fi
|
||||
fi
|
||||
# Reachability proof 2 — sshd's per-user EFFECTIVE config accepts them.
|
||||
# `sshd -T -C user=...` resolves Match blocks for exactly this login, so an
|
||||
# exclusion the global `sshd -T` never shows is caught. Allow/Deny entries
|
||||
# are judged fail-closed in BOTH directions: AllowUsers must name the admin
|
||||
# literally (a pattern that would in fact admit them still flags — the
|
||||
# operator proves patterns by hand), and DenyUsers flags on a literal hit
|
||||
# OR on any pattern/host-qualified token (deny_verdict, in the lib) —
|
||||
# 'DenyUsers dan*' really denies admin 'dan', and a token this check cannot
|
||||
# prove irrelevant must count as a hit, never as a pass. What no local
|
||||
# probe can resolve is a Match on the CLIENT's address — the -C probe pins
|
||||
# addr=127.0.0.1 — which is why the separate-session verification stays
|
||||
# load-bearing.
|
||||
if perT="$(sshd -T -C "user=$a,host=$(hostname),addr=127.0.0.1" 2>/dev/null)"; then
|
||||
if ! printf '%s\n' "$perT" | grep -qx 'pubkeyauthentication yes'; then
|
||||
flag "sshd resolves pubkeyauthentication != yes for this user"
|
||||
fi
|
||||
deny_line="$(printf '%s\n' "$perT" | grep -i '^denyusers ' | head -n1)"
|
||||
if [ -n "$deny_line" ]; then
|
||||
# shellcheck disable=SC2086 # word-splitting the tokens is the point
|
||||
deny_reason="$(deny_verdict "$a" ${deny_line#* })"
|
||||
[ -n "$deny_reason" ] && flag "$deny_reason"
|
||||
fi
|
||||
# The group directives close the same door through the other hinge: sshd
|
||||
# enforces Allow/DenyGroups against the candidate's ACTUAL membership, so
|
||||
# the gate resolves id -Gn and judges both with the same fail-closed
|
||||
# discipline as the *Users pair. id failing yields no groups, which makes
|
||||
# a set AllowGroups flag — the safe direction.
|
||||
a_groups="$(id -Gn -- "$a" 2>/dev/null)"
|
||||
denyg_line="$(printf '%s\n' "$perT" | grep -i '^denygroups ' | head -n1)"
|
||||
if [ -n "$denyg_line" ]; then
|
||||
# shellcheck disable=SC2086 # word-splitting the tokens is the point
|
||||
denyg_reason="$(group_deny_verdict "$a_groups" ${denyg_line#* })"
|
||||
[ -n "$denyg_reason" ] && flag "$denyg_reason"
|
||||
fi
|
||||
allowg_line="$(printf '%s\n' "$perT" | grep -i '^allowgroups ' | head -n1)"
|
||||
if [ -n "$allowg_line" ]; then
|
||||
# shellcheck disable=SC2086 # word-splitting the tokens is the point
|
||||
allowg_reason="$(group_allow_verdict "$a_groups" ${allowg_line#* })"
|
||||
[ -n "$allowg_reason" ] && flag "$allowg_reason"
|
||||
fi
|
||||
if printf '%s\n' "$perT" | grep -qi '^allowusers ' \
|
||||
&& ! printf '%s\n' "$perT" | grep -i '^allowusers ' | tr ' ' '\n' | grep -qx "$a"; then
|
||||
flag "sshd AllowUsers is set and does not name this user"
|
||||
fi
|
||||
else
|
||||
flag "sshd -T -C user=$a failed — cannot resolve the per-user config sshd would apply"
|
||||
fi
|
||||
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
|
||||
DETAIL="$DETAIL; $a: $bad"
|
||||
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')
|
||||
|
|
|
|||
161
docs/plans/2026-07-18-close-root-proves-the-door.md
Normal file
161
docs/plans/2026-07-18-close-root-proves-the-door.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# close-root proves the door + `@root` key seeding — Implementation Plan (finishes #17)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Finish what remains of issue #17 on top of the merged fleet-users
|
||||
design (#27). Main already ships the admin user (`rig users apply` — role
|
||||
`admin` → `rig-admin` group, NOPASSWD sudo) and the role-aware root door
|
||||
(`rig users close-root` — `PermitRootLogin no` gated on `class=human`, a
|
||||
StrictModes-shaped admin check, and the invoker gate). Three gaps remain,
|
||||
all named in #17: (1) close-root's gate proves the admin door *should* open,
|
||||
not that it *does* — add the two reachability proofs #17 specifies
|
||||
(`sudo -n true` under `runuser`, `sshd -T -C user=<admin>`); (2) #17's
|
||||
headline lockout-avoidance — seed the admin's `authorized_keys` from root's
|
||||
own, via a literal `@root` key-field token in the users file; (3) #17's
|
||||
table said runner "can close root once an admin is proven", but main's class
|
||||
model refuses close-root on `class=server` (runner) — reconcile in prose,
|
||||
not code.
|
||||
|
||||
**Why this shape:** the admin-door gate exists so close-root never welds
|
||||
shut the only door. But every check it makes today reads *files*: a sudoers
|
||||
drop-in that failed to land, or an `AllowUsers`/`Match` block elsewhere in
|
||||
sshd's config, leaves every file looking right while the door stays shut.
|
||||
#17 names the two checks that interrogate *behavior* instead: `sudo -n true`
|
||||
run as the admin (NOPASSWD sudo answers, or it doesn't — `-n` never
|
||||
prompts), and `sshd -T -C user=<admin>,host=...,addr=...` (the per-user
|
||||
EFFECTIVE config sshd would apply to exactly that login, Match blocks
|
||||
resolved). And the one thing no local check can prove — that the operator
|
||||
*holds* the admin's private key — is what `@root` answers at apply time: the
|
||||
operator is connected as root *right now* using one of root's keys, so
|
||||
seeding those keys is live evidence the private key is in their hands,
|
||||
strictly better than any check rig could invent over a pasted literal.
|
||||
|
||||
## The measured lock-root table (from #17, verbatim constraints)
|
||||
|
||||
| Measure | Key-based root SSH after | Verdict |
|
||||
|---|---|---|
|
||||
| `passwd -l root` | works | harmless |
|
||||
| `PermitRootLogin prohibit-password` | works | safe — bootstrap's state |
|
||||
| `usermod --expiredate 1 root` | **breaks** | never |
|
||||
| root shell → nologin | **breaks** | never |
|
||||
| `PermitRootLogin no` | breaks root only | close-root's move, gated |
|
||||
|
||||
Nothing here may introduce `usermod --expiredate` or a nologin shell for
|
||||
root — both break key SSH and rig's own root-run convergence path.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `commands/users-close-root.sh`: the reachability proofs join the existing
|
||||
per-candidate gate loop as **additive** `flag()` checks — same refusal
|
||||
shape, naming the failing check per candidate. `runuser` may be absent
|
||||
off-Debian: precomputed once; absence skips the sudo proof with a loud
|
||||
`warn`, never a die (a missing prover must not block the door — but the
|
||||
operator is told what to verify by hand). `sshd -T -C` failing *is* a
|
||||
flag: fail closed. Allow/Deny entries are matched literally — a pattern
|
||||
that would admit the admin still refuses (fail closed; the operator proves
|
||||
patterns by hand). Both proofs sit before the drop-in install, pinned by
|
||||
line-number ordering asserts.
|
||||
- `commands/lib/users-config.sh` (`parse_users_file`): the key field admits
|
||||
the literal token `@root` — shape-validated in the parse pass (exit 2,
|
||||
pre-root-check, testable non-root): exactly `@root`, no trailing material;
|
||||
a second `@root` for one user falls into the existing duplicate-line
|
||||
refusal for free; `root` as username stays refused. Mixing semantics
|
||||
(the simplest sound call): `@root` mixes with literal key lines — seeded
|
||||
keys land FIRST, literals append after, fixed order so the cmp-guard sees
|
||||
deterministic bytes.
|
||||
- `commands/users-apply.sh`: resolves `@root` ONCE, after the root check
|
||||
(/root/.ssh needs root): root's current `authorized_keys`, comments and
|
||||
blanks dropped, key lines copied verbatim (options included — a
|
||||
`from=`/`command=` restriction follows its key; rig will not silently
|
||||
widen what a key can do). Empty/absent root keys die with the repair.
|
||||
Convergence: every run re-seeds from root's then-current file — the
|
||||
honest exception #17 weighs (a hand-removed seeded key returns) is
|
||||
resolved toward convergence, with "switch the line to literal keys" as
|
||||
the escape hatch, documented in usage and README.
|
||||
- The runner row: **no gate change.** The `class=server` refusal in
|
||||
`assert_marker_human` grows the explanation (server-class machines are
|
||||
automation identities; root is the management plane; runner stays
|
||||
server-class deliberately; `--class human` at bootstrap is the path for a
|
||||
humanly-administered CI box), and the README identity-model section gets
|
||||
one short divergence paragraph.
|
||||
|
||||
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar
|
||||
shellcheck + `bash test/cli.sh`) — no workflow change.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No change to the class gate: `class=server` still refuses close-root, no
|
||||
`--force`, runner included.
|
||||
- No `--admin-key` flag (#17's option (b)) — the users file already carries
|
||||
literal keys; `@root` covers the lockout case.
|
||||
- No filtering/rewriting of root's key options on seed (no stripping
|
||||
`from=`/`command=`) — verbatim copy, documented caveat.
|
||||
- No root `authorized_keys` management (Coolify owns its key material).
|
||||
- No pattern-matching engine for `AllowUsers`/`DenyUsers` — literal match,
|
||||
fail closed, documented.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `set -euo pipefail`; `rig-users:` log/warn/die prefixes; exit 2 usage
|
||||
(pre-root-check), exit 1 runtime refusal; cmp-guarded convergent writes.
|
||||
- Never `usermod --expiredate 1 root`, never a nologin shell for root.
|
||||
- shellcheck-clean as CI runs it (`shopt -s globstar; shellcheck -x bin/*
|
||||
**/*.sh`); `bash test/cli.sh` green as non-root.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: reachability proofs in close-root's gate
|
||||
|
||||
**Files:** `commands/users-close-root.sh`, `test/cli.sh`
|
||||
|
||||
- [x] Tests: grep the two calls (`runuser -u "$a" -- sudo -n true`,
|
||||
`sshd -T -C "user=`) and pin both before the drop-in install line
|
||||
(`install -m 0644 "$TMP" "$DROPIN"`) with fail-closed line-number
|
||||
asserts; grep the graceful runuser-absent branch.
|
||||
- [x] Implement: per-candidate flags after the shape checks; `HAVE_RUNUSER`
|
||||
precomputed with a single warn; `sshd -T -C` parsed for
|
||||
`pubkeyauthentication yes`, literal DenyUsers hit, AllowUsers-without-
|
||||
the-admin; resolve failure flags.
|
||||
- [x] `bash test/cli.sh` green; shellcheck clean.
|
||||
|
||||
### Task 2: `@root` seeding
|
||||
|
||||
**Files:** `commands/lib/users-config.sh`, `commands/users-apply.sh`,
|
||||
`test/cli.sh`
|
||||
|
||||
- [x] Tests through the sourced `parse()` harness: exact token parses and
|
||||
emits `user|roles|@root`; trailing material refused; mixes with literal
|
||||
lines; second `@root` is a duplicate; root cannot seed itself; an
|
||||
`@root` fixture reaches the non-root refusal (proving parse-pass
|
||||
validation); grep apply's keyless-root die.
|
||||
- [x] Implement: parser token cases; apply resolves post-root-check, dies
|
||||
on empty; seeded-first/literals-after write through the existing
|
||||
cmp-guard; usage documents the semantics.
|
||||
- [x] `bash test/cli.sh` green; shellcheck clean.
|
||||
|
||||
### Task 3: the runner row, reconciled in prose
|
||||
|
||||
**Files:** `commands/lib/users-config.sh` (refusal message), `README.md`,
|
||||
`test/cli.sh`
|
||||
|
||||
- [x] Extend the `class=server` refusal to own the divergence ("runner
|
||||
included"); fixture test asserts the new wording alongside the existing
|
||||
"control plane" assert.
|
||||
- [x] README: one divergence paragraph in the identity-model section; the
|
||||
`@root` paragraph in the apply section; the reachability sentence in the
|
||||
close-root section. Surgical — a concurrent PR adds a class table to the
|
||||
same README area.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- **Harness:** the `@root` refusal matrix through the sourced parser
|
||||
(non-root, network-free); grep-the-shipped-script guards for both
|
||||
reachability calls with before-the-drop-in ordering asserts; the extended
|
||||
server-refusal wording through the fixture marker gate.
|
||||
- **Rehearsal (manual, out of harness — the #17 shape):** throwaway cloud
|
||||
box: bootstrap `--class human`; `users apply` a file whose admin line is
|
||||
`@root`; from a second terminal, SSH in as the admin with the SAME key
|
||||
used for root (the seeded one) — the inbound connection is the proof rig
|
||||
cannot self-assert; `close-root`; confirm root refused, admin accepted;
|
||||
re-run apply and close-root — both no-op; add a literal key line, re-run,
|
||||
confirm seeded-first ordering and convergence.
|
||||
117
test/cli.sh
117
test/cli.sh
|
|
@ -515,10 +515,40 @@ check "users parser: one run reports the root line" 0 "" grep -q "not a rig-man
|
|||
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
|
||||
rm -f "$MULTI_ERRS"
|
||||
|
||||
# --- '@root': seed the admin's keys from root's own authorized_keys (#17) ----
|
||||
# The operator provably holds a root private key — they SSHed in with it to
|
||||
# run apply at all — so seeding root's CURRENT authorized_keys is the one key
|
||||
# source that cannot lock them out. The parser owns only the token's SHAPE
|
||||
# (reading /root/.ssh needs root and is apply's business), so the shape is
|
||||
# proven here: the exact token parses, trailing material is refused, literal
|
||||
# key lines mix (append semantics), a second '@root' is a duplicate, and root
|
||||
# cannot seed itself.
|
||||
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
|
||||
check "users parser: '@root' is a valid key field" 0 "dan|admin|@root" parse "$FIX_BAD"
|
||||
printf '%s\n' 'dan admin @root ssh-ed25519 AAAA x' > "$FIX_BAD"
|
||||
check "users parser: '@root' takes no trailing material" 1 "whole key field" parse "$FIX_BAD"
|
||||
printf '%s\n' 'dan admin @root' 'dan admin ssh-ed25519 AAAAC3lit dan@desk' > "$FIX_BAD"
|
||||
check "users parser: '@root' mixes with literal key lines" \
|
||||
0 "dan|admin|ssh-ed25519 AAAAC3lit dan@desk" parse "$FIX_BAD"
|
||||
printf '%s\n' 'dan admin @root' 'dan admin @root' > "$FIX_BAD"
|
||||
check "users parser: a second '@root' line is a duplicate" 1 "duplicate key line" parse "$FIX_BAD"
|
||||
printf '%s\n' 'root admin @root' > "$FIX_BAD"
|
||||
check "users parser: root cannot seed from itself" 1 "not a rig-managed user" parse "$FIX_BAD"
|
||||
# The empty-seed refusal (root has no authorized_keys) sits behind the root
|
||||
# check — /root/.ssh is unreadable before it — so grep the die message, the
|
||||
# same way every root-only refusal in this harness is pinned.
|
||||
check "users apply: '@root' with a keyless root dies naming the repair" 0 "" \
|
||||
grep -q "root has no authorized_keys" "$ROOT/commands/users-apply.sh"
|
||||
|
||||
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"
|
||||
# An '@root' fixture reaching the root check proves the token is parse-pass
|
||||
# validation, not a runtime surprise.
|
||||
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
|
||||
check "users apply: '@root' fixture parses, refuses non-root" 1 "must run as root" \
|
||||
"$ROOT/commands/users-apply.sh" --file "$FIX_BAD"
|
||||
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)"
|
||||
|
|
@ -599,6 +629,87 @@ check "users close-root: no-op needs a daemon start newer than the config" 0 ""
|
|||
# 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"
|
||||
# The reachability proofs (#17): the shape checks prove the door SHOULD open;
|
||||
# these prove what can be proven from inside — NOPASSWD sudo actually answers
|
||||
# (`runuser ... sudo -n true`) and sshd's per-user EFFECTIVE config accepts
|
||||
# the login (`sshd -T -C user=...`). Both need root, real accounts, and a
|
||||
# live sshd, so grep the calls — and pin their ordering BEFORE the drop-in
|
||||
# install, because reachability proven after the door shut is no proof at
|
||||
# all. Match the calls, not the words (comments mention neither literal);
|
||||
# defaults fail closed.
|
||||
# The $a/$TMP/$DROPIN below are LITERALS we grep for in the script — single
|
||||
# quotes are the point, as in the db and box-install checks.
|
||||
# shellcheck disable=SC2016
|
||||
check "users close-root: gate proves NOPASSWD sudo answers" 0 "" \
|
||||
grep -qF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh"
|
||||
check "users close-root: gate resolves sshd's per-user config" 0 "" \
|
||||
grep -qF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh"
|
||||
# shellcheck disable=SC2016
|
||||
sudon_at="$(grep -nF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||
pert_at="$(grep -nF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||
# shellcheck disable=SC2016
|
||||
dropin_at="$(grep -nF 'install -m 0644 "$TMP" "$DROPIN"' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||
check "users close-root: sudo -n proof precedes the drop-in install" \
|
||||
0 "" test "${sudon_at:-999999}" -lt "${dropin_at:-0}"
|
||||
check "users close-root: per-user sshd resolve precedes the drop-in install" \
|
||||
0 "" test "${pert_at:-999999}" -lt "${dropin_at:-0}"
|
||||
# runuser may be absent off-Debian; the gate must skip that one proof loudly,
|
||||
# never die on a missing prover. Grep the graceful branch.
|
||||
check "users close-root: a missing runuser skips the sudo proof, loudly" 0 "" \
|
||||
grep -q "runuser not found" "$ROOT/commands/users-close-root.sh"
|
||||
# DenyUsers judged fail-closed through the lib's pure deny_verdict — sshd
|
||||
# accepts patterns and USER@HOST forms, and 'DenyUsers dan*' REALLY denies
|
||||
# admin 'dan', so a token the check cannot prove irrelevant must flag, never
|
||||
# pass (the review's regression: a wildcard denial). Empty output is the only
|
||||
# pass; every hit names its reason.
|
||||
deny_v() { # deny_v <user> <token...>
|
||||
bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/users-config.sh"; shift
|
||||
deny_verdict "$@"' _ "$ROOT" "$@"
|
||||
}
|
||||
check "users close-root: deny_verdict flags a literal hit" \
|
||||
0 "names this user" deny_v admin root admin
|
||||
check "users close-root: deny_verdict fails closed on a wildcard (dan* vs dan)" \
|
||||
0 "pattern entry 'dan*'" deny_v dan "dan*"
|
||||
check "users close-root: deny_verdict fails closed on '?' patterns" \
|
||||
0 "pattern entry" deny_v admin "admi?"
|
||||
check "users close-root: deny_verdict fails closed on USER@HOST forms" \
|
||||
0 "host-qualified" deny_v admin "admin@10.0.0.1"
|
||||
deny_pass() { [ -z "$(deny_v "$@")" ]; } # empty verdict IS the pass
|
||||
check "users close-root: deny_verdict passes provably-irrelevant literals" \
|
||||
0 "" deny_pass admin root git backup
|
||||
# The group directives, same discipline, judged against the candidate's ACTUAL
|
||||
# membership (the review's regressions: an unmet AllowGroups, a DenyGroups
|
||||
# naming a group they hold). First arg is the id -Gn word list.
|
||||
groups_v() { # groups_v <fn> <groups> <token...>
|
||||
bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/users-config.sh"; shift
|
||||
"$@"' _ "$ROOT" "$@"
|
||||
}
|
||||
groups_pass() { [ -z "$(groups_v "$@")" ]; }
|
||||
check "users close-root: DenyGroups naming a held group flags" \
|
||||
0 "a group this user is in" groups_v group_deny_verdict "dan sudo rig-admin" backup sudo
|
||||
check "users close-root: DenyGroups fails closed on patterns" \
|
||||
0 "pattern entry 'rig-*'" groups_v group_deny_verdict "dan rig-admin" "rig-*"
|
||||
check "users close-root: DenyGroups passes provably-irrelevant literals" \
|
||||
0 "" groups_pass group_deny_verdict "dan rig-admin" docker backup
|
||||
check "users close-root: an unmet AllowGroups flags (fail closed)" \
|
||||
0 "no entry literally names a group this user is in" groups_v group_allow_verdict "dan rig-admin" sudo
|
||||
check "users close-root: AllowGroups pattern is no proof (fail closed)" \
|
||||
0 "no entry literally names" groups_v group_allow_verdict "dan rig-admin" "rig-*"
|
||||
check "users close-root: a literally-named held group passes AllowGroups" \
|
||||
0 "" groups_pass group_allow_verdict "dan rig-admin" sudo rig-admin
|
||||
# ...and the shipped gate consults both, against real membership.
|
||||
# shellcheck disable=SC2016
|
||||
check "users close-root: the gate consults the group verdicts" 0 "" \
|
||||
grep -qE '^[[:space:]]*denyg_reason="\$\(group_deny_verdict ' "$ROOT/commands/users-close-root.sh"
|
||||
# shellcheck disable=SC2016
|
||||
check "users close-root: the gate resolves real membership (id -Gn)" 0 "" \
|
||||
grep -qF -- 'id -Gn -- "$a"' "$ROOT/commands/users-close-root.sh"
|
||||
# ...and the shipped gate must actually consult it (call, not comment).
|
||||
# shellcheck disable=SC2016
|
||||
check "users close-root: the gate consults deny_verdict" 0 "" \
|
||||
grep -qE '^[[:space:]]*deny_reason="\$\(deny_verdict ' "$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
|
||||
|
|
@ -616,6 +727,12 @@ check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
|||
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
||||
check "users close-root: class=server refuses, names the control plane" \
|
||||
1 "control plane" marker_gate "$MARKER_DIR/server"
|
||||
# #17's original table let the runner ROLE close root; the class model
|
||||
# supersedes it — runner is class=server, an automation identity, and the
|
||||
# refusal must SAY so or the divergence reads as a bug to anyone holding the
|
||||
# old table.
|
||||
check "users close-root: the server refusal owns the runner row (#17)" \
|
||||
1 "runner included" marker_gate "$MARKER_DIR/server"
|
||||
check "users close-root: class=human passes the gate" \
|
||||
0 "" marker_gate "$MARKER_DIR/human"
|
||||
rm -rf "$MARKER_DIR"
|
||||
|
|
|
|||
Loading…
Reference in a new issue