Machine traits + fleet users: class/host/join presets and rig users apply/status/close-root (#26 + #24) #27

Merged
dan-claude-bot merged 12 commits from feat/users-and-traits into main 2026-07-17 21:24:51 +00:00

12 commits

Author SHA1 Message Date
Dan Claude Van Damme
c44a645670 fix(users): close-root no-op must prove the door, not the file
The clean-file fast path exited before the sshd -T assertion, so matching
bytes alone bought the 'root already closed' claim. Two ways that lies: an
earlier-sorting drop-in wins the first-wins fight while our file sits
pretty, and a prior run that died between install and restart leaves a
daemon that never read the file — sshd -T can't see that one either, since
it re-parses disk rather than interrogating the running daemon.

Now the no-op is taken only when the bytes match AND systemd says sshd
started strictly after the newest mtime across everything sshd reads (main
config, drop-in dir, drop-ins); anything less restarts behind the same
sshd -t gate, and the effective-config assertion runs on every path before
any success claim. Harness pins both: assert-before-claim ordering and the
daemon-start-vs-config-mtime proof.

Addresses PR #27 review (clean-file fast path convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:49:12 +00:00
Claude
00c54a342d docs: plan addendum — review findings and the contract refinements they forced
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:02:09 +00:00
Claude
3eeab687d0 fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:

- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
  not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
  role rig silently root-equivalent through the very command that granted it.
  Identity management now refuses any sudo invoker outside rig-admin; direct
  root (bring-up, a root shell) proceeds.

- Offboarding revokes SSH, not just the password: a '!'-locked password is
  not a closed door under UsePAM — Debian sshd still honors the pubkey. A
  dropped user's account is now expired (usermod -L -e 1, the switch PAM
  actually enforces) and authorized_keys is renamed to
  authorized_keys.revoked-by-rig — access revoked, data kept, convergence
  never destroys. Present users get their expiry cleared idempotently, so a
  re-added user comes back to life.

- The ledger remembers: two-field lines ('name active' / 'name revoked',
  legacy bare names read as active), so dropped users no longer vanish from
  rig's memory on the next rewrite. status now reports the ledger state
  corroborated by the account's real expiry — passwd -S read L for everyone
  (apply locks all passwords always), so its locked/active was meaningless —
  and flags a mismatch loudly as drift.

- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
  and mode converge on every run, not only when content changes — StrictModes
  treats them as load-bearing, so drifted perms were a broken login that
  "already converged" lied about. Only the content write stays cmp-guarded.

- close-root's admin-door gate checks the StrictModes shape per candidate —
  ownership, group/world-writability of home/.ssh/authorized_keys, a real
  login shell, an unexpired account — and names which check failed. It proves
  the door SHOULD open, not that it does; the separate-session advisory stays
  load-bearing.

- Usernames are validated in the parser's one-pass refusal matrix
  (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
  stream, and a leading '-' read as a useradd flag mid-convergence.

- The box role is trait-aware: on a host=no box an absent incus group skips
  the role with a warning and converges everything else — one box-role user
  in a fleet-wide file must not abort apply everywhere VMs don't live.
  host=yes still dies pointing at box setup-host; a classless marker warns
  toward a bootstrap re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
Claude
062dad4ead fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion
Three refusals, one doctrine: detect, refuse, name the repair — and never
back out state rig did not create.

- verify_effective_tag grows the same <back-out|keep> mode discipline as
  verify_user_owned. First join keeps the logout-and-die on an untagged key;
  the already-joined path now refuses WITHOUT logout — the untagged node may
  be a login-joined workstation (untagged by design) that a join=authkey
  re-run must not tear off the tailnet. The die names both ways out.

- verify_user_owned fails CLOSED on a stalled backend: empty tags is its
  success signal, so a 30s poll that never saw Running waved a tagged node
  on a slow tailscaled through as user-owned. state!=Running now dies in
  both modes, logging nothing out — nothing was verified, so the repair is
  to re-run and verify, not to undo a join that may be fine.

- The permitrootlogin acceptance is class-gated. class=human keeps
  no|prohibit-password|without-password (`no` is the close-root state).
  class=server accepts only prohibit-password|without-password: root SSH is
  the control plane's automation door, and `no` there means a leftover
  00-rig-users.conf from a former class=human life has fleet management
  silently dead. Refused loudly, drop-in named, never auto-removed —
  silently reopening a root door is worse than a loud stop.

Harness greps pin all three die messages so a deleted guard cannot ship
green (repo precedent: the tag-refusal greps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
Claude
22ef458b1a docs: README identity model — traits, presets, fleet users, root's two fates
The bootstrap section now documents what shipped: roles as presets over the
three orthogonal traits (class/host/join, every one overridable, custom
states all of them), the derived tag:server policy, the /etc/rig/role marker
that records effective traits so an overridden role never lies, and the
join=login path where the tag assertion inverts — untagged is asserted, a
tag is the refusal. A new identity-model section carries the hybrid access
model: named operators on every class, humans never entering as root, class
deciding root SSH's fate after `rig users apply` — closed on human, kept as
the control plane's automation door on server — with the detection benefit
and the honest attribution-not-privilege caveat stated plainly. Per-command
sections cover apply/status/close-root, including the first-wins drop-in
mechanism and the README-only from= guidance for Coolify's key on servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:32:23 +00:00
Claude
2dc396d557 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
Claude
9bdc4db575 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
Claude
f2d343b0ef feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker
Roles become presets over three orthogonal traits declared in one map:
class (who lives here), host (runs VMs), join (authkey or interactive
login). Every per-role behavior now keys off the traits — the /dev/kvm
advisory rides host=yes, the next-steps log rides class and host — and
tag:server is derived policy, not a trait: only control-plane and
workload are shapes the control plane manages, so every other role
refuses the effective tag. join=login inverts the tag assertion (a
user-owned node must come up untagged; a tag is refused and backed out
on first join, refused without back-out on a box already joined) and
refuses a set TS_AUTHKEY before the root check. The verified shape is
recorded convergently in /etc/rig/role as ground truth for rig users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:15:06 +00:00
Claude
35ce40d05e docs: plan for machine traits + fleet users (#26 + #24, one release)
Traits (class/host/join) under the existing roles with dev/workstation/custom
presets and the /etc/rig/role marker; rig users apply/status/close-root under
the hybrid access model decided in #26's comments: operators on every class,
root SSH closed on class=human, kept as the control plane's automation
identity on class=server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:10:20 +00:00
Claude
583ac25448 docs: README section for the staging bootstrap role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:51:58 +00:00
Claude
c4d64fb037 feat(bootstrap): staging role — the host archetype for box-minted staging VMs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:51:36 +00:00
Claude
1b68d3ca49 docs: plan for the staging bootstrap role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:23 +00:00