Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9039682eb | ||
|
|
7686fd783e | ||
|
|
1c5705d6db | ||
|
|
d8bcf8066c | ||
|
|
eb4c756a14 | ||
|
|
f6b6a04b60 |
@@ -0,0 +1,926 @@
|
||||
# Machine grant path, design draft r6 (Vikunja #436, row 210 follow-up)
|
||||
|
||||
Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only. Code starts when
|
||||
Duckworth approves the design and row 210 lands (sage-1527).
|
||||
|
||||
Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, mos-1120, mos-1125, mos-1127, mos-1131, mos-1133, mos-1139,
|
||||
sage-1527, Duckworth's r1 review (REVIEW-r1.md, duckworth/reviews d025ef492, corrected at 749080115), Duckworth's
|
||||
r3 and r4 review (REVIEW-r3.md, duckworth/reviews 9dceb2148, duckworth-0060), Duckworth's r5 diff check
|
||||
(REVIEW-r5.md, duckworth/reviews c3dc58f30, duckworth-0063),
|
||||
Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036, REQ-TRUST-2 and REQ-GRANT-1 to -4), row 210 DESIGN r3
|
||||
(e021dfa26) and its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178
|
||||
unless they say "210:", which means the row 210 candidate 455964f23 (the same code as the branch head
|
||||
a2fd30390). Host facts come from ops-01's read-only check of .53 and .54 at 22:21Z (mos-1127).
|
||||
|
||||
## r6 changes (from r5 7686fd783)
|
||||
|
||||
Duckworth approved r5 with C1 and one note (duckworth-0063). The design is approved for code once row 210
|
||||
lands. r6 carries C1 and the note, for a diff check on those lines.
|
||||
|
||||
- C1: a revoke can't be undone by a commands-change undo. After step 5 commits, the executor ignores an
|
||||
abort (X5). Both undo paths that set a record back to `previous` only update a record that exists, never
|
||||
insert one, and do nothing when a `grant.revoked` is newer than the decision (X3). G28 and an upsert
|
||||
mutant cover it.
|
||||
- N1: a failed unlink at revoke step 2 no longer depends on the marker. At start, after the grant
|
||||
reconcile, the bus unlinks every `host.<id>` key file that has no `host_grants` record (section 6, G27).
|
||||
|
||||
## r5 changes (from r4 1c5705d6d)
|
||||
|
||||
Duckworth's review of r3 and r4 (duckworth-0060) asked for changes, B1 and B2, with notes N1 to N11. mos-1139
|
||||
says to fix both and rules on N8.
|
||||
|
||||
- B1: E2 step 1 is now one transaction: `#consumeGrant`, a check that the host isn't enrolled yet, then the
|
||||
insert. A second approved enrollment for the same `hostId` ends `grant.failed host-enrolled`. `hosts` gets
|
||||
the primary key (business, host id) in `3i-3j.sql`. G17 and two mutants cover it.
|
||||
- B2: X2 consumes first and claims second, in one turn of the event loop. A busy claim ends `grant.failed
|
||||
host-busy` with a message. At start, a grant decision resolved yes with no `action.allowed` ends
|
||||
`grant.failed interrupted` and never runs late (X3). `hosts.revoke` records `grant.revoked` even with no
|
||||
reference and no claim, so X5's first bullet now matches the code. G11 and G23 cover it.
|
||||
- N1: the `ID_PATTERN` cite is marked `210:`.
|
||||
- N2: X5 states the build rule that `issue` and its `commit` stay synchronous, with a mutant.
|
||||
- N3, N4: `hosts.revoke` is its own verb in `bus/src/host-grants.mjs`, not 210's `credential-verbs.mjs`. It
|
||||
writes a `pending` marker first, and the start reconcile finishes a revoke a restart cut short (section 6,
|
||||
G27).
|
||||
- N5: the writer also refuses `disk`, `libvirt` and `incus-admin` (E3).
|
||||
- N6: a first `add` creates the file, and a leftover regular tmp is unlinked under the `flock` (E3, G25).
|
||||
- N7: the installer's line is written out exactly (E3).
|
||||
- N8: a dev instance is a business whose `bus.tester` var is exactly true (mos-1139, section 6).
|
||||
- N9: the row a run sheet strips row 210's `hosts` keys before the `--migrate` re-pin (S1).
|
||||
- N10: the writer list in the r3 changes now includes `hosts.check`. ops-01 reads only the three key types the
|
||||
keyscan asks for (E1).
|
||||
- N11: Duckworth withdrew a PRD note. Section 6 step 1's record delete stays.
|
||||
- Row b grows by the revoke verb and its marker. My estimate goes from 1.5 days to 1.75 (section 12).
|
||||
|
||||
## r4 changes (from r3 d8bcf8066)
|
||||
|
||||
mos-1133 corrects S1's migration lines. r3 said the 3i-3j re-pin needs `--migrate` with Jason present (P19
|
||||
371). That was wrong. P19 371 predates Q173, and Q173 lets Mos, Sage or Darkwing re-pin, migrate, roll back
|
||||
and restore the mosaic-stack business on dragon-lin. S1 now says so and adds the .53 and .54 plan. Nothing
|
||||
else changed.
|
||||
|
||||
## r3 changes (from r2 eb4c756a1)
|
||||
|
||||
mos-1131 (from duckworth-0057 and mos-1129): under PRD 1.8 only an Approve creates a grant, and a hand edit
|
||||
of the business file makes nothing. So W1 is gone.
|
||||
|
||||
- Enrolled hosts and grants are records in the bus store, written only by the executor, revoke,
|
||||
`hosts.check` and `hosts.remove`, each in the same transaction as its event (section 5, S1). Nothing writes them into the
|
||||
business file.
|
||||
- Row 210's hand-edited `hosts` keys stay a test path until row a (#436a) moves `host.run` to the store
|
||||
records. After that the loader refuses those keys (S1).
|
||||
- B1 gets simpler. `host.run` reads the store at each call, so the broker needs no runtime apply. Only
|
||||
`Credentials.register` stays (X4).
|
||||
- `registrySha256` stays, now over the store's host record. It is what 1.8 calls "the target or its
|
||||
enrolled identity" (mos-1131 point 3).
|
||||
- Enrollment carries expected fingerprints, and the raise runs its own keyscan (E1, mos-1132 Q-M2).
|
||||
- Revokers follow mos-1124 d and mos-1132: the human, the assistant in its own organization (Q188's "Mos",
|
||||
Mos's reading for prd.approve), and the tester on a dev instance only (section 6).
|
||||
- ops-01's Q-O2 rules and writer points a to d are in E3 and section 9.
|
||||
- X5 (B2) is 1.8's N2: a revoke during creation stops it, and nothing it made stays usable (mos-1131 point
|
||||
4).
|
||||
- The store records need a new migration, `3i-3j.sql` (S1). That makes row a a schema change.
|
||||
|
||||
## r2 changes (from r1 f6b6a04b6)
|
||||
|
||||
- Enrollment is a request the chief raises and Jason approves, not a CLI Jason types (mos-1120 point 3).
|
||||
Section 3 is rewritten. `hosts.enroll` stays, as the verb the executor runs.
|
||||
- The action is `credential.mint` with a `grant` binding, not a new `host.grant` (sage-1527 Q-S3, Honker's
|
||||
REQ-GRANT-1). The hard-waits list doesn't change. R2 says why the binding still carries G1 to G10.
|
||||
- The binding adds a hash of the agent's role definition, because REQ-GRANT-2 voids an approval when the
|
||||
definition changes. It also carries the full commands list (N2).
|
||||
- The host side follows ops-01's answer to Q-O1 (mos-1125): lines in a root-owned
|
||||
`/etc/ssh/mosaic-keys/%u`, a dedicated install user, a forced command that only parses, and a root
|
||||
writer in this repo that checks everything again (E3). This answers B5.
|
||||
- A target user with sudo is enrolled by name, and Jason's Approve of the enrollment covers it (E1, E3,
|
||||
mos-1125's recommendation). Without this no `host.sudo` grant could be installed.
|
||||
- B1: a grant, a commands change, an enrollment and a revoke take effect in the running bus (X4).
|
||||
- B2: the executor holds an `#inflight` entry that revoke aborts, and a revoke works before any grant
|
||||
exists (X5).
|
||||
- B3: progress events before each step, and a start-time reconcile that undoes to the state before the
|
||||
decision (X3).
|
||||
- B4: the tag is a hash, recorded in events, and `list` and `sweep` only touch this business's recorded
|
||||
tags (X2, step 2).
|
||||
- N1 to N3 answered in X1, R4 and E4.
|
||||
- Section 9 adds ops-01's point that a host line has no `command=` (mos-1125).
|
||||
- Answers recorded: Q-S1 to Q-S4, Q-M1, Q-O1. New questions Q-M2, Q-S5 and Q-O2.
|
||||
|
||||
## 0. What Q188 asks for, and what this draft builds
|
||||
|
||||
Q188 says no agent creates a grant. A request goes up the chain, only the chief raises it to Jason's inbox,
|
||||
Jason clicks Approve or Deny, and on Approve protected stack code creates the grant. Revoke stays instant, by
|
||||
Jason, Mos or the tester. mos-1115 puts machines first: issue the key, install it on the host with
|
||||
`from="<stack host>",restrict`, pin the host fingerprint, and on revoke drop the key and cut the connection.
|
||||
This replaces row 210 section 4, where Jason hand-edits three layers, runs `issue`, and ops-01 pastes the
|
||||
`authorized_keys` line.
|
||||
|
||||
This draft covers:
|
||||
|
||||
- host enrollment, once per host (section 3)
|
||||
- the grant request and its decision (section 4)
|
||||
- the executor that runs on Approve (section 5)
|
||||
- revoke (section 6)
|
||||
- how row 210's per-call decision becomes manual mode (section 7)
|
||||
|
||||
Gitea and GitHub grants follow the same executor shape and are a later draft (section 11).
|
||||
|
||||
One thing up front, because it changes what Jason sees on 10-27. A grant made this way still doesn't let an
|
||||
agent run a command without a click. Under PRD 1.7 `host.sudo` is a hard wait, and every `host.run` needs its
|
||||
own decision (210 D4). "Runs without a click" needs REQ-CRED-4 in PRD 1.8 (Vikunja #434, Honker through Sage)
|
||||
and the reviewed commit that routes `host.sudo`. This path and that routing are separate pieces, and the
|
||||
first is useless for Q188's goal without the second.
|
||||
|
||||
## 1. Facts from the code
|
||||
|
||||
- F1. **No chain of command exists in the data.** A role definition has only `roleVersion, name, title,
|
||||
contract, tools, network, authority, credentials, messaging, skills` (`business/src/role.mjs:19-20`). There
|
||||
is no `reportsTo` or manager field. The one fixed rank is the chief, the definition named
|
||||
`chief-of-staff` (`bus/src/broker.mjs:157`), and the broker already checks an instance's definition against
|
||||
it (`broker.mjs:593`, `:1990`). Who may message whom is the only structure, `#checkMessaging`
|
||||
(`broker.mjs:617-627`).
|
||||
- F2. **Any agent may raise any action.** `decision.raise` stores `raised_by_role` and `raised_by_run`
|
||||
(`broker.mjs:1703-1708`). No rule limits which role raises which action, and no raise names another agent
|
||||
as its beneficiary.
|
||||
- F3. **A hard wait always routes to the human.** Any action outside `ROUTED` is in `GATED_ONLY`
|
||||
(`business/src/vocabulary.mjs:88`), classifies `gated` (`broker.mjs:597-609`) and routes to `human`
|
||||
(`broker.mjs:1677-1680`). A tester thread can't route to the human (`broker.mjs:1683`).
|
||||
- F4. **Resolving a decision runs nothing.** `decision.resolve` records a `decision_events` row and messages
|
||||
the raiser (`broker.mjs:1752-1828`). An effect happens later, when a verb consumes the decision through
|
||||
`#consumeAuthority`, which refuses `decision-consumed` if an `action.allowed` event other than the raise
|
||||
already cites it (`broker.mjs:808-831`). `#consumeAuthority` needs the raiser's own agent session: it
|
||||
checks `raised_by_role` and the raiser's thread against the caller (`broker.mjs:764`, `:786`). The
|
||||
landing path is the only place where stack code reacts to a resolution, and even there the agent's own
|
||||
`land_work` call does the work (`cli/src/launcher.mjs:555-600`).
|
||||
- F5. **The human proof.** `mosaic decide` resolves through `human-cli.mjs` (`cli/src/cli.mjs:142-175`,
|
||||
`cli/src/transport.mjs:55-75`). `verifyHuman` is a same-UID cooperative check (`bus/src/human.mjs:50-52`),
|
||||
and `#human` needs `via === 'cli'` and `outsideAgent` (`broker.mjs:375-378`).
|
||||
- F6. **The business file has one writer discipline.** `organizationWriter.write`
|
||||
(`business/src/organization.mjs:177-219`): a lock dir, a revision that is the sha256 of the current bytes,
|
||||
`previous.json`, tmp, fsync, rename, and receipts in `businesses/.history/<id>/changes.jsonl`. The bus child
|
||||
does the write (`cli/src/host.mjs:215`, `bus/src/process.mjs:1-3`). Agent-reachable keys are
|
||||
`ORGANIZATION_KEYS` and `INSTANCE_KEYS` (`broker.mjs:146-148`). Row 210's `hosts` keys sit outside both.
|
||||
- F7. **Row 210's pieces this path reuses:**
|
||||
- the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210:
|
||||
`bus/src/credentials.mjs:153-157`)
|
||||
- `issue`, which writes tmp, fsync, rename, then calls `commit`, then sets memory and clears the revoked
|
||||
mark (210: `credentials.mjs:243-280`, the clear at `:279`)
|
||||
- `revoke`, which drops memory, aborts the call in flight and unlinks the file (210:
|
||||
`credentials.mjs:284-298`, the abort at `:289`). It refuses `credential-unknown` for a service with no
|
||||
loaded reference (210: `credentials.mjs:235-238`).
|
||||
- the busy map `#inflight`, instance and service to an `AbortController` (210: `credentials.mjs:118`,
|
||||
`busy` at `:332-334`, used by `host-run.mjs:249`)
|
||||
- pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210:
|
||||
`bus/src/host-run.mjs:36`, `:170`)
|
||||
- F8. **Nothing on refactor generates keys or contacts a host.** No `ssh-keygen` or ssh spawn exists in
|
||||
`packages/*/src`. Row 210 adds the ssh spawn, and row 210's tests already require `ssh-keygen` on the
|
||||
machine (210 test list, first test).
|
||||
- F9. **The Console can't approve today.** It shows decisions and copies the `mosaic decide` command
|
||||
(`webui/src/public/bus.js:3-5`). `decision.resolve` is not in `BUS_VERBS` (`webui/src/serve.mjs:62`).
|
||||
Vikunja #435 adds Approve after Q175 sign-in (#412).
|
||||
- F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`).
|
||||
- F11. **The running bus never reloads the business file, except the limits.** The broker keeps a clone
|
||||
made once (210: `broker.mjs:234`), and `host.run` reads `b.hosts` and `b.roles[role].hosts` from it (210:
|
||||
`broker.mjs:1731-1735`). `Credentials` loads its references once, in the constructor (210:
|
||||
`bus/src/runtime.mjs:85-98`, `credentials.mjs:125-147`). Only the limits read the file at each ask, and
|
||||
`instances.set` answers `restart: true` (`business/src/instance-settings.mjs:73`, `:155`).
|
||||
- F12. **The business file has no room for state on a host or a grant.** A registry entry takes only
|
||||
`address, port, user, hostKeys` (210: `business/src/hosts.mjs:75`), and a role's grant only `commands`
|
||||
(210: `hosts.mjs:123`). Under 1.8 the file holds no grant at all (mos-1131), so hosts, grants, state, tags
|
||||
and progress live in the bus store (S1).
|
||||
|
||||
## 2. Shape
|
||||
|
||||
```
|
||||
agent --message--> chief --decision.raise credential.mint + grant--> Jason's inbox
|
||||
|
|
||||
mosaic decide (later: Console Approve, #435)
|
||||
|
|
||||
bus child grant executor (on resolve, approve only)
|
||||
keygen -> install via host installer -> verify -> key file + store record + live refs
|
||||
```
|
||||
|
||||
Revoke runs the other way: memory, then the in-flight call, then the key file, then the store record, then
|
||||
the host line.
|
||||
|
||||
## 3. Host enrollment (once per host, a request like a grant)
|
||||
|
||||
Installing a key on a host needs something on the host that already trusts the stack. Nothing on refactor
|
||||
holds that trust, so each host gets one enrollment. This is the step that remains for ops-01, once per
|
||||
host, and it goes on the ops-01 run sheet in the installer's I3 row (#432, mos-1120). Per-agent installs no
|
||||
longer need ops-01.
|
||||
|
||||
A host here is a registry entry, so it has one user (F12). Two users on one machine are two host ids with the
|
||||
same address, each enrolled on its own.
|
||||
|
||||
### E1. The request
|
||||
|
||||
ops-01 reads the host's ed25519, ecdsa and rsa key fingerprints on the host, read-only
|
||||
(`/etc/ssh/ssh_host_{ed25519,ecdsa,rsa}_key.pub`). Those are the three types the keyscan asks for, so a
|
||||
fingerprint of any other type would make the raise refuse `host-key-mismatch` (N10). Host keys never travel
|
||||
by message (mos-1132). The request carries ordinary fields: address, port, the target user,
|
||||
whether that user has sudo, and the expected fingerprints. On the #433 VM, Mos or Sage passes them to the
|
||||
chief as the tester (Q173). On .53 and .54, Jason sends the request in the Console (Q118), with text Mos
|
||||
builds from ops-01's values. The chief raises a `credential.mint` decision with the binding (section 4, R4):
|
||||
|
||||
```
|
||||
grant: { "method": "host-enroll", "host": "<hostId>", "address": "A", "port": 22, "user": "U",
|
||||
"sudo": true, "fingerprints": ["SHA256:...", ...] }
|
||||
```
|
||||
|
||||
The raise checks are 210 D3 layer 2's: the address rules, refusing loopback and this machine's names, the
|
||||
port range, the user pattern and the key types. It also refuses a `hostId` that is already enrolled, a user
|
||||
named `root`, `localadmin` or `mosaic-installer`, and a `sudo` that isn't a boolean.
|
||||
|
||||
Before the raise transaction, the raise runs its own `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa
|
||||
<address>` from the stack host. The set of scanned fingerprints must equal the set in the request, or the
|
||||
raise refuses `host-key-mismatch` and nothing is stored. So two independent reads, ops-01's on the host and
|
||||
the stack's over the network, have to agree before Jason sees the binding (mos-1132). The scanned key lines
|
||||
go in the raise event body as `hostKeys`, and those are what E2 writes and E4 pins. The keyscan only reads
|
||||
the host's public keys. It logs in to nothing.
|
||||
|
||||
The rendered block shows each fingerprint and the key line, so Jason can compare them with ops-01's. With `"sudo": true` it adds one line in plain words: "U has sudo on this host.
|
||||
Approving lets later grants for U be installed, and section 9's first limit applies to U."
|
||||
|
||||
Jason approves with `mosaic decide` until #412 brings the Console inbox. No one pastes a host key. The keys
|
||||
come from the keyscan, checked against ops-01's fingerprints.
|
||||
|
||||
The pin is never taken on trust from one connection (210 section 4). It is the keyscan result, and only
|
||||
when it matches the fingerprints ops-01 read on the host. E4's check fails closed on a wrong pin too.
|
||||
|
||||
Not every host can be enrolled yet. jarvis on .53 and melanie on .54 wait for #423 steps 4 and 5
|
||||
(mos-1125 point 5).
|
||||
|
||||
### E2. What the executor does
|
||||
|
||||
On Approve the bus child runs `hosts.enroll`, with the same start rules as a grant (section 5, X1):
|
||||
|
||||
1. **Recheck and consume** in one store transaction (B1): `#consumeGrant(business, decision, 'host-enroll')`
|
||||
(X1), a check that no `hosts` record has this (business, `hostId`), then `host.enroll.step {decision,
|
||||
host, step: 'registry'}` and the `hosts` record (S1) with the bound `address, port, user, sudo,
|
||||
hostKeys`. If the host is already enrolled, the transaction writes only the spend and `grant.failed
|
||||
{code: 'host-enrolled'}`, and the human and the chief get a message. The primary key (S1) refuses a
|
||||
second insert as well. So of two approved enrollments for one `hostId`, the second never replaces the
|
||||
pin, the user or the sudo mark under live grants, and enrolling again still means `hosts.remove` first
|
||||
(E4). Enrollment needs no `#inflight` claim, because a second enrollment stops here, before anything
|
||||
else is written.
|
||||
2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-installer-<tag>`, where `<tag>`
|
||||
is the hash of section 5's X2 step 2 over (business, `@installer`, hostId), in a 0700 dir under
|
||||
`<configDir>/secrets/run`, the dir row 210 already sweeps at start
|
||||
3. moves the private half to `<configDir>/secrets/hosts/<business>/<hostId>/installer`, mode 0600 in 0700
|
||||
dirs, by the same tmp, fsync, rename steps as 210 `issue`. The path follows row 199 D1's
|
||||
`secrets/agents/<business>/...` (Q-S2, mos-1120, sage-1527)
|
||||
4. records `host.enrolled {decision, host, registrySha256, installerFingerprint, user, sudo}` and messages
|
||||
the human and the chief with what ops-01 installs (E3)
|
||||
|
||||
No private material goes out. A failure at step 2 or 3 deletes the host record again and ends
|
||||
`grant.failed`, as X3 does. A restart after step 1 does the same (X3).
|
||||
|
||||
The host's state is in the store record (S1):
|
||||
|
||||
- `pending-install`: set by step 1
|
||||
- `ready`: set by a passing `hosts.check` whose `registrySha256` and installer fingerprint match the record
|
||||
|
||||
### E3. The host side
|
||||
|
||||
ops-01 answered Q-O1 with six changes (mos-1125). The stack side doesn't change. The host side is four
|
||||
files in this repo under `tools/host/`, each reviewed like code, and Duckworth reviews the writer, the
|
||||
sudoers line and the sshd drop-in before .53 (mos-1125 point 6):
|
||||
|
||||
| File | Installed as | Does |
|
||||
|---|---|---|
|
||||
| `mosaic-keys` | `/usr/local/sbin/mosaic-keys`, root, 0755 | the installer key's forced command. Parses `SSH_ORIGINAL_COMMAND` against fixed patterns and runs `sudo -n /usr/local/sbin/mosaic-keys-write <request>`. Nothing else. |
|
||||
| `mosaic-keys-write` | `/usr/local/sbin/mosaic-keys-write`, root, 0755 | checks everything again and writes the line |
|
||||
| `mosaic-keys.sudoers` | `/etc/sudoers.d/mosaic-keys`, root, 0440 | `mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write` |
|
||||
| `mosaic-keys.sshd.conf` | `/etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf` | `AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u` |
|
||||
|
||||
The drop-in keeps both paths the hosts use today. On .53 and .54, `sshd_config` includes
|
||||
`sshd_config.d/*.conf` at line 12 and no file sets `AuthorizedKeysFile` (mos-1127 point 2). Root never
|
||||
writes into a user's home (mos-1125 point 1).
|
||||
|
||||
Host config, all root-owned and written by ops-01 from the approved enrollment message:
|
||||
|
||||
- `/etc/mosaic-keys/users`: the users the writer may touch
|
||||
- `/etc/mosaic-keys/privileged`: the users with sudo that Jason approved by name (E1 `"sudo": true`)
|
||||
- `/etc/mosaic-keys/from`: the `from=` value. The stack can't choose it.
|
||||
- `/etc/ssh/mosaic-keys/`: root, 0755. `mosaic-installer`'s own line lives here, in a root-owned
|
||||
`mosaic-installer` file that ops-01 writes by hand.
|
||||
|
||||
The installer's line is exactly this, with `<from>` the same value as `/etc/mosaic-keys/from` (N7):
|
||||
|
||||
```
|
||||
restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ssh-ed25519 <base64> mosaic-installer-<tag>
|
||||
```
|
||||
|
||||
E2 step 4's message carries the line with the public key filled in, and the I3 run sheet's host step quotes
|
||||
it. So a hand-written line can't drop `restrict` or `command=`.
|
||||
|
||||
`mosaic-installer` is a dedicated user, never `localadmin` and never an agent user (mos-1125 point 2). It
|
||||
needs no entry in any sshd list, because .53 and .54 have no `AllowUsers`, `AllowGroups`, `DenyUsers`,
|
||||
`DenyGroups`, `ForceCommand` or `Match` block and `pam_access` is off (mos-1127 point 1).
|
||||
|
||||
Requests:
|
||||
|
||||
| Request | Effect |
|
||||
|---|---|
|
||||
| `list <user>` | prints each line of `/etc/ssh/mosaic-keys/<user>` whose tag matches the tag pattern |
|
||||
| `add <user> <type> <base64> <tag>` | appends `from="<from>",restrict <type> <base64> <tag>` unless the tag exists |
|
||||
| `remove <user> <tag>` | drops the line with that exact tag |
|
||||
|
||||
`remove` of a tag that isn't there changes nothing and exits 0, so a revoke and an executor undo can both run
|
||||
it.
|
||||
|
||||
What `mosaic-keys-write` checks, at every request (mos-1125 points 3 and 4):
|
||||
|
||||
- the request matches the patterns again. Each tag matches `^mosaic-[0-9a-f]{32}$`, and each type and
|
||||
base64 match E1's patterns.
|
||||
- `<user>` matches `^[a-z_][a-z0-9_-]{0,31}$` before it is used in any path. `O_NOFOLLOW` covers only the
|
||||
last component, so the pattern is what stops a `../x` relative to the dir fd (ops-01 point a).
|
||||
- `<user>` exists, is in `users`, and isn't uid 0, uid under 1000, `mosaic-installer` or `localadmin`. These
|
||||
names and the uid rule are in the code, not in a file. `users` is always required.
|
||||
- `<user>` is in none of the groups `sudo`, `admin`, `wheel`, `docker`, `lxd`, `disk`, `libvirt` and
|
||||
`incus-admin`, and has no sudo rights, unless it is in `privileged`. A group the host doesn't have counts
|
||||
as no membership. `disk` reads and writes raw block devices, and `libvirt` and `incus-admin` start guests
|
||||
with host mounts, so each is root by another route (N5). `privileged` only lifts this check. Being in it alone allows nothing (ops-01 Q-O2
|
||||
point 1).
|
||||
- the sudo test is `LC_ALL=C sudo -l -U <user>`. Any output other than the exact line
|
||||
`User <user> is not allowed to run sudo on <host>.` counts as having sudo (ops-01 Q-O2 point 4).
|
||||
- it never touches a line whose tag doesn't match the pattern.
|
||||
|
||||
How it writes, so no path trick moves the write:
|
||||
|
||||
- opens `/etc/ssh/mosaic-keys` with `O_DIRECTORY | O_NOFOLLOW` and refuses unless it is root-owned and not
|
||||
group or world writable
|
||||
- takes `flock` on `.lock` in that dir, a name the user pattern can't match, so sshd never reads it and
|
||||
`list` never shows it (ops-01 point b)
|
||||
- opens the user's file relative to the dir fd with `O_NOFOLLOW`, and refuses one that isn't a regular
|
||||
root-owned file. A first `add` for a user finds no file. `ENOENT` there means an empty list, and the
|
||||
rename creates the file. `list` and `remove` on a missing file print nothing and change nothing (N6).
|
||||
- the tmp is `.<user>.tmp`, a name the user pattern can't match. Under the `flock`, a leftover tmp can only
|
||||
come from a writer that crashed, so a regular root-owned one is unlinked first. A tmp that is a symlink
|
||||
or not a regular file refuses, and nothing is written (N6).
|
||||
- writes the tmp with `O_CREAT | O_EXCL | O_NOFOLLOW`, sets mode 0644, `fsync`, `renameat` over the file,
|
||||
then `fsync` on the dir
|
||||
- logs to syslog the result, user, tag and key fingerprint. The fingerprint is sha256 over the decoded
|
||||
blob, so no `ssh-keygen` runs
|
||||
|
||||
sudo's `env_reset` drops `SSH_CONNECTION` on both hosts. So `mosaic-keys` logs `SSH_CONNECTION` and the request
|
||||
with `logger -t mosaic-keys` before it calls sudo, and the sudoers file stays one line with no `env_keep`
|
||||
(ops-01 point c). The hosts set `use_pty`. With `restrict` there is no pty, and sudo shouldn't allocate one
|
||||
when stdin isn't a tty. The #433 proof checks that `list` output and the exit code pass through unchanged
|
||||
(ops-01 point d).
|
||||
|
||||
The writer is python3, standard library only, with `#!/usr/bin/python3 -I`, because `os.open` takes `dir_fd`
|
||||
and `O_NOFOLLOW` and a shell script can't do that safely. Both hosts have Python 3.12.3 at `/usr/bin/python3`,
|
||||
root-owned, and `python3 -I` imports every module the writer needs (ops-01, 22:26Z).
|
||||
|
||||
When ops-01 writes `users` and `privileged` from an approved enrollment, ops-01 first checks the E1 `sudo`
|
||||
mark on the host. If the host disagrees, for example `sudo: false` for a user in `docker`, ops-01 writes
|
||||
neither file and reports back, and the enrollment needs a new request (ops-01 Q-O2 point 2).
|
||||
|
||||
sshd's `StrictModes yes` (on both hosts, mos-1127 point 3) also refuses a key file whose path is writable by
|
||||
anyone but root or the user.
|
||||
|
||||
The install proof runs on the #433 VM first (mos-1125 point 6), as ops-01 does it (mos-1127): `sshd -t`,
|
||||
`systemctl reload ssh`, then a new connection. An existing session survives the reload. Rollback removes
|
||||
the drop-in and reloads, which restores the old value. The run sheet's host step (#432, I3) cites these.
|
||||
|
||||
This still doesn't make the installer key safe to lose. A key that can add a line for a privileged user is
|
||||
root on that host. Section 9 says so.
|
||||
|
||||
### E4. Check
|
||||
|
||||
After ops-01 installs the files and the installer line, `hosts.check <hostId>` connects as `mosaic-installer`
|
||||
with the installer key, the pinned `known_hosts` and 210's ssh options, and runs `list <user>`. A wrong pin
|
||||
exits 255 and refuses `host-key-mismatch`, as 210 does, and the host stays `pending-install`. A pass records
|
||||
`host.checked {host, registrySha256, installerFingerprint}` and sets the record `ready` in the same
|
||||
transaction. Only a `ready` host can be named in a grant (R4).
|
||||
|
||||
The chief, the tester or the human may run `hosts.check`. It reads the host's lines and widens nothing past
|
||||
what Jason approved, so it needs no decision. `status` shows each host's state, pin fingerprints, installer
|
||||
fingerprint and last check time.
|
||||
|
||||
`hosts.remove <hostId>` is for the human or the tester, as a revoke is (REQ-GRANT-4), because it only takes
|
||||
access away. It refuses `host-in-use` while any grant names the host and `host-line-left` while any event's
|
||||
`hostLineLeft` names the host and no later `sweep` cleared it (N3). Then it deletes the installer key and the
|
||||
host record, and records `host.removed` in the same transaction. ops-01 removes the
|
||||
installer line on the host. Enrolling the host again takes a new request.
|
||||
|
||||
## 4. The request and the decision
|
||||
|
||||
### R1. Up the chain
|
||||
|
||||
An agent that needs host access sends the chief a message, as any request to the chief goes today
|
||||
(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb (Q-S1). F1 gives the reason:
|
||||
nothing records who reports to whom, so the bus can't check a chain beyond "the chief raises it".
|
||||
|
||||
"Not every agent may request" (Q188) is enforced at the raise. The target agent's definition must declare
|
||||
`{"service": "host"}` in `credentials` (210 D3 layer 1, a reviewed commit). An agent whose definition lacks
|
||||
it can ask, but the chief's raise for it refuses `host-not-declared`. Which definitions declare `host` is
|
||||
the reviewed list of agents that may hold a host grant.
|
||||
|
||||
### R2. The action
|
||||
|
||||
The request is a `credential.mint` decision, as Honker's REQ-GRANT-1 says. `credential.mint` is already a
|
||||
hard wait (`vocabulary.mjs:38`, F10), so it classifies `gated` for every role and routes to the human (F3).
|
||||
No role file may list it (REQ-WAIT-2), so it can never be routed, and the hard-waits list doesn't change.
|
||||
|
||||
r1 used a new `host.grant` action because `credential.mint` names no host or argv. That reason doesn't
|
||||
hold. The binding lives in the raise's `grant` argument and the raise event body, not in the action name,
|
||||
so G1 to G10 test the same things either way. The executor starts only for a decision whose raise carries
|
||||
a `grant` binding.
|
||||
|
||||
A `credential.mint` raise without `grant` keeps today's behaviour. Any agent may raise one, Jason may
|
||||
answer it, and nothing runs. It is a question for a hand-made credential, as it is now.
|
||||
|
||||
### R3. Who may raise it
|
||||
|
||||
The broker refuses a raise that carries `grant` unless the raiser's definition is `chief-of-staff`
|
||||
(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A
|
||||
tester thread can't raise it, because it routes to the human (`broker.mjs:1683`). Any other action with a
|
||||
`grant` argument refuses `invalid-request`.
|
||||
|
||||
### R4. What the raise binds
|
||||
|
||||
New raise argument `grant`, allowed only with `action === 'credential.mint'`. For a host grant:
|
||||
|
||||
```
|
||||
grant: { "method": "host", "agent": "<instance>", "host": "<hostId>",
|
||||
"commands": [["systemctl", "status"], ...], "rotateBy": "YYYY-MM-DD" }
|
||||
```
|
||||
|
||||
`host-enroll` is E1's. A later draft adds `gitea` and `github`. Any other `method` refuses
|
||||
`invalid-request`.
|
||||
|
||||
Checks at raise, all inside the raise transaction:
|
||||
|
||||
- `agent` is a role instance of this business, and its definition declares `host` (R1)
|
||||
- `host` is `ready` (E2, E4), or the raise refuses `host-not-ready`
|
||||
- `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing
|
||||
refusals for `bash` and the like
|
||||
- `rotateBy` is a date inside 210's rotation window
|
||||
|
||||
The broker appends a rendered block to the question, as 210 D4 does for `host.sudo`: agent, host id,
|
||||
address, user, whether the user has sudo (from `host.enrolled`), each argv prefix JSON-quoted, the rotate
|
||||
date, and whether this is a new key or a change to an existing grant's commands. The chief can't shape that
|
||||
block. If the question with the block exceeds the 4096-character cap (`broker.mjs:126`), the raise refuses
|
||||
`invalid-request`, as 210 D4 does (N2). The chief then splits the commands over two requests.
|
||||
|
||||
The raise event body gets `grant = {method, agent, host, commands, commandsSha256, rotateBy, registrySha256,
|
||||
definitionSha256}`. The full `commands` list is stored there, and the executor reads it from that event at
|
||||
X2 step 1 and checks it against `commandsSha256` (N2). `registrySha256` hashes the host record's identity (`address, port, user, sudo,
|
||||
hostKeys`) at raise time, which is 1.8's "the target or its enrolled identity" (mos-1131), and `definitionSha256` hashes the agent's role definition file. REQ-GRANT-2 voids an approval
|
||||
when the agent, its definition, the target or the rights change after the raise. If either hash differs at
|
||||
X2 step 1, the executor refuses `decision-mismatch` and the chief raises again.
|
||||
|
||||
### R5. Jason decides
|
||||
|
||||
`mosaic decide <id> yes|no` as today (F5). After #435, the Console's Approve calls the same
|
||||
`decision.resolve` through `human-cli.mjs` behind Q175 sign-in. The executor keys on the resolution, not on
|
||||
the client, so #435 needs no executor change.
|
||||
|
||||
## 5. The executor
|
||||
|
||||
### X1. When it runs
|
||||
|
||||
At resolve. Today a resolution runs nothing (F4), and the raiser comes back later to consume the decision.
|
||||
Here the raiser is the chief, and Q188 says no agent creates the grant, so the chief must not be the one
|
||||
whose call does it. The bus child starts the executor itself after the human's `decision.resolve` commits,
|
||||
when all of these hold: the decision's action is `credential.mint` and its raise carries `grant`, the route
|
||||
is `human`, the choice equals `approvalChoice`, and `via === 'cli'`. A resolve by an arbiter, the tester or
|
||||
an agent starts nothing (REQ-GRANT-2).
|
||||
|
||||
The resolve call returns at once with `{grant: "started"}`. It does not wait on ssh. Jason sees the outcome
|
||||
in `mosaic hosts status` and as a message in his inbox from `system` when the executor ends.
|
||||
|
||||
The executor can't use `#consumeAuthority`, because that needs the raiser's agent session (F4, N1). It gets
|
||||
its own path, `#consumeGrant(business, decisionId, method)`. In one transaction it checks the decision is
|
||||
resolved yes by the human via cli, checks `#spent` the same way (`broker.mjs:808-831`), and writes the
|
||||
`action.allowed` event with `operation: 'grant.<method>'` and actor `system/grant`. A second start finds it
|
||||
spent and refuses `decision-consumed`. It is a private method, and only the executor calls it.
|
||||
|
||||
### X2. Steps
|
||||
|
||||
1. **Recheck, consume, claim.** One store transaction: `#consumeGrant` (X1), the agent and its definition
|
||||
still declare `host`, the definition hashes to `definitionSha256`, the host record hashes to
|
||||
`registrySha256`, the host is `ready`, the `commands` in the raise event hash to `commandsSha256`, and no
|
||||
revoke of this (agent, host) is newer than the raise. Any failure is `grant.failed` with its code, and
|
||||
nothing remote has happened. Right after the commit, with no `await` between, the executor claims the
|
||||
(agent, `host.<hostId>`) entry in `#inflight` (X5). The store is `DatabaseSync` (`store.mjs:1`), so the
|
||||
transaction and the claim run in one turn of the event loop, and no revoke or `host.run` can run between
|
||||
them. A claim that finds an entry, usually a running `host.run` from the same agent (210:
|
||||
`credentials.mjs:355`), ends `grant.failed {code: 'host-busy'}` with a message to the human and the
|
||||
chief, and the chief raises again (B2). The decision is spent before the claim, so every Approve ends in
|
||||
`grant.applied` or `grant.failed`, never in silence.
|
||||
I chose failing over waiting for the running call. A wait could last as long as 210's timeout while
|
||||
Jason's inbox says "started", and a failure with a message is easier to see than a slow grant.
|
||||
2. **Key.** The tag is `mosaic-` and the first 32 hex digits of sha256 over business, agent and hostId
|
||||
joined with a NUL byte. Names match `ID_PATTERN` (210: `vocabulary.mjs:143`, `:127` at f41729178), which has no NUL, so the join is
|
||||
one to one, and the tag has a fixed length the host pattern accepts (B4). For a new grant,
|
||||
`ssh-keygen -t ed25519 -N '' -C <tag>` in a fresh 0700 dir under `<configDir>/secrets/run`. For a
|
||||
commands change with a live key, skip to step 5.
|
||||
3. **Install.** Write `grant.step {decision, agent, host, user, tag, step: 'install'}`. Connect as
|
||||
`mosaic-installer` with the installer key, pinned (210's options), and run `list <user>`. If the tag is
|
||||
there, `remove` it first: the tag is this agent's on this host, and step 2 only runs when the stack holds
|
||||
no live key for it. Then `add <user> <type> <base64> <tag>`.
|
||||
4. **Verify.** Connect as `<user>` with the new key through a short-lived ssh-agent (210 D7) and run `true`.
|
||||
The connection proves the line, the `from=` and the pin together. A failure here runs `remove <user> <tag>`
|
||||
and ends `grant.failed`.
|
||||
5. **Go live.** Write `grant.step {..., step: 'commit', previous}`, where `previous` is the grant's
|
||||
`commands` before this decision, or `null` for a new key. Then check the abort signal (X5), and with no
|
||||
`await` between, run 210 `issue(agent, "host.<hostId>", privateKey, rotateBy, commit)`. For a new key the
|
||||
reference is registered first (X4). `issue` writes the key file, then calls `commit`, which writes the
|
||||
`host_grants` record (S1) with the decided `commands`, `rotateBy`, tag and decision id. Then `issue`
|
||||
fills memory. For a commands change, step 5 is the abort check and one store transaction that updates
|
||||
`commands` and the decision id, with no `issue`. The tmp dir from step 2 is removed in a `finally`.
|
||||
6. **Event and message.** `grant.applied {decision, agent, host, user, tag, fingerprint, commandsSha256}`
|
||||
and a message to the human and the chief. The private key appears in no event, message or log (210 D8's
|
||||
`assertCleanKey` runs on every event body).
|
||||
|
||||
After step 6, or after any failure, the executor releases its `#inflight` entry in a `finally`.
|
||||
|
||||
The tag is recorded in `grant.step` and `grant.applied`, so `list` output and `sweep` can be matched to this
|
||||
business's grants without trusting a comment on the host (B4).
|
||||
|
||||
### X3. Failure and restart
|
||||
|
||||
The steps are ordered so a failure leaves nothing an agent can use:
|
||||
|
||||
| Fails at | State left | Cleanup |
|
||||
|---|---|---|
|
||||
| 1 | nothing | none |
|
||||
| 2 | tmp dir | `finally`, and the start sweep |
|
||||
| 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort |
|
||||
| 4 | a line, no grant | `remove`, best effort |
|
||||
| 5 before `commit` | a line, a key file, no grant record | unlink the key file, unregister (X4), `remove` |
|
||||
| 5 after `commit` | a line, a grant record, a key file | the record back to `previous` (deleted for a new key), 210 `revoke`, `remove` |
|
||||
|
||||
Setting a record back to `previous`, here and in the restart table below, only updates a record that
|
||||
exists. It never inserts one, and it does nothing when a `grant.revoked` for the (agent, host) is newer than
|
||||
the decision. So a revoke that deleted the record stays done, and the next start loads no reference for it
|
||||
(C1).
|
||||
|
||||
A best-effort `remove` that fails adds `hostLineLeft: <tag>` to `grant.failed`, and `status` shows it until
|
||||
`mosaic hosts sweep <hostId>` (human or tester) succeeds. `sweep` runs `list <user>` and removes each line
|
||||
whose tag is in this business's recorded `grant.step` tags and has no live grant. A line with any other tag
|
||||
is left alone, so a sweep can't touch another business's line on the same user (B4).
|
||||
|
||||
**Restart.** A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or
|
||||
`grant.failed`. At start, before `Credentials` loads, the bus reconciles each one from its last
|
||||
`grant.step` (or `host.enroll.step`), back to the state before the decision (B3):
|
||||
|
||||
| Last step | Local undo, before load | Remote undo, after the bus is up |
|
||||
|---|---|---|
|
||||
| none | none (the tmp dir goes in 210's run-dir sweep) | none |
|
||||
| `install` | none | `remove <user> <tag>` |
|
||||
| `commit`, new key | unlink the key file if present, delete the grant record if present | `remove <user> <tag>` |
|
||||
| `commit`, commands change | set the record's `commands` back to `previous`, by the update-only rule above | none, the key and line didn't change |
|
||||
| enrollment `registry` | delete the installer key if present, delete the host record | none |
|
||||
|
||||
Then it records `grant.failed {code: 'interrupted', tag}` with the tag listed every time, and `hostLineLeft:
|
||||
<tag>` if the remote undo failed. Jason gets a message. The chief raises again, and Jason approves again. I
|
||||
chose a new decision over an automatic retry because Jason approved one attempt at one moment, and a retry
|
||||
after a restart might be hours later. The remote undo only takes access away, so it needs no decision.
|
||||
|
||||
A bus child can also die after the human's resolve commits and before step 1 runs. That leaves a grant
|
||||
decision resolved yes by the human via cli with no `action.allowed`. At start the reconcile spends each one
|
||||
through `#consumeGrant`, in the same transaction as `grant.failed {code: 'interrupted'}`, and Jason gets a
|
||||
message (B2). It never runs late, for the reason above, and a later start finds it spent. Enrollments get the
|
||||
same treatment.
|
||||
|
||||
### X4. Taking effect in the running bus (B1)
|
||||
|
||||
The running bus never reloads the business file (F11). The store records (S1) sidestep that:
|
||||
|
||||
- After row a, `host.run` reads the host record and the agent's grant record from the store inside its
|
||||
transaction at each call, and refuses `host-not-granted` unless the record's decision is a `credential.mint`
|
||||
resolved yes by the human via cli and spent by `#consumeGrant` with `operation: 'grant.host'`. That is the
|
||||
Approve record check. The record is read in place of `b.hosts` and `b.roles[role].hosts` (210: `broker.mjs:1731-1735`,
|
||||
`:1962-1968`). A grant, a commands change, a revoke and a `hosts.remove` are seen by the next call with no
|
||||
broker change.
|
||||
- `Credentials` still loads its references once (F11). It gets `register(instance, service, ref)`, which runs
|
||||
the constructor's reference checks for one entry (210: `credentials.mjs:128-143`, moved into a shared
|
||||
method) and sets `#refs`, and `unregister`, which drops an entry with no token. The executor registers
|
||||
before `issue`, because `issue` refuses an unknown service (210: `credentials.mjs:235-238`).
|
||||
- At start, the runtime adds a store reference for each `host_grants` record to the references it hands
|
||||
`Credentials` (210: `runtime.mjs:85-98`), so a restart finds the same keys.
|
||||
- Revoke deletes the grant record, and the reference left in memory reads as revoked with no file (199) until
|
||||
the next start drops it.
|
||||
|
||||
### X5. Revoke while the executor runs (B2)
|
||||
|
||||
The executor's `#inflight` entry is an `AbortController` (210: `credentials.mjs:118`), the same entry that
|
||||
`revoke`'s `abort()` reaches (210: `credentials.mjs:289`). A new `Credentials.claim(instance, service)` sets
|
||||
it and returns the signal, and `release` deletes it.
|
||||
|
||||
- A revoke before step 1 goes through `hosts.revoke` (section 6), which records `grant.revoked` for the
|
||||
(agent, host) even when `Credentials` holds no reference and no claim. Step 1's "no revoke newer than the
|
||||
raise" check finds it and ends `grant.failed {code: 'revoked'}` (B2). No revoke can fall between step 1's
|
||||
transaction and its claim (X2).
|
||||
- A revoke during steps 2 to 5 aborts the claim. `hosts.revoke` doesn't call 210's `revoke` when
|
||||
`Credentials` has no reference, because that refuses `credential-unknown` (210: `credentials.mjs:235-238`).
|
||||
It calls a new `Credentials.abortClaim(instance, service)`, which aborts a live claim and answers
|
||||
`{pending: true}`, and the broker records `grant.revoked {pending: true}`.
|
||||
- The executor checks `signal.aborted` after each `await`. At step 5 it checks right before `issue`, and
|
||||
nothing between the check and `issue` awaits, so no revoke can run in between. That matters, because
|
||||
`issue` clears the revoked mark (210: `credentials.mjs:279`).
|
||||
- Build rule (N2): `issue` and the `commit` it calls stay synchronous. `commit` is a store write and the
|
||||
store is `DatabaseSync` (`store.mjs:1`), so nothing in `issue` yields today. An async `commit` would let a
|
||||
revoke run between the key file write and the memory fill, and `issue` would then clear the revoked mark.
|
||||
A mutant covers it.
|
||||
- On abort the executor undoes as X3's failure table does at that step, then ends
|
||||
`grant.failed {code: 'revoked'}`.
|
||||
- A revoke after step 5 is an ordinary revoke (section 6). Once step 5 commits, the executor ignores an
|
||||
abort and goes on to step 6, so its undo never runs against a revoke that came after the commit (C1).
|
||||
|
||||
While the executor holds the claim, a `host.run` for the same agent and host refuses `host-busy`. That only
|
||||
matters for a commands change on a live key, and it lasts until the executor ends.
|
||||
|
||||
### S1. Grant records in the store
|
||||
|
||||
Two tables in the bus store, added by a new migration `3i-3j.sql` next to `3h-3i.sql`
|
||||
(`bus/src/migrations/`):
|
||||
|
||||
- `hosts`: business, host id, address, port, user, sudo, host keys, installer fingerprint, state
|
||||
(`pending-install` or `ready`), the enrolling decision id. Primary key (business, host id), so the store
|
||||
refuses a second enrollment of one host (B1).
|
||||
- `host_grants`: business, agent, host id, commands, rotate-by date, tag, the decision id that made or last
|
||||
changed it. Primary key (business, agent, host id).
|
||||
|
||||
The executor, revoke, `hosts.check` and `hosts.remove` are the only writers, each in the same transaction as
|
||||
its event. No broker verb takes these records from a request body. Every write cites a decision id or the
|
||||
revoker, as W1's receipts did.
|
||||
|
||||
The business file holds none of it (mos-1131 point 2). A hand edit makes nothing:
|
||||
|
||||
- Until row a lands, row 210's `hosts` keys and role grants in the business file stay as they are, a test
|
||||
path (mos-1131).
|
||||
- Row a moves `host.run` to the store records. From then the loader refuses `hosts` at the top level and
|
||||
`hosts` in a role (210: `hosts.mjs:75`, `:123`), so a hand-edited grant fails closed at load instead of
|
||||
doing nothing quietly. Row 210's tests move to store fixtures in the same row.
|
||||
- The credential reference for `host.<id>` comes from the grant record (X4), not from
|
||||
`roles.<agent>.credentials`.
|
||||
|
||||
The store file is writable by the stack's OS user, like the key files (199 D6). Section 9's same-user limit
|
||||
covers it.
|
||||
|
||||
A new store schema is a migration (mos-1133):
|
||||
|
||||
- On dragon-lin the 3i-3j re-pin is an agent-run `--migrate` window under Q173. Mos, Sage or Darkwing runs
|
||||
it as the tester, takes a backup first and sends a STATE report after. Jason doesn't need to be present.
|
||||
P19 371 predates Q173 and doesn't apply.
|
||||
- On .53 and .54 the plan is for row a to land before the installs (row a in review by 10-24, installs
|
||||
10-26). Both hosts then start at 3j and need no migration.
|
||||
- The row a run sheet removes row 210's `hosts` keys and role grants from the business file before the
|
||||
`--migrate` re-pin, because the loader refuses them from then on (G14). Any 210 grant in use stops there,
|
||||
and the agent needs a chief raise and an Approve to get it back (N9).
|
||||
- If row a slips past an install, a later round or the row a packet says so. That host then goes 3i to 3j through I3's
|
||||
upgrade driver (#432), and Mos rules on it at that point.
|
||||
|
||||
## 6. Revoke
|
||||
|
||||
Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke <agent> <hostId> --reason R`. Every revoke
|
||||
is logged with who made it (REQ-GRANT-4).
|
||||
|
||||
Who may revoke follows mos-1124 d and PRD 1.8 r3's REQ-GRANT-4 (d16ccdb91):
|
||||
|
||||
- the human, always
|
||||
- the assistant (`THREAD_DEFINITION`, `vocabulary.mjs:131`), for grants in its own organization only. The
|
||||
broker checks the grant's business against the session's.
|
||||
- the tester, with `--tester` (210 D9 already gives the tester `revoke`), on a dev instance only. On any
|
||||
other instance a tester revoke refuses `tester-dev-only`.
|
||||
|
||||
A dev instance is a business whose var `bus.tester` is exactly `true` (mos-1139, N8). `vars.mjs:125` defines
|
||||
the var for the business layer only, with a default of `false`, and `broker.mjs:544` already refuses to bind a
|
||||
tester unless it is exactly `true` (`tester-off`). `hosts.revoke` checks the same var again for the grant's
|
||||
business. A missing value or any other value means not dev, so `tester-dev-only` fails closed. I2's install
|
||||
never sets the var, so .53 and .54 are never dev (mos-1139).
|
||||
|
||||
Q188's "Mos" is read as the assistant role, Jason's main interaction agent (mos-1132). That is Mos's reading,
|
||||
labelled as such in the PRD 1.8 approval notes, and Jason settles it at prd.approve. This replaces mos-1120's
|
||||
"Mos revokes with `--tester`".
|
||||
|
||||
`hosts.revoke` is its own broker verb in a new module, `bus/src/host-grants.mjs`, next to the executor. It
|
||||
doesn't go through 210's `credential-verbs.mjs:21-24`, which refuses `credential-unknown` when the business
|
||||
file has no reference. After row a no `host.<id>` reference is in the business file at all (N3).
|
||||
|
||||
Order, local first so the part that matters can't wait on the network:
|
||||
|
||||
1. One store transaction deletes the `host_grants` record, if there is one, and records `grant.revoked
|
||||
{tag, hostLine: 'pending'}`. The tag is computed (X2 step 2), so it exists even with no record (N4). The
|
||||
next `host.run` refuses `host-not-granted` (X4). A new grant needs a new decision.
|
||||
2. Right after the commit, with no `await` between: 210 `revoke` if `Credentials` holds a reference
|
||||
(memory, abort the `host.run` or the executor in flight, unlink the key file), or `abortClaim` if it
|
||||
holds only a claim (X5). Steps 1 and 2 are the "instant" part, and neither touches the network. If the
|
||||
unlink fails, the verb reports it. The key is already out of memory, and the next start removes the
|
||||
file (below).
|
||||
3. `remove <user> <tag>` on the host through the installer. Success records `grant.revoke.closed {tag,
|
||||
hostLine: 'removed'}`. If the host can't be reached, it records `grant.revoke.closed {tag, hostLineLeft:
|
||||
<tag>}`, and `sweep` clears it later.
|
||||
|
||||
A restart between the steps (N4) leaves a `grant.revoked` with `hostLine: 'pending'` and no
|
||||
`grant.revoke.closed`. At start, before `Credentials` loads, the reconcile unlinks that key file if it is
|
||||
there. No reference loads for it, because the record is gone (X4). After the bus is up, the reconcile runs
|
||||
step 3 and closes the marker. `status` shows an open marker as a revoke in progress.
|
||||
|
||||
Step 3 closes the marker even when step 2's unlink failed. So the file doesn't wait on the marker: at every
|
||||
start, after the grant reconcile (X3) and before `Credentials` loads, the bus unlinks each `host.<id>` key
|
||||
file in the agent store that has no `host_grants` record (N1 of duckworth-0063). The grant reconcile runs
|
||||
first, so a key an interrupted executor wrote is already handled there.
|
||||
|
||||
Step 3 is defense in depth. After step 1 the stack holds no copy of the key. The line only matters if
|
||||
something copied the key file before the revoke, which section 9's same-user limit allows.
|
||||
|
||||
Remote processes that the killed ssh started may keep running (210 section 6). Revoke cuts the stack's
|
||||
connection, not what already runs on the host.
|
||||
|
||||
`hosts remove` (E4) refuses while grants exist or a line is left. Revoking the installer itself is
|
||||
`hosts remove` and ops-01 deleting the line.
|
||||
|
||||
## 7. Row 210's per-call decision becomes manual mode
|
||||
|
||||
Row 210 lands as reviewed (mos-1115). Its D4 stays the code path: a `host.run` whose class is `gated` needs a
|
||||
decision bound to host, argv hash and stdin hash, spent once.
|
||||
|
||||
What changes is when the class is `gated`:
|
||||
|
||||
- Under PRD 1.7, `host.sudo` is a hard wait, so every call is gated. That is today.
|
||||
- Under PRD 1.8 with REQ-CRED-4, `host.sudo` is routed, and a definition that lists it within-role runs
|
||||
granted commands with no decision (210 section 5).
|
||||
- Manual mode (Q188) puts the per-call decision back. Honker's REQ-TRUST-2 takes the list form: the
|
||||
organization file holds a list of actions in manual mode, empty by default, set only by the user. While
|
||||
an action is listed, it classifies `gated` for every role. `#classification` checks the list first
|
||||
(`broker.mjs:597-609`).
|
||||
|
||||
Manual mode is built from REQ-TRUST-2, not in the grant rows (sage-1527 Q-S3).
|
||||
|
||||
## 8. PRD lines
|
||||
|
||||
Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036) carries this design as REQ-GRANT-1 to -4, generalized to
|
||||
all three methods. There is no REQ-CRED-5. Duckworth's PRD review (duckworth-0057) notes that this design
|
||||
uses `credential.mint` with the method in the `grant` body, not a new `host.grant` action. Points r2 sends
|
||||
Honker to match:
|
||||
|
||||
- REQ-GRANT-2 says a host's identity comes from the host side "when the user enrolls it". Under mos-1120
|
||||
the user approves the enrollment and the stack runs it, so "when the user approves its enrollment" fits
|
||||
better.
|
||||
- REQ-GRANT-4 names the assistant as a revoker. Honker kept it under mos-1124 d, and this design now follows
|
||||
it (section 6). Done in PRD r3 d16ccdb91.
|
||||
- An enrollment names a target user with sudo, and the Approve covers that user by name (E1, mos-1125).
|
||||
|
||||
I don't edit the PRD.
|
||||
|
||||
## 9. Limits, said plainly
|
||||
|
||||
- **The installer key is root on a host with a privileged user.** It can add a line for a user in
|
||||
`privileged`, and that user has sudo. It sits in `<configDir>/secrets`, readable by the stack's OS user
|
||||
like every other key (199 D6). The writer limits which users it can touch, and a user with sudo is
|
||||
touchable only after Jason approved that user by name in the enrollment (E1, E3). It doesn't limit what
|
||||
those users can do.
|
||||
- **Rights gained later are caught at the next add, not before.** A user who gains sudo or `docker` after
|
||||
enrollment, and isn't in `privileged`, is refused on the next `add`. Lines already in place stay until a
|
||||
revoke, `remove` or `sweep` (ops-01 Q-O2 point 3).
|
||||
- **A host line has no `command=`** (ops-01, mos-1125). `add` writes `from=` and `restrict` only, so the host
|
||||
doesn't enforce the granted argv prefixes. The bus enforces them (210 D6). A copied agent key is a full
|
||||
shell as that user from the stack's address. A forced command per key that checks
|
||||
`SSH_ORIGINAL_COMMAND` against the granted list would close that, but it means the writer also carries
|
||||
every commands change to the host, and 210's argv encoding has to round-trip through it. I'd make it a
|
||||
follow-up row and not part of rows a and b.
|
||||
- **The seat network isn't confined** (`seat/src/sandbox.mjs:29-31`, 210 section 6). An agent granted a
|
||||
command that writes a file as root can plant its own key and ssh around the bus. `from=` doesn't help,
|
||||
because the sandbox shares the stack's address. A network-confined sandbox closes it (Sage's follow-up).
|
||||
- **The human proof is cooperative same-UID** (`bus/src/human.mjs:50-52`). Approve is as strong as
|
||||
`verifyHuman`, no stronger. This path adds nothing weaker, but grants raise the stakes of that check.
|
||||
- **Approve is not "runs without a click"** until PRD 1.8 routes `host.sudo` (section 0).
|
||||
- **Enrollment still needs ops-01 once per host.** I don't see a way around it without a trust anchor on the
|
||||
host, and I'd rather say so than claim the flow is hands-free.
|
||||
- **The pin needs two reads to agree.** A wrong pin needs both the fingerprints in the request and the
|
||||
stack's network path to the host changed in the same way. Either one alone makes the raise refuse.
|
||||
- **Tags assume one stack per business id.** Two stacks that both run a business with the same id and grant
|
||||
the same agent name on the same host user would compute the same tag. Nothing runs two stacks today.
|
||||
|
||||
## 10. Tests and mutants (for the rows, not built here)
|
||||
|
||||
Tests use a scratch sshd on 127.0.0.1 with throwaway keys, as row 210 does, with `mosaic-keys` running as the
|
||||
scratch sshd's forced command. The writer's checks are tested by calling its functions with a temp dir as
|
||||
the root and a fake group and sudo lookup. The installed entry point fixes the `/etc` paths and passes no
|
||||
such argument. No real host is contacted.
|
||||
|
||||
| Test | Claim |
|
||||
|---|---|
|
||||
| G1 | a `credential.mint` raise with `grant` from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored. A `grant` on any other action refuses `invalid-request` |
|
||||
| G2 | a raise for an agent whose definition lacks `host` refuses `host-not-declared` |
|
||||
| G3 | a raise with a wildcard, shell or empty argv prefix refuses, the same cases as 210 D6 |
|
||||
| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and a question over 4096 characters with the block refuses `invalid-request` |
|
||||
| G5 | a `credential.mint` raise with `grant` routes to `human`, a tester thread can't raise it, and an approved `credential.mint` without `grant` runs nothing |
|
||||
| G6 | a yes from the human via cli starts the executor, while a no, a withdraw, or an arbiter resolve start nothing |
|
||||
| G7 | executor end to end on scratch sshd: line installed with the `from=` and tag, verify login works, the grant record written with its decision id, key file 0600, `grant.applied` with the tag |
|
||||
| G8 | each failure row of X3 leaves the stated state, and a failed `remove` leaves `hostLineLeft` |
|
||||
| G9 | a registry, role definition or commands change between raise and Approve refuses `decision-mismatch` before any remote step |
|
||||
| G10 | a second start for the same decision refuses `decision-consumed`, and `#consumeGrant` refuses an unresolved or agent-resolved decision |
|
||||
| G11 | a bus restart after `install`, after the key file is written and before `commit`, and after `commit` each reconcile to the state before the decision: no key file, no grant, no line, `grant.failed interrupted` with the tag. A restart after an enrollment's `registry` step drops the entry and the installer key. A restart after the resolve commits and before step 1 spends the decision, ends `grant.failed interrupted` with a message, and nothing runs later, for a grant and for an enrollment (B2) |
|
||||
| G12 | revoke: the grant record is gone and the `pending` marker written, then in the same turn the call in flight dies and the key file is gone, then the host line is gone and the marker closed, in that order |
|
||||
| G13 | revoke with the host down still does steps 1 and 2 and reports `hostLineLeft` |
|
||||
| G14 | `organization.set`, `limits.set` and `instances.set` still refuse `hosts` and `manual`. After row a, a business file with `hosts` or a role grant refuses at load |
|
||||
| G15 | no event, message, log line or receipt holds private key material (210 D8 check on every body) |
|
||||
| G16 | `mosaic-keys` refuses bad requests without calling the writer. The writer refuses a user not matching the pattern (`../x` included), a `privileged` user not in `users`, users outside `users`, root, uid under 1000, `mosaic-installer`, `localadmin`, a member of each refused group (`disk`, `libvirt` and `incus-admin` included), a user with a `sudo -l` line who isn't in `privileged`, bad tags and bad key text, and takes `from=` from the host file |
|
||||
| G17 | a `host-enroll` raise refuses loopback and this machine's names, bad fingerprints, an enrolled id and the reserved users. A keyscan set that differs from the request's fingerprints by one key, extra or missing, refuses `host-key-mismatch` and stores nothing. On Approve the executor writes the entry and the installer key 0600, records `host.enrolled`, and its message holds no private material. Of two approved enrollments for one `hostId`, the second ends `grant.failed host-enrolled` with its decision spent, and the first record, pin and installer key are unchanged. A direct second insert fails on the primary key (B1) |
|
||||
| G18 | `hosts.check` with a wrong pin exits 255, refuses `host-key-mismatch`, runs nothing and leaves the host `pending-install` |
|
||||
| G19 | a grant naming a host that isn't `ready` refuses `host-not-ready` |
|
||||
| G20 | `hosts.check` from the chief, the tester and the human marks the host `ready`, and any other role refuses |
|
||||
| G26 | revoke by the human works. The assistant's works in its own organization and refuses another's. The tester's works where `bus.tester` is exactly `true`, and refuses `tester-dev-only` where the grant's business has it missing or set to anything else (N8). Any other role refuses |
|
||||
| G21 | `hosts.remove` refuses `host-in-use` while a grant names the host, `host-line-left` while a line is left, and any agent other than the tester |
|
||||
| G22 | one bus, no restart: enroll, check, grant, a `host.run` that works, a commands change that the next `host.run` uses, and a revoke that the next `host.run` refuses `credential-unavailable` (B1) |
|
||||
| G23 | a revoke during install (a scratch `mosaic-keys` that waits on a fifo) ends `grant.failed revoked` with no key file, no grant and no line. A revoke before the executor starts, with no reference and no claim, records `grant.revoked`, and step 1 then ends `grant.failed revoked`. A grant whose claim finds a running `host.run` ends `grant.failed host-busy` with its decision spent and a message (B2) |
|
||||
| G24 | agent and host names at 64 characters with `.` and `_` give distinct tags of the fixed length. `sweep` leaves another business's line and an unrecorded `mosaic-` line on the same user (B4) |
|
||||
| G25 | the writer refuses a symlinked `/etc/ssh/mosaic-keys`, a symlinked user file, a group-writable dir, and a tmp that is a symlink or not a regular file. A leftover regular tmp is unlinked and the write goes ahead. A first `add` creates the file. A written file is root-owned 0644 (B5, N6) |
|
||||
| G27 | `hosts.revoke` works for an agent whose business file has no credentials reference (N3). A restart after revoke step 1, and one after step 2, each end at start with no key file, no record, the line removed and the marker closed (N4). A key file left by a failed step 2 unlink is gone after the next start, though the marker closed |
|
||||
| G28 | a commands change commits at step 5, a revoke follows before `grant.applied`, and the bus restarts: the start ends with no grant record, no reference and no key file. The same with a failure after step 5 in place of the restart (C1) |
|
||||
|
||||
Mutants, a first list. The row's BUILD.md numbers them and adds more:
|
||||
|
||||
- drop the chief check
|
||||
- start the executor on a `credential.mint` with no `grant`
|
||||
- skip the `ready` check
|
||||
- skip the keyscan compare, or compare only one key
|
||||
- skip the definition hash compare
|
||||
- drop the definition check
|
||||
- skip the commands hash compare
|
||||
- skip the host record hash compare
|
||||
- swap the order of install and the grant record write
|
||||
- skip `remove` on a verify failure
|
||||
- skip the start sweep
|
||||
- skip the reconcile's remote `remove`
|
||||
- skip the `grant.step` before install
|
||||
- `host.run` reads the business file instead of the grant record
|
||||
- revoke leaves the grant record
|
||||
- skip `register` before `issue`
|
||||
- drop the abort check before `issue`
|
||||
- add an `await` between the abort check and `issue`
|
||||
- revoke ignores a claim with no reference
|
||||
- build the tag without the business
|
||||
- `sweep` removes any `mosaic-` line
|
||||
- drop `from=` in the writer
|
||||
- let the writer touch a line outside the tag pattern
|
||||
- drop `O_NOFOLLOW` on the user file
|
||||
- drop one group from the writer's refused list
|
||||
- drop the user pattern check before the path use
|
||||
- treat a `privileged` entry as enough without `users`
|
||||
- let the writer accept a sudo user not in `privileged`
|
||||
- run revoke's host step before the local steps
|
||||
- start the executor on an arbiter resolve
|
||||
- start it on `no`
|
||||
- write the private key into the event body
|
||||
- drop the "not yet enrolled" check in E2 step 1
|
||||
- drop the `hosts` primary key
|
||||
- claim before consume, or put an `await` between them
|
||||
- skip the start check for a resolved, unspent grant decision
|
||||
- make `commit` async
|
||||
- route `hosts.revoke` through `credential-verbs.mjs`
|
||||
- skip the revoke marker reconcile
|
||||
- let the undo to `previous` insert a record (an upsert)
|
||||
- let the undo to `previous` run past a newer `grant.revoked`
|
||||
- let the executor act on an abort after step 5 commits
|
||||
- skip the start sweep of key files with no record
|
||||
- treat a missing `bus.tester` as dev
|
||||
|
||||
## 11. Not in this draft
|
||||
|
||||
- Gitea and GitHub grants (`method` `gitea` and `github`). The same flow: chief raise, Jason approves, the
|
||||
executor mints a token through an admin credential Jason enters, writes the reference, and revoke deletes
|
||||
the token at the service. The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a
|
||||
design of its own.
|
||||
- The Console Approve (#435) and the Console token entry. Both wait on Q175 sign-in (#412).
|
||||
- The network-confined sandbox (Sage's follow-up from 210 section 6).
|
||||
- A per-key forced command on the host (section 9).
|
||||
- Changing an enrolled host's user or sudo mark. Today that is `hosts remove` and a new enrollment.
|
||||
|
||||
## 12. Questions
|
||||
|
||||
Answered since r1:
|
||||
|
||||
- Q-S1 (sage-1527): no `grant.ask` in row a. Messaging the chief, with the definition gating the raise, is
|
||||
enough. If review finds a forgery path, the verb comes then. Duckworth agrees (N4).
|
||||
- Q-S2 (mos-1120, sage-1527): `<configDir>/secrets/hosts/<business>/<hostId>/installer`.
|
||||
- Q-S3 (sage-1527): manual mode is Honker's REQ-TRUST-2, and REQ-CRED-5 is folded into REQ-GRANT-1 to -4.
|
||||
- Q-S4 (sage-1527): the split stands, both rows are mine, and Duckworth reviews both.
|
||||
- Row a (#436a): enrollment as the approved request, the four `tools/host` files, the store records and
|
||||
their migration, `host.run` reading them, and `hosts status`. More than r2's 1 day, because of the
|
||||
migration and moving 210's tests. My estimate is 1.5 days.
|
||||
- Row b: the raise binding, the executor, reconcile and revoke, after row a. r5 adds the `hosts.revoke`
|
||||
verb and its marker, so my estimate goes from 1.5 days to 1.75.
|
||||
- Both come after row 210 lands and need the 1.8 line before landing. Target: in review by 10-24, so
|
||||
row a lands before the 10-26 installs (S1, mos-1133).
|
||||
- Q-M1 (mos-1120): Mos revokes as the tester. Replaced by mos-1132, see below and section 6.
|
||||
- Q-O1 (ops-01 through mos-1125): yes, with the six changes now in E3.
|
||||
- Q-O2 (ops-01, reply to gizmoduck-0063): the `privileged` file is accepted, with four rules and four writer
|
||||
points, all in E3 and section 9.
|
||||
- Q-S5 (mos-1124 d, through honker-0035): the assistant may revoke grants in its own organization, and the
|
||||
tester only on a dev instance. PRD 1.8 r3 (d16ccdb91) REQ-GRANT-4 keeps that. Section 6 follows it.
|
||||
- **Q-M2 (mos-1132).** ops-01 is a T3 seat and can't message a chief. Host keys never travel by message.
|
||||
The request carries expected fingerprints, and the raise runs its own keyscan (E1). On the #433 VM, Mos or
|
||||
Sage passes the fields to the chief as the tester (Q173). On .53 and .54, Jason sends the request in the
|
||||
Console (Q118), with text Mos builds from ops-01's values.
|
||||
- **Mos as a revoker (mos-1132).** Q188's "Mos" is read as the stack's assistant role, Jason's main
|
||||
interaction agent. This is Mos's reading, labelled as such, and goes in the PRD 1.8 approval notes. Jason
|
||||
settles it at prd.approve. Section 6 is written to it.
|
||||
|
||||
Open: none from this draft.
|
||||
Reference in New Issue
Block a user