feat(users): declarative operators — apply/status over a users file, every class
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 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
#!/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 <path>
#
# 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
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# 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.
feat(users): declarative operators — apply/status over a users file, every class
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 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
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
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
# 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
feat(users): declarative operators — apply/status over a users file, every class
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 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
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 <path> — 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
feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
# (apply notes it, close-root refuses on it) — this reader only reads.
feat(users): declarative operators — apply/status over a users file, every class
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 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
read_role_marker( ) {
[ -r " $1 " ] || return 0
head -n1 " $1 "
}
feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
# assert_marker_human <marker_path> — close-root's marker gate: return 0,
# silently, only when the marker says class=human; otherwise print the refusal
# reason on stdout and return 1 (the caller wraps it in its own die). The
# policy is a pure lib function on purpose: the CLI path sits behind the root
# check, so the harness proves every refusal HERE, against fixture markers,
# non-root (repo precedent: parse_users_file, assert_runner_repo).
assert_marker_human( ) {
local marker
marker = " $( read_role_marker " $1 " ) "
if [ -z " $marker " ] ; then
# No marker means rig cannot know whether root here is a human's bad habit
# or the control plane's automation door — refuse to shut it blind.
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
return 1
fi
case " $marker " in
*class= human*) return 0 ; ;
*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"
return 1 ; ;
*)
printf '%s\n' " marker names no class ( ${ marker } ): re-run rig bootstrap; refusing to shut the root door blind "
return 1 ; ;
esac
}