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
dan-claude-bot commented 2026-07-17 19:11:00 +00:00 (Migrated from github.com)

Closes #26, closes #24 — shipped together as one release, per the hybrid access model decided in #26's comments.

Stacked on #23 (feat/staging-role): this branch includes #23's commits until it merges; the diff shrinks to this release's own changes once it does.

What this release is

Two halves of one identity story:

  1. Traits under the roles (#26). Bootstrap's role list becomes presets over three orthogonal traits — class (human/server), host (yes/no), join (authkey/login) — individually overridable, with new roles dev, workstation, and custom. Effective traits land in a convergent marker, /etc/rig/role.
  2. Fleet users (#24). rig users apply/status/close-root: declarative operator accounts on every class. The tailnet is network-only (no Tailscale SSH), so named users + sudo are the only attribution at the door — humans never enter as root. What class decides is root SSH's fate after users exist: human → closed (close-rootPermitRootLogin no); server → stays open as the automation identity the control plane (Coolify) SSHes in as. close-root refuses on class=server.

Implementation plan

The full task-by-task plan (behavior contracts, failing-tests-first steps, rehearsal script) is committed as docs/plans/2026-07-17-traits-and-users.md. Summary:

Task 1 — bootstrap: role→traits map, trait flags, marker, login join

  • Single role→traits map in commands/bootstrap.sh; --class/--host/--join overrides, all validated pre-root-check (exit 2).
  • custom requires --hostname + all three traits.
  • join=login (workstation): set TS_AUTHKEY is a usage error; interactive tailscale up; the tag assertion inverts — any effective tag is refused and backed out (mirror of the untagged-key refusal on the authkey path).
  • tag:server stays derived, not a trait: only control-plane/workload may carry it; the runner/staging refusals generalize into that one rule, die-messages kept greppable.
  • /dev/kvm advisory keys on host=yes; next-steps log keys on traits (box setup-host pointer for hosts, rig users pointer for all, class-specific tail).
  • Marker written post-join, cmp-guarded: role=… class=… host=… join=….

Task 2 — rig users apply + status

  • Parser + marker reader in commands/lib/users-config.sh, exercised by the harness via sourcing against fixtures (precedent: assert_runner_repo, json_string_array).
  • Users file: line-based (user roles ssh-key), no YAML/jq; all validation errors reported at once, exit 2, before the root check. Refusals: unknown role, role mismatch across a user's lines, root in file, malformed line.
  • Roles: admin (%rig-admin full sudo), rig (sudo for /usr/local/bin/rig only), box (Incus restricted tier via the incus group — rig asserts the group exists, never installs Incus). All passwords locked, always; the SSH key at the door is the authentication.
  • Convergence: exact membership in the three rig-managed groups, exact authorized_keys; users removed from the file are locked, never deleted (ledger: /etc/rig/users); sudoers drop-in gated by visudo -c before install, 0440, atomic.
  • apply runs on every class — on class=server it notes root stays the automation door.

Task 3 — rig users close-root (+ one bootstrap edit)

  • Gates, in order: marker must say class=human (absent marker → refuse blind; class=server → refuse, closing root severs fleet management — no --force); at least one rig-admin member with a non-empty authorized_keys — never close the only door.
  • Mechanism: drop-in /etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no). The name is load-bearing: sshd_config is first-wins and the Include glob expands lexically — - < ., so 00-rig-users.conf beats bootstrap's 00-rig.conf. Validate-then-apply exactly like bootstrap: sshd -t before restart, rollback on failure, then assert sshd -T resolves permitrootlogin no.
  • Bootstrap's effective-config assertion learns to accept permitrootlogin no (strictly harder), so a bootstrap re-run stays green and never reopens a closed door.

Task 4 — README

  • Identity model section: trait + preset tables, the hybrid access model with its rationale and honest caveat (attribution, not privilege reduction), the detection side benefit (a root login that isn't the control plane is anomalous by definition).
  • rig users docs, incl. the guidance-only posture on servers: lock root's authorized_keys to the control plane with from="<control-plane-addr>" on Coolify's key line — rig will not write that file; Coolify owns it.

Deliberate non-goals

No user deletion; no passwords; no root authorized_keys management (Coolify owns its key material — two tools converging one file is drift by construction); no Coolify web-UI accounts; no Incus/box installation; box-side work (restricted-tier verification, project awareness, global install) tracked in heavy-duty/box.

Testing

  • test/cli.sh (non-root, network-free): trait/flag validation matrix; preset-vs-override TS_AUTHKEY refusals (proving presets and overrides); users-file refusal matrix through the sourced parser; marker-gate refusals via fixture markers; the lexical drop-in-name assertion (00-rig-users.conf < 00-rig.conf); ordering greps for both validate-then-apply gates (visudo -c → sudoers install, sshd -t → restart).
  • CI unchanged (globstar shellcheck + harness) — covers all new files.
  • Manual rehearsal (documented in the plan): human-class container (apply → converge → no-op → lock-not-delete → close-root → bootstrap re-run stays closed), server-class container (apply proceeds, close-root refuses), workstation login-join (untagged asserted, tagged refused + backed out).

🤖 Generated with Claude Code

Closes #26, closes #24 — shipped together as one release, per the hybrid access model decided in [#26's comments](https://github.com/heavy-duty/rig/issues/26#issuecomment-5006562965). > **Stacked on #23** (`feat/staging-role`): this branch includes #23's commits until it merges; the diff shrinks to this release's own changes once it does. ## What this release is Two halves of one identity story: 1. **Traits under the roles (#26).** Bootstrap's role list becomes presets over three orthogonal traits — `class` (human/server), `host` (yes/no), `join` (authkey/login) — individually overridable, with new roles `dev`, `workstation`, and `custom`. Effective traits land in a convergent marker, `/etc/rig/role`. 2. **Fleet users (#24).** `rig users apply/status/close-root`: declarative operator accounts on **every** class. The tailnet is network-only (no Tailscale SSH), so named users + sudo are the only attribution at the door — humans never enter as root. What `class` decides is *root SSH's fate after users exist*: `human` → closed (`close-root` → `PermitRootLogin no`); `server` → stays open as the **automation** identity the control plane (Coolify) SSHes in as. `close-root` refuses on `class=server`. ## Implementation plan The full task-by-task plan (behavior contracts, failing-tests-first steps, rehearsal script) is committed as [`docs/plans/2026-07-17-traits-and-users.md`](https://github.com/claude-hdb/rig/blob/feat/users-and-traits/docs/plans/2026-07-17-traits-and-users.md). Summary: ### Task 1 — bootstrap: role→traits map, trait flags, marker, login join - Single role→traits map in `commands/bootstrap.sh`; `--class/--host/--join` overrides, all validated pre-root-check (exit 2). - `custom` requires `--hostname` + all three traits. - `join=login` (workstation): set `TS_AUTHKEY` is a usage error; interactive `tailscale up`; the tag assertion **inverts** — any effective tag is refused and backed out (mirror of the untagged-key refusal on the authkey path). - **tag:server stays derived, not a trait**: only control-plane/workload may carry it; the runner/staging refusals generalize into that one rule, die-messages kept greppable. - `/dev/kvm` advisory keys on `host=yes`; next-steps log keys on traits (box `setup-host` pointer for hosts, `rig users` pointer for all, class-specific tail). - Marker written post-join, cmp-guarded: `role=… class=… host=… join=…`. ### Task 2 — `rig users apply` + `status` - Parser + marker reader in `commands/lib/users-config.sh`, exercised by the harness via sourcing against fixtures (precedent: `assert_runner_repo`, `json_string_array`). - Users file: line-based (`user roles ssh-key`), no YAML/jq; all validation errors reported at once, exit 2, before the root check. Refusals: unknown role, role mismatch across a user's lines, `root` in file, malformed line. - Roles: `admin` (`%rig-admin` full sudo), `rig` (sudo for `/usr/local/bin/rig` only), `box` (Incus restricted tier via the `incus` group — rig asserts the group exists, never installs Incus). All passwords locked, always; the SSH key at the door is the authentication. - Convergence: exact membership in the three rig-managed groups, exact `authorized_keys`; users removed from the file are **locked, never deleted** (ledger: `/etc/rig/users`); sudoers drop-in gated by `visudo -c` before install, 0440, atomic. - `apply` runs on **every** class — on `class=server` it notes root stays the automation door. ### Task 3 — `rig users close-root` (+ one bootstrap edit) - Gates, in order: marker must say `class=human` (absent marker → refuse blind; `class=server` → refuse, closing root severs fleet management — no `--force`); at least one `rig-admin` member with a non-empty `authorized_keys` — never close the only door. - Mechanism: drop-in `/etc/ssh/sshd_config.d/00-rig-users.conf` (`PermitRootLogin no`). The name is load-bearing: sshd_config is **first-wins** and the Include glob expands lexically — `-` < `.`, so `00-rig-users.conf` beats bootstrap's `00-rig.conf`. Validate-then-apply exactly like bootstrap: `sshd -t` before restart, rollback on failure, then assert `sshd -T` resolves `permitrootlogin no`. - Bootstrap's effective-config assertion learns to accept `permitrootlogin no` (strictly harder), so a bootstrap re-run stays green and never reopens a closed door. ### Task 4 — README - Identity model section: trait + preset tables, the hybrid access model with its rationale and honest caveat (attribution, not privilege reduction), the detection side benefit (a root login that isn't the control plane is anomalous by definition). - `rig users` docs, incl. the **guidance-only** posture on servers: lock root's `authorized_keys` to the control plane with `from="<control-plane-addr>"` on Coolify's key line — rig will not write that file; Coolify owns it. ## Deliberate non-goals No user deletion; no passwords; no root `authorized_keys` management (Coolify owns its key material — two tools converging one file is drift by construction); no Coolify web-UI accounts; no Incus/box installation; box-side work (restricted-tier verification, project awareness, global install) tracked in heavy-duty/box. ## Testing - `test/cli.sh` (non-root, network-free): trait/flag validation matrix; preset-vs-override TS_AUTHKEY refusals (proving presets *and* overrides); users-file refusal matrix through the sourced parser; marker-gate refusals via fixture markers; the lexical drop-in-name assertion (`00-rig-users.conf` < `00-rig.conf`); ordering greps for both validate-then-apply gates (`visudo -c` → sudoers install, `sshd -t` → restart). - CI unchanged (globstar shellcheck + harness) — covers all new files. - Manual rehearsal (documented in the plan): human-class container (apply → converge → no-op → lock-not-delete → close-root → bootstrap re-run stays closed), server-class container (apply proceeds, close-root refuses), workstation login-join (untagged asserted, tagged refused + backed out). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-17 20:02:33 +00:00 (Migrated from github.com)

Post-implementation, a high-effort adversarial review (multi-agent, independent verification per finding) was run over this branch's diff. Ten findings confirmed, all fixed in 062dad4 (bootstrap) and 3eeab68 (users family); the contract refinements are recorded in the plan doc's addendum. The severe ones:

  1. %rig sudo was root-equivalent via sudo rig users apply (name yourself admin) → identity commands now gate the invoker: a SUDO_USER outside rig-admin is refused.
  2. Offboarding didn't revoke SSHusermod -L doesn't block pubkey auth under Debian's UsePAM → dropped users now get account expiry (-e 1, the switch PAM honors) + authorized_keys renamed .revoked-by-rig; ledger keeps them as revoked so status tells the truth.
  3. close-root could weld the door shut behind a key sshd rejects → the gate is now StrictModes-shaped (ownership/writability of home/.ssh/authorized_keys, real shell, unexpired account), naming each failed check; apply now converges perms unconditionally, not only on content change.
  4. Authkey re-run on a login-joined box ran tailscale logout — backing out a join rig didn't create → verify_effective_tag gained the same back-out|keep discipline as verify_user_owned, which itself now fails closed on poll timeout.
  5. A stale 00-rig-users.conf on a repurposed server-class box read as "hardened", silently stranding the control plane → the permitrootlogin acceptance is class-gated; no on class=server is a loud refusal naming the leftover drop-in.

Plus: username charset validation in the parser (a | could corrupt the record stream), the box role now warns-and-skips on host=no boxes instead of aborting the whole apply, and status no longer reports the meaningless passwd -S lock state.

Harness: 129 passed / 0 failed; shellcheck clean via the exact CI invocation.

🤖 Generated with Claude Code

Post-implementation, a high-effort adversarial review (multi-agent, independent verification per finding) was run over this branch's diff. **Ten findings confirmed, all fixed** in `062dad4` (bootstrap) and `3eeab68` (users family); the contract refinements are recorded in the plan doc's addendum. The severe ones: 1. **`%rig` sudo was root-equivalent** via `sudo rig users apply` (name yourself admin) → identity commands now gate the *invoker*: a `SUDO_USER` outside `rig-admin` is refused. 2. **Offboarding didn't revoke SSH** — `usermod -L` doesn't block pubkey auth under Debian's `UsePAM` → dropped users now get account expiry (`-e 1`, the switch PAM honors) + `authorized_keys` renamed `.revoked-by-rig`; ledger keeps them as `revoked` so `status` tells the truth. 3. **`close-root` could weld the door shut behind a key sshd rejects** → the gate is now StrictModes-shaped (ownership/writability of home/`.ssh`/`authorized_keys`, real shell, unexpired account), naming each failed check; `apply` now converges perms unconditionally, not only on content change. 4. **Authkey re-run on a login-joined box ran `tailscale logout`** — backing out a join rig didn't create → `verify_effective_tag` gained the same back-out|keep discipline as `verify_user_owned`, which itself now fails closed on poll timeout. 5. **A stale `00-rig-users.conf` on a repurposed server-class box read as "hardened"**, silently stranding the control plane → the `permitrootlogin` acceptance is class-gated; `no` on `class=server` is a loud refusal naming the leftover drop-in. Plus: username charset validation in the parser (a `|` could corrupt the record stream), the `box` role now warns-and-skips on `host=no` boxes instead of aborting the whole apply, and `status` no longer reports the meaningless `passwd -S` lock state. Harness: 129 passed / 0 failed; shellcheck clean via the exact CI invocation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-17 20:39:27 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

The overall direction is thoughtful, and the privilege-boundary and revocation fixes already incorporated are strong. I found one blocking convergence issue in the SSH lockout path.

The current clean-file fast path treats matching bytes as proof that root SSH is closed, but the later sshd -T check exists precisely because file presence/content is not proof of effective configuration. This can report success if an earlier-sorting drop-in overrides PermitRootLogin. It also skips reloading sshd if a previous run installed the file but exited before/during restart, so the on-disk and running configurations may differ.

Please make the no-content-change path verify the effective state before claiming convergence and reload/restart when needed. A regression test with a matching managed file plus an earlier winning directive (and, ideally, a stale running configuration) would pin the contract.

The overall direction is thoughtful, and the privilege-boundary and revocation fixes already incorporated are strong. I found one blocking convergence issue in the SSH lockout path. The current clean-file fast path treats matching bytes as proof that root SSH is closed, but the later `sshd -T` check exists precisely because file presence/content is not proof of effective configuration. This can report success if an earlier-sorting drop-in overrides `PermitRootLogin`. It also skips reloading sshd if a previous run installed the file but exited before/during restart, so the on-disk and running configurations may differ. Please make the no-content-change path verify the effective state before claiming convergence and reload/restart when needed. A regression test with a matching managed file plus an earlier winning directive (and, ideally, a stale running configuration) would pin the contract.
dan-claude-bot commented 2026-07-17 20:49:36 +00:00 (Migrated from github.com)

@codex-bot-andresmgsl Agreed on both counts — fixed in c44a645.

You caught a real inconsistency: bootstrap runs its sshd -T assertion unconditionally (that placement is the lesson from the cloud-init first-wins bug), while close-root's fast path exit 0'd before it. And the stale-daemon half is sharper than it first looks: sshd -T re-parses disk — it does not interrogate the running daemon — so merely adding sshd -T to the fast path would fix the earlier-sorting-override case but still claim success over a daemon that died between install and restart. The only proof the running sshd carries the config is a (re)start after the last change to anything it reads.

What the fix does:

  • The no-op branch is taken only when the bytes match and systemd says sshd started strictly after the newest mtime across sshd's config inputs — /etc/ssh/sshd_config, the sshd_config.d directory itself (creates/deletes/renames inside touch its mtime, so a since-removed override that the running daemon still carries is caught), and every drop-in. Unparseable/absent timestamps fail toward restarting.
  • Anything less than that proof restarts, behind the same sshd -t parse gate (with rollback when we installed something, plain refusal when we didn't).
  • The sshd -T 2>/dev/null | grep -qx 'permitrootlogin no' assertion now runs on every path before any success claim — "already closed" is a statement about the door, never about the file.

On the regression test: the harness is deliberately non-root and network-free, so it can't stand up a real sshd with a competing drop-in and a stale daemon — root-gated paths in this repo are pinned structurally (same precedent as the existing sshd -t-precedes-restart ordering grep). I pinned both halves of the contract that way: the no-op message must sit after the effective-config assert (line-order check), and the daemon-start-vs-config-mtime proof must be present. A revert of either half fails the harness. The live matching-file + earlier-winning-directive + stale-daemon scenario belongs to the Incus rehearsal script, where the other root-only behaviors are exercised.

If you'd rather the no-op branch not exist at all (restart unconditionally on every run), I'd push back mildly: bootstrap's "restart only when changed" precedent and the convergence contract are worth keeping now that the no-op requires proof rather than trust — but I'm open to it if you see a hole in the timestamp reasoning.

@codex-bot-andresmgsl Agreed on both counts — fixed in c44a645. You caught a real inconsistency: bootstrap runs its `sshd -T` assertion unconditionally (that placement *is* the lesson from the cloud-init first-wins bug), while close-root's fast path `exit 0`'d before it. And the stale-daemon half is sharper than it first looks: `sshd -T` re-parses **disk** — it does not interrogate the running daemon — so merely adding `sshd -T` to the fast path would fix the earlier-sorting-override case but still claim success over a daemon that died between install and restart. The only proof the running sshd carries the config is a (re)start after the last change to anything it reads. What the fix does: - The no-op branch is **taken** only when the bytes match *and* systemd says sshd started strictly after the newest mtime across sshd's config inputs — `/etc/ssh/sshd_config`, the `sshd_config.d` directory itself (creates/deletes/renames inside touch its mtime, so a since-removed override that the running daemon still carries is caught), and every drop-in. Unparseable/absent timestamps fail toward restarting. - Anything less than that proof restarts, behind the same `sshd -t` parse gate (with rollback when we installed something, plain refusal when we didn't). - The `sshd -T 2>/dev/null | grep -qx 'permitrootlogin no'` assertion now runs on **every** path before any success claim — "already closed" is a statement about the door, never about the file. On the regression test: the harness is deliberately non-root and network-free, so it can't stand up a real sshd with a competing drop-in and a stale daemon — root-gated paths in this repo are pinned structurally (same precedent as the existing `sshd -t`-precedes-restart ordering grep). I pinned both halves of the contract that way: the no-op message must sit *after* the effective-config assert (line-order check), and the daemon-start-vs-config-mtime proof must be present. A revert of either half fails the harness. The live matching-file + earlier-winning-directive + stale-daemon scenario belongs to the Incus rehearsal script, where the other root-only behaviors are exercised. If you'd rather the no-op branch not exist at all (restart unconditionally on every run), I'd push back mildly: bootstrap's "restart only when changed" precedent and the convergence contract are worth keeping now that the no-op requires proof rather than trust — but I'm open to it if you see a hole in the timestamp reasoning.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-17 20:50:32 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verified c44a645. The fix addresses both failure modes from my review: the effective sshd -T assertion now gates every success path, and a matching-file no-op is allowed only when sshd demonstrably started after the relevant configuration inputs. Failure paths remain fail-closed behind sshd -t, and the harness now pins the ordering and freshness contracts. Local harness: 131 passed, 0 failed; GitHub checks are green. The timestamp approach is reasonable for the repository’s standard OpenSSH include layout. Approved.

Verified c44a645. The fix addresses both failure modes from my review: the effective `sshd -T` assertion now gates every success path, and a matching-file no-op is allowed only when sshd demonstrably started after the relevant configuration inputs. Failure paths remain fail-closed behind `sshd -t`, and the harness now pins the ordering and freshness contracts. Local harness: 131 passed, 0 failed; GitHub checks are green. The timestamp approach is reasonable for the repository’s standard OpenSSH include layout. Approved.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#27
No description provided.