row 235: machine grant row b, raise binding, executor, reconcile, revoke and sweep, creative roles refused at raise, recheck and host.run, N10 (#1741)
Lands Gizmoduck's round 1 candidate,ed4193316on gizmoduck/row235-e (one commit on62ddc96cc), approved by Duckworth (duckworth-0089, REVIEW-r1d9ecec514on duckworth/reviews, #1741 comment 30022). 13 paths, manifest dad377ca8fe4. No 235 path changed between62ddc96ccand this commit's parent. Patch-id c355d2fc457d, the same as 62ddc96cc..ed4193316. broker.mjs keeps row 213's import and decision.raise creative refusal as landed. Creative roles (mos-1163, mos-1164, sage-1908): #hostDefinition refuses host-creative-role at the raise and at the step 1 recheck, and #hostAccess refuses it at host.run before reading the grant record, which stays for hosts.revoke. Duckworth checked from the code that a creative role can't raise, approve or spend a host grant, and ruled the upsert mutant equivalent. Evidence: Gizmoduck's runs on my slot. B1-B37 on 5df4b52c5, and run 2 on 534f2254a (10:12:52-10:14:51Z): grant-host 9/9, grant-executor 37/37 and grant-enroll 24/24, each run whole, with B16, B23, B27, B38 and B39 killed. B1-B39: 38 killed, and B17 is equivalent (sage-1904). Gate on my slot, 10:30:55-10:42:43Z on29ce1e360plus the 13 paths: bus 596/0 (its first whole run on this head, duckworth-0089 N1), business 200/0, cli 666/0, and every scripts/test-*.sh green (auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 and 28, release 14, task 98, all 0 failed).
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
# Row 235 build: machine grant row b (#436, Gitea #1741)
|
||||
|
||||
Brief: `docs/plans/2026-10-20_host-credentials.md`, "Machine grant row b: raise binding, executor,
|
||||
reconcile and revoke". Design r6 f9039682e. Built on row 234's landing c771a36f4 (sage-1775), branch
|
||||
gizmoduck/row235-b. Line numbers below were read on b0320d395, whose src files are byte-identical to the landing's.
|
||||
|
||||
## Squash onto 4ced5e63d (sage-1812, sage-1831)
|
||||
|
||||
Steps 1-6 were six local commits on c771a36f4 (gizmoduck/row235-b, tip f9fff4300). Sage named
|
||||
4ced5e63d as the base, so this branch, gizmoduck/row235-c, holds one commit on it: the diff
|
||||
c771a36f4..f9fff4300 applied with `git apply --index`. N10 is in that commit. N12 needs no change,
|
||||
because row b doesn't touch schema.sql.
|
||||
|
||||
Rows 240, 228 and 238 landed between c771a36f4 and 4ced5e63d and changed 18 paths. One of them is
|
||||
also mine:
|
||||
|
||||
- `packages/cli/README.md`. Row 228 (86249ac55) changed the session credential paragraphs at about
|
||||
lines 432 and 706. My hunk adds three lines on `hosts revoke` and `hosts sweep` at about line 1052.
|
||||
The two don't overlap, so the blob here is row 228's blob plus my three lines, with no landed line
|
||||
reverted. cli tests/host.test.mjs:855 reads this README, and run 1's cli suite covers it.
|
||||
|
||||
The other 12 paths are byte-identical to f9fff4300. The line numbers in the step notes below were read
|
||||
on the old base.
|
||||
|
||||
## Run 1 (sage-1831, sage-1833, sage-1838)
|
||||
|
||||
Run 1 on 4d4bb4af6, 07:45-07:54Z, failed: 1351 tests, 1285 pass, 66 fail. The 66 are 33 tests counted
|
||||
twice, once in the files run and once in the bus suite. The cli suite, the cli hosts test, seat
|
||||
sandbox.test and bus project-runtime.test passed. Two defects, both mine:
|
||||
|
||||
- grant-executor.test.mjs, 32 tests. setup() never made `dir/data`, and `new Store` lstats its root
|
||||
(store.mjs:137), so every test stopped at setup with ENOENT. setup() now makes it, mode 0700.
|
||||
- credential-claim.test.mjs:76 (X4), 1 test. A register after a revoke left the key in `#revoked`, so
|
||||
status() said `revoked` for a new grant's reference that has no token yet. register now clears
|
||||
`#revoked`, as unregister does. `#revoked` feeds only status(), and issue clears it as well.
|
||||
|
||||
Both fixes are amended into the one commit. The rerun covers the five bus files, the bus suite, and
|
||||
cli credentials.test.mjs and verbs.test.mjs, which read credential state strings.
|
||||
|
||||
## Run 1 rerun (sage-1842, sage-1843)
|
||||
|
||||
The rerun on ac7c45975, 08:06-08:07Z, failed: 701 tests, 695 pass, 6 fail, which are 3 grant-executor
|
||||
tests counted in both the files run and the bus suite. All three were test defects that run 1's setup
|
||||
failure had hidden. No src file changed for them:
|
||||
|
||||
- G10. The two fixture decisions were built after `before`, and grantDecision (host-fixtures.mjs)
|
||||
INSERTs one events row each, so the count read 13 for 11. The test now builds them first, asserts
|
||||
they added exactly 2 rows, and then pins the executor's 0 against that count.
|
||||
- G27. setup's t.after removes the scratch dir before a hook the test adds later runs, so the test's
|
||||
chmod hook hit ENOENT after the body had passed. The body now restores the mode in a try/finally.
|
||||
- G22. The slow run's promise had no handler until `refusesWith`, four lines on. The revoke rejected it
|
||||
first, and node failed the test on the unhandled rejection. The handler now goes on at creation.
|
||||
|
||||
The fix is one more amend of the single commit. The rerun is grant-executor.test.mjs whole plus the bus
|
||||
suite.
|
||||
|
||||
Rerun 1c on 5df4b52c5, 08:15Z, was green: 623 of 623, grant-executor 34 of 34 and the bus suite 589 of 589.
|
||||
|
||||
## Mutants B1-B37 (sage-1894, sage-1899, sage-1904)
|
||||
|
||||
Three chunks ran on 5df4b52c5, 09:22-09:37Z. Restores matched HEAD after each. 33 were killed and four
|
||||
survived. Two of the misses were mine, in the file lists:
|
||||
|
||||
- B2 and B23 listed only grant-executor, and that file never makes the raise either mutant changes.
|
||||
grant-enroll.test.mjs:227 (A2, A4) resolves a plain `credential.mint` yes and a host grant no, and
|
||||
asserts no grant events for either. mutants.py now lists grant-enroll and grant-executor for both.
|
||||
B2 then ran alone and A2 killed it. B23 runs again with this round's tests.
|
||||
|
||||
The other two survivors needed tests, and Sage ruled on all four (sage-1904):
|
||||
|
||||
- B16 drops the abort check after `verify`. The check after `step('commit')` still ends the grant
|
||||
revoked, so G23 X5's asserts held. The run then logs a commit step for a grant that never committed.
|
||||
G23 X5 now asserts that its only `grant.step` is `install`.
|
||||
- B17 adds `await null` after the commit check. Sage accepted it as equivalent. `await null` yields
|
||||
only to microtasks. Every revoke starts in a macrotask of its own (a socket request, or a test's
|
||||
call), so none can land in that gap. There is no test for it.
|
||||
- B27 makes issue's commit callback async. issue() doesn't await it, so the executor returns `applied`
|
||||
before the INSERT runs. The two differ only when the commit transaction throws, and a real store can
|
||||
throw there with a full disk, an I/O error or a busy timeout. Under the mutant that throw becomes an
|
||||
unhandled rejection after the key file is written, and the catch's undo never runs. The new G8 test
|
||||
makes the store's `run` throw SQLITE_FULL once on the host_grants INSERT. It asserts host-failed, no
|
||||
record, key file, reference or line, and the steps install, commit, failed. The store object comes
|
||||
from the test's setup, so src gains no seam.
|
||||
|
||||
These two test changes went in as a new commit on gizmoduck/row235-d (8b13d0ca0). That branch then
|
||||
moved onto row 213's landing as gizmoduck/row235-e, below. B16, B23 and B27 run there, after the touched
|
||||
test files run whole.
|
||||
|
||||
## Rebase onto 62ddc96cc (sage-1922)
|
||||
|
||||
Row 213 landed as 361ba2387, and refactor's tip is 62ddc96cc, with only docs past it. gizmoduck/row235-e
|
||||
starts at 62ddc96cc and cherry-picks 8b13d0ca0's one commit. The patch-id is unchanged (c062b73fa9f1).
|
||||
row235-c and row235-d stay as they were.
|
||||
|
||||
Between 4ced5e63d and 62ddc96cc, 361ba2387 is the only commit that touches a file of mine. The one
|
||||
drifted path is `packages/bus/src/broker.mjs`:
|
||||
|
||||
- 213 adds `CREATIVE_DEFINITIONS` to the vocabulary import at :6, and refuses `decision.raise` from a
|
||||
creative role with `decision-not-allowed` at :2176. Both hunks stay as landed.
|
||||
- My 31 removed broker lines read the same before and after the cherry-pick, so no landed line is
|
||||
reverted.
|
||||
|
||||
`packages/cli/README.md` isn't in 361ba2387. Every other path of mine is byte-identical to 8b13d0ca0.
|
||||
Line numbers in the section below were read on the row235-e working tree; earlier sections keep theirs.
|
||||
|
||||
## Creative roles get no host grant (mos-1163, sage-1905, sage-1908, sage-1916)
|
||||
|
||||
Brief blob b16e236188f6 (4364dddb0, rev 2784) requires two refusals: the writer, at the raise and at the
|
||||
step 1 recheck, and host.run at spend time, with the record kept. Webby's N2 on row 213 r4 asks whether
|
||||
a creative role can spend a decision another role raised.
|
||||
|
||||
The spend answer. Mos confirmed it read-only (mos-1164, sage-1916) on 8b13d0ca0 and 01e1fe512; the
|
||||
row235-e line follows each:
|
||||
|
||||
- Only the chief raises a host grant. `#raiseGrant` refuses `grant-chief-only` for any other
|
||||
definition: :1232 on 8b13d0ca0, :870 on 01e1fe512, :1238 here. The hosts verbs check the same at
|
||||
:1307 on 8b13d0ca0, :896 on 01e1fe512, :1313 here. `chief-of-staff` isn't in `CREATIVE_DEFINITIONS`.
|
||||
- No agent spends one. `#consumeGrant` (:697) is called for a host grant only from `#runHostGrant`
|
||||
(:902) and from the restart reconcile (:1106), which ends it interrupted. `#runHostGrant` starts from
|
||||
the resolve hook (:2881) only when `#approvedGrant` returns a grant, and that needs a close by the
|
||||
business's human, via cli, with the raise's approval choice (:691). An agent's resolve records via
|
||||
`broker`, so it starts nothing (grant-enroll A3). The public `runHostGrant` (:891) is a trusted API
|
||||
that no verb reaches.
|
||||
|
||||
So a creative role can't raise, approve or spend a host grant. The gap was a different one: the chief
|
||||
could raise a grant whose `agent` is a creative role, and the human could approve it. The only check on
|
||||
that agent was `#hostDefinition`, which refuses `host-not-declared` (:864) unless the definition's
|
||||
`credentials` names `host`. Row 213's `checkCreative` (business/src/role.mjs:143) limits a creative
|
||||
role's tools, authority and messaging, not its `credentials`, so a creative role file naming `host`
|
||||
passed both the raise and the step 1 recheck.
|
||||
|
||||
As built:
|
||||
|
||||
- Writer. `#hostDefinition` refuses `host-creative-role` (:862) before the `host-not-declared` check.
|
||||
The raise binding (:1266) and the step 1 recheck (:879) both call it, so a grant raised before its
|
||||
agent's instance moved to a creative definition ends at step 1, spent, with no remote step.
|
||||
- Spend. `#hostAccess` refuses `host-creative-role` (:714) when the agent's current definition is
|
||||
creative, before it reads the record. Both callers go through it: host.run (:2543) and the host.sudo
|
||||
raise (:2778). The record stays, because revoke still owns the cleanup (sage-1908). This goes past
|
||||
the brief's writer line in the same direction; Sage tells Mos.
|
||||
|
||||
Tests:
|
||||
|
||||
- grant-host.test.mjs:115. A `muse` role on a `research` definition that declares host. The raise
|
||||
refuses `host-creative-role` and adds no decision.
|
||||
- grant-executor.test.mjs:947. The restart moves `ops` to a creative definition that declares host,
|
||||
then a fixture grant runs. It ends `host-creative-role` at step 1, the decision is spent, and there is
|
||||
no remote request and no record. The fixture comes after the restart, because the restart's reconcile
|
||||
ends any resolved, unspent host grant as interrupted.
|
||||
- grant-executor.test.mjs:958. An applied grant, then a restart with `ops` creative, then host.run
|
||||
refuses `host-creative-role`. No remote request is added, sudo.log has no `systemctl`, and the record
|
||||
is still there.
|
||||
|
||||
The broker clones `businesses` when it's built, so a definition change needs a new Broker. The test's
|
||||
`restart()` gains a `business` parameter, default unchanged, for that.
|
||||
|
||||
Mutants: B38 removes the `#hostDefinition` line, run against grant-host and grant-executor. B39 removes
|
||||
the `#hostAccess` line, run against grant-executor.
|
||||
|
||||
## Run 2 (sage-1928)
|
||||
|
||||
Run 2 on 534f2254a, 10:12:52-10:14:51Z, was green. grant-host.test.mjs 9 of 9, grant-executor.test.mjs
|
||||
37 of 37 and grant-enroll.test.mjs 24 of 24, each run whole. grant-enroll is unchanged, but it drives the
|
||||
broker, and broker.mjs changed. Then the five mutants, all killed, with the restore matching HEAD:
|
||||
|
||||
| Id | Killed by |
|
||||
|---|---|
|
||||
| B16 | G23 (X5), the revoke as verify returns |
|
||||
| B23 | A2, A4 |
|
||||
| B27 | G8, the store fault in the commit transaction |
|
||||
| B38 | both mos-1163 tests |
|
||||
| B39 | the sage-1908 test |
|
||||
|
||||
## Mutant results, B1-B39
|
||||
|
||||
| Run | Head | Killed | Survived |
|
||||
|---|---|---|---|
|
||||
| B1-B13, 09:22Z | 5df4b52c5 | 12 | B2 (file list) |
|
||||
| B2 alone, B14-B37, 09:28-09:37Z | 5df4b52c5 | 21 | B16, B17, B23, B27 |
|
||||
| B16 B23 B27 B38 B39, 10:13Z | 534f2254a | 5 | none |
|
||||
|
||||
38 of 39 are killed. B17 is equivalent (sage-1904), for the macrotask reason in the B1-B37 section.
|
||||
B1-B37 ran on 5df4b52c5, before the move onto row 213's landing, and weren't rerun on 534f2254a.
|
||||
`--check` matches all 39 anchors once on 534f2254a.
|
||||
|
||||
## Carry list
|
||||
|
||||
- N10 (duckworth-0084, row 234 r1): `cli/src/hosts.mjs:30-31` reads `shape[verb]` off a plain object,
|
||||
so `mosaic hosts constructor` passes usage and the bus refuses it with exit 2 instead of 4. Row b adds
|
||||
`hosts revoke` and `hosts sweep` to the same table (design r6 :562, :680), so the fix goes in with
|
||||
them: `Object.hasOwn(shape, verb)` and a usage test for an `Object.prototype` name (sage-1726).
|
||||
- Untimed tables (sage-1745, row 234 r3): stack-release T46 lists hosts and host_grants as untimed
|
||||
because every write to them shares one store transaction with a `grant.*` events row. Row a writes only
|
||||
hosts. Every host_grants INSERT, UPDATE and DELETE row b adds must do the same, and BUILD.md lists each
|
||||
with its event line. A write without one is a stop to raise with Sage.
|
||||
- N12 (duckworth-0086, row 234 r3, doc-only): row 234's round 3 list of other schema hits leaves out
|
||||
`packages/cli/tests/stack-release-fixture.mjs:214` and `scripts/test-land-work.sh:254`. Both look up
|
||||
one table by name and need no change. If row b changes the schema, its grep list names them.
|
||||
|
||||
## What row a already gives row b (b0320d395)
|
||||
|
||||
- `#approvedGrant`, `#consumeGrant(business, decision, method)` and `GRANT_EVIDENCE` (broker.mjs:667-690).
|
||||
Row b calls `#consumeGrant` with `'host'`.
|
||||
- `#openGrantDecisions(business, method)` and `reconcileGrants()` (broker.mjs:823-860), which today handle
|
||||
`host-enroll` only. Row b adds the `host` method, the `grant.step` undo table (r6 X3) and the
|
||||
revoke-marker reconcile (r6 section 6, N4).
|
||||
- The resolve hook at broker.mjs:2444-2445 starts `#runEnrollment` for `host-enroll`. Row b adds the same
|
||||
start for `host`, under the same human, via cli and choice checks.
|
||||
- `host.run` already reads the store grant record and checks the Approve record (broker.mjs:691-703).
|
||||
- The raise check `grant-chief-only` (broker.mjs:870, :896). `host-grant.mjs:55` refuses every method but
|
||||
`host-enroll`, and row b opens it to `host`.
|
||||
- `Credentials` has `#inflight` with the `host-busy` claim inside `runHost` (credentials.mjs:118, :355).
|
||||
It has no `claim`, `abortClaim`, `register` or `unregister`. Row b adds those (r6 X4, X5).
|
||||
|
||||
## Plan, in build order
|
||||
|
||||
1. Raise binding (r6 R1-R4): the `grant` argument for `method: 'host'`, `host-not-declared`,
|
||||
`host-not-ready`, the 210 D6 argv rules, `rotateBy`, the rendered block and the 4096 cap, and the raise
|
||||
body hashes. Tests G1-G5, G19.
|
||||
2. `Credentials.claim`, `release`, `abortClaim`, `register`, `unregister`, with the shared reference check.
|
||||
3. The executor in `bus/src/host-grants.mjs`, steps 1-6 (r6 X2), with failure undo (X3) and the abort rule
|
||||
(X5, C1). Tests G7-G10, G15, G23, G24.
|
||||
4. Restart reconcile for `grant.step`, unspent `host` decisions, and the start sweep of `host.<id>` key
|
||||
files with no record (r6 X3, N1). Tests G11, G27, G28.
|
||||
5. `hosts.revoke` and `hosts.sweep` verbs, plus the CLI `revoke` and `sweep` with N10. Tests G12, G13,
|
||||
G26, G27.
|
||||
6. G22 end to end on one bus. Then the mutant list from r6 section 10 for row b, numbered B1 onward in
|
||||
this file, with the upsert mutant and the abort-after-commit mutant included.
|
||||
|
||||
There is no schema step. If one turns out to be needed, I stop and ask Sage to re-brief first.
|
||||
|
||||
## Step 1 notes (raise binding, read on b0320d395)
|
||||
|
||||
Where the code goes:
|
||||
|
||||
- `#raiseGrant` (broker.mjs:866-885) runs the checks and keyscan for `host-enroll` today, and every raise
|
||||
with `grant` reaches it (:1738-1739). Row b branches on `a.grant?.method`. `host` takes no keyscan, so
|
||||
its checks can all run inside the one raise transaction, as R4 asks.
|
||||
- `enrollCheck` (host-grant.mjs:50-69) refuses every method but `host-enroll` (:55). Row b adds a sibling
|
||||
`hostGrantCheck` for the R4 shape `{method, agent, host, commands, rotateBy}` and keeps `enrollCheck`
|
||||
as it is, so the 234 tests don't move.
|
||||
- The raise body is at broker.mjs:2270, :2345-2353 and :2391. `enroll` becomes a generic `grant`
|
||||
binding. The question block for `host` follows the `host.sudo` block (:2337-2341): agent, host id,
|
||||
address, user, sudo, each prefix JSON-quoted, rotate date, and new key or changed commands. The
|
||||
4096 cap comes from `string(question)` (:2354).
|
||||
- The argv rules come from `checkCommands` (business/src/hosts.mjs:67-87): no wildcard, no empty prefix,
|
||||
no glob characters, a bare name or an absolute path, and `refusedCommand` for shells and runners. It
|
||||
throws `refuse(...)` with a message, so the bus maps any throw to `invalid-request`, as `enrollCheck`
|
||||
maps `checkAddress` (host-grant.mjs:57-61).
|
||||
- `host-not-declared`: the agent's definition must list `{"service": "host"}`. business.mjs:126-129 reads
|
||||
`definition.credentials` and filters out `HOST_SERVICE` (vocabulary.mjs:113). The bus needs the
|
||||
definition's service list. Read how the running business exposes the definition before choosing.
|
||||
- Both lookups are answered. `loadBusiness` returns `definitions`, each role file's validated document
|
||||
(business/src/business.mjs:114-124, :159, returned at :267). `busBusiness` clones the whole business
|
||||
(bus/src/business.mjs:7), so the broker has `b.definitions`. `#definitions` already reads it
|
||||
(broker.mjs:1027-1029), and the comment there says the broker doesn't read `roles/` (sage-0590).
|
||||
- `host-not-declared` checks `b.definitions[b.roles[agent].definition].credentials` for
|
||||
`{service: 'host'}`. role.mjs:118 normalizes a host entry to exactly that.
|
||||
- Deviation from R4: `definitionSha256` is sha256 over `canonicalJson` of that validated definition,
|
||||
not over the file's bytes. The broker doesn't read `roles/`. A whitespace-only edit to the file no
|
||||
longer voids an approval, but any change to a field the loader keeps still does. The bus sees a
|
||||
definition change only after a business reload or restart, the same as every other role field.
|
||||
This goes in the round 1 note.
|
||||
|
||||
Assumption, accepted for round 1 by Sage (sage-1782), for Duckworth to check in review: design R4 says `rotateBy` is "a date inside 210's rotation window", but 210
|
||||
defines no window in code. It checks only that the value is a real calendar day (bus
|
||||
credentials.mjs:16-20, issue at :245). The business load calls a past `rotateBy` a problem
|
||||
(business/src/credentials.mjs:114). So the raise takes a real day strictly after today, UTC, and refuses
|
||||
`invalid-request` otherwise. There is no upper bound, because no requirement states one and 210
|
||||
checks none.
|
||||
|
||||
## Step 1 as built (not run yet; no slot)
|
||||
|
||||
- `hostGrantCheck(grant, today)` in host-grant.mjs takes exactly `method`, `agent`, `host`, `commands` and
|
||||
`rotateBy`. `agent` must match ID_PATTERN and `host` HOST_ID. `commands` goes through `checkCommands`,
|
||||
and any throw becomes `invalid-request`. `rotateBy` must pass 210's `day` (now exported from bus
|
||||
credentials.mjs) and be later than the bus clock's UTC day.
|
||||
- `#raiseGrant` branches on `a.grant?.method === 'host'` after the chief check and before `enrollCheck`.
|
||||
So a host-enroll shape with `method: 'host'` still refuses `invalid-request`, and the 234 G1 list holds.
|
||||
- `#hostGrantBinding` runs inside the raise transaction. It refuses `unknown-role` for an agent that
|
||||
isn't a role instance (the code bindLaunch uses, broker.mjs:522). It refuses `host-not-declared`, and
|
||||
`host-unknown` for a host with no record, as `#hostAccess` does. A record in `pending-install` refuses
|
||||
`host-not-ready`. R4 names only `host-not-ready`. I picked `host-unknown` for a missing record so
|
||||
"not enrolled" and "enrolled but not checked" stay apart.
|
||||
- `commandsSha256` is sha256 over `JSON.stringify(commands)`. For arrays of strings this equals
|
||||
canonicalJson, and step 3's executor recomputes it the same way.
|
||||
- `#dispatch` takes `binding = {body, block}` in place of `enroll`. The enrollment block moved into
|
||||
`enrollBinding` at module level with its text unchanged, so the 234 G4 test reads the same question.
|
||||
- No new import in broker.mjs, so RUNNER_FILES doesn't change. host-grant.mjs now imports
|
||||
business/src/util.mjs for canonicalJson. Nothing in the sandbox loads host-grant.mjs. Before in-review
|
||||
I still run seat sandbox.test.mjs and bus project-runtime.test.mjs whole.
|
||||
- The new-key line reads host_grants (a SELECT). Step 1 writes no host_grants row.
|
||||
- Tests: bus/tests/grant-host.test.mjs, G1 (host), G2, G3, rotateBy, G19, R4/G5 body hashes, G4 block.
|
||||
The G5 tester-thread case: a thread run is always THREAD_DEFINITION, never chief-of-staff, so
|
||||
`grant-chief-only` refuses it first. The test covers the tester's own cap (`tester-refused`).
|
||||
|
||||
## Step 2 as built (not run yet)
|
||||
|
||||
- The constructor's per-reference checks moved into `#checkRef`, in the same order, and `register` calls
|
||||
it (r6 X4). `register` reads no file. A key file that a failed revoke unlink left at the path stays
|
||||
unused until the start sweep (step 4) removes it. `register` refuses `credential-reference` while the
|
||||
entry holds a live token. An entry with no token, revoked or never issued, is replaced, so a new grant
|
||||
after a revoke registers again.
|
||||
- `unregister` drops an entry with no token and clears its revoked mark. A live token refuses.
|
||||
- `claim` refuses `host-busy` when the slot is held, by a claim or a `host.run`, and returns the signal.
|
||||
It needs no reference, because the executor claims at step 1 and registers at step 5.
|
||||
`release(instance, service, signal)` frees the slot only for its own signal. I added the signal
|
||||
argument so a late release can't free a later claim.
|
||||
- `abortClaim` aborts whatever holds the slot and returns `{pending}`. An aborted claim keeps the slot
|
||||
until the executor releases it in its `finally` (X2).
|
||||
- Tests: bus/tests/credential-claim.test.mjs. The D5 `use(` allowlist is unchanged.
|
||||
|
||||
## Step 3 as built (not run yet)
|
||||
|
||||
Where the code goes. The remote steps are in host-grant.mjs (`grantKey`, `install`, `verify`, `remove`)
|
||||
and the orchestration is `#runHostGrant` in broker.mjs, next to `#runEnrollment`. BUILD.md's plan said a
|
||||
new `bus/src/host-grants.mjs`. I kept row 234's split instead: the broker holds every store write and
|
||||
host-grant.mjs holds the network and key files. A new broker import would also change what the sandbox
|
||||
must mount (RUNNER_FILES), and this way broker.mjs gains none.
|
||||
|
||||
The broker gets `hostKeys`, made by `grantKeys({credentials, storeRoot})` in host-grant.mjs. It wraps
|
||||
only `claim`, `release`, `abortClaim`, `register`, `unregister`, `issue` and `revoke`, keyed by
|
||||
(business, agent, hostId). The broker still never gets `use()`. runtime.mjs builds it and passes it in.
|
||||
`hostReference` gives the store reference, and the runtime's start load uses the same function.
|
||||
|
||||
Every host_grants write in step 3, with its event (sage-1745):
|
||||
|
||||
| Write | Where | Event in the same transaction |
|
||||
|---|---|---|
|
||||
| INSERT, a new key | the `commit` callback that `issue` calls | `grant.applied` |
|
||||
| UPDATE commands and decision, a commands change | step 5's transaction | `grant.applied` |
|
||||
|
||||
No other step 3 code writes host_grants.
|
||||
|
||||
Deviations from r6, for the round 1 note:
|
||||
|
||||
- `grant.applied` commits with the record, inside `commit`, not after `issue` at step 6. The untimed-table
|
||||
rule needs an event in the record's transaction. `grant.step commit` can't be that event, because X2
|
||||
writes it before the key file. So X3's "5 after commit" row has nothing to undo in the executor: once the
|
||||
record commits, `grant.applied` has too. What's left in `issue` after `commit` is the memory fill, which
|
||||
writes no file and no store row. The executor sets `committed` after the transaction returns and from
|
||||
then ignores an abort (C1). The restart reconcile (step 4) keeps the commit row, because a crash can fall
|
||||
between the key file write and `commit`.
|
||||
- Event bodies name the decision as `grantDecision`, as row 234's enrollment events do. The consume record
|
||||
is the only grant body with `decision`, so `#spent` counts it and nothing else.
|
||||
- For a commands change, `grant.applied` has `fingerprint: null`. The executor holds no key on that path.
|
||||
The record's `rotate_by` stays. X2 names only commands and the decision id.
|
||||
- The keygen dir goes as soon as `grantKey` has read the key back into memory, not in step 5's `finally`.
|
||||
- Step 4 runs `true` as the remote command as is, not through the sudo argv, since a user with no sudo
|
||||
must pass it too.
|
||||
- `hostGrant()` takes `installerUser`, `mosaic-installer` by default. Only the tests change it, because
|
||||
the scratch sshd logs in only as the test's own user.
|
||||
- A commands-change UPDATE that changes no row ends `host-changed`. Only a revoke deletes a live record,
|
||||
and a revoke aborts the claim first, so this is a guard.
|
||||
|
||||
Step 1 codes, after the spend and in its transaction: `host-not-configured`, `unknown-role`,
|
||||
`host-not-declared`, `decision-mismatch` (definition, registry or commands hash), `host-unknown`,
|
||||
`host-not-ready`, `revoked` (a `grant.revoked` of the agent and host after the raise). The definition
|
||||
check moved into `#hostDefinition`, which the raise binding uses too.
|
||||
|
||||
Tests: bus/tests/grant-executor.test.mjs, a scratch sshd on 127.0.0.1 with stub `list`, `add` and
|
||||
`remove` that edit a second authorized-keys file. G7 (new key, and a commands change), G8 (install, verify,
|
||||
a failed remove, step 5 before commit, a failed step 1 check), G9, G10, G15, G23 (a revoke during install
|
||||
through `abortClaim`, a `grant.revoked` before start, and a held slot), G24's tag part. G23's held slot
|
||||
uses `claim` in place of a running `host.run`. Both hold the same `#inflight` entry. The `revoke` verb
|
||||
comes in step 5, so G23 here writes `grant.revoked` through the fixture writer and aborts through
|
||||
`abortClaim`. Step 5 adds the verb's own path.
|
||||
|
||||
## Step 4 as built (not run yet)
|
||||
|
||||
The restart reconcile has three parts, in start order:
|
||||
|
||||
1. `sweepHostKeys` (host-grant.mjs), in runtime.mjs right before `Credentials` loads. In each loaded
|
||||
business's agent store it unlinks every `host.<id>` file that has no `host_grants` record, and logs
|
||||
one `hosts: {"event":"host-key-swept",...}` line per file to stderr. A store dir that isn't private
|
||||
refuses `credential-file`, and so does the start. A name that isn't `host.` plus a valid host id
|
||||
stays, so ssh-keygen's `.pub` stays too. A business the bus didn't load is left alone.
|
||||
2. `reconcileGrants`, where row 234 already calls it, after `Credentials` loads and before the socket
|
||||
opens. For a `host` grant it ends a spent decision that has no `grant.applied` and no `grant.failed`.
|
||||
It also spends and ends an unspent one the human resolved yes. Each ends `grant.failed interrupted`,
|
||||
with the tag and a notice. When the last step is `install`, or `commit` with `previous: null`, a line
|
||||
may be left. For those, it claims the (agent, `host.<id>`) slot and queues the decision for part 3.
|
||||
3. `reconcileHosts`, which runtime.mjs calls once `serve` returns, with no await, so a start doesn't
|
||||
wait on the network. Each queued decision runs `remove <user> <tag>` through the installer, as the
|
||||
step's `user`. Then it ends `grant.failed interrupted`, with `hostLineLeft` if the remove failed or
|
||||
there's no host record, and releases the claim. The claim makes a new grant for the same agent and
|
||||
host refuse `host-busy` while the remove runs. Otherwise its new line, with the same tag, could be
|
||||
removed.
|
||||
|
||||
Deviations from r6's restart table, for the round 1 note:
|
||||
|
||||
- No record write on restart. Step 3 commits the record with `grant.applied` in one transaction. So a
|
||||
decision with no `grant.applied` has no record. For a new key, the record undo has nothing to delete.
|
||||
For a commands change, the "back to `previous`" undo has nothing to put back. What's left of the
|
||||
`commit, new key` row is the key file, written before `commit`, and the start sweep unlinks it.
|
||||
The `commit, commands change` row ends interrupted with nothing undone. G28's "a revoke before
|
||||
`grant.applied`" can't happen, because they commit together. Step 5 tests G28 as a change, then a
|
||||
revoke, then a restart.
|
||||
- The local undo of row 234's enrollments stays after `Credentials` loads, where row 234 put it. That
|
||||
undo touches only the installer key and the host record, and `Credentials` reads neither.
|
||||
- The host grant part writes no `host_grants` row, so the untimed-table rule (sage-1745) adds nothing
|
||||
here.
|
||||
|
||||
Fixture change: `hostGrantRecord` (tests/host-fixtures.mjs) writes `grant.applied` after a `grant.host`
|
||||
spend, as the executor always does. Without it, a start over a fixture record ends that record's
|
||||
decision `interrupted`. Row 234's M3 start test (host-run.test.mjs:602) does that start. No test counts
|
||||
the fixture's grant events (checked by grep across the bus tests).
|
||||
|
||||
Tests in grant-executor.test.mjs, through a `restart` helper that follows runtime.mjs's order on the
|
||||
same store:
|
||||
|
||||
- G11 after `install`: the line goes, the slot is held between the two parts, and the grant ends
|
||||
interrupted. A second start adds nothing, and `runHostGrant` refuses `decision-consumed`.
|
||||
- G11 after a new key's `commit` with the key file written: no key file, record, reference or line.
|
||||
- G11 after a change's `commit`: the record, key and line are unchanged, and the host gets no request.
|
||||
- G11 (B2) before step 1, spent and unspent: each ends interrupted with a notice, and the unspent one
|
||||
is spent exactly once.
|
||||
- A failed remote undo, and a missing host record: each ends with `hostLineLeft` and the sweep hint.
|
||||
- A finished grant: the reconcile adds nothing.
|
||||
- N1 (G27's sweep part): only files with no record in loaded businesses go, and a dir that isn't private
|
||||
refuses.
|
||||
|
||||
The revoke-marker part of the reconcile (section 6, N4) comes with the revoke verb in step 5.
|
||||
|
||||
## Step 5 as built (not run yet)
|
||||
|
||||
Two new bus verbs, `hosts.revoke` and `hosts.sweep`, join `HOSTS_VERBS`. The CLI gets
|
||||
`mosaic hosts revoke AGENT HOST --reason R` and `mosaic hosts sweep HOST`.
|
||||
|
||||
`hosts.revoke {agent, host, reason}` follows r6 section 6:
|
||||
|
||||
1. One transaction deletes the `host_grants` record and writes `grant.revoked` with a new `revoke` id
|
||||
(a UUID), the computed tag, `hostLine: "pending"`, `granted`, `reason` and `by`. With no record it
|
||||
still writes the marker, with `granted: false`, so a stale line can be named (N4). A host with no
|
||||
record refuses `host-unknown` and writes nothing.
|
||||
2. With no await between, `Credentials.revoke` drops the key from memory, aborts a `host.run` in flight
|
||||
and unlinks the file. With no reference (`credential-unknown`), an executor holds only a claim, so
|
||||
`abortClaim` stops it. Any other throw, such as an unlink that fails, comes back as `keyFile: "left"`.
|
||||
The record is already gone, so the next start's sweep unlinks that file.
|
||||
3. `remove <user> <tag>` through the installer, as the host record's user (the executor's user too).
|
||||
Then `grant.revoke.closed` with the same `revoke` id and `hostLine: "removed"`, or `hostLineLeft`.
|
||||
|
||||
Callers (G26): the human, and the assistant of the session's business, checked with `#threaded`. The
|
||||
tester can call it when `bus.tester` is exactly `true`, read again at the call. Any other agent gets
|
||||
`revoke-refused`, a code new in this row. A tester session can't hold a business whose `bus.tester`
|
||||
isn't `true`, since `bindTester` refuses `tester-off`, and the broker's businesses don't change while
|
||||
it runs. So `tester-dev-only` can't be reached from a test. The check stays for N8, in case a later
|
||||
row reloads businesses.
|
||||
|
||||
`hosts.sweep {host}` is the human's or the tester's; an agent gets `human-required`. It runs
|
||||
`list <user>`, then removes each listed tag that this business recorded for the host in a `grant.step`
|
||||
or `grant.revoked` and that no live grant holds. Another business's line and an unrecorded `mosaic-`
|
||||
line stay (G24's sweep part). It writes `grant.host.swept {host, removed, left, by}`, where `left` is
|
||||
each earlier left tag still listed and not removed. `#hostLinesLeft` now starts from the last
|
||||
`grant.host.swept` and adds only later `hostLineLeft` bodies (F4).
|
||||
|
||||
The restart reconcile adds the marker part: `reconcileGrants` collects each `grant.revoked` with
|
||||
`hostLine: "pending"`, a text `revoke` id and no `grant.revoke.closed` of that id. `reconcileHosts`
|
||||
runs step 3 for each. A `grant.revoked` with no `revoke` id, as G23's fixtures write, isn't reopened.
|
||||
`hosts.status` adds `revoking`, the tags of the open markers on the host.
|
||||
|
||||
Other changes:
|
||||
|
||||
- N10: the CLI checks the verb with `Object.hasOwn`, so `constructor`, `toString` or `__proto__` is a
|
||||
usage error.
|
||||
- `--reason` is required with `revoke` and refused with any other verb.
|
||||
- The reason goes through the broker's `string(x, 500)`, as `credentials.revoke`'s does, so a reason of
|
||||
only spaces is accepted there too.
|
||||
- Correction to step 4: the first G11 test expected `list` then `remove`. `reconcileHosts` sends only
|
||||
`remove`, so the test now expects that.
|
||||
|
||||
Residuals for the round 1 note:
|
||||
|
||||
- A new grant whose line goes in while a revoke's step 3 or a sweep runs can lose that line, if it has
|
||||
the revoked tag. Revoke holds no claim during step 3. If the remove lands after the new grant's
|
||||
verify, the grant is recorded but its line is gone, so its logins fail. I didn't add a claim, because
|
||||
a revoke must not wait on a slot.
|
||||
- The enrollment's installer tag is never in the sweep's set, so a `hostLineLeft` naming it stays
|
||||
until the human removes it by hand.
|
||||
|
||||
Tests in grant-executor.test.mjs:
|
||||
|
||||
- G12: the human's revoke of a live grant, and the bodies of both markers. A restart adds nothing.
|
||||
- G12 (N4): a revoke with no record removes the computed tag's line. Bad args refuse
|
||||
`invalid-request` and an unknown host `host-unknown`.
|
||||
- G13: a revoke during `install` aborts the executor, which ends `revoked`. A raise before a revoke
|
||||
and started after it ends `revoked` at step 1.
|
||||
- G26: who may revoke and who may sweep.
|
||||
- G27: a restart after step 1 (the sweep takes the key file, then step 3 runs), after step 2 with the
|
||||
host down (`hostLineLeft`, then a sweep clears it), and a file a revoke couldn't unlink.
|
||||
- G28: a commands change, then a revoke, then a restart.
|
||||
- G24 (B4): the sweep's tag set, a failing `list`, and the left list it records.
|
||||
|
||||
The CLI tests in packages/cli/tests/hosts.test.mjs cover revoke and sweep, the N10 usage cases and the
|
||||
`revoking` field in a status line.
|
||||
|
||||
## Step 6 as built (not run yet)
|
||||
|
||||
New tests in grant-executor.test.mjs:
|
||||
|
||||
- G22 (B1), one bus with no restart. The chief enrolls web1, the human approves, and `hosts.check`
|
||||
marks it ready. A grant of `echo` follows, then a `host.run` of `echo hi` that prints `hi`. A
|
||||
`sleep` raise refuses `command-not-granted`, and after a commands change to `echo` and `sleep` the
|
||||
next `host.run` of `sleep 30` starts. A revoke kills that run with `credential-unavailable`. The next
|
||||
call refuses `host-not-granted`.
|
||||
- G12: the local steps finish before the host step. With the host's `remove` held, the record and key
|
||||
file are already gone and `hosts.status` shows the tag under `revoking`.
|
||||
- G13: a revoke whose host `remove` fails returns `hostLineLeft`, drops the record and key, and closes
|
||||
the marker, so a restart adds nothing.
|
||||
- G23 (X5): a revoke right after the executor's spend reaches its claim and the grant ends `revoked`.
|
||||
- G23 (X5): a revoke that lands as `verify` returns ends `revoked` with no record, key file or line.
|
||||
Only the abort check after `verify` catches it.
|
||||
|
||||
The fixture gains a `sudo` stub (logs to sudo.log, then runs the command as the test's user), a
|
||||
`hold-remove` flag, `opts.enroll` (no host record and no installer key) and `opts.wrap` (changes the
|
||||
host API the broker gets). `hostGrant` gets a fake `scan` that returns the scratch sshd's host key.
|
||||
|
||||
G22 deviations, for the round 1 note:
|
||||
|
||||
- `checkAddress` (business/src/hosts.mjs:62) refuses this machine's address, so the test enrolls
|
||||
192.0.2.10 with the fake keyscan. It then points the record at the scratch sshd with an SQL update
|
||||
and adds the enrolled installer key to the sshd's authorized keys. Enrollment, check, grant, run and
|
||||
revoke all go through the real code after that.
|
||||
- r6 B1 says the next `host.run` after a revoke refuses `credential-unavailable`. Here it refuses
|
||||
`host-not-granted`, because step 1 deletes the record and `requestHost` checks the record
|
||||
(`#hostAccess`) before it reaches the key. The call in flight does refuse `credential-unavailable`.
|
||||
|
||||
### Mutants
|
||||
|
||||
`/mnt/storage/scratch/gizmo-235-out/mutants.py` holds each mutant as anchored edits. `--check` applies
|
||||
them in memory and runs no test. All 39 anchors match once. Each mutant runs its named test files.
|
||||
|
||||
From r6 section 10, as they apply to row b:
|
||||
|
||||
| Id | Mutant | Test file |
|
||||
|---|---|---|
|
||||
| B1 | drop the chief check on a host grant raise | grant-host (G1) |
|
||||
| B2 | start the executor on a `credential.mint` with no `grant` | grant-enroll (A2), grant-executor |
|
||||
| B3 | skip the `ready` check at the raise | grant-host (G19) |
|
||||
| B4 | skip the `ready` check at step 1 | grant-executor |
|
||||
| B5 | skip the definition hash compare | grant-executor (G9) |
|
||||
| B6 | drop the definition check (`host-not-declared`) | grant-host (G2), grant-executor (G9) |
|
||||
| B7 | skip the commands hash compare | grant-executor (G9) |
|
||||
| B8 | skip the host record hash compare | grant-executor (G9) |
|
||||
| B9 | write the grant record before install | grant-executor (G8) |
|
||||
| B10 | skip `remove` on a verify failure | grant-executor (G8) |
|
||||
| B11 | skip the start sweep of key files with no record | grant-executor (N1, G27) |
|
||||
| B12 | skip the reconcile's remote `remove` | grant-executor (G11) |
|
||||
| B13 | skip the `grant.step` before install | grant-executor (G11) |
|
||||
| B14 | revoke leaves the grant record | grant-executor (G12) |
|
||||
| B15 | skip `register` before `issue` | grant-executor (G7) |
|
||||
| B16 | drop the abort check after `verify`, the last one before `issue` | grant-executor (G23 X5) |
|
||||
| B17 | add an `await` between the abort check and `issue` | grant-executor |
|
||||
| B18 | revoke ignores a claim with no reference | grant-executor (G13, G23 X5) |
|
||||
| B19 | build the tag without the business | grant-executor (G24) |
|
||||
| B20 | `sweep` removes any `mosaic-` line | grant-executor (G24 B4) |
|
||||
| B21 | run revoke's host step before the local steps | grant-executor (G12) |
|
||||
| B22 | start the executor on an arbiter resolve | grant-executor (G10) |
|
||||
| B23 | start it on `no` | grant-enroll (A2, A4), grant-executor |
|
||||
| B24 | write the private key into the event body | grant-executor (G15) |
|
||||
| B25 | put an `await` between the spend and the claim | grant-executor (G23 X5) |
|
||||
| B26 | skip the start check for a resolved, unspent grant decision | grant-executor (G11 B2) |
|
||||
| B27 | make `commit` async | grant-executor (G8) |
|
||||
| B28 | route `hosts.revoke` to `Credentials.revoke` alone, as `credential-verbs.mjs` would | grant-executor (G12, G27) |
|
||||
| B29 | skip the revoke marker reconcile | grant-executor (G27) |
|
||||
|
||||
Row 235's own:
|
||||
|
||||
| Id | Mutant | Test file |
|
||||
|---|---|---|
|
||||
| B30 | skip the `grant.revoked` check at step 1 | grant-executor (G13, G23) |
|
||||
| B31 | the sweep set keeps a live grant's tag | grant-executor (G24 B4) |
|
||||
| B32 | reopen a `grant.revoked` with no revoke id | grant-executor (G27) |
|
||||
| B33 | step 3 reports `removed` when the host remove fails | grant-executor (G13, G27) |
|
||||
| B34 | any agent revokes, not just the assistant | grant-executor (G26) |
|
||||
| B35 | `hostLineLeft` ignores the last sweep | grant-executor (G24 B4, G27) |
|
||||
| B36 | a stale `release` frees a later claim | credential-claim (X5), grant-executor |
|
||||
| B37 | `register` replaces an entry with a live token | credential-claim (X4) |
|
||||
| B38 | drop the creative check in `#hostDefinition` | grant-host (mos-1163), grant-executor (mos-1163) |
|
||||
| B39 | drop the creative check in `#hostAccess` | grant-executor (sage-1908) |
|
||||
|
||||
B17 may survive. `await null` yields only to microtasks, and every revoke starts in a macrotask of its
|
||||
own (a socket request, or a test's call), so none can land in that gap. If it survives I'll report it
|
||||
as equivalent under this build, not add a test that fakes a microtask revoke.
|
||||
|
||||
Section 10 items not run as row b mutants, and why:
|
||||
|
||||
- The upsert mutant ("let the undo to `previous` insert a record") and "run past a newer
|
||||
`grant.revoked`". Row 235 has no undo to `previous`. A commands change commits the UPDATE and
|
||||
`grant.applied` in one transaction (sage-1745), so a restart after it leaves the record as it is
|
||||
(G11). The UPDATE can't meet a missing record either: step 1 reads the live record, and nothing
|
||||
awaits between there and the UPDATE. An upsert there would be equivalent.
|
||||
- "let the executor act on an abort after step 5 commits". The record and `grant.applied` commit
|
||||
together, and no abort check follows the commit, so no code path reaches the `if (committed)` guard
|
||||
with an abort. Removing the guard is equivalent. C1's case is covered by G28 with a later revoke.
|
||||
- "treat a missing `bus.tester` as dev". The tester check can't be reached from a test (step 5 notes).
|
||||
- Row a's: the keyscan compare, the writer items, the "not yet enrolled" check, the `hosts` primary key
|
||||
and `host.run` reading the business file. Row 234 ran those as A5, A6, A8, A9, A11 and A13-A19.
|
||||
@@ -218,9 +218,11 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
|
||||
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
|
||||
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
|
||||
| `hosts.status` | No args; the human, the tester or the business's chief (row 234, #1740). `[{host,address,port,user,sudo,state,hostKeys,installerFingerprint,checkedAt,hostLineLeft}]` from the `hosts` records, with host keys as fingerprints. Never a key. |
|
||||
| `hosts.status` | No args; the human, the tester or the business's chief (row 234, #1740). `[{host,address,port,user,sudo,state,hostKeys,installerFingerprint,checkedAt,hostLineLeft,revoking}]` from the `hosts` records, with host keys as fingerprints. Never a key. |
|
||||
| `hosts.check` | `{host}`; same callers. Runs the host's `list <user>` as `mosaic-installer` with the installer key and the pinned host keys, then sets `state` `ready` and `checkedAt` and writes `grant.host.checked`. A nonzero exit is `host-failed`, a record that changed during the run `host-changed`, a record with no installer key `host-not-enrolled`. |
|
||||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||||
|
||||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||||
|
||||
+462
-31
@@ -221,11 +221,23 @@ export function writeGrantEvent(store, at, business, body, subject = null) {
|
||||
);
|
||||
return eid;
|
||||
}
|
||||
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove']);
|
||||
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']);
|
||||
// The OpenSSH fingerprint of a "<type> <base64>" host key line. host-grant.mjs has the same; the broker
|
||||
// doesn't import it, so the sandbox's RUNNER_FILES stay as they are.
|
||||
const fingerprintOf = (line) =>
|
||||
'SHA256:' + createHash('sha256').update(Buffer.from(line.split(' ')[1], 'base64')).digest('base64').replace(/=+$/, '');
|
||||
// Row 234 (#1740, r6 E1): an enrollment's raise binding. The human sees each scanned key with its
|
||||
// fingerprint, and the sudo mark in plain words.
|
||||
const enrollBinding = (enroll) => ({
|
||||
body: enroll,
|
||||
block:
|
||||
`\n\nEnroll host ${enroll.host}: user ${enroll.user} at ${enroll.address} port ${enroll.port}.` +
|
||||
`\nHost keys from ssh-keyscan, equal to the requested fingerprints:` +
|
||||
enroll.hostKeys.map((k) => `\n${fingerprintOf(k)} ${k}`).join('') +
|
||||
(enroll.sudo
|
||||
? `\n${enroll.user} has sudo on this host. Approving lets later grants for ${enroll.user} be installed, and section 9's first limit applies to ${enroll.user}.`
|
||||
: `\n${enroll.user} has no sudo on this host.`),
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
@@ -272,11 +284,17 @@ export class Broker {
|
||||
#harnessOn;
|
||||
// Row 234 (#1740): the trusted enrollment half (host-grant.mjs), or null where hosts are off.
|
||||
#hostGrant;
|
||||
// Row 235 (#1741, r6 X2 to X5): grantKeys from host-grant.mjs, the executor's calls on Credentials, or null.
|
||||
#hostKeys;
|
||||
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
|
||||
#hostUndo = [];
|
||||
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
|
||||
#revokeUndo = [];
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -286,6 +304,7 @@ export class Broker {
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#hostGrant = hostGrant;
|
||||
this.#hostKeys = hostKeys;
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
@@ -690,6 +709,9 @@ export class Broker {
|
||||
// decision is a credential.mint with a `host` grant, resolved yes by the human via cli and spent by
|
||||
// #consumeGrant as grant.host. Read at each call, so a grant, a revoke and a hosts.remove need no reload.
|
||||
#hostAccess(s, host) {
|
||||
// mos-1164 (sage-1908): a creative role has no path to host.run, even through a record written before
|
||||
// its instance became creative. The record stays, so hosts.revoke still finds it.
|
||||
if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');
|
||||
const r = this.#hostRecord(s.business, host);
|
||||
if (!r) fail('host-unknown');
|
||||
const g = this.#store.get('SELECT * FROM host_grants WHERE business=? AND agent=? AND host=?', s.business, s.role, host);
|
||||
@@ -818,6 +840,193 @@ export class Broker {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// Row 235 (#1741, r6 X2): a host grant's end, in the caller's transaction. The tag is listed every time,
|
||||
// so sweep and status can match a line left on the host.
|
||||
#hostGrantFailed(business, decision, g, code, extra = {}) {
|
||||
const tag = this.#hostGrant?.grantTag(business, g.agent, g.host) ?? null;
|
||||
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, agent: g.agent, host: g.host, code, tag, ...extra }, g.host);
|
||||
this.#grantNotice(
|
||||
business,
|
||||
decision,
|
||||
`Grant of host ${g.host} to ${g.agent} (decision ${decision}) failed: ${code}.` +
|
||||
(extra.hostLineLeft ? ` A line tagged ${extra.hostLineLeft} may be left on the host. Run: mosaic hosts sweep ${g.host}` : ''),
|
||||
);
|
||||
}
|
||||
// r6 R1: the agent's definition, if the agent is a role of the business and the definition declares host.
|
||||
// mos-1163: a creative role gets no host grant, whatever its definition declares. The raise binding and
|
||||
// the step 1 recheck both come here.
|
||||
#hostDefinition(business, agent) {
|
||||
const b = this.#business(business);
|
||||
if (!Object.hasOwn(b.roles, agent)) fail('unknown-role');
|
||||
const name = b.roles[agent].definition;
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) fail('host-creative-role');
|
||||
const definition = Object.hasOwn(b.definitions ?? {}, name) ? b.definitions[name] : null;
|
||||
if (!definition?.credentials?.some((c) => c.service === 'host')) fail('host-not-declared');
|
||||
return definition;
|
||||
}
|
||||
// r6 X2 step 1, X5: a grant.revoked of (agent, host) recorded after the decision's raise.
|
||||
#revokedSince(business, decision, agent, host) {
|
||||
return Boolean(
|
||||
this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND json_extract(body,'$.host')=? AND seq > (SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=? ORDER BY seq LIMIT 1) LIMIT 1`,
|
||||
business, agent, host, business, decision,
|
||||
),
|
||||
);
|
||||
}
|
||||
// r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant.
|
||||
#hostGrantRecheck(business, decision, g) {
|
||||
if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured');
|
||||
const definition = this.#hostDefinition(business, g.agent);
|
||||
if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
|
||||
const r = this.#hostRecord(business, g.host);
|
||||
if (!r) fail('host-unknown');
|
||||
if (registrySha256(r) !== g.registrySha256) fail('decision-mismatch');
|
||||
if (r.state !== 'ready') fail('host-not-ready');
|
||||
if (sha256(JSON.stringify(g.commands)) !== g.commandsSha256) fail('decision-mismatch');
|
||||
if (this.#revokedSince(business, decision, g.agent, g.host)) fail('revoked');
|
||||
return r;
|
||||
}
|
||||
// Trusted host API, as runEnrollment is, and no verb reaches it: the grant executor's entry for one
|
||||
// decision. A second start ends decision-consumed with nothing written (r6 G10).
|
||||
runHostGrant(business, decision) {
|
||||
return this.#runHostGrant(business, decision);
|
||||
}
|
||||
// r6 X2, started by the human's cli yes. Never throws: the outcome is in the store and the notices, and
|
||||
// the return value is 'applied' or the code grant.failed records. Every host_grants write shares its
|
||||
// transaction with grant.applied (sage-1745).
|
||||
async #runHostGrant(business, decision) {
|
||||
let start;
|
||||
try {
|
||||
// Step 1, one transaction: the spend, then R4's checks again. A failed check still spends.
|
||||
start = this.#store.transaction(() => {
|
||||
const g = this.#consumeGrant(business, decision, 'host');
|
||||
let record;
|
||||
try {
|
||||
record = this.#hostGrantRecheck(business, decision, g);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
this.#hostGrantFailed(business, decision, g, e.code);
|
||||
return { g, code: e.code };
|
||||
}
|
||||
const live = this.#store.get('SELECT commands FROM host_grants WHERE business=? AND agent=? AND host=?', business, g.agent, g.host);
|
||||
return { g, record, previous: live ? JSON.parse(live.commands) : null };
|
||||
});
|
||||
} catch (e) {
|
||||
// Spent, not approved or no store: nothing was written. The start reconcile ends a resolved,
|
||||
// unspent decision.
|
||||
return e instanceof BusError ? e.code : 'host-failed';
|
||||
}
|
||||
if (start.code) return start.code;
|
||||
const { g, record, previous } = start;
|
||||
// Right after the commit, with no await between, so no revoke or host.run falls in between (X2).
|
||||
let signal;
|
||||
try {
|
||||
signal = this.#hostKeys.claim(business, g.agent, g.host);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'host-failed';
|
||||
try {
|
||||
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code));
|
||||
} catch {}
|
||||
return code;
|
||||
}
|
||||
const tag = this.#hostGrant.grantTag(business, g.agent, g.host);
|
||||
const change = previous !== null;
|
||||
const step = (name, extra = {}) =>
|
||||
this.#store.transaction(() =>
|
||||
this.#grantEvent(business, { action: 'grant.step', grantDecision: decision, agent: g.agent, host: g.host, user: record.user, tag, step: name, ...extra }, g.host),
|
||||
);
|
||||
// X5: checked after each await. A revoke during steps 2 to 5 ends the grant revoked.
|
||||
const stop = () => {
|
||||
if (signal.aborted) fail('revoked');
|
||||
};
|
||||
let installing = false,
|
||||
registered = false,
|
||||
committed = false;
|
||||
try {
|
||||
let key = null;
|
||||
if (!change) {
|
||||
// Step 2. A commands change keeps its key and line and skips to step 5.
|
||||
key = await this.#hostGrant.grantKey(tag);
|
||||
stop();
|
||||
// Steps 3 and 4.
|
||||
step('install');
|
||||
installing = true;
|
||||
await this.#hostGrant.install(business, g.host, record, tag, key.publicLine, signal);
|
||||
stop();
|
||||
await this.#hostGrant.verify(g.host, record, key, signal);
|
||||
stop();
|
||||
}
|
||||
// Step 5. Nothing from the abort check to the end of commit awaits (X5, N2).
|
||||
step('commit', { previous });
|
||||
stop();
|
||||
const applied = () => {
|
||||
this.#grantEvent(
|
||||
business,
|
||||
{ action: 'grant.applied', grantDecision: decision, agent: g.agent, host: g.host, user: record.user, tag, fingerprint: key?.fingerprint ?? null, commandsSha256: g.commandsSha256 },
|
||||
g.host,
|
||||
);
|
||||
this.#grantNotice(
|
||||
business,
|
||||
decision,
|
||||
change
|
||||
? `The commands of ${g.agent}'s grant on host ${g.host} changed (decision ${decision}).`
|
||||
: `${g.agent} has access to host ${g.host} as ${record.user} (decision ${decision}), tag ${tag}.`,
|
||||
);
|
||||
};
|
||||
if (change) {
|
||||
this.#store.transaction(() => {
|
||||
const out = this.#store.run(
|
||||
'UPDATE host_grants SET commands=?, decision=? WHERE business=? AND agent=? AND host=?',
|
||||
JSON.stringify(g.commands), decision, business, g.agent, g.host,
|
||||
);
|
||||
if (out.changes !== 1) fail('host-changed');
|
||||
applied();
|
||||
});
|
||||
committed = true;
|
||||
} else {
|
||||
this.#hostKeys.register(business, g.agent, g.host, g.rotateBy);
|
||||
registered = true;
|
||||
this.#hostKeys.issue(business, g.agent, g.host, key.text, g.rotateBy, () => {
|
||||
this.#store.transaction(() => {
|
||||
this.#store.run(
|
||||
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(?,?,?,?,?,?,?)',
|
||||
business, g.agent, g.host, JSON.stringify(g.commands), g.rotateBy, tag, decision,
|
||||
);
|
||||
applied();
|
||||
});
|
||||
committed = true;
|
||||
});
|
||||
}
|
||||
return 'applied';
|
||||
} catch (e) {
|
||||
// Once the record and grant.applied commit, an abort is an ordinary revoke's to handle (C1).
|
||||
if (committed) return 'applied';
|
||||
const code = signal.aborted ? 'revoked' : e instanceof BusError ? e.code : 'host-failed';
|
||||
// X3's table: a key file and its reference go, then the line, best effort.
|
||||
if (registered) {
|
||||
try {
|
||||
this.#hostKeys.revoke(business, g.agent, g.host);
|
||||
} catch {}
|
||||
try {
|
||||
this.#hostKeys.unregister(business, g.agent, g.host);
|
||||
} catch {}
|
||||
}
|
||||
let extra = {};
|
||||
if (installing) {
|
||||
try {
|
||||
await this.#hostGrant.remove(business, g.host, record, tag);
|
||||
} catch {
|
||||
extra = { hostLineLeft: tag };
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code, extra));
|
||||
} catch {}
|
||||
return code;
|
||||
} finally {
|
||||
this.#hostKeys.release(business, g.agent, g.host, signal);
|
||||
}
|
||||
}
|
||||
// r6 X3 and duckworth-0067 N1: credential.mint decisions for `method`, resolved yes by the human via
|
||||
// cli and never spent.
|
||||
#openGrantDecisions(business, method) {
|
||||
@@ -859,23 +1068,190 @@ export class Broker {
|
||||
});
|
||||
ended.push(decision);
|
||||
}
|
||||
// Row 235 (r6 X3): a host grant spent with no grant.applied or grant.failed died mid-executor. The
|
||||
// record and grant.applied commit in one transaction (X2 step 5), so no record names it and the
|
||||
// start sweep has unlinked any key file it wrote. A last step of install, or commit for a new key,
|
||||
// may have left a line: the slot is claimed here, before anyone can call, and reconcileHosts
|
||||
// removes the line. Any other ends interrupted now.
|
||||
const hostSpent = this.#store.all(
|
||||
`SELECT json_extract(body,'$.decision') AS decision FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host' ORDER BY seq`,
|
||||
business,
|
||||
);
|
||||
for (const { decision } of hostSpent) {
|
||||
const end = this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.applied','grant.failed') LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
);
|
||||
if (end) continue;
|
||||
const g = this.#raised(business, decision).grant;
|
||||
const last = this.#store.get(
|
||||
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action')='grant.step' ORDER BY seq DESC LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
);
|
||||
const step = last ? JSON.parse(last.body) : null;
|
||||
if (step && (step.step === 'install' || (step.step === 'commit' && step.previous === null))) {
|
||||
let signal = null;
|
||||
try {
|
||||
signal = this.#hostKeys.claim(business, g.agent, g.host);
|
||||
} catch {}
|
||||
this.#hostUndo.push({ business, decision, g, user: step.user, tag: step.tag, signal });
|
||||
continue;
|
||||
}
|
||||
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, 'interrupted'));
|
||||
ended.push(decision);
|
||||
}
|
||||
for (const decision of this.#openGrantDecisions(business, 'host')) {
|
||||
this.#store.transaction(() => this.#hostGrantFailed(business, decision, this.#consumeGrant(business, decision, 'host'), 'interrupted'));
|
||||
ended.push(decision);
|
||||
}
|
||||
// r6 section 6 (N4): a grant.revoked still pending has done step 1 and maybe step 2. The record is
|
||||
// gone, so the start sweep took any key file left. reconcileHosts runs step 3.
|
||||
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
// r6 E1, R3: the raise checks and the keyscan, then the raise transaction.
|
||||
// Trusted host API, after the socket opens (r6 X3): the remote undo of each grant reconcileGrants left
|
||||
// claimed. `remove <user> <tag>` through the installer, then grant.failed interrupted, with
|
||||
// hostLineLeft if the remove failed, and the claim released. It only takes access away, so it needs no
|
||||
// decision. Never throws, and returns the decisions it ended.
|
||||
async reconcileHosts() {
|
||||
const ended = [];
|
||||
for (const u of this.#hostUndo.splice(0)) {
|
||||
let extra = {};
|
||||
try {
|
||||
const record = this.#hostRecord(u.business, u.g.host);
|
||||
if (!record || !this.#hostGrant || !u.signal) fail('host-failed');
|
||||
await this.#hostGrant.remove(u.business, u.g.host, { ...record, user: u.user }, u.tag);
|
||||
} catch {
|
||||
extra = { hostLineLeft: u.tag };
|
||||
}
|
||||
try {
|
||||
this.#store.transaction(() => this.#hostGrantFailed(u.business, u.decision, u.g, 'interrupted', extra));
|
||||
ended.push(u.decision);
|
||||
} catch {}
|
||||
try {
|
||||
if (u.signal) this.#hostKeys.release(u.business, u.g.agent, u.g.host, u.signal);
|
||||
} catch {}
|
||||
}
|
||||
for (const m of this.#revokeUndo.splice(0)) {
|
||||
try {
|
||||
await this.#closeRevoke(m.business, m);
|
||||
ended.push(m.revoke);
|
||||
} catch {}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
// The grant.revoked bodies still pending, with no grant.revoke.closed of the same revoke id. A body
|
||||
// with no revoke id predates row 235 and has no step 3.
|
||||
#openRevokes(business, host = null) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.hostLine')='pending' AND json_type(body,'$.revoke')='text' AND (? IS NULL OR json_extract(body,'$.host')=?) ORDER BY seq`,
|
||||
business,
|
||||
host,
|
||||
host,
|
||||
)
|
||||
.map((e) => JSON.parse(e.body))
|
||||
.filter(
|
||||
(m) =>
|
||||
!this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoke.closed' AND json_extract(body,'$.revoke')=? LIMIT 1`,
|
||||
business,
|
||||
m.revoke,
|
||||
),
|
||||
);
|
||||
}
|
||||
// r6 section 6 step 3: `remove <user> <tag>` through the installer, then grant.revoke.closed, with
|
||||
// hostLineLeft if the host can't be reached or has no record. The user is the host record's, as the
|
||||
// executor's. Returns the closed body.
|
||||
async #closeRevoke(business, m) {
|
||||
let line = { hostLine: 'removed' };
|
||||
try {
|
||||
const record = this.#hostRecord(business, m.host);
|
||||
if (!record || !this.#hostGrant) fail('host-failed');
|
||||
await this.#hostGrant.remove(business, m.host, record, m.tag);
|
||||
} catch {
|
||||
line = { hostLineLeft: m.tag };
|
||||
}
|
||||
const body = { action: 'grant.revoke.closed', revoke: m.revoke, agent: m.agent, host: m.host, tag: m.tag, ...line, by: m.by };
|
||||
this.#store.transaction(() => this.#grantEvent(business, body, m.host));
|
||||
return body;
|
||||
}
|
||||
// r6 section 6: local first, so the part that matters can't wait on the network. Step 1 is one
|
||||
// transaction: the record goes, if there is one, and grant.revoked records the pending marker. The tag
|
||||
// is computed, so a revoke with no record still names the line (N4). Step 2 follows with no await
|
||||
// between: the key leaves memory, its file goes, and a host.run or executor in flight aborts (X5).
|
||||
// Step 3 removes the line.
|
||||
async #revokeHost(s, a, by) {
|
||||
keys(a, ['agent', 'host', 'reason'], ['agent', 'host', 'reason']);
|
||||
if (typeof a.agent !== 'string' || !ID_PATTERN.test(a.agent) || typeof a.host !== 'string' || !HOST_ID.test(a.host)) fail('invalid-request');
|
||||
string(a.reason, 500);
|
||||
if (!this.#hostGrant) fail('host-not-configured');
|
||||
const tag = this.#hostGrant.grantTag(s.business, a.agent, a.host);
|
||||
const revoke = randomUUID();
|
||||
const granted = this.#store.transaction(() => {
|
||||
if (!this.#hostRecord(s.business, a.host)) fail('host-unknown');
|
||||
const out = this.#store.run('DELETE FROM host_grants WHERE business=? AND agent=? AND host=?', s.business, a.agent, a.host);
|
||||
this.#grantEvent(
|
||||
s.business,
|
||||
{ action: 'grant.revoked', revoke, agent: a.agent, host: a.host, tag, hostLine: 'pending', granted: out.changes === 1, reason: a.reason, by },
|
||||
a.host,
|
||||
);
|
||||
return out.changes === 1;
|
||||
});
|
||||
let keyFile = 'removed';
|
||||
if (this.#hostKeys) {
|
||||
try {
|
||||
this.#hostKeys.revoke(s.business, a.agent, a.host);
|
||||
} catch (e) {
|
||||
// No reference: only a claim can be held, an executor's before its register.
|
||||
if (e instanceof BusError && e.code === 'credential-unknown') this.#hostKeys.abortClaim(s.business, a.agent, a.host);
|
||||
else keyFile = 'left';
|
||||
}
|
||||
}
|
||||
const closed = await this.#closeRevoke(s.business, { revoke, agent: a.agent, host: a.host, tag, by });
|
||||
return { agent: a.agent, host: a.host, tag, granted, keyFile, ...(closed.hostLineLeft ? { hostLineLeft: closed.hostLineLeft } : { hostLine: 'removed' }) };
|
||||
}
|
||||
// r6 X3 sweep (B4): the tags this business recorded on the host, from grant.step and grant.revoked,
|
||||
// less those of live grants.
|
||||
#sweepTags(business, host) {
|
||||
const tags = new Set(
|
||||
this.#store
|
||||
.all(
|
||||
`SELECT json_extract(body,'$.tag') AS tag FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.host')=? AND json_extract(body,'$.action') IN ('grant.step','grant.revoked') AND json_type(body,'$.tag')='text'`,
|
||||
business,
|
||||
host,
|
||||
)
|
||||
.map((e) => e.tag),
|
||||
);
|
||||
for (const { tag } of this.#store.all('SELECT tag FROM host_grants WHERE business=? AND host=?', business, host)) tags.delete(tag);
|
||||
return tags;
|
||||
}
|
||||
// r6 E1, R3: the raise checks and the keyscan, then the raise transaction. Row 235 (#1741, r6 R4): a
|
||||
// host grant has no keyscan, so its store checks all run inside the raise transaction.
|
||||
async #raiseGrant(s, a) {
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
|
||||
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
|
||||
if (!this.#hostGrant) fail('host-not-configured');
|
||||
if (a.grant?.method === 'host') {
|
||||
const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10));
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
const enroll = this.#hostGrant.enrollCheck(a.grant);
|
||||
if (this.#hostRecord(s.business, enroll.host)) fail('host-enrolled');
|
||||
const hostKeys = await this.#hostGrant.keyscan(enroll.address, enroll.port);
|
||||
if (!this.#hostGrant.sameFingerprints(hostKeys, enroll.fingerprints)) fail('host-key-mismatch');
|
||||
return this.#store.transaction(() => {
|
||||
if (this.#hostRecord(s.business, enroll.host)) fail('host-enrolled');
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, { ...enroll, hostKeys });
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, enrollBinding({ ...enroll, hostKeys }));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
@@ -883,20 +1259,62 @@ export class Broker {
|
||||
throw this.#refused(s, e, { verb: 'decision.raise', args: a });
|
||||
}
|
||||
}
|
||||
// r6 R4: a host grant's store checks and its raise binding. The caller owns the transaction. The body
|
||||
// is R4's eight fields. The block shows the human the host as enrolled, each prefix and whether the
|
||||
// key is new, and the chief can't shape it.
|
||||
#hostGrantBinding(business, g) {
|
||||
const definition = this.#hostDefinition(business, g.agent);
|
||||
const h = this.#hostRecord(business, g.host);
|
||||
if (!h) fail('host-unknown');
|
||||
if (h.state !== 'ready') fail('host-not-ready');
|
||||
const live = this.#store.get('SELECT 1 FROM host_grants WHERE business=? AND agent=? AND host=?', business, g.agent, g.host);
|
||||
const body = {
|
||||
method: 'host',
|
||||
agent: g.agent,
|
||||
host: g.host,
|
||||
commands: g.commands,
|
||||
commandsSha256: sha256(JSON.stringify(g.commands)),
|
||||
rotateBy: g.rotateBy,
|
||||
registrySha256: registrySha256(h),
|
||||
definitionSha256: this.#hostGrant.definitionSha256(definition),
|
||||
};
|
||||
const block =
|
||||
`\n\nGrant ${g.agent} access to host ${g.host}: user ${h.user} at ${h.address} port ${h.port}.` +
|
||||
(h.sudo ? `\n${h.user} has sudo on this host.` : `\n${h.user} has no sudo on this host.`) +
|
||||
`\nCommands, each an argv prefix:` +
|
||||
g.commands.map((prefix) => `\n${prefix.map((x) => JSON.stringify(x)).join(' ')}`).join('') +
|
||||
`\nRotate by ${g.rotateBy}.` +
|
||||
(live
|
||||
? `\nThis changes the commands of ${g.agent}'s grant on ${g.host}. The key stays.`
|
||||
: `\nThis makes a new key for ${g.agent} on ${g.host}.`);
|
||||
return { body, block };
|
||||
}
|
||||
#hostsCaller(s, verb) {
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.tester) return 'tester';
|
||||
if (s.tester) {
|
||||
// r6 section 6 (N8): a tester revoke needs bus.tester exactly true on the session's business, read
|
||||
// again here. Missing or any other value isn't dev.
|
||||
if (verb === 'hosts.revoke' && this.#business(s.business).vars?.['bus.tester'] !== true) fail('tester-dev-only');
|
||||
return 'tester';
|
||||
}
|
||||
if (s.human) {
|
||||
this.#human(s);
|
||||
return s.human;
|
||||
}
|
||||
// hosts.remove takes access away, as a revoke does: the human or the tester (r6 E4).
|
||||
if (verb === 'hosts.remove') fail('human-required');
|
||||
// hosts.remove and hosts.sweep take access away, as a revoke does: the human or the tester (r6 E4, X3).
|
||||
if (verb === 'hosts.remove' || verb === 'hosts.sweep') fail('human-required');
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
// r6 section 6 (G26): the assistant revokes in its own business. The verb acts on the session's
|
||||
// business only, so another business's grant can't be named.
|
||||
if (verb === 'hosts.revoke') {
|
||||
if (!this.#threaded(s.business, s.role)) fail('revoke-refused');
|
||||
return s.role;
|
||||
}
|
||||
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
|
||||
return s.role;
|
||||
}
|
||||
// r6 E4: hosts.status, hosts.check and hosts.remove. Async, because check runs ssh.
|
||||
// r6 E4: hosts.status, hosts.check and hosts.remove. Async, because check runs ssh. Row 235 adds
|
||||
// hosts.revoke and hosts.sweep (r6 section 6, X3).
|
||||
async #requestHosts(s, verb, a) {
|
||||
try {
|
||||
const by = this.#hostsCaller(s, verb);
|
||||
@@ -917,12 +1335,25 @@ export class Broker {
|
||||
installerFingerprint: r.installer_fingerprint,
|
||||
checkedAt: r.checked_at,
|
||||
hostLineLeft: this.#hostLinesLeft(s.business, host),
|
||||
// r6 section 6: the tags of revokes still in progress.
|
||||
revoking: this.#openRevokes(s.business, host).map((m) => m.tag),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (verb === 'hosts.revoke') return await this.#revokeHost(s, a, by);
|
||||
keys(a, ['host'], ['host']);
|
||||
if (typeof a.host !== 'string' || !HOST_ID.test(a.host)) fail('invalid-request');
|
||||
if (!this.#hostGrant) fail('host-not-configured');
|
||||
if (verb === 'hosts.sweep') {
|
||||
const r = this.#hostRecord(s.business, a.host);
|
||||
if (!r) fail('host-unknown');
|
||||
const left = this.#hostLinesLeft(s.business, a.host);
|
||||
const { listed, removed } = await this.#hostGrant.sweep(s.business, a.host, r, this.#sweepTags(s.business, a.host));
|
||||
// A left tag still listed and not removed stays left.
|
||||
const still = left.filter((tag) => listed.includes(tag) && !removed.includes(tag));
|
||||
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.host.swept', host: a.host, removed, left: still, by }, a.host));
|
||||
return { host: a.host, removed, hostLineLeft: still };
|
||||
}
|
||||
if (verb === 'hosts.remove') {
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#hostRecord(s.business, a.host);
|
||||
@@ -960,15 +1391,23 @@ export class Broker {
|
||||
}
|
||||
}
|
||||
// r6 E4, duckworth-0079 F2 and F3: every guarded body that left a line on the host names the host.
|
||||
// Row b adds the sweep clear (F4).
|
||||
// Row 235 adds the sweep clear (F4): the last grant.host.swept carries what it left, and only later
|
||||
// bodies add to it.
|
||||
#hostLinesLeft(business, host) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT json_extract(body,'$.hostLineLeft') AS tag FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.host')=? AND json_extract(body,'$.hostLineLeft') IS NOT NULL ORDER BY seq`,
|
||||
business,
|
||||
host,
|
||||
)
|
||||
.map((e) => e.tag);
|
||||
const swept = this.#store.get(
|
||||
`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.host.swept' AND json_extract(body,'$.host')=? ORDER BY seq DESC LIMIT 1`,
|
||||
business,
|
||||
host,
|
||||
);
|
||||
const left = swept ? JSON.parse(swept.body).left : [];
|
||||
for (const e of this.#store.all(
|
||||
`SELECT json_extract(body,'$.hostLineLeft') AS tag FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.host')=? AND json_extract(body,'$.hostLineLeft') IS NOT NULL AND seq > ? ORDER BY seq`,
|
||||
business,
|
||||
host,
|
||||
swept?.seq ?? 0,
|
||||
))
|
||||
if (!left.includes(e.tag)) left.push(e.tag);
|
||||
return left;
|
||||
}
|
||||
#hardWait(s, action, target) {
|
||||
if (GATED.has(action)) return true;
|
||||
@@ -2162,7 +2601,7 @@ export class Broker {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
#dispatch(s, verb, a, spec = null, enroll = null) {
|
||||
#dispatch(s, verb, a, spec = null, binding = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
switch (verb) {
|
||||
@@ -2271,8 +2710,8 @@ export class Broker {
|
||||
],
|
||||
['action', 'question', 'options', 'recommendation', 'blocking'],
|
||||
);
|
||||
// Only #raiseGrant passes `enroll`, after its checks and keyscan.
|
||||
if (('grant' in a) !== (enroll !== null)) fail('invalid-request');
|
||||
// Only #raiseGrant passes `binding`, after its checks.
|
||||
if (('grant' in a) !== (binding !== null)) fail('invalid-request');
|
||||
this.#agent(s);
|
||||
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
|
||||
const cls = this.#classification(s, a.action, a.target);
|
||||
@@ -2345,17 +2784,8 @@ export class Broker {
|
||||
`\nargv: ${a.argv.map((x) => JSON.stringify(x)).join(' ')}` +
|
||||
`\nstdin: ${JSON.stringify(a.stdin ?? '')}`;
|
||||
}
|
||||
// Row 234 (#1740, r6 E1): the human sees each scanned key with its fingerprint, and the sudo
|
||||
// mark in plain words. The chief can't shape this block.
|
||||
if (enroll) {
|
||||
question +=
|
||||
`\n\nEnroll host ${enroll.host}: user ${enroll.user} at ${enroll.address} port ${enroll.port}.` +
|
||||
`\nHost keys from ssh-keyscan, equal to the requested fingerprints:` +
|
||||
enroll.hostKeys.map((k) => `\n${fingerprintOf(k)} ${k}`).join('') +
|
||||
(enroll.sudo
|
||||
? `\n${enroll.user} has sudo on this host. Approving lets later grants for ${enroll.user} be installed, and section 9's first limit applies to ${enroll.user}.`
|
||||
: `\n${enroll.user} has no sudo on this host.`);
|
||||
}
|
||||
// Rows 234 and 235: the grant's block, built by the broker. The chief can't shape it.
|
||||
if (binding) question += binding.block;
|
||||
string(question);
|
||||
const did = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -2393,7 +2823,7 @@ export class Broker {
|
||||
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
|
||||
...(pushCommit ? { pushCommit } : {}),
|
||||
...(sudo ? { hostRun: hostRun(a.target, a.argv, a.stdin) } : {}),
|
||||
...(enroll ? { grant: enroll } : {}),
|
||||
...(binding ? { grant: binding.body } : {}),
|
||||
},
|
||||
a.task_ref ?? (taskRef(a.target) ? a.target : did),
|
||||
);
|
||||
@@ -2448,6 +2878,7 @@ export class Broker {
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
|
||||
@@ -13,7 +13,7 @@ const inside = (root, path) => {
|
||||
const r = relative(root, path);
|
||||
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
|
||||
};
|
||||
const day = (value) =>
|
||||
export const day = (value) =>
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
!Number.isNaN(Date.parse(value)) &&
|
||||
@@ -128,19 +128,7 @@ export class Credentials {
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
if (!credentialKind(service) || !ref || typeof ref !== 'object')
|
||||
deny('credential-reference');
|
||||
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||||
if (!day(date)) deny('credential-date');
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env) ||
|
||||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
|
||||
)
|
||||
deny('credential-reference');
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
this.#refs.set(instance + ':' + service, { ref, date });
|
||||
const value = this.#read(ref, env, service);
|
||||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
@@ -150,6 +138,60 @@ export class Credentials {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
|
||||
#checkRef(instance, service, ref) {
|
||||
if (!credentialKind(service) || !ref || typeof ref !== 'object') deny('credential-reference');
|
||||
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||||
if (!day(date)) deny('credential-date');
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env) ||
|
||||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
|
||||
)
|
||||
deny('credential-reference');
|
||||
return date;
|
||||
}
|
||||
// Row 235 (#1741, r6 X4): the grant executor adds a reference before `issue`, which refuses an unknown
|
||||
// one. It reads no file, so a key file a failed revoke left can't come back. An entry with a live
|
||||
// token refuses, and an entry with none, revoked or never issued, is replaced.
|
||||
register(instance, service, ref) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#tokens.has(k)) deny('credential-reference');
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
this.#refs.set(k, { ref, date });
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// r6 X3, X4: the executor's undo for a reference it registered. An entry with a live token refuses.
|
||||
unregister(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#tokens.has(k)) deny('credential-reference');
|
||||
this.#refs.delete(k);
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy
|
||||
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
|
||||
claim(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#inflight.has(k)) deny('host-busy');
|
||||
const controller = new AbortController();
|
||||
this.#inflight.set(k, controller);
|
||||
return controller.signal;
|
||||
}
|
||||
// Frees the slot only if `signal` is the claim's own, so a late release can't free a later claim.
|
||||
release(instance, service, signal) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#inflight.get(k)?.signal === signal) this.#inflight.delete(k);
|
||||
}
|
||||
// r6 X5: hosts.revoke with no reference to revoke. Aborts a live claim or call and says whether there
|
||||
// was one.
|
||||
abortClaim(instance, service) {
|
||||
const controller = this.#inflight.get(instance + ':' + service);
|
||||
controller?.abort();
|
||||
return { pending: Boolean(controller) };
|
||||
}
|
||||
#storePath(instance, service) {
|
||||
if (!this.#storeRoot) deny('credential-reference');
|
||||
const [business, agent, ...rest] = instance.split('/');
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
// Row 234 (#1740, #436a row a): the trusted half of host enrollment, run in the bus child. The broker
|
||||
// gets it as `hostGrant` and holds every store write. This module holds what touches the network or
|
||||
// a key file: the raise's ssh-keyscan (E1), the installer key pair (E2 steps 2 and 3) and the installer's
|
||||
// `list` for hosts.check (E4).
|
||||
// `list` for hosts.check (E4). Row 235 (#1741, #436 row b) adds the host grant raise binding (R4), the
|
||||
// grant executor's remote steps (X2 steps 2 to 4 and the `remove` undo) and `grantKeys`, the broker's
|
||||
// narrow handle on Credentials.
|
||||
//
|
||||
// The installer key lives at <configDir>/secrets/hosts/<business>/<hostId>/installer, 0600 in 0700 dirs,
|
||||
// written as 210's issue writes (tmp, fsync, rename). It is never read into an event or a message.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
closeSync, constants, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, rmdirSync,
|
||||
unlinkSync, writeSync,
|
||||
closeSync, constants, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync,
|
||||
rmdirSync, unlinkSync, writeSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { parseHostKey, privateDir } from './credentials.mjs';
|
||||
import { day, parseHostKey, privateDir } from './credentials.mjs';
|
||||
import { runHost, HOST_LIMITS } from './host-run.mjs';
|
||||
import { checkAddress, HOST_KEY, USER } from '../../business/src/hosts.mjs';
|
||||
import { HOST_ID } from '../../business/src/vocabulary.mjs';
|
||||
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
@@ -68,6 +71,86 @@ export function enrollCheck(grant) {
|
||||
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
|
||||
}
|
||||
|
||||
// R4's binding for a host grant. Anything off refuses invalid-request. `today` is the bus clock's UTC
|
||||
// day. rotateBy must be a real calendar day after it (210's day check, credentials.mjs; sage-1782). The
|
||||
// broker checks the agent, its definition and the host inside the raise transaction. Returns a fresh
|
||||
// object with only the bound keys.
|
||||
export function hostGrantCheck(grant, today) {
|
||||
const allowed = ['method', 'agent', 'host', 'commands', 'rotateBy'];
|
||||
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
|
||||
const own = Object.keys(grant);
|
||||
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
|
||||
if (grant.method !== 'host') deny('invalid-request');
|
||||
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
|
||||
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
|
||||
let commands;
|
||||
try {
|
||||
commands = checkCommands(grant.commands, 'grant.commands');
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
|
||||
return { method: 'host', agent: grant.agent, host: grant.host, commands, rotateBy: grant.rotateBy };
|
||||
}
|
||||
|
||||
// R4's definitionSha256: sha256 over the canonical JSON of the definition the business loaded, since
|
||||
// the broker doesn't read roles/ (sage-0590). A change to any field the loader keeps changes it.
|
||||
export const definitionSha256 = (definition) => createHash('sha256').update(canonicalJson(definition)).digest('hex');
|
||||
|
||||
// r6 X4: the store reference for an agent's host.<id> key, as Credentials checks it. The runtime's start
|
||||
// load and the executor's register both use it.
|
||||
export const hostReference = (storeRoot, business, agent, hostId, rotateBy) => ({
|
||||
store: true,
|
||||
file: storeRoot ? join(storeRoot, business, agent, 'host.' + hostId) : null,
|
||||
rotateBy,
|
||||
});
|
||||
|
||||
// Row 235 (r6 X2 to X5): the grant executor's calls on Credentials, keyed by (business, agent, hostId).
|
||||
// The broker gets this and never Credentials itself, so it still has no use().
|
||||
export function grantKeys({ credentials, storeRoot }) {
|
||||
const at = (business, agent) => business + '/' + agent;
|
||||
const service = (hostId) => 'host.' + hostId;
|
||||
return {
|
||||
claim: (business, agent, hostId) => credentials.claim(at(business, agent), service(hostId)),
|
||||
release: (business, agent, hostId, signal) => credentials.release(at(business, agent), service(hostId), signal),
|
||||
abortClaim: (business, agent, hostId) => credentials.abortClaim(at(business, agent), service(hostId)),
|
||||
register: (business, agent, hostId, rotateBy) =>
|
||||
credentials.register(at(business, agent), service(hostId), hostReference(storeRoot, business, agent, hostId, rotateBy)),
|
||||
unregister: (business, agent, hostId) => credentials.unregister(at(business, agent), service(hostId)),
|
||||
issue: (business, agent, hostId, text, rotateBy, commit) => credentials.issue(at(business, agent), service(hostId), text, rotateBy, commit),
|
||||
revoke: (business, agent, hostId) => credentials.revoke(at(business, agent), service(hostId)),
|
||||
};
|
||||
}
|
||||
|
||||
// r6 section 6 and N1 of duckworth-0063: at start, before Credentials loads, unlink each host.<id> key
|
||||
// file in a loaded business's agent store that has no host_grants record. `granted` holds
|
||||
// 'business/agent/hostId' for each record. A file an interrupted executor wrote before its commit, or one
|
||||
// a revoke failed to unlink, goes here. No reference loads for a file with no record (X4), so nothing
|
||||
// holds it. A store dir that isn't private refuses credential-file, as Credentials would.
|
||||
export function sweepHostKeys(storeRoot, businesses, granted) {
|
||||
const removed = [];
|
||||
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
|
||||
for (const business of businesses) {
|
||||
const dir = join(storeRoot, business);
|
||||
if (!privateDir(dir, { missing: true })) continue;
|
||||
for (const agent of readdirSync(dir)) {
|
||||
const agentDir = join(dir, agent);
|
||||
if (!lstatSync(agentDir).isDirectory()) continue;
|
||||
privateDir(agentDir);
|
||||
for (const name of readdirSync(agentDir)) {
|
||||
const hostId = name.startsWith('host.') ? name.slice(5) : null;
|
||||
if (!hostId || !HOST_ID.test(hostId) || granted.has(business + '/' + agent + '/' + hostId)) continue;
|
||||
unlinkSync(join(agentDir, name));
|
||||
removed.push({ instance: business + '/' + agent, service: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The tags in `list` output, one per line, the last field of each.
|
||||
export const listedTags = (stdout) => stdout.split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/).at(-1));
|
||||
|
||||
// The keyscan's output lines, "<host> <type> <base64>", as "<type> <base64>". Comment lines and any
|
||||
// other type drop out. A key listed twice counts once.
|
||||
export function scanLines(stdout) {
|
||||
@@ -122,7 +205,9 @@ const run = (bin, args) =>
|
||||
child.on('close', (code) => (code === 0 ? resolve() : reject(new BusError('host-failed'))));
|
||||
});
|
||||
|
||||
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen' }) {
|
||||
// `installerUser` is the installer's login. Only the tests change it, because a scratch sshd can only
|
||||
// log in as the test's own user.
|
||||
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen', installerUser = INSTALLER_USER }) {
|
||||
const keyRoot = join(configDir, 'secrets', 'hosts');
|
||||
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
|
||||
const readKey = (business, hostId) => {
|
||||
@@ -139,11 +224,24 @@ export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = key
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
};
|
||||
// One installer request, `list`, `add` or `remove`, pinned to the record's host keys (E3).
|
||||
const installer = (business, hostId, record, command, signal = null) => {
|
||||
const key = readKey(business, hostId);
|
||||
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
|
||||
return runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command, signal });
|
||||
};
|
||||
const ok = (out) => {
|
||||
if (out.exit !== 0) deny('host-failed');
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
enrollCheck,
|
||||
hostGrantCheck,
|
||||
definitionSha256,
|
||||
sameFingerprints,
|
||||
fingerprint,
|
||||
installerTag,
|
||||
grantTag,
|
||||
keyscan: (address, port) => scan(address, port),
|
||||
// E2 steps 2 and 3. Returns the public line and fingerprint; the private half only reaches the file.
|
||||
async enroll(business, hostId) {
|
||||
@@ -209,9 +307,51 @@ export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = key
|
||||
// pin refuses host-key-mismatch (runHost). Returns the installer fingerprint and the exit.
|
||||
async check(business, hostId, record) {
|
||||
const key = readKey(business, hostId);
|
||||
const entry = { address: record.address, port: record.port, user: INSTALLER_USER, hostKeys: record.hostKeys };
|
||||
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
|
||||
const out = await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: `list ${record.user}` });
|
||||
return { installerFingerprint: key.fingerprint, exit: out.exit };
|
||||
},
|
||||
// X2 step 2: a new ed25519 pair with the tag as its comment, made in a 0700 dir under the run root
|
||||
// and read back into memory. The dir goes before this returns, so no private half stays on disk.
|
||||
async grantKey(tag) {
|
||||
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(runRoot);
|
||||
const dir = mkdtempSync(join(runRoot, 'h-'));
|
||||
try {
|
||||
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', tag, '-f', join(dir, 'key')]);
|
||||
return parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
// X2 step 3: `list`, `remove` the tag if it's there, then `add`. Any request that doesn't exit 0
|
||||
// refuses host-failed. `signal` is the executor's claim, so a revoke stops the call.
|
||||
async install(business, hostId, record, tag, publicLine, signal) {
|
||||
const listed = ok(await installer(business, hostId, record, `list ${record.user}`, signal));
|
||||
if (listedTags(listed.stdout).includes(tag)) ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`, signal));
|
||||
ok(await installer(business, hostId, record, `add ${record.user} ${publicLine} ${tag}`, signal));
|
||||
},
|
||||
// X2 step 4: log in as the record's user with the new key and run `true`.
|
||||
async verify(hostId, record, key, signal) {
|
||||
const entry = { address: record.address, port: record.port, user: record.user, hostKeys: record.hostKeys };
|
||||
ok(await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: 'true', signal }));
|
||||
},
|
||||
// X3's undo and sweep: `remove <user> <tag>`, with no signal, since it runs after an abort too.
|
||||
async remove(business, hostId, record, tag) {
|
||||
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
|
||||
},
|
||||
// X3's sweep (B4): `list <user>`, then `remove` for each listed tag in `tags`. A line with any other
|
||||
// tag stays, so another business's line and an unrecorded one are left alone. Returns the listed
|
||||
// tags and the removed ones.
|
||||
async sweep(business, hostId, record, tags) {
|
||||
const listed = listedTags(ok(await installer(business, hostId, record, `list ${record.user}`)).stdout);
|
||||
const removed = [];
|
||||
for (const tag of listed) {
|
||||
if (!tags.has(tag) || removed.includes(tag)) continue;
|
||||
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
|
||||
removed.push(tag);
|
||||
}
|
||||
return { listed, removed };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { organizationWriter } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { hostGrant } from './host-grant.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
import { createLimits } from './limits.mjs';
|
||||
@@ -100,11 +100,16 @@ export async function startBroker({
|
||||
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
|
||||
// host.<id>, so a restart finds the keys the grant executor wrote. The loader refuses host.<id> in a
|
||||
// role's credentials, so these never meet a business-file reference.
|
||||
const granted = new Set();
|
||||
for (const g of store.all('SELECT business, agent, host, rotate_by FROM host_grants ORDER BY business, agent, host')) {
|
||||
granted.add(g.business + '/' + g.agent + '/' + g.host);
|
||||
if (!Object.hasOwn(businesses, g.business)) continue;
|
||||
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
|
||||
refs['host.' + g.host] = { store: true, file: storeRoot ? join(storeRoot, g.business, g.agent, 'host.' + g.host) : null, rotateBy: g.rotate_by };
|
||||
refs['host.' + g.host] = hostReference(storeRoot, g.business, g.agent, g.host, g.rotate_by);
|
||||
}
|
||||
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
|
||||
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
|
||||
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
@@ -125,6 +130,8 @@ export async function startBroker({
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
const broker = new Broker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -222,6 +229,9 @@ export async function startBroker({
|
||||
return broker.bindTester({ business });
|
||||
},
|
||||
});
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
broker.reconcileHosts();
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and
|
||||
// unregister add and drop one store reference in the running bus, and claim, release and abortClaim
|
||||
// hold the (agent, host.<id>) slot that host.run and revoke share. Keys are made by ssh-keygen in a
|
||||
// scratch root, and nothing connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
|
||||
const AGENT = 'demo/ops';
|
||||
const SERVICE = 'host.web1';
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-claim-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const file = join(storeRoot, 'demo', 'ops', SERVICE);
|
||||
const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' };
|
||||
const key = (name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]);
|
||||
return readFileSync(join(root, name), 'utf8');
|
||||
};
|
||||
const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
t.after(() => c.close());
|
||||
const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state;
|
||||
const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true }));
|
||||
return { root, storeRoot, file, ref, key, c, state, run };
|
||||
}
|
||||
|
||||
test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => {
|
||||
const s = setup(t);
|
||||
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
await assert.rejects(s.run(), /credential-unavailable/);
|
||||
assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid');
|
||||
assert.deepEqual(await s.run(), { ok: true });
|
||||
});
|
||||
|
||||
test('X4: register runs the constructor\'s reference checks', (t) => {
|
||||
const s = setup(t);
|
||||
const other = join(s.storeRoot, 'demo', 'pm', SERVICE);
|
||||
for (const [ref, code] of [
|
||||
[null, /credential-reference/],
|
||||
[{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/],
|
||||
[{ ...s.ref, file: other }, /credential-reference/],
|
||||
[{ ...s.ref, extra: 1 }, /credential-reference/],
|
||||
[{ ...s.ref, service: 'host.web2' }, /credential-reference/],
|
||||
[{ ...s.ref, env: 'X' }, /credential-reference/],
|
||||
])
|
||||
assert.throws(() => s.c.register(AGENT, SERVICE, ref), code);
|
||||
assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/);
|
||||
assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/);
|
||||
assert.equal(s.state(), undefined);
|
||||
});
|
||||
|
||||
test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => {
|
||||
const s = setup(t);
|
||||
mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(s.file, s.key('left'), { mode: 0o600 });
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
await assert.rejects(s.run(), /credential-unavailable/);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/);
|
||||
assert.equal(s.state(), 'valid');
|
||||
// A revoked entry has no token, so a new grant registers again.
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(s.state(), 'revoked');
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
});
|
||||
|
||||
test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => {
|
||||
const s = setup(t);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
assert.equal(s.state(), undefined);
|
||||
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01');
|
||||
assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/);
|
||||
assert.equal(s.state(), 'valid');
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(existsSync(s.file), false);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
assert.equal(s.state(), undefined);
|
||||
});
|
||||
|
||||
test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => {
|
||||
const s = setup(t);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
const signal = s.c.claim(AGENT, SERVICE);
|
||||
assert.equal(signal.aborted, false);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
||||
await assert.rejects(s.run(), /host-busy/);
|
||||
// A claim needs no reference: the executor claims before it registers a new key.
|
||||
const other = s.c.claim('demo/pm', SERVICE);
|
||||
s.c.release('demo/pm', SERVICE, other);
|
||||
s.c.release(AGENT, SERVICE, signal);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
||||
assert.deepEqual(await s.run(), { ok: true });
|
||||
});
|
||||
|
||||
test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => {
|
||||
const s = setup(t);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
let finish;
|
||||
const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r)));
|
||||
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
||||
finish({ ok: true });
|
||||
assert.deepEqual(await running, { ok: true });
|
||||
s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE));
|
||||
});
|
||||
|
||||
test('X5: a stale release leaves a later claim in place', (t) => {
|
||||
const s = setup(t);
|
||||
const first = s.c.claim(AGENT, SERVICE);
|
||||
s.c.release(AGENT, SERVICE, first);
|
||||
const second = s.c.claim(AGENT, SERVICE);
|
||||
s.c.release(AGENT, SERVICE, first);
|
||||
s.c.release(AGENT, SERVICE);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
s.c.release(AGENT, SERVICE, second);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
||||
});
|
||||
|
||||
test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => {
|
||||
const s = setup(t);
|
||||
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false });
|
||||
const signal = s.c.claim(AGENT, SERVICE);
|
||||
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true });
|
||||
assert.equal(signal.aborted, true);
|
||||
// The slot stays held until the executor releases it.
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
s.c.release(AGENT, SERVICE, signal);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
const live = s.c.claim(AGENT, SERVICE);
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(live.aborted, true);
|
||||
s.c.release(AGENT, SERVICE, live);
|
||||
});
|
||||
@@ -0,0 +1,970 @@
|
||||
// Row 235 (#1741, #436 row b): the host grant executor (r6 X2, X3 failure table, X5) over a scratch sshd
|
||||
// on 127.0.0.1 that this test starts. The sshd logs in only as the test's own user, so the installer
|
||||
// and the granted agent share it. The installer's `list`, `add` and `remove` are stubs on the sshd's
|
||||
// PATH that edit a second authorized-keys file, so the new key's login works only once `add` has run.
|
||||
// The host writer itself is tested in host-tools.test.mjs (G16). Nothing runs as root and nothing
|
||||
// reaches another host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { connect } from 'node:net';
|
||||
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, userInfo } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, registrySha256, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { Credentials, parseHostKey } from '../src/credentials.mjs';
|
||||
import { definitionSha256, fingerprint, grantKeys, grantTag, hostGrant, hostReference, sweepHostKeys } from '../src/host-grant.mjs';
|
||||
import { hostExecutor, runHost } from '../src/host-run.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { grantDecision, hostRecord } from './host-fixtures.mjs';
|
||||
|
||||
const SSHD = '/usr/bin/sshd';
|
||||
const USER = userInfo().username;
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const keygen = (file) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', file]);
|
||||
const pubLine = (file) => readFileSync(file + '.pub', 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
const readOr = (file, fallback) => (existsSync(file) ? readFileSync(file, 'utf8') : fallback);
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const DEFINITIONS = {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
|
||||
};
|
||||
const listening = (port) =>
|
||||
new Promise((resolve) => {
|
||||
const s = connect(port, '127.0.0.1');
|
||||
s.on('connect', () => (s.destroy(), resolve(true)));
|
||||
s.on('error', () => resolve(false));
|
||||
});
|
||||
async function until(fn, ms = 10000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) throw new Error('timed out');
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
// The stubs read flag files in the test dir: fail-<verb> exits 1, noop-add exits 0 and writes nothing,
|
||||
// hold-add waits for `release` (then exits 1 if `skip` is there), and hold-remove waits for `release`.
|
||||
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
|
||||
// test's user.
|
||||
async function sshd(t, dir) {
|
||||
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
|
||||
keygen(join(dir, 'hostkey'));
|
||||
keygen(join(dir, 'installer'));
|
||||
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'installer.pub')), { mode: 0o600 });
|
||||
mkdirSync(join(dir, 'fake'));
|
||||
const stub = (name, body) => writeFileSync(join(dir, 'fake', name), `#!/bin/sh\nD='${dir}'\n${body}\n`, { mode: 0o755 });
|
||||
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
|
||||
stub(
|
||||
'add',
|
||||
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
|
||||
'[ -e "$D/fail-add" ] && exit 1\n[ -e "$D/noop-add" ] && exit 0\n' +
|
||||
'grep -q " $4\\$" "$D/granted" 2>/dev/null && exit 0\necho "restrict $2 $3 $4" >> "$D/granted"',
|
||||
);
|
||||
stub('sudo', 'printf "%s\\n" "$*" >> "$D/sudo.log"\nshift 2\nexec "$@"');
|
||||
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ]; do sleep 0.05; done; fi\n' +
|
||||
'[ -e "$D/fail-remove" ] && exit 1\n[ -e "$D/granted" ] || exit 0\ngrep -v " $2\\$" "$D/granted" > "$D/granted.tmp"\nmv "$D/granted.tmp" "$D/granted"');
|
||||
writeFileSync(
|
||||
join(dir, 'wrap.sh'),
|
||||
`#!/bin/sh\nprintf '%s\\n' "$SSH_ORIGINAL_COMMAND" >> '${dir}/requests.log'\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||
writeFileSync(
|
||||
join(dir, 'sshd_config'),
|
||||
[`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`,
|
||||
`PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
|
||||
`ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
|
||||
);
|
||||
const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
|
||||
t.after(() => daemon.kill('SIGKILL'));
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && daemon.exitCode === null) {
|
||||
if (await listening(port)) return port;
|
||||
await sleep(30);
|
||||
}
|
||||
daemon.kill('SIGKILL');
|
||||
}
|
||||
throw new Error('scratch sshd did not start');
|
||||
}
|
||||
|
||||
async function setup(t, opts = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g235x-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const port = await sshd(t, dir);
|
||||
const configDir = join(dir, 'cfg');
|
||||
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
|
||||
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
||||
for (const d of [configDir, join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
|
||||
// opts.enroll: no host record and no installer key, so the test enrolls the host itself (G22). opts.wrap
|
||||
// changes the host API the broker gets.
|
||||
if (!opts.enroll) writeFileSync(join(keyDir, 'installer'), readFileSync(join(dir, 'installer')), { mode: 0o600 });
|
||||
const runRoot = join(configDir, 'secrets', 'run');
|
||||
const storeRoot = join(configDir, 'secrets', 'agents');
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const store = new Store(join(dir, 'data'));
|
||||
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
||||
t.after(() => {
|
||||
credentials.close();
|
||||
store.close();
|
||||
});
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none }, pm: { definition: 'pm', ...none }, ops: { definition: 'ops', ...none },
|
||||
...(opts.assistant ? { assistant: { definition: 'assistant', ...none } } : {}),
|
||||
},
|
||||
definitions: { ...structuredClone(DEFINITIONS), ...(opts.assistant ? { assistant: { roleVersion: 2, credentials: [] } } : {}) },
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: { demo: business },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
hostGrant: (opts.wrap ?? ((h) => h))(hostGrant({ configDir, runRoot, installerUser: USER, scan: async () => [pubLine(join(dir, 'hostkey'))] })),
|
||||
hostKeys: grantKeys({ credentials, storeRoot }),
|
||||
});
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, 'hostkey'))] };
|
||||
if (!opts.enroll) hostRecord(store, { host: 'web1', entry });
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (grant = {}) =>
|
||||
call('chief', 'decision.raise', {
|
||||
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...grant },
|
||||
});
|
||||
const grantEvents = () =>
|
||||
store
|
||||
.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed' AND actor_run='grant' ORDER BY seq")
|
||||
.map((e) => JSON.parse(e.body))
|
||||
.filter((b) => b.action?.startsWith('grant.'));
|
||||
const ended = (decision) => grantEvents().find((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
// The human's cli yes starts the executor; this waits for its end.
|
||||
const approve = async (grant) => {
|
||||
const d = await raise(grant);
|
||||
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => ended(d.id));
|
||||
return { id: d.id, end: ended(d.id) };
|
||||
};
|
||||
// A decision put in by SQL, with R4's body built here so one field can be broken.
|
||||
const fixtureGrant = (over = {}, close) => {
|
||||
const commands = over.commands ?? [['uptime']];
|
||||
const grant = {
|
||||
method: 'host', agent: 'ops', host: 'web1', commands, commandsSha256: sha256(JSON.stringify(commands)), rotateBy: dayFrom(30),
|
||||
registrySha256: registrySha256({ ...entry, sudo: true }), definitionSha256: definitionSha256(DEFINITIONS.ops), ...over,
|
||||
};
|
||||
return grantDecision(store, { grant, ...(close !== undefined ? { close } : {}) });
|
||||
};
|
||||
const tag = grantTag('demo', 'ops', 'web1');
|
||||
const keyFile = join(storeRoot, 'demo', 'ops', 'host.web1');
|
||||
const record = () => store.get("SELECT * FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
|
||||
const requests = () => readOr(join(dir, 'requests.log'), '').split('\n').filter(Boolean);
|
||||
const granted = () => readOr(join(dir, 'granted'), '').split('\n').filter(Boolean);
|
||||
const state = () => credentials.status().find((e) => e.instance === 'demo/ops' && e.service === 'host.web1')?.state;
|
||||
// The executor's notices, apart from the close notice the resolve sends the chief.
|
||||
const messages = (decision) =>
|
||||
store.all("SELECT to_role, body FROM messages WHERE business=? AND decision=? AND from_role='_bus' AND instr(body, ?) ORDER BY rowid", 'demo', decision, `(decision ${decision})`);
|
||||
const flag = (name) => writeFileSync(join(dir, name), '');
|
||||
return { caps, dir, port, entry, store, broker, credentials, call, raise, approve, fixtureGrant, grantEvents, ended, tag, keyFile, record, requests, granted, state, messages, flag, runRoot, storeRoot, configDir, business };
|
||||
}
|
||||
|
||||
// A restart as runtime.mjs does it, on the same store: the key sweep, Credentials with a reference per
|
||||
// record, a new broker, reconcileGrants before the socket opens, then reconcileHosts. `busy` is the
|
||||
// slot between the two.
|
||||
async function restart(t, f, business = f.business) {
|
||||
const granted = new Set();
|
||||
const references = {};
|
||||
for (const g of f.store.all('SELECT business, agent, host, rotate_by FROM host_grants')) {
|
||||
granted.add(`${g.business}/${g.agent}/${g.host}`);
|
||||
(references[`${g.business}/${g.agent}`] ??= {})['host.' + g.host] = hostReference(f.storeRoot, g.business, g.agent, g.host, g.rotate_by);
|
||||
}
|
||||
const swept = sweepHostKeys(f.storeRoot, ['demo'], granted);
|
||||
const credentials = new Credentials({ references, storeRoot: f.storeRoot, dataRoot: join(f.dir, 'data') });
|
||||
t.after(() => credentials.close());
|
||||
const broker = new Broker({
|
||||
store: f.store,
|
||||
businesses: { demo: business },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
hostGrant: hostGrant({ configDir: f.configDir, runRoot: f.runRoot, installerUser: USER }),
|
||||
hostKeys: grantKeys({ credentials, storeRoot: f.storeRoot }),
|
||||
});
|
||||
const now = broker.reconcileGrants();
|
||||
const busy = credentials.busy('demo/ops', 'host.web1');
|
||||
const later = await broker.reconcileHosts();
|
||||
const state = credentials.status().find((e) => e.instance === 'demo/ops' && e.service === 'host.web1')?.state;
|
||||
return { swept, credentials, broker, now, busy, later, state };
|
||||
}
|
||||
|
||||
// The business after the human moved ops's instance to the creative `research` definition, which has
|
||||
// ops's content, so the grant's definitionSha256 still matches and only the creative check refuses.
|
||||
const creativeOps = (business) => ({
|
||||
...structuredClone(business),
|
||||
roles: { ...structuredClone(business.roles), ops: { ...structuredClone(business.roles.ops), definition: 'research' } },
|
||||
definitions: { ...structuredClone(business.definitions), research: structuredClone(DEFINITIONS.ops) },
|
||||
});
|
||||
|
||||
// A decision the human approved and a killed bus child spent, with its grant.step events up to `steps`.
|
||||
function interrupted(f, steps, over = {}) {
|
||||
const id = f.fixtureGrant(over);
|
||||
writeGrantEvent(f.store, AT, 'demo', { operation: 'grant.host', decision: id }, id);
|
||||
for (const [step, extra = {}] of steps)
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', grantDecision: id, agent: 'ops', host: 'web1', user: USER, tag: f.tag, step, ...extra }, 'web1');
|
||||
return id;
|
||||
}
|
||||
|
||||
// The line an interrupted add left: a key nobody holds, under the grant's tag.
|
||||
function leftLine(f) {
|
||||
keygen(join(f.dir, 'orphan'));
|
||||
writeFileSync(join(f.dir, 'granted'), `restrict ${pubLine(join(f.dir, 'orphan'))} ${f.tag}\n`);
|
||||
}
|
||||
|
||||
test('G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied', async (t) => {
|
||||
const f = await setup(t);
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.action, 'grant.applied');
|
||||
const key = parseHostKey(readFileSync(f.keyFile, 'utf8'));
|
||||
assert.deepEqual(end, {
|
||||
action: 'grant.applied', grantDecision: id, agent: 'ops', host: 'web1', user: USER, tag: f.tag,
|
||||
fingerprint: key.fingerprint, commandsSha256: sha256(JSON.stringify([['systemctl', 'status']])),
|
||||
});
|
||||
assert.equal(f.tag.length, 39);
|
||||
assert.deepEqual(f.requests(), [`list ${USER}`, `add ${USER} ${key.publicLine} ${f.tag}`, 'true']);
|
||||
assert.deepEqual(f.granted(), [`restrict ${key.publicLine} ${f.tag}`]);
|
||||
assert.equal(statSync(f.keyFile).mode & 0o777, 0o600);
|
||||
assert.deepEqual({ ...f.record() }, {
|
||||
business: 'demo', agent: 'ops', host: 'web1', commands: '[["systemctl","status"]]', rotate_by: dayFrom(30), tag: f.tag, decision: id,
|
||||
});
|
||||
assert.equal(f.state(), 'valid');
|
||||
assert.deepEqual(
|
||||
f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')),
|
||||
['grant.step install', 'grant.step commit', 'grant.applied'],
|
||||
);
|
||||
assert.equal(f.grantEvents().find((e) => e.step === 'commit').previous, null);
|
||||
assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']);
|
||||
// The key file logs in as the user, and the slot is free again.
|
||||
const out = await runHost({ root: f.runRoot, hostId: 'web1', entry: f.entry, key, timeout: 10, command: 'true' });
|
||||
assert.equal(out.exit, 0);
|
||||
f.credentials.release('demo/ops', 'host.web1', f.credentials.claim('demo/ops', 'host.web1'));
|
||||
});
|
||||
|
||||
test('G7: a commands change on a live grant updates the record in one transaction and keeps the key and line', async (t) => {
|
||||
const f = await setup(t);
|
||||
const first = await f.approve();
|
||||
const before = readFileSync(f.keyFile, 'utf8');
|
||||
const asked = f.requests().length;
|
||||
const { id, end } = await f.approve({ commands: [['uptime'], ['systemctl', 'status']] });
|
||||
assert.equal(end.action, 'grant.applied');
|
||||
assert.equal(end.fingerprint, null);
|
||||
assert.equal(f.requests().length, asked);
|
||||
assert.equal(readFileSync(f.keyFile, 'utf8'), before);
|
||||
assert.equal(f.record().commands, '[["uptime"],["systemctl","status"]]');
|
||||
assert.equal(f.record().decision, id);
|
||||
assert.deepEqual(f.grantEvents().find((e) => e.grantDecision === id && e.step === 'commit').previous, [['systemctl', 'status']]);
|
||||
assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.step === 'install'), false);
|
||||
assert.equal(f.state(), 'valid');
|
||||
assert.notEqual(first.id, id);
|
||||
});
|
||||
|
||||
test('G8: a failure at install or verify removes the line and leaves no record, key file or reference', async (t) => {
|
||||
for (const flag of ['fail-list', 'fail-add', 'noop-add']) {
|
||||
const f = await setup(t);
|
||||
f.flag(flag);
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.code, 'host-failed', flag);
|
||||
assert.equal(end.tag, f.tag);
|
||||
assert.equal('hostLineLeft' in end, false);
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`, flag);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action), ['grant.step', 'grant.failed']);
|
||||
assert.match(f.messages(id)[0].body, /failed: host-failed\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('G8: a failed remove adds hostLineLeft and tells the human to sweep', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.flag('noop-add');
|
||||
f.flag('fail-remove');
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.code, 'host-failed');
|
||||
assert.equal(end.hostLineLeft, f.tag);
|
||||
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
|
||||
});
|
||||
|
||||
test('G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line', async (t) => {
|
||||
const f = await setup(t);
|
||||
// A file where the agent's store dir goes makes issue refuse credential-file.
|
||||
mkdirSync(f.storeRoot, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(f.storeRoot, 'demo'), '');
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.code, 'credential-file');
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')), [
|
||||
'grant.step install', 'grant.step commit', 'grant.failed',
|
||||
]);
|
||||
});
|
||||
|
||||
// A store fault at commit (a full disk, an I/O error, a busy timeout): the record's INSERT throws once,
|
||||
// after issue wrote the key file. The undo unlinks it, drops the reference and removes the line.
|
||||
test('G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line', async (t) => {
|
||||
const f = await setup(t);
|
||||
const run = f.store.run.bind(f.store);
|
||||
let faults = 0;
|
||||
f.store.run = (sql, ...args) => {
|
||||
if (sql.startsWith('INSERT INTO host_grants') && faults++ === 0) throw Object.assign(new Error('database or disk is full'), { code: 'SQLITE_FULL' });
|
||||
return run(sql, ...args);
|
||||
};
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(faults, 1);
|
||||
assert.equal(end.code, 'host-failed');
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')), [
|
||||
'grant.step install', 'grant.step commit', 'grant.failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('G8: a failed step 1 check spends the decision and runs nothing remote', async (t) => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raise();
|
||||
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web1'");
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => f.ended(d.id));
|
||||
assert.equal(f.ended(d.id).code, 'host-not-ready');
|
||||
assert.deepEqual(f.requests(), []);
|
||||
assert.equal(await f.broker.runHostGrant('demo', d.id), 'decision-consumed');
|
||||
});
|
||||
|
||||
test('G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step', async (t) => {
|
||||
const f = await setup(t);
|
||||
for (const over of [
|
||||
{ registrySha256: registrySha256({ ...f.entry, port: f.entry.port + 1, sudo: true }) },
|
||||
{ registrySha256: registrySha256({ ...f.entry, sudo: false }) },
|
||||
{ definitionSha256: definitionSha256({ ...DEFINITIONS.ops, roleVersion: 3 }) },
|
||||
{ commandsSha256: sha256(JSON.stringify([['uptime'], ['reboot']])) },
|
||||
]) {
|
||||
const id = f.fixtureGrant(over);
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-mismatch');
|
||||
assert.equal(f.ended(id).code, 'decision-mismatch');
|
||||
}
|
||||
// The control passes step 1 and goes remote.
|
||||
const id = f.fixtureGrant();
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), 'applied');
|
||||
assert.deepEqual(f.requests().slice(0, 1), [`list ${USER}`]);
|
||||
assert.equal(f.requests().length, 3);
|
||||
});
|
||||
|
||||
test('G9: an agent no longer a role or no longer declaring host ends with its code, spent', async (t) => {
|
||||
const f = await setup(t);
|
||||
for (const [over, code] of [[{ agent: 'nobody' }, 'unknown-role'], [{ agent: 'pm' }, 'host-not-declared'], [{ host: 'web9' }, 'host-unknown']]) {
|
||||
const id = f.fixtureGrant(over);
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), code);
|
||||
assert.equal(f.ended(id).code, code);
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-consumed');
|
||||
}
|
||||
assert.deepEqual(f.requests(), []);
|
||||
});
|
||||
|
||||
test('G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing', async (t) => {
|
||||
const f = await setup(t);
|
||||
const { id } = await f.approve();
|
||||
const count = () => f.store.get("SELECT count(*) AS n FROM events WHERE business='demo'").n;
|
||||
// Each fixture decision writes one events row of its own, so the count starts after both.
|
||||
const fixtures = count();
|
||||
const open = f.fixtureGrant({}, null);
|
||||
const byAgent = f.fixtureGrant({}, { op: 'resolved', by: 'pm', choice: 'yes', via: 'broker' });
|
||||
assert.equal(count(), fixtures + 2);
|
||||
const before = count();
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-consumed');
|
||||
assert.equal(await f.broker.runHostGrant('demo', open), 'not-authorized');
|
||||
assert.equal(await f.broker.runHostGrant('demo', byAgent), 'not-authorized');
|
||||
assert.equal(count(), before);
|
||||
// The enrollment executor won't take a host grant either.
|
||||
assert.equal(await f.broker.runEnrollment('demo', f.fixtureGrant()), 'not-authorized');
|
||||
});
|
||||
|
||||
test('G15: no event, message or decision holds the private key', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const pem = readFileSync(f.keyFile, 'utf8');
|
||||
const body = pem.split('\n').slice(1, -2).join('');
|
||||
const text = [
|
||||
...f.store.all('SELECT body FROM events').map((r) => r.body),
|
||||
...f.store.all('SELECT body FROM messages').map((r) => r.body),
|
||||
...f.store.all('SELECT question FROM decisions').map((r) => r.question),
|
||||
].join('\n');
|
||||
assert.ok(body.length > 64);
|
||||
for (const line of pem.split('\n').slice(1, -2).filter((l) => l.length >= 20)) assert.equal(text.includes(line), false);
|
||||
assert.equal(text.includes('PRIVATE KEY'), false);
|
||||
assert.equal(readOr(join(f.dir, 'requests.log'), '').includes('PRIVATE KEY'), false);
|
||||
});
|
||||
|
||||
test('G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.flag('hold-add');
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => existsSync(join(f.dir, 'holding')));
|
||||
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
|
||||
await until(() => f.ended(d.id));
|
||||
// The held add then exits without writing, as a killed session's would.
|
||||
f.flag('skip');
|
||||
f.flag('release');
|
||||
assert.equal(f.ended(d.id).code, 'revoked');
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
|
||||
});
|
||||
|
||||
test('G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy', async (t) => {
|
||||
const f = await setup(t);
|
||||
const revoked = f.fixtureGrant();
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
|
||||
assert.equal(await f.broker.runHostGrant('demo', revoked), 'revoked');
|
||||
// A revoke of another agent or host doesn't count, and one before the raise doesn't either.
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'pm', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
|
||||
const busy = f.fixtureGrant();
|
||||
const signal = f.credentials.claim('demo/ops', 'host.web1');
|
||||
assert.equal(await f.broker.runHostGrant('demo', busy), 'host-busy');
|
||||
assert.equal(f.ended(busy).code, 'host-busy');
|
||||
assert.match(f.messages(busy)[0].body, /failed: host-busy\.$/);
|
||||
assert.equal(await f.broker.runHostGrant('demo', busy), 'decision-consumed');
|
||||
f.credentials.release('demo/ops', 'host.web1', signal);
|
||||
assert.deepEqual(f.requests(), []);
|
||||
assert.equal(await f.broker.runHostGrant('demo', f.fixtureGrant()), 'applied');
|
||||
});
|
||||
|
||||
test('G24: names at 64 characters with . and _ give distinct tags of the fixed length', () => {
|
||||
const long = (c) => ('a' + c).repeat(32);
|
||||
const tags = [
|
||||
grantTag('demo', long('.'), 'web1'),
|
||||
grantTag('demo', long('_'), 'web1'),
|
||||
grantTag('demo.' + 'x'.repeat(59), 'ops', 'web1'),
|
||||
grantTag('demo', 'ops', 'web1'),
|
||||
grantTag('demo', 'ops', 'web2'),
|
||||
grantTag('other', 'ops', 'web1'),
|
||||
];
|
||||
assert.equal(new Set(tags).size, tags.length);
|
||||
for (const tag of tags) assert.match(tag, /^mosaic-[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
test('G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = interrupted(f, [['install']]);
|
||||
leftLine(f);
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.now, []);
|
||||
assert.equal(r.busy, true);
|
||||
assert.deepEqual(r.later, [id]);
|
||||
assert.equal(r.credentials.busy('demo/ops', 'host.web1'), false);
|
||||
assert.deepEqual(f.requests(), [`remove ${USER} ${f.tag}`]);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag });
|
||||
assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']);
|
||||
assert.match(f.messages(id)[0].body, /failed: interrupted\.$/);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(r.state, undefined);
|
||||
// It never runs late, and a second start finds it ended.
|
||||
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
|
||||
const again = await restart(t, f);
|
||||
assert.deepEqual([again.now, again.later], [[], []]);
|
||||
});
|
||||
|
||||
test('G11: a restart after a new key\'s commit, with the key file written, ends with no key file, record, reference or line', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = interrupted(f, [['install'], ['commit', { previous: null }]]);
|
||||
leftLine(f);
|
||||
mkdirSync(join(f.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(f.storeRoot, 0o700);
|
||||
chmodSync(join(f.storeRoot, 'demo'), 0o700);
|
||||
keygen(f.keyFile);
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
// ssh-keygen's .pub isn't a host.<id> name, so the sweep leaves it.
|
||||
assert.equal(existsSync(f.keyFile + '.pub'), true);
|
||||
assert.deepEqual(r.later, [id]);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(r.state, undefined);
|
||||
assert.equal(f.ended(id).code, 'interrupted');
|
||||
});
|
||||
|
||||
test('G11: a restart after a commands change\'s commit leaves the record as it was and asks nothing of the host', async (t) => {
|
||||
const f = await setup(t);
|
||||
const first = await f.approve();
|
||||
const before = { record: { ...f.record() }, key: readFileSync(f.keyFile, 'utf8'), granted: f.granted(), asked: f.requests().length };
|
||||
const id = interrupted(f, [['commit', { previous: [['systemctl', 'status']] }]], { commands: [['uptime']] });
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.now, [id]);
|
||||
assert.equal(r.busy, false);
|
||||
assert.deepEqual(r.later, []);
|
||||
assert.deepEqual({ ...f.record() }, before.record);
|
||||
assert.equal(f.record().decision, first.id);
|
||||
assert.equal(readFileSync(f.keyFile, 'utf8'), before.key);
|
||||
assert.deepEqual(f.granted(), before.granted);
|
||||
assert.equal(f.requests().length, before.asked);
|
||||
assert.equal(r.state, 'valid');
|
||||
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag });
|
||||
// The applied grant isn't touched.
|
||||
assert.equal(f.ended(first.id).action, 'grant.applied');
|
||||
});
|
||||
|
||||
test('G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later', async (t) => {
|
||||
const f = await setup(t);
|
||||
const spent = interrupted(f, []);
|
||||
const open = f.fixtureGrant();
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.now, [spent, open]);
|
||||
assert.deepEqual(r.later, []);
|
||||
assert.deepEqual(f.requests(), []);
|
||||
for (const id of [spent, open]) {
|
||||
assert.equal(f.ended(id).code, 'interrupted');
|
||||
assert.equal(f.ended(id).tag, f.tag);
|
||||
assert.equal(f.messages(id).length, 2);
|
||||
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
|
||||
}
|
||||
// The open one was spent once, by the reconcile.
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND actor_run='grant' AND json_extract(body,'$.operation')='grant.host' AND json_extract(body,'$.decision')=?", open).n, 1);
|
||||
assert.equal(f.record(), undefined);
|
||||
});
|
||||
|
||||
test('G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = interrupted(f, [['install']]);
|
||||
leftLine(f);
|
||||
f.flag('fail-remove');
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.later, [id]);
|
||||
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag, hostLineLeft: f.tag });
|
||||
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
|
||||
assert.equal(f.granted().length, 1);
|
||||
assert.equal(r.credentials.busy('demo/ops', 'host.web1'), false);
|
||||
// The same with the host record gone: nothing to reach, so the line is left.
|
||||
const g = await setup(t);
|
||||
const id2 = interrupted(g, [['install']]);
|
||||
g.store.transaction(() => {
|
||||
g.store.run("DELETE FROM hosts WHERE business='demo' AND host='web1'");
|
||||
writeGrantEvent(g.store, AT, 'demo', { action: 'grant.host.removed', host: 'web1', by: 'jason' }, 'web1');
|
||||
});
|
||||
const r2 = await restart(t, g);
|
||||
assert.deepEqual(r2.later, [id2]);
|
||||
assert.equal(g.ended(id2).hostLineLeft, g.tag);
|
||||
assert.deepEqual(g.requests(), []);
|
||||
});
|
||||
|
||||
test('G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it', async (t) => {
|
||||
const f = await setup(t);
|
||||
const { id } = await f.approve();
|
||||
const events = f.grantEvents().length;
|
||||
const asked = f.requests().length;
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual([r.swept, r.now, r.later], [[], [], []]);
|
||||
assert.equal(f.grantEvents().length, events);
|
||||
assert.equal(f.requests().length, asked);
|
||||
assert.equal(existsSync(f.keyFile), true);
|
||||
assert.equal(r.state, 'valid');
|
||||
assert.equal(f.ended(id).action, 'grant.applied');
|
||||
});
|
||||
|
||||
test('N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'g235s-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'agents');
|
||||
assert.deepEqual(sweepHostKeys(storeRoot, ['demo'], new Set()), []);
|
||||
assert.deepEqual(sweepHostKeys(null, ['demo'], new Set()), []);
|
||||
const put = (business, agent, name) => {
|
||||
const d = join(storeRoot, business, agent);
|
||||
mkdirSync(d, { recursive: true, mode: 0o700 });
|
||||
for (const x of [storeRoot, join(storeRoot, business), d]) chmodSync(x, 0o700);
|
||||
writeFileSync(join(d, name), 'k', { mode: 0o600 });
|
||||
return join(d, name);
|
||||
};
|
||||
const kept = put('demo', 'ops', 'host.web1');
|
||||
const gone = put('demo', 'ops', 'host.web2');
|
||||
const goneToo = put('demo', 'pm', 'host.web1');
|
||||
const other = [put('demo', 'ops', 'gitea'), put('demo', 'ops', 'host.Web3'), put('demo', 'ops', 'host.'), put('other', 'ops', 'host.web1')];
|
||||
const swept = sweepHostKeys(storeRoot, ['demo'], new Set(['demo/ops/web1']));
|
||||
assert.deepEqual(swept.sort((a, b) => a.instance.localeCompare(b.instance) || a.service.localeCompare(b.service)), [
|
||||
{ instance: 'demo/ops', service: 'host.web2' },
|
||||
{ instance: 'demo/pm', service: 'host.web1' },
|
||||
]);
|
||||
assert.equal(existsSync(kept), true);
|
||||
assert.equal(existsSync(gone), false);
|
||||
assert.equal(existsSync(goneToo), false);
|
||||
for (const file of other) assert.equal(existsSync(file), true, file);
|
||||
chmodSync(join(storeRoot, 'demo', 'ops'), 0o750);
|
||||
assert.throws(() => sweepHostKeys(storeRoot, ['demo'], new Set()), /credential-file/);
|
||||
assert.equal(existsSync(kept), true);
|
||||
});
|
||||
|
||||
// Row 235 step 5 (r6 section 6): an assistant thread in the business, for G26.
|
||||
function assistant(f) {
|
||||
const project = f.call('human', 'project.create', { name: 'hosts' }).id;
|
||||
const thread = f.call('human', 'thread.create', { project, name: 'main' }).id;
|
||||
f.caps.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a-run', harness: 'pi', thread });
|
||||
f.broker.request(f.caps.assistant, { verb: 'role.claim' });
|
||||
}
|
||||
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
|
||||
const revokeBodies = (f) => f.grantEvents().filter((e) => ['grant.revoked', 'grant.revoke.closed'].includes(e.action));
|
||||
|
||||
test('G12: the human\'s hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const asked = f.requests().length;
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'done' });
|
||||
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: true, keyFile: 'removed', hostLine: 'removed' });
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.notEqual(f.state(), 'valid');
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.deepEqual(f.requests().slice(asked), [`remove ${USER} ${f.tag}`]);
|
||||
const [open, closed] = revokeBodies(f);
|
||||
assert.equal(typeof open.revoke, 'string');
|
||||
assert.deepEqual(open, { action: 'grant.revoked', revoke: open.revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'done', by: 'jason' });
|
||||
assert.deepEqual(closed, { action: 'grant.revoke.closed', revoke: open.revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'removed', by: 'jason' });
|
||||
const [row] = await f.call('human', 'hosts.status');
|
||||
assert.deepEqual([row.revoking, row.hostLineLeft], [[], []]);
|
||||
// A restart finds the revoke closed and does nothing for it.
|
||||
const again = await restart(t, f);
|
||||
assert.deepEqual([again.swept, again.now, again.later], [[], [], []]);
|
||||
assert.equal(again.state, undefined);
|
||||
});
|
||||
|
||||
test('G12 (N4): a revoke with no record still removes the computed tag\'s line, with granted false; bad args refuse', async (t) => {
|
||||
const f = await setup(t);
|
||||
leftLine(f);
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'stale line' });
|
||||
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: false, keyFile: 'removed', hostLine: 'removed' });
|
||||
assert.deepEqual(f.granted(), []);
|
||||
for (const args of [{ agent: 'ops', host: 'web1' }, { agent: 'ops', host: 'web1', reason: '' }, { agent: 'ops', host: 'web1', reason: 'x'.repeat(501) },
|
||||
{ agent: 'Ops!', host: 'web1', reason: 'r' }, { agent: 'ops', host: 'Web1', reason: 'r' }, { agent: 'ops', host: 'web1', reason: 'r', extra: 1 }])
|
||||
await refusesWith(f.call('human', 'hosts.revoke', args), 'invalid-request');
|
||||
await refusesWith(f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web9', reason: 'r' }), 'host-unknown');
|
||||
assert.equal(revokeBodies(f).length, 2);
|
||||
});
|
||||
|
||||
test('G13: a revoke during install aborts the executor, which ends revoked, and the line goes', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.flag('hold-add');
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => existsSync(join(f.dir, 'holding')));
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'stop' });
|
||||
assert.equal(r.granted, false);
|
||||
await until(() => f.ended(d.id));
|
||||
f.flag('skip');
|
||||
f.flag('release');
|
||||
assert.equal(f.ended(d.id).code, 'revoked');
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
|
||||
// A decision raised before the revoke and started after it ends revoked at step 1.
|
||||
const late = await f.raise();
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke: 'x', agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
|
||||
f.call('human', 'decision.resolve', { id: late.id, choice: 'yes' });
|
||||
await until(() => f.ended(late.id));
|
||||
assert.equal(f.ended(late.id).code, 'revoked');
|
||||
});
|
||||
|
||||
test('G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human\'s or the tester\'s', async (t) => {
|
||||
const f = await setup(t, { assistant: true });
|
||||
assistant(f);
|
||||
f.caps.tester = f.broker.bindTester({ business: 'demo' });
|
||||
f.caps.reader = f.broker.bindReader({ business: 'demo' });
|
||||
const args = { agent: 'ops', host: 'web1', reason: 'r' };
|
||||
await refusesWith(f.call('chief', 'hosts.revoke', args), 'revoke-refused');
|
||||
await refusesWith(f.call('pm', 'hosts.revoke', args), 'revoke-refused');
|
||||
await refusesWith(f.call('reader', 'hosts.revoke', args), 'read-only');
|
||||
for (const who of ['chief', 'pm', 'assistant']) await refusesWith(f.call(who, 'hosts.sweep', { host: 'web1' }), 'human-required');
|
||||
assert.equal(revokeBodies(f).length, 0);
|
||||
assert.equal((await f.call('assistant', 'hosts.revoke', args)).agent, 'ops');
|
||||
assert.equal((await f.call('tester', 'hosts.revoke', args)).agent, 'ops');
|
||||
assert.deepEqual(revokeBodies(f).filter((e) => e.action === 'grant.revoked').map((e) => e.by), ['assistant', 'tester']);
|
||||
assert.deepEqual((await f.call('tester', 'hosts.sweep', { host: 'web1' })).removed, []);
|
||||
});
|
||||
|
||||
test('G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const revoke = 'b6d0f0c4-6c3e-4c35-9d55-1f3c2b8f0a11';
|
||||
// Step 1 as hosts.revoke writes it, then the bus dies.
|
||||
f.store.transaction(() => {
|
||||
f.store.run("DELETE FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'r', by: 'jason' }, 'web1');
|
||||
});
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].revoking, [f.tag]);
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.deepEqual(r.now, []);
|
||||
assert.deepEqual(r.later, [revoke]);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(r.state, undefined);
|
||||
assert.deepEqual(revokeBodies(f).at(-1), { action: 'grant.revoke.closed', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'removed', by: 'jason' });
|
||||
assert.deepEqual((await r.broker.request(r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'hosts.status', args: {} }))[0].revoking, []);
|
||||
const again = await restart(t, f);
|
||||
assert.deepEqual([again.swept, again.later], [[], []]);
|
||||
});
|
||||
|
||||
test('G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const revoke = 'c1d2e3f4-0000-4000-8000-000000000001';
|
||||
f.store.transaction(() => {
|
||||
f.store.run("DELETE FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'r', by: 'jason' }, 'web1');
|
||||
});
|
||||
rmSync(f.keyFile);
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'pm', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
|
||||
f.flag('fail-remove');
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual(r.swept, []);
|
||||
assert.deepEqual(r.later, [revoke]);
|
||||
assert.equal(revokeBodies(f).at(-1).hostLineLeft, f.tag);
|
||||
assert.equal(f.granted().length, 1);
|
||||
const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.deepEqual((await r.broker.request(human, { verb: 'hosts.status', args: {} }))[0].hostLineLeft, [f.tag]);
|
||||
// The sweep then removes it and clears the left line.
|
||||
rmSync(join(f.dir, 'fail-remove'));
|
||||
assert.deepEqual(await r.broker.request(human, { verb: 'hosts.sweep', args: { host: 'web1' } }), { host: 'web1', removed: [f.tag], hostLineLeft: [] });
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.deepEqual((await r.broker.request(human, { verb: 'hosts.status', args: {} }))[0].hostLineLeft, []);
|
||||
});
|
||||
|
||||
test('G27: a key file a revoke could not unlink is reported left and goes at the next start', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const agentDir = join(f.storeRoot, 'demo', 'ops');
|
||||
// setup's t.after removes the scratch dir before any hook added here runs, so the body restores the mode.
|
||||
chmodSync(agentDir, 0o500);
|
||||
try {
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
assert.equal(r.keyFile, 'left');
|
||||
assert.equal(r.hostLine, 'removed');
|
||||
assert.equal(existsSync(f.keyFile), true);
|
||||
assert.notEqual(f.state(), 'valid');
|
||||
} finally {
|
||||
chmodSync(agentDir, 0o700);
|
||||
}
|
||||
const again = await restart(t, f);
|
||||
assert.deepEqual(again.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.deepEqual(again.later, []);
|
||||
});
|
||||
|
||||
test('G28: a commands change, then a revoke, then a restart leave no record, key, reference or line', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
await f.approve({ commands: [['uptime']] });
|
||||
assert.equal(f.record().commands, '[["uptime"]]');
|
||||
await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
const r = await restart(t, f);
|
||||
assert.deepEqual([r.swept, r.now, r.later], [[], [], []]);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(r.state, undefined);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
});
|
||||
|
||||
test('G24 (B4): hosts.sweep removes only this business\'s recorded tags no live grant holds, and records what it left', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
const live = f.granted();
|
||||
const pmTag = grantTag('demo', 'pm', 'web1');
|
||||
const otherTag = grantTag('other', 'ops', 'web1');
|
||||
const unrecorded = 'mosaic-' + 'f'.repeat(32);
|
||||
keygen(join(f.dir, 'k2'));
|
||||
const line = (tag) => `restrict ${pubLine(join(f.dir, 'k2'))} ${tag}`;
|
||||
writeFileSync(join(f.dir, 'granted'), [...live, line(pmTag), line(otherTag), line(unrecorded), ''].join('\n'));
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', grantDecision: 'x', agent: 'pm', host: 'web1', user: USER, tag: pmTag, step: 'install' }, 'web1');
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'x', agent: 'pm', host: 'web1', code: 'host-failed', tag: pmTag, hostLineLeft: pmTag }, 'web1');
|
||||
// A left tag no longer on the host drops out at the sweep.
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'y', agent: 'pm', host: 'web1', code: 'host-failed', tag: 'mosaic-' + '1'.repeat(32), hostLineLeft: 'mosaic-' + '1'.repeat(32) }, 'web1');
|
||||
assert.equal((await f.call('human', 'hosts.status'))[0].hostLineLeft.length, 2);
|
||||
f.flag('fail-list');
|
||||
await refusesWith(f.call('human', 'hosts.sweep', { host: 'web1' }), 'host-failed');
|
||||
assert.equal(f.grantEvents().some((e) => e.action === 'grant.host.swept'), false);
|
||||
rmSync(join(f.dir, 'fail-list'));
|
||||
await refusesWith(f.call('human', 'hosts.sweep', { host: 'web9' }), 'host-unknown');
|
||||
const asked = f.requests().length;
|
||||
assert.deepEqual(await f.call('human', 'hosts.sweep', { host: 'web1' }), { host: 'web1', removed: [pmTag], hostLineLeft: [] });
|
||||
assert.deepEqual(f.requests().slice(asked), [`list ${USER}`, `remove ${USER} ${pmTag}`]);
|
||||
assert.deepEqual(f.granted(), [...live, line(otherTag), line(unrecorded)]);
|
||||
assert.deepEqual(f.grantEvents().at(-1), { action: 'grant.host.swept', host: 'web1', removed: [pmTag], left: [], by: 'jason' });
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, []);
|
||||
// A later failure adds to the cleared list.
|
||||
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'z', agent: 'pm', host: 'web1', code: 'host-failed', tag: pmTag, hostLineLeft: pmTag }, 'web1');
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, [pmTag]);
|
||||
assert.equal(f.state(), 'valid');
|
||||
});
|
||||
|
||||
test('G12: the local steps finish before the host step, so a host that hangs leaves the agent no access', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
f.flag('hold-remove');
|
||||
const revoking = f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
await until(() => existsSync(join(f.dir, 'holding-remove')));
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.notEqual(f.state(), 'valid');
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].revoking, [f.tag]);
|
||||
f.flag('release');
|
||||
assert.equal((await revoking).hostLine, 'removed');
|
||||
assert.deepEqual(f.granted(), []);
|
||||
});
|
||||
|
||||
// X5: the executor claims the slot in the same tick as its spend, so a revoke right after the start
|
||||
// call returns finds the claim and aborts it.
|
||||
test('G23 (X5): a revoke right after the spend reaches the executor\'s claim, so the grant ends revoked', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = f.fixtureGrant();
|
||||
const run = f.broker.runHostGrant('demo', id);
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
assert.equal(r.granted, false);
|
||||
assert.equal(await run, 'revoked');
|
||||
assert.equal(f.ended(id).code, 'revoked');
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.deepEqual(f.requests(), [`remove ${USER} ${f.tag}`]);
|
||||
});
|
||||
|
||||
// X5: the abort check after verify. The revoke lands once the login has worked and before the commit,
|
||||
// so only that check stops the record and key file.
|
||||
test('G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line', async (t) => {
|
||||
let revoking;
|
||||
const f = await setup(t, {
|
||||
wrap: (hg) => ({
|
||||
...hg,
|
||||
verify: async (...args) => {
|
||||
const out = await hg.verify(...args);
|
||||
revoking = f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.code, 'revoked');
|
||||
assert.equal((await revoking).granted, false);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
// The check stops the grant before step 5, so no commit step is logged.
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id && e.action === 'grant.step').map((e) => e.step), ['install']);
|
||||
});
|
||||
|
||||
test('G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
f.flag('fail-remove');
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
|
||||
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: true, keyFile: 'removed', hostLineLeft: f.tag });
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.notEqual(f.state(), 'valid');
|
||||
assert.equal(f.granted().length, 1);
|
||||
assert.equal(revokeBodies(f).at(-1).hostLineLeft, f.tag);
|
||||
const [row] = await f.call('human', 'hosts.status');
|
||||
assert.deepEqual([row.revoking, row.hostLineLeft], [[], [f.tag]]);
|
||||
// The marker is closed, so a restart doesn't run step 3 again.
|
||||
const again = await restart(t, f);
|
||||
assert.deepEqual(again.later, []);
|
||||
});
|
||||
|
||||
// G22 (B1) on one bus with no restart. Enrollment refuses this machine's address, so the chief enrolls
|
||||
// 192.0.2.10 through the fake keyscan. The test then points the record at the scratch sshd and lets the
|
||||
// enrolled installer key log in there. Everything after that is the real path.
|
||||
test('G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight', async (t) => {
|
||||
const f = await setup(t, { enroll: true });
|
||||
const enroll = await f.call('chief', 'decision.raise', {
|
||||
action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: USER, sudo: true, fingerprints: [fingerprint(pubLine(join(f.dir, 'hostkey')))] },
|
||||
});
|
||||
f.call('human', 'decision.resolve', { id: enroll.id, choice: 'yes' });
|
||||
const enrolled = () => f.grantEvents().find((e) => e.grantDecision === enroll.id && ['grant.host.enrolled', 'grant.failed'].includes(e.action));
|
||||
await until(enrolled);
|
||||
assert.equal(enrolled().action, 'grant.host.enrolled');
|
||||
const installer = parseHostKey(readFileSync(join(f.configDir, 'secrets', 'hosts', 'demo', 'web1', 'installer'), 'utf8'));
|
||||
appendFileSync(join(f.dir, 'authorized_keys'), installer.publicLine + '\n');
|
||||
f.store.run("UPDATE hosts SET address='127.0.0.1', port=? WHERE business='demo' AND host='web1'", f.port);
|
||||
await refusesWith(f.raise(), 'host-not-ready');
|
||||
assert.equal((await f.call('chief', 'hosts.check', { host: 'web1' })).state, 'ready');
|
||||
assert.equal((await f.approve({ commands: [['echo']] })).end.action, 'grant.applied');
|
||||
f.caps.ops = f.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
f.broker.request(f.caps.ops, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: f.credentials, root: join(f.dir, 'host-run') });
|
||||
const run = async (argv) => {
|
||||
const d = await f.call('ops', 'decision.raise', { action: 'host.sudo', target: 'web1', argv, question: 'Run it?', options, recommendation: 'yes', blocking: false });
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
return f.broker.requestHost(f.caps.ops, { verb: 'host.run', args: { host: 'web1', argv, decision: d.id } }, executor);
|
||||
};
|
||||
const out = await run(['echo', 'hi']);
|
||||
assert.deepEqual([out.exit, out.stdout], [0, 'hi\n']);
|
||||
await refusesWith(run(['sleep', '30']), 'command-not-granted');
|
||||
assert.equal((await f.approve({ commands: [['echo'], ['sleep']] })).end.action, 'grant.applied');
|
||||
// The handler goes on at creation: the revoke rejects the run while the test still awaits the revoke.
|
||||
const slow = refusesWith(run(['sleep', '30']), 'credential-unavailable');
|
||||
await until(() => readOr(join(f.dir, 'sudo.log'), '').includes('sleep 30'));
|
||||
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'done' });
|
||||
assert.deepEqual([r.granted, r.keyFile, r.hostLine], [true, 'removed', 'removed']);
|
||||
await slow;
|
||||
// The record goes at step 1, so the next call refuses at the record check, before the key (B1 names
|
||||
// credential-unavailable; BUILD.md records the difference).
|
||||
await refusesWith(f.broker.requestHost(f.caps.ops, { verb: 'host.run', args: { host: 'web1', argv: ['echo', 'hi'] } }, executor), 'host-not-granted');
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
});
|
||||
|
||||
// The decision goes in after the restart, so the reconcile doesn't end it first.
|
||||
test('mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote', async (t) => {
|
||||
const f = await setup(t);
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
const id = f.fixtureGrant();
|
||||
assert.equal(await r.broker.runHostGrant('demo', id), 'host-creative-role');
|
||||
assert.equal(f.ended(id).code, 'host-creative-role');
|
||||
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
|
||||
assert.deepEqual(f.requests(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
});
|
||||
|
||||
test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {
|
||||
const f = await setup(t);
|
||||
assert.equal((await f.approve()).end.action, 'grant.applied');
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
r.broker.request(cap, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
|
||||
const before = f.requests().length;
|
||||
await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');
|
||||
assert.equal(f.requests().length, before);
|
||||
assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);
|
||||
assert.notEqual(f.record(), undefined);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// Row 235 (#1741, #436 row b): the host grant raise (r6 R1-R4). A credential.mint raise whose `grant`
|
||||
// has method `host` binds an agent, an enrolled host, argv prefixes and a rotate date. The host record
|
||||
// comes from the fixture, and nothing here connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, registrySha256 } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
|
||||
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const DEFINITIONS = {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
ops: { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository'] }, { service: 'host' }] },
|
||||
};
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'g235-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const configDir = join(root, 'config');
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
ops: { definition: 'ops', ...none },
|
||||
odd: { definition: 'constructor', ...none },
|
||||
// A creative role whose definition declares host, so only the creative check refuses it.
|
||||
muse: { definition: 'research', ...none },
|
||||
},
|
||||
definitions: { ...structuredClone(DEFINITIONS), research: structuredClone(DEFINITIONS.ops) },
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }) });
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
hostRecord(store, { host: 'web1', entry: ENTRY });
|
||||
const grant = (over = {}) => ({ method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...over });
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (over = {}, who = 'chief', extra = {}) =>
|
||||
call(who, 'decision.raise', { action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, grant: grant(over), ...extra });
|
||||
const decisions = () => store.get('SELECT count(*) AS n FROM decisions').n;
|
||||
const question = (id) => store.get('SELECT question FROM decisions WHERE id=?', id).question;
|
||||
const raiseBody = (id) =>
|
||||
JSON.parse(store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=?", id).body);
|
||||
const refusals = () => store.all("SELECT body FROM events WHERE business='demo' AND kind='action.refused' ORDER BY seq").map((e) => JSON.parse(e.body).code);
|
||||
return { store, broker, caps, call, raise, grant, decisions, question, raiseBody, refusals };
|
||||
}
|
||||
|
||||
test('G1 (host): only the chief raises a host grant, a grant on any other action refuses, and nothing is stored', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
|
||||
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
|
||||
await refusesWith(f.raise({}, 'tester'), 'tester-refused');
|
||||
assert.equal(f.decisions(), before);
|
||||
assert.deepEqual(f.refusals(), ['grant-chief-only', 'invalid-request', 'tester-refused']);
|
||||
});
|
||||
|
||||
test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy, and an unknown method refuses', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
const g = f.grant();
|
||||
const { rotateBy, ...noRotate } = g;
|
||||
for (const bad of [
|
||||
noRotate,
|
||||
{ ...g, extra: 1 },
|
||||
{ ...g, method: 'gitea' },
|
||||
{ ...g, method: 'Host' },
|
||||
{ ...g, agent: 'Ops' },
|
||||
{ ...g, agent: '' },
|
||||
{ ...g, agent: 7 },
|
||||
{ ...g, host: 'Web_1' },
|
||||
{ ...g, host: 'a'.repeat(33) },
|
||||
])
|
||||
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
|
||||
for (const bad of [null, [], 'host'])
|
||||
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
assert.ok(rotateBy);
|
||||
});
|
||||
|
||||
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ agent: 'pm' }), 'host-not-declared');
|
||||
await refusesWith(f.raise({ agent: 'chief' }), 'host-not-declared');
|
||||
await refusesWith(f.raise({ agent: 'nobody' }), 'unknown-role');
|
||||
// An inherited name is not a definition.
|
||||
await refusesWith(f.raise({ agent: 'odd' }), 'host-not-declared');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('mos-1163: a grant whose agent is a creative role refuses host-creative-role, even when its definition declares host', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ agent: 'muse' }), 'host-creative-role');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('G3: wildcard, shell, empty and other 210 D6 argv prefixes refuse invalid-request', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
for (const commands of [
|
||||
'any',
|
||||
'*',
|
||||
{},
|
||||
[],
|
||||
[[]],
|
||||
[['*']],
|
||||
[['systemctl', 'status*']],
|
||||
[['ls', '[a]']],
|
||||
[['bash']],
|
||||
[['/bin/sh', '-c']],
|
||||
[['sudo', 'ls']],
|
||||
[['python3']],
|
||||
[['env', 'ls']],
|
||||
[['bin/ls']],
|
||||
[['']],
|
||||
[['ls', 'a\0b']],
|
||||
[['ls', 3]],
|
||||
[['ls'], ['ls']],
|
||||
[Array.from({ length: 257 }, () => 'x')],
|
||||
])
|
||||
await refusesWith(f.raise({ commands }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('R4 rotateBy (sage-1782): a real calendar day after today UTC, or invalid-request', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
for (const rotateBy of [dayFrom(0), dayFrom(-1), '2026-02-30', '2026-13-01', '20991231', '2099-1-01', 20991231, null])
|
||||
await refusesWith(f.raise({ rotateBy }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
await f.raise({ rotateBy: dayFrom(1) });
|
||||
await f.raise({ rotateBy: '2999-12-31' });
|
||||
assert.equal(f.decisions(), before + 2);
|
||||
});
|
||||
|
||||
test('G19: a grant naming a host that is pending-install refuses host-not-ready, and an unknown host host-unknown', async (t) => {
|
||||
const f = setup(t);
|
||||
hostRecord(f.store, { host: 'web2', entry: ENTRY });
|
||||
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web2'");
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ host: 'web2' }), 'host-not-ready');
|
||||
await refusesWith(f.raise({ host: 'web9' }), 'host-unknown');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('R4, G5: the raise routes to the human and its event body binds the eight fields with their hashes', async (t) => {
|
||||
const f = setup(t);
|
||||
const commands = [['systemctl', 'status'], ['/usr/bin/journalctl', '-u', 'nginx']];
|
||||
const d = await f.raise({ commands });
|
||||
const row = f.store.get('SELECT action, class, route_to FROM decisions WHERE id=?', d.id);
|
||||
assert.deepEqual({ ...row }, { action: 'credential.mint', class: 'gated', route_to: 'human' });
|
||||
const body = f.raiseBody(d.id);
|
||||
assert.deepEqual(Object.keys(body.grant), ['method', 'agent', 'host', 'commands', 'commandsSha256', 'rotateBy', 'registrySha256', 'definitionSha256']);
|
||||
assert.deepEqual(body.grant, {
|
||||
method: 'host',
|
||||
agent: 'ops',
|
||||
host: 'web1',
|
||||
commands,
|
||||
commandsSha256: createHash('sha256').update(JSON.stringify(commands)).digest('hex'),
|
||||
rotateBy: f.grant().rotateBy,
|
||||
registrySha256: registrySha256({ ...ENTRY, sudo: true }),
|
||||
definitionSha256: createHash('sha256').update(canonicalJson(DEFINITIONS.ops)).digest('hex'),
|
||||
});
|
||||
// Key order in the definition doesn't change the hash, and a field change does.
|
||||
const reordered = { credentials: DEFINITIONS.ops.credentials, roleVersion: 2 };
|
||||
assert.equal(definitionSha256(reordered), body.grant.definitionSha256);
|
||||
assert.notEqual(definitionSha256({ ...DEFINITIONS.ops, credentials: [{ service: 'host' }] }), body.grant.definitionSha256);
|
||||
assert.deepEqual(f.call('human', 'inbox').map((x) => x.id), [d.id]);
|
||||
});
|
||||
|
||||
test('G4 (host block): the broker appends the host, each prefix, the rotate date and new key or change', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise({ commands: [['systemctl', 'status'], ['echo', 'a b"c']] });
|
||||
const rotateBy = f.grant().rotateBy;
|
||||
assert.equal(
|
||||
f.question(d.id),
|
||||
'Grant ops web1?' +
|
||||
'\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.' +
|
||||
'\njarvis has sudo on this host.' +
|
||||
'\nCommands, each an argv prefix:' +
|
||||
'\n"systemctl" "status"' +
|
||||
'\n"echo" "a b\\"c"' +
|
||||
`\nRotate by ${rotateBy}.` +
|
||||
'\nThis makes a new key for ops on web1.',
|
||||
);
|
||||
// A host without sudo, and an agent with a grant record already: the block says so.
|
||||
hostRecord(f.store, { host: 'web2', entry: ENTRY, sudo: false });
|
||||
hostGrantRecord(f.store, { agent: 'ops', host: 'web2', commands: [['uptime']] });
|
||||
const d2 = await f.raise({ host: 'web2' });
|
||||
const q2 = f.question(d2.id);
|
||||
assert.ok(q2.includes('\njarvis has no sudo on this host.\n'));
|
||||
assert.ok(q2.endsWith("\nThis changes the commands of ops's grant on web2. The key stays."));
|
||||
// A forged block in the chief's question still gets the broker's block after it.
|
||||
const forged = 'x\n\nGrant ops access to host web1: user other at 192.0.2.99 port 22.\nCommands, each an argv prefix:\n"bash"';
|
||||
const d3 = await f.raise({}, 'chief', { question: forged });
|
||||
assert.ok(f.question(d3.id).startsWith(forged + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
// The 4096 cap counts the block: a question that fits alone refuses with it.
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({}, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
|
||||
const long = Array.from({ length: 64 }, (_, i) => ['/usr/bin/tool' + i, 'x'.repeat(60)]);
|
||||
await refusesWith(f.raise({ commands: long }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
@@ -54,6 +54,10 @@ export function hostGrantRecord(store, { business = 'demo', agent, host = 'web1'
|
||||
grant: grant === null ? undefined : { method: 'host', agent, host, commands, rotateBy, ...grant },
|
||||
});
|
||||
if (spend) writeGrantEvent(store, AT, business, { operation: spend, decision }, decision);
|
||||
// Row 235: the executor commits each record with its grant.applied (r6 X2 step 5), so the start
|
||||
// reconcile reads a fixture record as a finished grant.
|
||||
if (spend === 'grant.host')
|
||||
writeGrantEvent(store, AT, business, { action: 'grant.applied', grantDecision: decision, agent, host, tag: grantTag(business, agent, host) }, host);
|
||||
store.run(
|
||||
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(?,?,?,?,?,?,?)',
|
||||
business, agent, host, JSON.stringify(commands), rotateBy, grantTag(business, agent, host), decision,
|
||||
|
||||
@@ -1055,6 +1055,9 @@ the old token in Gitea or Vikunja yourself; the bus only forgets it.
|
||||
enrolled host: id, state, `user@address:port`, `sudo` or `no-sudo`, the last check time or
|
||||
`unchecked`, and any host lines a failed step left. `--json` prints the bus rows. `check HOST`
|
||||
runs the bus's `hosts.check` and `remove HOST` its `hosts.remove`, each printing the JSON result.
|
||||
`revoke AGENT HOST --reason R` runs `hosts.revoke`, which takes the agent's grant on the host away
|
||||
at once, and `sweep HOST` runs `hosts.sweep`, which removes the host lines a failure left (row 235).
|
||||
A status line shows `revoking:` with the tags of a revoke still in progress.
|
||||
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
|
||||
command. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// `mosaic hosts` (row 234, #1740, r6 E4): the broker's hosts.status, hosts.check and hosts.remove. The
|
||||
// human runs all three. With --tester the tester's session runs them. check logs in as mosaic-installer
|
||||
// and runs `list` on the host, then marks the host ready. remove drops a host no grant uses. Nothing here
|
||||
// reads a key: the answers carry fingerprints only.
|
||||
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
|
||||
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const HOSTS_USAGE = "mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove HOST [--tester] [--business ID]";
|
||||
export const HOSTS_USAGE =
|
||||
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--tester] [--business ID]";
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
||||
else if (arg === "--business") {
|
||||
else if (arg === "--business" || arg === "--reason") {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
|
||||
@@ -22,13 +24,18 @@ function parse(args) {
|
||||
|
||||
const line = (h) =>
|
||||
[h.host, h.state, `${h.user}@${h.address}:${h.port}`, h.sudo ? "sudo" : "no-sudo", h.checkedAt ?? "unchecked",
|
||||
...(h.hostLineLeft.length ? [`line left: ${h.hostLineLeft.join(" ")}`] : [])].join("\t");
|
||||
...(h.hostLineLeft.length ? [`line left: ${h.hostLineLeft.join(" ")}`] : []),
|
||||
...(h.revoking?.length ? [`revoking: ${h.revoking.join(" ")}`] : [])].join("\t");
|
||||
|
||||
export async function hostsCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
const [verb = "status", host, ...extra] = parsed.positional;
|
||||
const shape = { status: host === undefined, check: host !== undefined, remove: host !== undefined };
|
||||
if (extra.length || !shape[verb] || (parsed.flags.has("--json") && verb !== "status")) throw new CliError(HOSTS_USAGE, 4);
|
||||
const [verb = "status", first, second, ...extra] = parsed.positional;
|
||||
const one = first !== undefined && second === undefined;
|
||||
const shape = { status: first === undefined, check: one, remove: one, sweep: one, revoke: second !== undefined };
|
||||
// N10: own keys only, so `constructor` or `toString` is a usage error, not a verb.
|
||||
if (extra.length || !Object.hasOwn(shape, verb) || !shape[verb] || (parsed.flags.has("--json") && verb !== "status") ||
|
||||
(verb === "revoke") !== Object.hasOwn(parsed.values, "--reason"))
|
||||
throw new CliError(HOSTS_USAGE, 4);
|
||||
try {
|
||||
const call = callFor(parsed);
|
||||
if (verb === "status") {
|
||||
@@ -36,7 +43,8 @@ export async function hostsCommand(args, io, callFor) {
|
||||
io.stdout.write(parsed.flags.has("--json") ? JSON.stringify(rows, null, 2) + "\n" : rows.map(line).join("\n") + (rows.length ? "\n" : ""));
|
||||
return;
|
||||
}
|
||||
io.stdout.write(JSON.stringify(await call(`hosts.${verb}`, { host }), null, 2) + "\n");
|
||||
const args = verb === "revoke" ? { agent: first, host: second, reason: parsed.values["--reason"] } : { host: first };
|
||||
io.stdout.write(JSON.stringify(await call(`hosts.${verb}`, args), null, 2) + "\n");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`. A fake transport records the calls; no bus
|
||||
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`, and row 235 (#1741) revoke and sweep. A
|
||||
// fake transport records the calls; no bus
|
||||
// runs here. The bus side of each verb is in packages/bus/tests/grant-enroll.test.mjs.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -72,3 +73,32 @@ test("row 234: scripts/mosaic sends hosts to packages/cli, not the seat CLI", (t
|
||||
assert.match(r.stderr, /^mosaic: mosaic hosts \[status\]/);
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </);
|
||||
});
|
||||
|
||||
test("row 235: hosts revoke sends agent, host and reason, and sweep the host, as the human or the tester", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["hosts", "revoke", "ops", "web1", "--reason", "left the team", "--business", "acme"], x, deps(calls));
|
||||
await main(["hosts", "sweep", "web1", "--tester", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
|
||||
{ who: "human", verb: "hosts.revoke", args: { agent: "ops", host: "web1", reason: "left the team" } },
|
||||
{ who: "tester", verb: "hosts.sweep", args: { host: "web1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("row 235 (N10): revoke without --reason, --reason on another verb, and inherited names are usage errors", async () => {
|
||||
const calls = [];
|
||||
for (const argv of [["hosts", "revoke", "ops", "web1"], ["hosts", "revoke", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "x", "--reason", "r"],
|
||||
["hosts", "sweep"], ["hosts", "sweep", "web1", "web2"], ["hosts", "check", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "--reason"],
|
||||
["hosts", "revoke", "ops", "web1", "--reason", "a", "--reason", "b"], ["hosts", "constructor", "web1"], ["hosts", "toString", "web1"],
|
||||
["hosts", "hasOwnProperty"], ["hosts", "__proto__", "web1"]])
|
||||
await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic hosts/), true));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("row 235: a status line shows the tags of a revoke in progress", async () => {
|
||||
const rows = [{ ...ROWS[0], revoking: ["mosaic-" + "a".repeat(32)] }];
|
||||
const x = io();
|
||||
const d = { ...deps([]), transport: () => async () => rows };
|
||||
await main(["hosts", "--business", "acme"], x, d);
|
||||
assert.equal(x.out.text, `web1\tready\t[email protected]:22\tsudo\t2026-10-21T00:00:00.000Z\trevoking: mosaic-${"a".repeat(32)}\n`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user