diff --git a/docs/plans/2026-07-17-traits-and-users.md b/docs/plans/2026-07-17-traits-and-users.md new file mode 100644 index 0000000..cdea9b7 --- /dev/null +++ b/docs/plans/2026-07-17-traits-and-users.md @@ -0,0 +1,413 @@ +# Machine Traits + `rig users` Implementation Plan (issues #26 + #24, one release) + +> **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:** Ship the trait model (#26) and declarative fleet users (#24) as a +single release. Bootstrap's role list becomes presets over three orthogonal +traits (`class`, `host`, `join`), recorded in a convergent marker +`/etc/rig/role`. A new `rig users` command family makes operator accounts a +first-class, declarative concern on **every** class — the hybrid access model: +humans always enter as themselves and elevate via sudo; root SSH is what +`class` decides *after* users exist. `class=human` machines close it +(`rig users close-root`); `class=server` machines keep it as the **automation** +identity the control plane (Coolify) SSHes in as. The tailnet is network-only +(no Tailscale SSH), so named users are the only attribution at the door. + +**Why this shape:** #25's server/host binary bundled three questions that +merely correlated (who lives there / hosts VMs / how it joins). #26 unbundles +them; roles stay as presets so the usual shapes remain one command. The +earlier "server is humanless — `rig users` refuses" was superseded in #26's +comments: a shared root login is unattributable, so operators belong +everywhere; what stays class-specific is the *fate of root SSH*. Root's key +hygiene on servers (`from=`-locking Coolify's key line) is README guidance, +not automation — Coolify owns its key material on the servers it registers, +and two tools converging one file is drift by construction. + +**Architecture:** + +- `commands/bootstrap.sh` gains a **role→traits map** — the single place a + role's shape is declared — plus `--class/--host/--join` overrides and roles + `dev`, `workstation`, `custom`. All per-class behavior keys off traits: + `/dev/kvm` advisory (`host=yes`), tag policy (derived), next-steps log. + The marker is written post-join, convergently. +- `join=login` (workstation): a set `TS_AUTHKEY` is a usage error (exit 2, + before the root check — testable); interactive `tailscale up`; the tag + assertion **inverts** — any effective tag is a refusal, backed out with + `tailscale logout`, mirroring the untagged-key refusal on the authkey path. +- **tag:server policy is derived, not a trait**: only `control-plane` and + `workload` may carry it; every other role refuses it on the effective tags + (generalizes today's runner + staging checks into one rule). +- New `commands/users-apply.sh`, `commands/users-status.sh`, + `commands/users-close-root.sh`, with parsing/marker helpers in + `commands/lib/users-config.sh` so the harness can exercise refusals via + sourced functions against fixtures (repo precedent: `assert_runner_repo`, + `json_string_array`). +- `close-root` installs `/etc/ssh/sshd_config.d/00-rig-users.conf` + (`PermitRootLogin no`). The **name is load-bearing**: sshd_config is + first-wins, the Include glob expands lexically, and `-` (0x2D) < `.` + (0x2E), so `00-rig-users.conf` is read before bootstrap's `00-rig.conf` + and wins. Validate-then-apply exactly like bootstrap (`sshd -t`, rollback, + `sshd -T` assertion). Bootstrap's effective-config assertion learns to + accept `permitrootlogin no` (strictly harder) so a re-run never reopens + root. +- Managed-user bookkeeping: `apply` maintains `/etc/rig/users` (one username + per line) so a user removed from the input file is found and **locked** + (never deleted) on the next run. + +**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck ++ `bash test/cli.sh`) — no workflow change needed. + +## Non-Goals + +- No user deletion; no passwords (all locked, always); no LDAP/SSO/PAM; no + per-user quotas (Incus project limits are a box concern). +- No management of root's `authorized_keys` (Coolify owns its key on the + servers it manages; the `from=` lock is README guidance, not code). +- No Coolify application-account (web UI) management. +- No Incus / box installation (box's `setup-host` owns the daemon; the `box` + role *asserts* the `incus` group exists and refuses with a pointer + otherwise). +- No behavior change for control-plane/workload/runner beyond the marker + write and the generalized (identical-in-effect) tag policy. +- Cross-repo box work (restricted-tier verification, project-awareness, + global install) is referenced in #24 and tracked in heavy-duty/box. + +## Global Constraints + +- `#!/usr/bin/env bash` + `set -euo pipefail`; per-command log prefix via + `log`/`warn`/`die` helpers (`rig-users:` for the new family). +- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All + argument validation runs BEFORE the root check** so error paths are + testable as non-root. File parsing counts as validation: a bad users file + exits 2 with **all** errors reported at once. +- Convergent everywhere: a second identical run is a no-op, says so, exits 0. +- Validate-then-apply for anything that can lock the door or break sudo: + `sshd -t` before restart (rollback on failure), `visudo -c` before the + sudoers drop-in lands. +- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x + bin/* **/*.sh`); `bash test/cli.sh` green as non-root. +- Keep the diff minimal — no drive-by refactors. + +--- + +### Task 1: role→traits map, trait flags, marker, login join path (bootstrap) + +**Files:** +- Modify: `commands/bootstrap.sh` (usage heredoc, role case + trait map + + override flags, TS_AUTHKEY/login validation, kvm advisory keyed on traits, + generalized tag policy, login-path inverted assertion, marker write, + next-steps log) +- Modify: `bin/rig` (bootstrap usage lines: roles + trait flags) +- Modify: `test/cli.sh` (bootstrap section additions) + +**Behavior contract, in file order:** + +1. Usage heredoc: roles ``; + flags `--hostname `, `--class `, `--host `, + `--join `. Document: roles are presets over traits, any + trait overridable; `custom` requires `--hostname` and all three traits; + `join=login` needs no pre-auth key and refuses a set `TS_AUTHKEY` + (unset it or pass `--join authkey`); the preset table in one compact + block. +2. Role case: the seven roles shift; `role required` / `unknown role` + messages name them. Then the **role→traits map** — one `case "$ROLE"` + assigning `CLASS`/`HOST`/`JOIN` per the preset table in issue #26; + `custom` leaves all three empty. +3. Flag loop gains `--class/--host/--join`, each validating its value set + (bad value → exit 2 naming the valid values). After the loop: + `custom` missing `--hostname` → exit 2; `custom` missing any trait → + exit 2. `TS_HOSTNAME` default stays the role name (custom has none). +4. Post-parse validation (still pre-root-check): `JOIN=login` with a set + `TS_AUTHKEY` → `die` exit 2: "join=login is interactive: unset TS_AUTHKEY + or pass --join authkey". +5. Guards: the `/dev/kvm` advisory keys on `HOST=yes` (message unchanged in + spirit; drops the staging-only wording). +6. `verify_effective_tag` generalizes the runner/staging refusals into the + derived policy: if role is not `control-plane`/`workload` and the + effective tags contain `tag:server` → die (message keeps the + role-specific repair pointers for runner/staging; a generic message for + other shapes). Keep the existing two die-message strings greppable — + the harness greps them (`role staging joined with tag:server`, + runner equivalent). +7. **login path**: when `JOIN=login`, skip the pre-auth key acquisition; + run `tailscale up --hostname="$TS_HOSTNAME"` (interactive, no + `--authkey`); then the **inverted** assertion — poll as + `verify_effective_tag` does, but *any* non-empty effective tag → + `tailscale logout` + die: "joined TAGGED (…) but join=login expects a + user-owned, untagged node — a tag here means control granted this device + fleet identity; use a pre-auth key path (--join authkey) for fleet + machines." Empty tags + `Running` → OK, log "user-owned join verified". + The already-joined path runs the same class of check (tags present → + refusal; no logout on a box that was already joined — detect, refuse, + name the repair by hand). +8. **Marker**: after tag verification, write `/etc/rig/role` (mkdir -p + `/etc/rig`) with exactly one line: + `role=$ROLE class=$CLASS host=$HOST join=$JOIN` — cmp-guarded + (write+log only on change; "marker already current" otherwise). +9. Next-steps log keys off traits + role: `control-plane` → coolify install + pointer (as today); `runner` → runner install pointer (as today); + `HOST=yes` → box setup-host pointer (replaces the staging-only branch); + all classes → `rig users apply` pointer, with the class-specific tail: + `class=human` adds "then `rig users close-root` once your admin key + works"; `class=server` adds "root SSH stays — it is the control plane's + automation door". +10. `bin/rig` usage: bootstrap line gains the three roles and trait flags, + one sentence for the trait model. + +- [ ] **Step 1: Append failing tests** (`test/cli.sh`, bootstrap section) + +```bash +# --- traits: roles are presets, every trait individually settable (#26) ----- +check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato +check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato +check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe +check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon +check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ + "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey +check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 +# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it +# must die BEFORE the root check — provable non-root, which also proves the +# preset actually landed. +check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation +# A trait override changes derived behavior, provable non-root: dev is +# join=authkey (TS_AUTHKEY fine → falls through to the root check), but +# --join login flips it into the TS_AUTHKEY refusal. +check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login +# The login-path inverted assertion needs a real tailnet; grep the refusal so a +# deleted guard cannot ship green (repo precedent: staging/runner tag greps). +check "bootstrap: login-path tagged refusal is present" 0 "" \ + grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh" +# The marker is the traits' ground truth for rig users; assert the write exists. +check "bootstrap: role marker write is present" 0 "" \ + grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" +``` + +and in the existing non-root block: + +```bash +check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev +check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation +check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey +``` + +- [ ] **Step 2: Run `bash test/cli.sh`** — new checks FAIL (unknown role / + unknown flag / missing messages), existing stay green, harness exits 1. +- [ ] **Step 3: Implement** per the behavior contract. +- [ ] **Step 4: `bash test/cli.sh`** — all green, exit 0. +- [ ] **Step 5: shellcheck + syntax** — `shopt -s globstar; shellcheck -x + bin/* **/*.sh`; `bash -n` each edited script. +- [ ] **Step 6: Commit** + +```bash +git add commands/bootstrap.sh bin/rig test/cli.sh +git commit -m "feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker" +``` + +--- + +### Task 2: `rig users apply` + `rig users status` + +**Files:** +- Create: `commands/lib/users-config.sh` (users-file parser, marker reader, + role→group mapping — pure functions, no side effects, sourceable by the + harness) +- Create: `commands/users-apply.sh`, `commands/users-status.sh` +- Modify: `bin/rig` (users subcommand dispatch + usage) +- Modify: `test/cli.sh` (users section) + +**Behavior contract:** + +1. `commands/lib/users-config.sh`: + - `parse_users_file `: emits normalized `user|roles|key` lines on + stdout, or **all** validation errors on stderr and returns 1. Refusals: + unknown role (naming the valid set `admin rig box`), differing roles + across one user's lines, `root` as username, malformed line (fewer than + 3 fields / key not starting `ssh-` or `ecdsa-`), duplicate identical + key line. `#` comments and blanks skipped. + - `read_role_marker `: prints `class=` etc. from the marker; + empty output when absent. No policy here — callers decide. +2. `users-apply.sh` (`rig-users:` prefix): + - Args: `--file ` required (`-` = stdin, read once into a temp); + `--help`; unknown flag exit 2. Parse+validate the whole file (exit 2 on + any error, all reported) **before** the root check. + - Root check. Then marker note (never a refusal): `class=server` or no + marker → log that root SSH stays the automation door / advise + bootstrapping a marker, respectively. + - Install `sudo` if missing and any parsed role needs it (`admin`, `rig`). + - `groupadd -f rig-admin rig`; if any user carries `box`, assert group + `incus` exists else die 1 pointing at box `setup-host`. + - Converge each user: `useradd -m -s /bin/bash` if absent, then always + `usermod -L`; membership in the three rig-managed groups exactly + (add and remove; other groups untouched); `~/.ssh/authorized_keys` + written 0700/0600, user-owned, to exactly the file's keys (cmp-guarded). + - Previously managed users absent from the file (diff against + `/etc/rig/users`): `usermod -L`, strip the three rig groups, keep home, + `warn` each. Then rewrite `/etc/rig/users` to the file's users. + - Sudoers: write the two `%rig-admin`/`%rig` rules to a temp, `visudo -c` + against it, then install atomically to `/etc/sudoers.d/rig-roles` 0440 + (cmp-guarded; die 1 with the temp preserved for inspection on a + `visudo` failure, sudoers untouched). + - Converged-no-change run says "already converged; no changes". +3. `users-status.sh`: `--help`; root check; reads `/etc/rig/users`, prints + per user: roles (derived from actual group membership), key count from + `authorized_keys`, locked/active. Exits 0 with "no rig-managed users" + when the ledger is absent. Reads only — no network, no writes. +4. `bin/rig`: `users` dispatch (`apply`/`status`/`close-root` → their + scripts; bare or unknown sub → usage exit 2); usage block documents the + three subcommands in the existing voice. + +- [ ] **Step 1: Append failing tests** (`test/cli.sh`; fixtures via mktemp, + parser exercised through the sourced lib — precedent: `guard()`/`tags()`): + bare `users` exit 2; `users frobnicate` exit 2; apply `--help` 0; + `--file` required 2; `--file` needs value 2; missing file 2; unknown flag + 2; parser fixtures: unknown role (message lists valid set), role mismatch + across lines, `root` refused, malformed line, a valid two-user file parses + (and a multi-error file reports **both** errors in one run); status + `--help` 0; non-root refusals for apply (valid fixture file) and status; + ordering grep: `visudo -c` line precedes the `sudoers.d/rig-roles` + install line in `users-apply.sh`. +- [ ] **Step 2: `bash test/cli.sh`** — new checks fail. +- [ ] **Step 3: Implement** per contract. +- [ ] **Step 4: `bash test/cli.sh`** — green. +- [ ] **Step 5: shellcheck + syntax** (CI invocation). +- [ ] **Step 6: Commit** + +```bash +git add commands/lib/users-config.sh commands/users-apply.sh commands/users-status.sh bin/rig test/cli.sh +git commit -m "feat(users): declarative operators — apply/status over a users file, every class" +``` + +--- + +### Task 3: `rig users close-root` + bootstrap accepts the closed state + +**Files:** +- Create: `commands/users-close-root.sh` +- Modify: `commands/bootstrap.sh` (effective-config assertion accepts + `permitrootlogin no`) +- Modify: `test/cli.sh` + +**Behavior contract:** + +1. `users-close-root.sh`: `--help` documents the model (human-class only; + verify your admin login in a separate session first) ; unknown flag 2; + root check; then, in order: + - Marker gate via `read_role_marker` (path overridable for tests via + `RIG_ROLE_MARKER`, default `/etc/rig/role`): absent marker → die 1 + "no /etc/rig/role marker: re-run rig bootstrap so this box knows what + it is; refusing to shut the root door blind"; `class=server` → die 1 + "class=server: root here is the control plane's automation identity — + closing it severs fleet management"; only `class=human` proceeds. + - Admin-door gate: at least one member of `rig-admin` with a non-empty + `~/.ssh/authorized_keys` → else die 1 "no admin user with a key on this + box — run rig users apply first; never close the only door". + - Install `/etc/ssh/sshd_config.d/00-rig-users.conf` containing exactly + `PermitRootLogin no`, cmp-guarded; `sshd -t` on the merged config + BEFORE `systemctl restart ssh`, rollback (remove/restore) on failure — + the bootstrap shape verbatim. Then assert `sshd -T` resolves + `permitrootlogin no` or die. + - Second run: "root already closed; nothing to do", exit 0. +2. `bootstrap.sh`: the `permitrootlogin` assertion regex becomes + `(no|prohibit-password|without-password)` with a comment: `no` is the + post-`close-root` state, strictly harder; bootstrap must never read a + closed door as a broken one (or reopen it — its drop-in loses to + `00-rig-users.conf` by first-wins, which is the point). + +- [ ] **Step 1: Append failing tests** + +```bash +check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help +check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope +# The whole command rests on first-wins + lexical include order: '-' < '.', so +# 00-rig-users.conf is read before 00-rig.conf. Assert the actual comparison the +# glob makes, so a renamed drop-in cannot silently lose the fight. +check "users close-root: drop-in name sorts before bootstrap's" 0 "" \ + bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]' +check "users close-root: drop-in name is the load-bearing one" 0 "" \ + grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh" +# Validate-then-apply ordering, greppable (repo precedent: repo-guard ordering). +# sshd -t must precede the restart in file order. +# marker refusals via the sourced lib against fixture markers: +# class=server marker → refusal names the control plane +# absent marker → refusal names bootstrap as the repair +# class=human marker → gate passes (function returns 0) +# non-root: close-root refuses non-root (exit 1) after arg validation. +``` + +(Exact harness lines mirror the `guard()` fixture pattern; ordering check +mirrors the `guard_at`/`start_at` line-number comparison.) + +- [ ] **Step 2: `bash test/cli.sh`** — new checks fail. +- [ ] **Step 3: Implement** per contract. +- [ ] **Step 4: `bash test/cli.sh`** — green. +- [ ] **Step 5: shellcheck + syntax** (CI invocation). +- [ ] **Step 6: Commit** + +```bash +git add commands/users-close-root.sh commands/bootstrap.sh test/cli.sh +git commit -m "feat(users): close-root — shut the human-class root door once an admin key works" +``` + +--- + +### Task 4: README — identity model, trait tables, users commands + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Write it** (in the README's existing voice): + - Bootstrap section: role list/examples gain `dev`, `workstation`, + `custom`; the trait table and roles-as-presets table from #26; the + marker; the `join=login` story (untagged/user-owned is the *assertion*, + a tag is the refusal). + - New **identity model** subsection: the hybrid access model — operators + on every class, humans never enter as root, `class` decides root SSH's + fate after `rig users apply`; the attribution rationale (network-only + tailnet, no identity broker at the door); the detection side benefit + (any root login that isn't the control plane is anomalous by + definition); the honest caveat (attribution, not privilege reduction — + sudo on a Docker box is root-equivalent). + - `rig users` section: file format, roles table (admin/rig/box, + incus-admin deliberately not a role), apply/status/close-root, the + locked-not-deleted convergence rule, close-root's gates, and the + **README-only** guidance: on `class=server`, lock root's + `authorized_keys` to the control plane with a + `from=""` clause on Coolify's key line (rig will + not write that file — Coolify owns it). +- [ ] **Step 2: Full local gate** — CI shellcheck invocation + + `bash test/cli.sh`. +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README identity model — traits, presets, fleet users, root's two fates" +``` + +--- + +## Test Plan + +- **Harness (`bash test/cli.sh`, non-root, network-free):** everything in the + per-task steps — trait/flag validation, preset-driven TS_AUTHKEY refusals + (proving both presets and overrides), users-file refusal matrix through the + sourced parser, marker-gate refusals through fixtures, the lexical + drop-in-name assertion, and the two validate-then-apply ordering greps + (`visudo -c` before sudoers install, `sshd -t` before restart). +- **CI:** unchanged `ci.yml` (globstar shellcheck + harness) covers all new + files. +- **Rehearsal (manual, out of harness):** Incus container pair — + 1. human-class: `bootstrap dev` (real `tag:local` key) → marker says + `class=human host=yes join=authkey`; `users apply` a two-user file → + users/groups/keys/sudoers as specified; re-apply no-ops; remove a user + → locked, home intact; `status` truthful; `close-root` → `sshd -T` + resolves `permitrootlogin no`; re-run bootstrap → green, root stays + closed; drop a user's key, `ssh` as them fails, as the other succeeds. + 2. server-class: `bootstrap workload` → marker `class=server`; `users + apply` proceeds (operators exist); `close-root` refuses naming the + control plane. + 3. workstation: `bootstrap workstation` with no key → interactive login + join, untagged asserted; with a tagged key's identity → refused and + backed out.