rig users: declarative operators and roles across the fleet #24

Closed
opened 2026-07-17 16:33:10 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-17 16:33:10 +00:00 (Migrated from github.com)

Context

Three operators will share the fleet — multiple boxes each — and today nothing defines who exists on a machine or what they may do. The identity model was reworked in #26 (hybrid access model, decided in its comments): named operator accounts exist on every class, because this tailnet is network-only — no Tailscale SSH — so there is no identity broker at the door, and humans sharing the root login would be unattributable. Humans always enter as themselves and elevate via sudo; root is never a human door. What class decides is root SSH's fate after apply: class=human machines close it (rig users close-rootPermitRootLogin no); class=server machines keep it — root there is the automation identity the control plane (Coolify) SSHes in as, key-only. Rig already creates one user (github-runner, a service account) — this issue makes user structure a first-class, declarative rig concern. Companion traits issue: #26, shipped together as one release.

The guiding constraint is rig's charter: public tool, private state. The user file is state — it lives in the private infra repo and is passed in per invocation; rig never persists it and it contains no secrets (SSH public keys only).

Command surface

rig users apply --file <path>     # converge the machine to the file ('-' = stdin)
rig users status                  # read-only: rig-managed users, roles, key counts
rig users close-root              # class=human only: PermitRootLogin no, once an admin key is on the box

Conventions as everywhere in rig: run as root (via sudo rig from an operator account, or direct root during bring-up); arg validation before the root check; exit 2 = usage error, 1 = runtime refusal; convergent — second run is a no-op.

File format

Line-based and bash-parseable — no YAML, no jq (repo precedent: json_field exists precisely to avoid dependencies).

# user   roles          ssh public key
dan      admin,box      ssh-ed25519 AAAA... dan@laptop
maria    rig,box        ssh-ed25519 AAAA... maria@mac
maria    rig,box        ssh-ed25519 AAAA... maria@desktop
  • Repeated username lines = additional authorized keys. Roles must be identical across a user's lines — mismatch is a refusal (loud beats silent).
  • # comments and blank lines allowed.
  • root may never appear in the file (refusal).
  • Unknown role names are a refusal, listing the valid set.

Roles

role group mechanism grants
admin rig-admin sudoers drop-in: %rig-admin ALL=(ALL:ALL) NOPASSWD: ALL full machine admin
rig rig sudoers drop-in: %rig ALL=(root) NOPASSWD: /usr/local/bin/rig run rig, nothing else
box incus Incus's restricted tier: the incus-user daemon confines each member to their own Incus project — no sudo involved at all mint/manage own boxes; cannot see or touch other users' instances

Notes:

  • All passwords are locked, always. Bootstrap already enforces key-only SSH (PasswordAuthentication no); users are created with no password and never get one. NOPASSWD sudo is therefore not a weakening — the key at the door is the authentication, same pattern box templates already use (NOPASSWD + lock_passwd: true).
  • incus-admin is deliberately NOT a role. It is host-root-equivalent (full socket: any instance, any config, host mounts) — break-glass tier, granted manually and sparingly. The whole point of the box role riding the incus group is that the three of us share the dev-server without collisions and without holding the skeleton key.
  • Sudoers land as /etc/sudoers.d/rig-roles, mode 0440, validated with visudo -c before install (same validate-then-apply shape as bootstrap's sshd -t gate).
  • On class=server machines the useful role set is typically just admin (and rig): the box role needs Incus, which only hosts have — its absence is already a refusal (see apply, step 3).

Behavior of apply

  1. Parse + validate the whole file before touching the system (all errors reported at once, exit 2).
  2. Install sudo if missing and any role requires it (first legitimate appearance of sudo on a rig machine).
  3. Ensure groups rig-admin, rig exist; assert incus exists if any user carries the box role (the group belongs to the Incus package — if absent, refuse with a pointer to box's setup-host, which owns Incus installation; rig does not install Incus).
  4. For each user: create if missing (useradd -m, shell /bin/bash, locked password); converge membership in the three rig-managed groups exactly (add and remove); never touch any other group; write ~/.ssh/authorized_keys (0700 dir / 0600 file, user-owned) to exactly the file's keys.
  5. Users previously rig-managed but now absent from the file: lock (usermod -L, strip rig-managed groups, keep home) and warn. Deletion is a non-goal — a convergence run must never destroy data.
  6. Write/refresh the sudoers drop-in; visudo -c; install atomically.
  7. Second run with the same file: no-op, says so.

Class marker (replaces the old class gate)

The earlier model refused apply on server-class ("only admins belong there and root already is that"). The hybrid access model inverts that: operators belong everywhere — attribution is the point — so apply runs on every class. The marker (#26's /etc/rig/role) still matters, at the root-closing step:

  • rig users close-root refuses on class=server — root there is the control plane's automation identity; closing it severs fleet management. No --force.
  • close-root also refuses when no marker exists (pre-existing machines): the command that shuts the root door must know which kind of box it is on. Fail closed; re-run bootstrap to write the marker first.
  • apply itself never refuses on class — on class=server or an unmarked box it proceeds, noting that root SSH stays until/unless the box is marked human and close-root is run.

Behavior of close-root

  1. Refuse unless the marker says class=human (above).
  2. Refuse unless at least one rig-admin user exists with a non-empty authorized_keys — never close the only door. The refusal names the repair: rig users apply an admin first.
  3. Advise loudly (in help and output): verify ssh <admin>@<box> works in a separate session before running this.
  4. Install /etc/ssh/sshd_config.d/00-rig-users.conf containing PermitRootLogin no. The name is load-bearing: sshd_config is first-wins and the Include glob expands lexically, and - (0x2D) sorts before . (0x2E), so 00-rig-users.conf is read before bootstrap's 00-rig.conf and its PermitRootLogin no beats prohibit-password. Same validate-then-apply shape as bootstrap: sshd -t on the merged config before restarting, roll back on failure, then assert sshd -T resolves permitrootlogin no.
  5. Convergent: a second run is a no-op and says so.
  6. Bootstrap learns to accept the closed state. Its effective-config assertion (permitrootlogin (prohibit-password|without-password)) must also accept no — strictly harder — so re-running bootstrap after close-root stays green and never reopens root. (Small #26-side edit, shipped in the same release.)

Root's key hygiene on class=server is documentation, not automation: locking root's authorized_keys to the control plane (a from="<control-plane-addr>" clause on Coolify's key line) is the recommended posture and goes in the README — but Coolify owns its key material on the servers it registers, and rig writing root's authorized_keys would be two tools converging one file. Same drift-by-construction argument as rig not touching Incus.

Cross-repo dependencies (referenced, tracked in heavy-duty/box)

  • Verify the incus-group restricted tier (incus-user daemon + per-user projects) is present and functional on the Incus version the M900s run — task 0, before anything else lands.
  • box project-awareness audit: box assumes daemon-global resources (box-net profile, boxnet network, ACL, user.box=1 tags with no owner). Under per-user projects the profile must exist per project and box list semantics change. Needs its own box issue.
  • box global install: per-user install means every new operator reinstalls box; box's install.sh should grow a root branch mirroring rig's (system tree + /usr/local/bin/box symlink). Needs its own box issue.

Testing

  • test/cli.sh: usage paths; fixture files exercising every refusal (unknown role, role mismatch across lines, root in file, malformed line, missing file) — all non-root, network-free, exit 2 with greppable messages.
  • close-root: usage paths non-root; marker refusals exercised against a fixture marker path; a static lexical assertion that 00-rig-users.conf sorts before 00-rig.conf (the first-wins guarantee the whole command rests on), and an ordering grep that sshd -t precedes the restart (repo precedent: the runner repo-guard ordering check).
  • Static assertion that visudo -c gates the sudoers install (same ordering-grep shape).
  • Rehearsal (manual, documented in the plan): Incus container — apply a two-user file, assert users/groups/keys/sudoers; re-apply = no-op; remove a user from the file, assert locked-not-deleted; key-only SSH works; on a human-marked container, close-root flips sshd -T to permitrootlogin no and a bootstrap re-run does not reopen it.

Non-goals

  • No user deletion. No password management (there are no passwords). No LDAP/SSO/PAM. No per-user resource quotas (Incus project limits are a later 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 management (web UI users are Coolify's own concern).

Acceptance

  • rig users apply --file <fixtures> refusal matrix passes in test/cli.sh; close-root marker/ordering assertions pass; shellcheck green via the exact CI invocation.
  • On a rehearsal container: two applies converge then no-op; a user removed from the file is locked with home intact; rig users status reports the truth; close-root on a human-marked box closes root and survives a bootstrap re-run.
  • A box-role user on the dev-server can mint and manage their own boxes and cannot see another user's (pending the box-side dependencies above).

Companion traits issue: #26 (supersedes #25) — one release together.

## Context Three operators will share the fleet — multiple boxes each — and today nothing defines who exists on a machine or what they may do. The identity model was reworked in #26 (**hybrid access model**, decided in its comments): named operator accounts exist on **every** class, because this tailnet is network-only — no Tailscale SSH — so there is no identity broker at the door, and humans sharing the root login would be unattributable. Humans always enter as themselves and elevate via sudo; root is never a human door. What `class` decides is **root SSH's fate after `apply`**: `class=human` machines close it (`rig users close-root` → `PermitRootLogin no`); `class=server` machines keep it — root there is the *automation* identity the control plane (Coolify) SSHes in as, key-only. Rig already creates one user (`github-runner`, a service account) — this issue makes user structure a first-class, declarative rig concern. Companion traits issue: #26, shipped together as one release. The guiding constraint is rig's charter: **public tool, private state**. The user file is state — it lives in the private infra repo and is passed in per invocation; rig never persists it and it contains no secrets (SSH *public* keys only). ## Command surface ``` rig users apply --file <path> # converge the machine to the file ('-' = stdin) rig users status # read-only: rig-managed users, roles, key counts rig users close-root # class=human only: PermitRootLogin no, once an admin key is on the box ``` Conventions as everywhere in rig: run as root (via `sudo rig` from an operator account, or direct root during bring-up); arg validation before the root check; exit 2 = usage error, 1 = runtime refusal; convergent — second run is a no-op. ## File format Line-based and bash-parseable — no YAML, no jq (repo precedent: `json_field` exists precisely to avoid dependencies). ``` # user roles ssh public key dan admin,box ssh-ed25519 AAAA... dan@laptop maria rig,box ssh-ed25519 AAAA... maria@mac maria rig,box ssh-ed25519 AAAA... maria@desktop ``` - Repeated username lines = additional authorized keys. Roles must be identical across a user's lines — mismatch is a refusal (loud beats silent). - `#` comments and blank lines allowed. - `root` may never appear in the file (refusal). - Unknown role names are a refusal, listing the valid set. ## Roles | role | group | mechanism | grants | |---|---|---|---| | `admin` | `rig-admin` | sudoers drop-in: `%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL` | full machine admin | | `rig` | `rig` | sudoers drop-in: `%rig ALL=(root) NOPASSWD: /usr/local/bin/rig` | run rig, nothing else | | `box` | `incus` | Incus's restricted tier: the `incus-user` daemon confines each member to **their own Incus project** — no sudo involved at all | mint/manage own boxes; cannot see or touch other users' instances | Notes: - **All passwords are locked, always.** Bootstrap already enforces key-only SSH (`PasswordAuthentication no`); users are created with no password and never get one. NOPASSWD sudo is therefore not a weakening — the key at the door is the authentication, same pattern box templates already use (`NOPASSWD` + `lock_passwd: true`). - **`incus-admin` is deliberately NOT a role.** It is host-root-equivalent (full socket: any instance, any config, host mounts) — break-glass tier, granted manually and sparingly. The whole point of the `box` role riding the `incus` group is that the three of us share the dev-server without collisions *and* without holding the skeleton key. - Sudoers land as `/etc/sudoers.d/rig-roles`, mode 0440, validated with `visudo -c` **before** install (same validate-then-apply shape as bootstrap's `sshd -t` gate). - On `class=server` machines the useful role set is typically just `admin` (and `rig`): the `box` role needs Incus, which only hosts have — its absence is already a refusal (see apply, step 3). ## Behavior of `apply` 1. Parse + validate the whole file before touching the system (all errors reported at once, exit 2). 2. Install `sudo` if missing and any role requires it (first legitimate appearance of sudo on a rig machine). 3. Ensure groups `rig-admin`, `rig` exist; assert `incus` exists **if any user carries the `box` role** (the group belongs to the Incus package — if absent, refuse with a pointer to box's `setup-host`, which owns Incus installation; rig does not install Incus). 4. For each user: create if missing (`useradd -m`, shell `/bin/bash`, locked password); converge membership in the three rig-managed groups **exactly** (add and remove); never touch any other group; write `~/.ssh/authorized_keys` (0700 dir / 0600 file, user-owned) to exactly the file's keys. 5. Users previously rig-managed but now absent from the file: **lock** (`usermod -L`, strip rig-managed groups, keep home) and warn. Deletion is a non-goal — a convergence run must never destroy data. 6. Write/refresh the sudoers drop-in; `visudo -c`; install atomically. 7. Second run with the same file: no-op, says so. ## Class marker (replaces the old class gate) The earlier model refused `apply` on server-class ("only admins belong there and root already is that"). The hybrid access model inverts that: operators belong **everywhere** — attribution is the point — so **`apply` runs on every class**. The marker (#26's `/etc/rig/role`) still matters, at the root-closing step: - `rig users close-root` **refuses on `class=server`** — root there is the control plane's automation identity; closing it severs fleet management. No `--force`. - `close-root` also refuses when **no marker** exists (pre-existing machines): the command that shuts the root door must know which kind of box it is on. Fail closed; re-run bootstrap to write the marker first. - `apply` itself never refuses on class — on `class=server` or an unmarked box it proceeds, noting that root SSH stays until/unless the box is marked human and `close-root` is run. ## Behavior of `close-root` 1. Refuse unless the marker says `class=human` (above). 2. Refuse unless at least one `rig-admin` user exists with a non-empty `authorized_keys` — never close the only door. The refusal names the repair: `rig users apply` an admin first. 3. Advise loudly (in help and output): verify `ssh <admin>@<box>` works in a *separate session* before running this. 4. Install `/etc/ssh/sshd_config.d/00-rig-users.conf` containing `PermitRootLogin no`. The name is load-bearing: `sshd_config` is **first-wins** and the Include glob expands lexically, and `-` (0x2D) sorts before `.` (0x2E), so `00-rig-users.conf` is read *before* bootstrap's `00-rig.conf` and its `PermitRootLogin no` beats `prohibit-password`. Same validate-then-apply shape as bootstrap: `sshd -t` on the merged config before restarting, roll back on failure, then assert `sshd -T` resolves `permitrootlogin no`. 5. Convergent: a second run is a no-op and says so. 6. **Bootstrap learns to accept the closed state.** Its effective-config assertion (`permitrootlogin (prohibit-password|without-password)`) must also accept `no` — strictly harder — so re-running bootstrap after `close-root` stays green and never reopens root. (Small #26-side edit, shipped in the same release.) **Root's key hygiene on `class=server` is documentation, not automation:** locking root's `authorized_keys` to the control plane (a `from="<control-plane-addr>"` clause on Coolify's key line) is the recommended posture and goes in the README — but Coolify owns its key material on the servers it registers, and rig writing root's `authorized_keys` would be two tools converging one file. Same drift-by-construction argument as rig not touching Incus. ## Cross-repo dependencies (referenced, tracked in heavy-duty/box) - **Verify the `incus`-group restricted tier** (`incus-user` daemon + per-user projects) is present and functional on the Incus version the M900s run — task 0, before anything else lands. - **box project-awareness audit**: box assumes daemon-global resources (`box-net` profile, `boxnet` network, ACL, `user.box=1` tags with no owner). Under per-user projects the profile must exist per project and `box list` semantics change. Needs its own box issue. - **box global install**: per-user install means every new operator reinstalls box; box's `install.sh` should grow a root branch mirroring rig's (system tree + `/usr/local/bin/box` symlink). Needs its own box issue. ## Testing - `test/cli.sh`: usage paths; fixture files exercising every refusal (unknown role, role mismatch across lines, `root` in file, malformed line, missing file) — all non-root, network-free, exit 2 with greppable messages. - `close-root`: usage paths non-root; marker refusals exercised against a fixture marker path; a static lexical assertion that `00-rig-users.conf` sorts before `00-rig.conf` (the first-wins guarantee the whole command rests on), and an ordering grep that `sshd -t` precedes the restart (repo precedent: the runner repo-guard ordering check). - Static assertion that `visudo -c` gates the sudoers install (same ordering-grep shape). - Rehearsal (manual, documented in the plan): Incus container — apply a two-user file, assert users/groups/keys/sudoers; re-apply = no-op; remove a user from the file, assert locked-not-deleted; key-only SSH works; on a human-marked container, `close-root` flips `sshd -T` to `permitrootlogin no` and a bootstrap re-run does not reopen it. ## Non-goals - No user deletion. No password management (there are no passwords). No LDAP/SSO/PAM. No per-user resource quotas (Incus project limits are a later 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 management (web UI users are Coolify's own concern). ## Acceptance - `rig users apply --file <fixtures>` refusal matrix passes in `test/cli.sh`; `close-root` marker/ordering assertions pass; shellcheck green via the exact CI invocation. - On a rehearsal container: two applies converge then no-op; a user removed from the file is locked with home intact; `rig users status` reports the truth; `close-root` on a human-marked box closes root and survives a bootstrap re-run. - A `box`-role user on the dev-server can mint and manage their own boxes and cannot see another user's (pending the box-side dependencies above). --- Companion traits issue: #26 (supersedes #25) — one release together.
dan-claude-bot commented 2026-07-17 18:07:42 +00:00 (Migrated from github.com)

Follow-up decided in #26: on class=human machines, closing root SSH entirely (PermitRootLogin no) belongs here, not in bootstrap — bring-up happens over root SSH before any user exists, so it only becomes safe after rig users apply has converged an admin user with a working key. Not part of this issue's scope; noting it so the follow-up has a home.

Follow-up decided in #26: on `class=human` machines, closing root SSH entirely (`PermitRootLogin no`) belongs here, not in bootstrap — bring-up happens over root SSH before any user exists, so it only becomes safe after `rig users apply` has converged an admin user with a working key. Not part of this issue's scope; noting it so the follow-up has a home.
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#24
No description provided.