No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#27
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/users-and-traits"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #26, closes #24 — shipped together as one release, per the hybrid access model decided in #26's comments.
What this release is
Two halves of one identity story:
class(human/server),host(yes/no),join(authkey/login) — individually overridable, with new rolesdev,workstation, andcustom. Effective traits land in a convergent marker,/etc/rig/role.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. Whatclassdecides 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-rootrefuses onclass=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
commands/bootstrap.sh;--class/--host/--joinoverrides, all validated pre-root-check (exit 2).customrequires--hostname+ all three traits.join=login(workstation): setTS_AUTHKEYis a usage error; interactivetailscale up; the tag assertion inverts — any effective tag is refused and backed out (mirror of the untagged-key refusal on the authkey path)./dev/kvmadvisory keys onhost=yes; next-steps log keys on traits (boxsetup-hostpointer for hosts,rig userspointer for all, class-specific tail).role=… class=… host=… join=….Task 2 —
rig users apply+statuscommands/lib/users-config.sh, exercised by the harness via sourcing against fixtures (precedent:assert_runner_repo,json_string_array).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,rootin file, malformed line.admin(%rig-adminfull sudo),rig(sudo for/usr/local/bin/rigonly),box(Incus restricted tier via theincusgroup — rig asserts the group exists, never installs Incus). All passwords locked, always; the SSH key at the door is the authentication.authorized_keys; users removed from the file are locked, never deleted (ledger:/etc/rig/users); sudoers drop-in gated byvisudo -cbefore install, 0440, atomic.applyruns on every class — onclass=serverit notes root stays the automation door.Task 3 —
rig users close-root(+ one bootstrap edit)class=human(absent marker → refuse blind;class=server→ refuse, closing root severs fleet management — no--force); at least onerig-adminmember with a non-emptyauthorized_keys— never close the only door./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 —-<., so00-rig-users.confbeats bootstrap's00-rig.conf. Validate-then-apply exactly like bootstrap:sshd -tbefore restart, rollback on failure, then assertsshd -Tresolvespermitrootlogin no.permitrootlogin no(strictly harder), so a bootstrap re-run stays green and never reopens a closed door.Task 4 — README
rig usersdocs, incl. the guidance-only posture on servers: lock root'sauthorized_keysto the control plane withfrom="<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_keysmanagement (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).🤖 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) and3eeab68(users family); the contract refinements are recorded in the plan doc's addendum. The severe ones:%rigsudo was root-equivalent viasudo rig users apply(name yourself admin) → identity commands now gate the invoker: aSUDO_USERoutsiderig-adminis refused.usermod -Ldoesn't block pubkey auth under Debian'sUsePAM→ dropped users now get account expiry (-e 1, the switch PAM honors) +authorized_keysrenamed.revoked-by-rig; ledger keeps them asrevokedsostatustells the truth.close-rootcould 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;applynow converges perms unconditionally, not only on content change.tailscale logout— backing out a join rig didn't create →verify_effective_taggained the same back-out|keep discipline asverify_user_owned, which itself now fails closed on poll timeout.00-rig-users.confon a repurposed server-class box read as "hardened", silently stranding the control plane → thepermitrootloginacceptance is class-gated;noonclass=serveris a loud refusal naming the leftover drop-in.Plus: username charset validation in the parser (a
|could corrupt the record stream), theboxrole now warns-and-skips onhost=noboxes instead of aborting the whole apply, andstatusno longer reports the meaninglesspasswd -Slock state.Harness: 129 passed / 0 failed; shellcheck clean via the exact CI invocation.
🤖 Generated with Claude Code
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 -Tcheck exists precisely because file presence/content is not proof of effective configuration. This can report success if an earlier-sorting drop-in overridesPermitRootLogin. 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.
@codex-bot-andresmgsl Agreed on both counts — fixed in
c44a645.You caught a real inconsistency: bootstrap runs its
sshd -Tassertion unconditionally (that placement is the lesson from the cloud-init first-wins bug), while close-root's fast pathexit 0'd before it. And the stale-daemon half is sharper than it first looks:sshd -Tre-parses disk — it does not interrogate the running daemon — so merely addingsshd -Tto 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:
/etc/ssh/sshd_config, thesshd_config.ddirectory 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.sshd -tparse gate (with rollback when we installed something, plain refusal when we didn't).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.
Verified
c44a645. The fix addresses both failure modes from my review: the effectivesshd -Tassertion 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 behindsshd -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.