Carries the r2 paths (b0320d395) unchanged. schema.test counts 18 tables and 71 triggers.
stack-release T46 lists hosts and host_grants as untimed (sage-1745). BUILD.md round 3.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# Row 234 (#436a row a, Gitea #1740): host enrollment, store records and bus schema 3i to 3j
|
||||
|
||||
Gizmoduck, 2026-10-21. Reviewer Duckworth, gate owner Sage. Lands by 10-25, before the .53 and .54 installs
|
||||
(mos-1134).
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_host-credentials.md`, section "Machine grant row a: enrollment, store records
|
||||
and migration 3i-3j", blob 64451be5 (queue rev 2617).
|
||||
- Design: `agents/gizmoduck/work/machine-grant/DESIGN.md` r6, f9039682e on `gizmoduck/machine-grant-design`,
|
||||
approved by Duckworth (duckworth-0064, REVIEW-r6.md at 8ea792256). Not reopened here. Cites "r6:" are lines
|
||||
of that file.
|
||||
- Grant event rule: #436b section 7, fa5658941 on `megavolt/row199`,
|
||||
`agents/megavolt/work/436b/DESIGN.md:431-490`, file sha256
|
||||
96510b83778e79a71b33d0d5f6fd0c806dfe74f6de315ff4a9f1fbe5620f6c69, approved by Duckworth (duckworth-0067).
|
||||
- Base: origin/refactor 8fede5fc6, with rows 199 (cb5fdb064) and 210 (f5753c0e8) landed. Branch
|
||||
`gizmoduck/row234`. Code cites are `path:line` at 8fede5fc6 unless they say otherwise.
|
||||
|
||||
## Names: r6 against section 7
|
||||
|
||||
r6's `grant.*` and `host.enroll.*` names are `body.action` values on `action.allowed` events, not event
|
||||
kinds. The `events` kind CHECK isn't rebuilt. Four r6 names move under `grant.`:
|
||||
|
||||
| r6 | This row |
|
||||
|---|---|
|
||||
| `host.enroll.step` | `grant.host.enroll.step` |
|
||||
| `host.enrolled` | `grant.host.enrolled` |
|
||||
| `host.checked` | `grant.host.checked` |
|
||||
| `host.removed` | `grant.host.removed` |
|
||||
|
||||
Only the consume record carries `decision`: `{operation: 'grant.host-enroll', decision}`. Every other
|
||||
guarded body names the decision `grantDecision`, so r6's `decision` field in step, enrolled and failed bodies
|
||||
is `grantDecision` here. The actor of every guarded event is `actor_role` NULL with `actor_run` 'grant',
|
||||
and `body.by` names the caller the broker checked.
|
||||
|
||||
## Split (r6 section 12 Q-S4)
|
||||
|
||||
Row a builds:
|
||||
|
||||
1. **Schema 3i to 3j.** `migrations/3i-3j.sql` adds `hosts` and `host_grants` (r6 S1, primary keys
|
||||
(business, host) and (business, agent, host)) and the guard trigger: an `action.allowed` whose
|
||||
`body.action` or `body.operation` GLOBs `'grant.*'` aborts unless the actor is (NULL, 'grant').
|
||||
`SCHEMA_VERSION` 3j, a migration test with a step that fails partway.
|
||||
2. **Grant event writer.** A private session-less broker writer for guarded events. `#event` doesn't change.
|
||||
`recordEvent` refuses `reserved-event` for the guarded namespace.
|
||||
3. **The `grant` raise frame.** `decision.raise` takes `grant` only with `credential.mint`, only from the chief
|
||||
(`grant-chief-only`), and in row a only with `method: 'host-enroll'`. Any other method refuses
|
||||
`invalid-request` until row b adds `host`. E1's checks, the `ssh-keyscan` set compare before the raise
|
||||
transaction, and the rendered block.
|
||||
4. **Enrollment executor.** `#consumeGrant`, the start on the human's cli yes (X1), E2 steps 1 to 4, the
|
||||
installer key at `<configDir>/secrets/hosts/<business>/<hostId>/installer`, the failure cleanup, and the
|
||||
start reconcile for enrollments: the `registry` step undo, and a resolved-yes unspent enrollment ending
|
||||
`grant.failed interrupted` (X3).
|
||||
5. **`hosts.check` and `hosts.remove`** (E4), with `mosaic hosts status|check|remove`.
|
||||
6. **`tools/host/`**: `mosaic-keys`, `mosaic-keys-write`, `mosaic-keys.sudoers`, `mosaic-keys.sshd.conf` (E3).
|
||||
7. **`host.run` on the store** (X4 first and third bullets): it reads the `hosts` and `host_grants` records
|
||||
and the Approve record check, and at start the runtime gives `Credentials` a reference per `host_grants`
|
||||
record. The loader refuses `hosts` at the top level and in a role (S1). Row 210's tests move to store
|
||||
fixtures.
|
||||
8. **Reader filter** (duckworth-0067 N1): every grant-evidence reader in this row (status, the start
|
||||
reconcile, `openGrantDecisions`) filters `kind = 'action.allowed' AND actor_role IS NULL AND actor_run =
|
||||
'grant'`, with a test that an `action.refused` look-alike and a session-written one are ignored.
|
||||
`hosts.remove`'s `hostLineLeft` reader is one of them (duckworth-0079 M4).
|
||||
|
||||
Row b keeps the `host` method's raise binding (R4), the grant executor (X2), the grant rows of X3, X5,
|
||||
`Credentials.register`/`claim`/`abortClaim`, revoke (section 6) and `sweep`. So row a has no way to make a
|
||||
`host_grants` record outside a test fixture, and from its landing to row b's no agent holds a host grant
|
||||
(r6 N9, the run sheet strips row 210's keys).
|
||||
|
||||
Tests in row a (r6 section 10): G1 and G5 for the `host-enroll` raise, G6 and G10 for the enrollment
|
||||
executor, G11's enrollment cases, G14, G15, G16, G17, G18, G20, G21 (its `host-line-left` case reads an
|
||||
event fixture until row b writes `hostLineLeft`), G25, the migration test, the trigger cases, the reader
|
||||
filter test, and row 210's T-tests on store fixtures. Row b: G2 to G4, G7 to G9, G11's grant cases, G12,
|
||||
G13, G19, G22 to G24, G26 to G28.
|
||||
|
||||
## Early check items (duckworth-0079, REVIEW-build-split.md 10672772e on `duckworth/reviews`)
|
||||
|
||||
Moves into row a:
|
||||
- M1. G4 for the `host-enroll` block (r6:240-242): a question can't forge the block, and the 4096-character
|
||||
limit holds with the block appended. G4 for the `host` block stays in row b.
|
||||
- M2. The tag function (r6:265-266), because E2 step 2 tags the installer line. G24's first sentence moves
|
||||
here: names of 64 characters with `.` and `_` give distinct tags of the fixed length. The `sweep` half
|
||||
stays in row b.
|
||||
- M3. A store-fixture test that `host.run` refuses `host-not-granted` for no record, an unresolved decision,
|
||||
a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operation
|
||||
`grant.host-enroll` in place of `grant.host`, and an unspent decision (X4 first bullet, r6:593-596). A
|
||||
restart test that a `host_grants` fixture gives `Credentials` a reference (X4 third bullet).
|
||||
|
||||
G21's `host-line-left` case:
|
||||
- F1. The fixture goes through this row's private grant writer in the real shape: `action.allowed`, actor
|
||||
(NULL, 'grant'), `body.action` 'grant.failed', `body.host`, `body.hostLineLeft`. No raw SQL around the
|
||||
trigger.
|
||||
- F2. `hosts.remove` reads `body.hostLineLeft` from any guarded body, not only `grant.failed`.
|
||||
- F3. Decided: a guarded body that carries `hostLineLeft` also carries `host`. Row b adds `host` to
|
||||
`grant.revoke.closed`, so its body is `{tag, host, hostLineLeft}` (r6:716 has `{tag, hostLineLeft}`).
|
||||
The reader matches on `body.host`, and doesn't map tags back to hosts through `grant.step`. A direct
|
||||
field is one equality in SQL, and a tag lookup would add a second reader that the N1 filter also has to
|
||||
cover.
|
||||
- F4. Row b carry: "no later `sweep` cleared it" (r6:403) needs `grant.sweep`, which only row b writes. Row
|
||||
b adds the sweep clear to `hosts.remove` with a G21 case, and its G8 or G13 shows `hosts.remove`
|
||||
refusing after a real `hostLineLeft` write, not only after the fixture. Until then row a's check is "any
|
||||
guarded `hostLineLeft` for this host refuses".
|
||||
|
||||
## Mutants (r6:838-883, numbered here)
|
||||
|
||||
Row a, killed on this row's tree:
|
||||
|
||||
| # | Mutant |
|
||||
|---|---|
|
||||
| A1 | drop the chief check |
|
||||
| A2 | start the executor on a `credential.mint` with no `grant` |
|
||||
| A3 | start it on an arbiter resolve |
|
||||
| A3b | start it on a `cli` yes by someone other than the human (duckworth-0083 N8) |
|
||||
| A4 | start it on `no` |
|
||||
| A5 | skip the keyscan compare |
|
||||
| A6 | compare only one key |
|
||||
| A7 | write the private key into the event body |
|
||||
| A8 | drop the "not yet enrolled" check in E2 step 1 |
|
||||
| A9 | drop the `hosts` primary key |
|
||||
| A10 | skip the start check for a resolved, unspent enrollment decision |
|
||||
| A11 | `host.run` reads the business file instead of the grant record |
|
||||
| A12 | build the tag without the business |
|
||||
| A13 | drop `from=` in the writer |
|
||||
| A14 | let the writer touch a line outside the tag pattern |
|
||||
| A15 | drop `O_NOFOLLOW` on the user file |
|
||||
| A16 | drop one group from the writer's refused list |
|
||||
| A17 | drop the user pattern check before the path use |
|
||||
| A18 | treat a `privileged` entry as enough without `users` |
|
||||
| A19 | let the writer accept a sudo user not in `privileged` |
|
||||
| A20 | trigger: drop the `body.operation` arm, so the consume record passes from a session |
|
||||
| A21 | trigger: accept actor_role NULL with any actor_run |
|
||||
| A22 | a grant reader drops the `kind = 'action.allowed'` or actor filter. The runner splits it: A22a drops the kind, and G21's `action.refused` look-alikes kill it. A22b drops the actor, which the trigger can't stand in for, because 3i-3j adds the trigger without checking old rows. Its killer is the grant-migration test "A22b, sage-1703": a session-written `grant.host.removed` with `hostLineLeft` and a `grant.host-enroll` row from 3i, migrated to 3j, where `hosts.remove` still removes the host and `reconcileGrants` ends nothing |
|
||||
| A23 | `host.run` skips the Approve record check (any `host_grants` record is enough) |
|
||||
|
||||
Row b keeps the rest of r6's list: the `ready` check, the three hash compares, the definition check, the
|
||||
install and record order, `remove` on a verify failure, both start sweeps, the reconcile's remote `remove`,
|
||||
the `grant.step` before install, revoke leaving the record, `register` before `issue`, the abort check and
|
||||
the `await` before `issue`, revoke and an unreferenced claim, `sweep` and any `mosaic-` line, revoke's host
|
||||
step order, claim before consume, the resolved unspent grant decision, an async `commit`, `hosts.revoke`
|
||||
through `credential-verbs.mjs`, the revoke marker reconcile, the upsert, the undo past `grant.revoked`, the
|
||||
abort after step 5 and a missing `bus.tester` as dev.
|
||||
|
||||
## Broker functions this row touches (sage-1677)
|
||||
|
||||
For row 236 (Megavolt), which rebuilds on this landing. Old lines are `packages/bus/src/broker.mjs` at
|
||||
8fede5fc6, new lines are at the last commit that changed this file (they moved by 10 after `runEnrollment`
|
||||
went in). The planned list is kept in this file's history (827b89ea6).
|
||||
|
||||
Changed:
|
||||
- `constructor` (:279): one more option, `hostGrant`, the object `host-grant.mjs` builds.
|
||||
- `recordEvent` (old :1243-1251, new :1606): one more refusal. An `action.allowed` whose `body.action` or
|
||||
`body.operation` is a `grant.*` name refuses `reserved-event`.
|
||||
- `request()` (old :1362, new :1726): two routes ahead of the sync path, a `decision.raise` with `grant` to
|
||||
`#raiseGrant` and the `hosts.*` verbs to `#requestHosts`. Both are async.
|
||||
- `requestHost` (old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one
|
||||
`#hostAccess` call, in the same transaction as `#agent`. `commands` and `entry` come from it.
|
||||
- `#dispatch` (old :1796, new :2160) gets a fifth parameter, `enroll`, which only `#raiseGrant` passes.
|
||||
- `decision.raise` (new :2244): `grant` joins the allowed keys, and `grant` without `enroll` (or the
|
||||
reverse) refuses `invalid-request`. The `host.sudo` block reads `#hostAccess` in place of `b.hosts`
|
||||
and the role's `hosts`, and an undefined `target` still refuses `host-unknown`. `validArgv` and
|
||||
`validStdin` now run after the access check, so a malformed argv for an ungranted host refuses
|
||||
`host-not-granted` first. The enrollment block is appended to the question before `string(question)`,
|
||||
so the 4096 cap counts it (M1). The decision's raise body carries `grant: enroll`.
|
||||
- `decision.resolve` (new :2409): when `op` is `resolved`, `#approvedGrant` reads the decision, and a
|
||||
`host-enroll` grant starts `#runEnrollment` from `setImmediate`, after the transaction commits. The
|
||||
result adds `grant: 'started'`.
|
||||
|
||||
New, all private but two:
|
||||
- `#grantEvent` (:657), the writer of guarded events. It runs the secret check, then `writeGrantEvent`.
|
||||
- `#approvedGrant` (:667), `#consumeGrant` (:678). `#consumeGrant` sits after `#approvedGrant`, not next
|
||||
to `#consumeAuthority` as planned.
|
||||
- `#hostRecord` (:685), `#hostAccess` (:692), `#grantNotice` (:708), `#enrollFailed` (:736),
|
||||
`#runEnrollment` (:750), `#openGrantDecisions` (:823).
|
||||
- `runEnrollment` (:745), public, a trusted entry to `#runEnrollment` for G10. No verb reaches it.
|
||||
- `reconcileGrants` (:833), public, which `runtime.mjs` calls once at start.
|
||||
- `#raiseGrant` (:866), `#hostsCaller` (:886), `#requestHosts` (:900), `#hostLinesLeft` (:964).
|
||||
|
||||
Module level, new (:184-230): `GRANT_RUN`, `grantName`, `GRANT_EVIDENCE` (the N1 filter as SQL),
|
||||
`registrySha256`, `writeGrantEvent` (exported, the one writer of guarded events, which test fixtures use
|
||||
for F1), `HOSTS_VERBS`, `fingerprintOf`, `INSTALLER_COMMENT`.
|
||||
|
||||
Unchanged: `#event`, `#spent`, `#consumeAuthority`.
|
||||
|
||||
Other `src` files:
|
||||
- `bus/src/host-grant.mjs`, new: the keyscan, the installer key pair, and `hosts.check`'s `list`.
|
||||
- `bus/src/runtime.mjs`: builds `hostGrant`, calls `reconcileGrants` before the server starts, and adds
|
||||
the `host_grants` references before `new Credentials` (X4 third bullet).
|
||||
- `bus/src/host-run.mjs`: `runHost` takes `command`, sent as is in place of the sudo argv, for the
|
||||
installer's `list`.
|
||||
- `bus/src/server.mjs`: the `hosts.*` verbs and a raise with `grant` go down the async path.
|
||||
- `bus/src/store.mjs`: the 3i-3j step, `SCHEMA_VERSION` 3j. `bus/src/credentials.mjs` exports
|
||||
`privateDir`.
|
||||
- `business/src/business.mjs` and `business/src/hosts.mjs`: the loader refuses `hosts` (S1).
|
||||
`checkHosts` and `checkHostGrants` are gone.
|
||||
- `cli/src/hosts.mjs`, new, routed from `cli.mjs` and `scripts/mosaic`.
|
||||
|
||||
## Decisions taken in the build
|
||||
|
||||
- **`#hostAccess` binds the agent and the host.** X4 says the Approve record check passes when the
|
||||
decision is an approved `host` grant with a `grant.host` consume. The check also requires the grant's
|
||||
`agent` to equal the session's role and its `host` to equal the requested host. Without that, an
|
||||
approved grant for one agent and host would vouch for any `host_grants` row that names its decision.
|
||||
Cost: none for row b, whose executor writes the row from the same grant.
|
||||
- **Store fixtures are written the real way.** `bus/tests/host-fixtures.mjs` inserts the decision rows by
|
||||
SQL, but every guarded event goes through `writeGrantEvent`, so the trigger sees it (F1). An enrollment
|
||||
fixture writes `grant.host.enrolled`, or the start reconcile reads it as interrupted.
|
||||
- **The resolver test for host kinds is gone.** `business/tests/hosts.test.mjs` lost its D5 case, because
|
||||
the loader now refuses `host.<id>` credential refs. The `isHostKind` filter in `business/src/resolve.mjs`
|
||||
can't be reached now. I left it in, since row b may want it back, and it's one line.
|
||||
- **Store before Credentials.** The runtime already opened the store before `Credentials`. The
|
||||
`host_grants` references depend on that order, and the comment there says so.
|
||||
- **CLI.** `mosaic hosts` prints tab lines, and `--json` prints the bus rows. Usage errors exit 4 and bus
|
||||
refusals exit 2, as `mosaic credentials` does. There is no enroll command: enrollment is the chief's raise.
|
||||
- **Host tools tests live in the bus suite.** `bus/tests/host-tools.test.mjs` loads the writer by path
|
||||
through a Python harness under `python3 -B -I`. The harness passes its own key dir, config dir, owner
|
||||
uid and user, group and sudo lookups, and runs `mosaic-keys` from a copy with stub `sudo` and `logger`.
|
||||
The gate runs the bus suite, so G16 and G25 run there. The real `pwd`, `grp` and `sudo -l` paths are
|
||||
checked on the #433 VM, not here. The test also runs `visudo -c` on the sudoers file and `sshd -T` on
|
||||
the drop-in.
|
||||
- **Writer details the design left open.** The `from` file must hold exactly one value matching
|
||||
`[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*`, so it can't close the quote. `users`, `privileged` and `from` must be
|
||||
regular, owned by the owner uid and not group or world writable, and are opened without following a
|
||||
symlink. Lines starting with `#` are comments. A refusal exits 1 with its code on stderr and in syslog.
|
||||
`add` of a tag that is already there exits 0 and writes nothing.
|
||||
- **`runEnrollment` is public for G10 (duckworth-0081 B1).** G10 needs a second start for a spent
|
||||
decision, and the resolve path can't give one: a second resolve refuses `decision-closed`
|
||||
(broker.mjs:2416) before the executor runs. So
|
||||
`#runEnrollment` returns its code (`enrolled`, `host-enrolled` or the refusal), and a public
|
||||
`runEnrollment` calls it, as `reconcileGrants` is public. The test checks a spent
|
||||
decision gives `decision-consumed`, and an open or agent-resolved one gives `not-authorized`, with
|
||||
nothing written. That second case also reaches the `via` and `by` checks in `#approvedGrant`, so A3 is
|
||||
no longer vacuous (N1).
|
||||
Who can call it (sage-1692): only code holding the `Broker` object, which is the bus process and the
|
||||
tests. The one `new Broker` in `src` is `runtime.mjs:126`, and no `src` file calls `runEnrollment`;
|
||||
the resolve path calls the private `#runEnrollment` from `setImmediate` (broker.mjs:2445). A seat
|
||||
holds no `Broker`. It reaches the bus only over `broker.sock`, where `server.mjs` calls a fixed set of
|
||||
methods by name (`request` at :134 and :140, `identity`, `disconnect`). `request()` routes on the verb
|
||||
through the `#dispatch` switch, and neither file looks a method up by name (no `this[` in either), so no
|
||||
verb string can reach `runEnrollment`.
|
||||
- **A `sudo -l` that times out refuses `user-sudo` (N2).** `_sudo` returns an empty answer on
|
||||
`TimeoutExpired`, which isn't the expected line, so `main` logs the refusal and exits 1.
|
||||
- **Keyscan comment (N3).** A scan that can't start refuses `host-failed`. One that runs and finds no key
|
||||
returns no lines, and the raise refuses `host-key-mismatch`. The comment in `host-grant.mjs` now says so.
|
||||
- **No tag on in-run `grant.failed` (N4).** r6:578 tags only the `interrupted` one, so the code follows it.
|
||||
- **Run sheet (N5).** The writer refuses `config-privileged` when the file is missing, so each host needs an
|
||||
empty, root-owned `/etc/mosaic-keys/privileged` (0644) before the first enrollment.
|
||||
|
||||
## Mutant results (Sage's slot, 2026-10-21)
|
||||
|
||||
All 25 killed: A1 to A23, with A3b added for duckworth-0083 N8 and A22 split into A22a and A22b. The
|
||||
runner is `mutants.py` outside the repo. Each mutant gets its own session, a 420 s limit with a group kill,
|
||||
and a byte restore checked against git status. None hung. Run 1 (04:24Z, on 95f966b1c) killed A1 to A22a
|
||||
and stopped at A22b, which survived. Run 2 (04:43Z, on 097892741) killed A22b and A23.
|
||||
|
||||
- Each kill comes from a named assertion in the mutant's own test files, except A9. Dropping the `hosts`
|
||||
primary key leaves `host_grants`' foreign key with no parent key, so the store refuses to open
|
||||
("foreign key mismatch") and 28 of 29 tests fail at setup. That's a structural kill, not the S1 test.
|
||||
The schema can't exist without the key (sage-1709).
|
||||
- A22b drops the actor filter from `GRANT_EVIDENCE`. The trigger can't stand in for it, because a 3i store
|
||||
can hold a session-written grant row that the 3i-3j step keeps. The grant-migration test "A22b,
|
||||
sage-1703" kills it (see the A22 row above).
|
||||
|
||||
Follow-up, not in row a: G18's control calls `runHost` directly, so nothing runs `hg.check` end to end
|
||||
with a good pin (duckworth-0083 N9). The #433 VM run covers it.
|
||||
|
||||
## Status at 097892741
|
||||
|
||||
Done: every Split item, 1 to 8, duckworth-0081 B1 to B3, duckworth-0083 N8, and all 25 mutants killed.
|
||||
|
||||
Single files in the slot, all exit 0, 178 pass and 0 fail by an awk sum over 23 logs: bus grant-enroll
|
||||
24, host-run 24, grant-migration 6, host-tools 6, account-migration 3, chief-of-staff 18,
|
||||
comment-migration 5, hard-waits 13, name-migration 1, project-runtime 3, spec-migration 1,
|
||||
thread-migration 3, usage-migration 1, project-files 8, spec-socket 6, spec-review 12, land 17, spec 4;
|
||||
harness spec-tools 2, raise-domain 2; business hosts 4; cli hosts 5; seat sandbox 10.
|
||||
|
||||
Left: Sage runs the whole bus, business and cli suites and every `scripts/test-*.sh` at the landing gate,
|
||||
after Duckworth approves.
|
||||
|
||||
## Round 3 (sage-1742): two tests that count the schema
|
||||
|
||||
The landing gate failed `packages/bus/tests/schema.test.mjs:522`. It counts the tables and triggers that
|
||||
`schema.sql` creates, and row a adds two tables and a trigger, so the counts go from 16 to 18 tables and
|
||||
from 70 to 71 triggers. My singles didn't run it because I took it for a test of other rows' tables.
|
||||
|
||||
I grepped `packages/*/tests/*` for `sqlite_master` and `schema.sql`, with `schema.test.mjs:517` as the
|
||||
positive control. That turned up one more test that would fail: T46 in
|
||||
`packages/cli/tests/stack-release.test.mjs:910`. It puts every schema table in exactly one of four lists, and
|
||||
hosts and host_grants were in none. I'd left the stack-release tests out of the r2 singles on the claim that
|
||||
they don't load the bus, but T46 reads `schema.sql`. Both tables go in its untimed list (Sage's ruling,
|
||||
sage-1745: the message_threads pattern). Neither has an `at` column. Row a writes hosts at five lines,
|
||||
and each runs in one store transaction with a `grant.*` events row, which the restore guard counts.
|
||||
`#grantEvent` (broker.mjs:657) writes that row through `writeGrantEvent` (:201, INSERT INTO events at
|
||||
:212).
|
||||
|
||||
- :737 DELETE, in `#enrollFailed`, with grant.failed at :738. Both callers wrap it in a transaction,
|
||||
:816 and :849.
|
||||
- :763 INSERT, in the step 1 transaction (:754), with grant.host.enroll.step at :761.
|
||||
- :783 UPDATE installer_fingerprint, in the step 4 transaction (:780), with grant.host.enrolled at :784.
|
||||
- :932 DELETE, in the hosts.remove transaction (:927), with grant.host.removed at :933.
|
||||
- :950 UPDATE state ready, in the hosts.check transaction (:944), with grant.host.checked at :951.
|
||||
|
||||
Row a writes no host_grants rows. It only reads them (broker.mjs:695, :930, runtime.mjs:103). Row b adds
|
||||
the writes, and each must share a transaction with a `grant.*` event the same way. That goes on row
|
||||
235's carry list. The other hits pin an older schema
|
||||
(account, comment, name, spec, thread and usage migrations, chief-of-staff 3c) or count only their own
|
||||
tables (state.test.mjs:106 reads every table but pins no list, threads.test.mjs:129).
|
||||
|
||||
hosts and host_grants are mutable by design, so the refused-labels loop above `schema.test.mjs:517` gets no
|
||||
entry for them. hosts.check updates a host to ready. hosts.remove and a failed enrollment delete the
|
||||
record. Row b updates and deletes grant records. Neither table has append-only triggers. The one new
|
||||
trigger, events_grant_actor, guards events. grant-migration.test.mjs tests it, and it killed mutants
|
||||
A20 and A21.
|
||||
+15
-4
@@ -218,6 +218,9 @@ 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.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`. |
|
||||
|
||||
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
|
||||
@@ -704,10 +707,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3i, SHA-256
|
||||
`6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737`.
|
||||
It has 16 tables, 71 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3i`, WAL,
|
||||
`schema.sql` is v3j, SHA-256
|
||||
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
|
||||
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
@@ -867,6 +870,14 @@ one transaction; a failure leaves the store at 3h. It runs only through
|
||||
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
|
||||
81f26551b's tree; no live store is used.
|
||||
|
||||
Schema 3j (row 234, #1740) adds only new objects: the `hosts` and `host_grants` tables, which
|
||||
hold enrolled hosts and host grants, and the `events_grant_actor` trigger. The trigger refuses an
|
||||
`action.allowed` event whose `body.action` or `body.operation` matches `grant.*` unless its actor
|
||||
is `actor_role` NULL with `actor_run` 'grant', the broker's grant writer (#436b section 7). The
|
||||
step runs in one transaction, and a failure leaves the store at 3i. The 3i fixture,
|
||||
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
|
||||
covers the step and the trigger.
|
||||
|
||||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||||
The broker requires the bound role holder and its delivery, binds run identity,
|
||||
|
||||
@@ -363,3 +363,34 @@ CREATE TABLE usage_records (
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
|
||||
+400
-17
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -182,6 +182,52 @@ const STAFF_COUNT = 20;
|
||||
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
|
||||
// action.allowed for git.push.working that names its commit, written only by recordPush.
|
||||
const REVIEW_KINDS = ['review.requested', 'review.verdict'];
|
||||
// Row 234 (#1740, #436b section 7): grant evidence is an action.allowed whose body.action or
|
||||
// body.operation starts with grant., written only by #grantEvent under this actor. No session has a
|
||||
// NULL role with a non-NULL run, and the 3j trigger refuses any other actor.
|
||||
const GRANT_RUN = 'grant';
|
||||
const grantName = (body) => [body?.action, body?.operation].some(n => typeof n === 'string' && n.startsWith('grant.'));
|
||||
// Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or
|
||||
// actor is not evidence.
|
||||
const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'";
|
||||
// r6 R4: the host record's identity, hashed at raise and at each later check.
|
||||
export const registrySha256 = ({ address, port, user, sudo, hostKeys }) =>
|
||||
createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex');
|
||||
// Row 234 (#1740, #436b section 7): the one writer of grant evidence, session-less, as actor (NULL,
|
||||
// 'grant'). The broker's #grantEvent calls it after its secret check, and the tests write their fixtures
|
||||
// through it (duckworth-0079 F1). No verb reaches it. Only the consume record {operation, decision}
|
||||
// names the decision as `decision`, so #spent counts it and nothing else. Every other body has a
|
||||
// grant.* action, names the decision grantDecision, and body.by names the caller the broker checked.
|
||||
export function writeGrantEvent(store, at, business, body, subject = null) {
|
||||
const consume = body?.operation !== undefined;
|
||||
if (
|
||||
!grantName(body) ||
|
||||
(consume
|
||||
? Object.keys(body).length !== 2 || typeof body.decision !== 'string' || !body.operation.startsWith('grant.')
|
||||
: typeof body.action !== 'string' || !body.action.startsWith('grant.') || 'decision' in body)
|
||||
)
|
||||
throw new Error('grant event shape');
|
||||
const eid = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
eid,
|
||||
at,
|
||||
business,
|
||||
'action.allowed',
|
||||
null,
|
||||
GRANT_RUN,
|
||||
subject,
|
||||
JSON.stringify(body),
|
||||
);
|
||||
return eid;
|
||||
}
|
||||
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove']);
|
||||
// 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(/=+$/, '');
|
||||
// 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
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
@@ -224,11 +270,13 @@ export class Broker {
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
// Row 234 (#1740): the trusted enrollment half (host-grant.mjs), or null where hosts are off.
|
||||
#hostGrant;
|
||||
// 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 }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -237,6 +285,7 @@ export class Broker {
|
||||
this.#organization = organization;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#hostGrant = hostGrant;
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
@@ -604,9 +653,323 @@ export class Broker {
|
||||
);
|
||||
return eid;
|
||||
}
|
||||
// Row 234 (#1740): grant evidence, through writeGrantEvent. Caller owns the transaction.
|
||||
#grantEvent(business, body, subject = null) {
|
||||
this.#secretCheck({ kind: 'action.allowed', body, subject });
|
||||
return writeGrantEvent(this.#store, this.#now(), business, body, subject);
|
||||
}
|
||||
// PRD 1.6 REQ-WAIT-1: a hard wait goes to the human at every class, cited or not. Jason's ruling
|
||||
// Q169 makes a role.revoke whose target's definition is in END_PROTECTED one, so no arbiter
|
||||
// resolves a revoke of the assistant.
|
||||
// Row 234 (#1740, r6 X1): the raise's grant binding, if the human's cli resolve chose its approval
|
||||
// option on a credential.mint routed to the human. Null otherwise: an arbiter, the tester or an agent
|
||||
// closes with via 'broker'.
|
||||
#approvedGrant(business, decision) {
|
||||
const d = this.#store.get('SELECT action,route_to FROM decisions WHERE business=? AND id=?', business, decision);
|
||||
const raise = this.#raised(business, decision);
|
||||
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
|
||||
const r = this.#closed(decision);
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
|
||||
return null;
|
||||
return raise.grant;
|
||||
}
|
||||
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
|
||||
// guarded body with `decision` (#436b section 7), so #spent counts it.
|
||||
#consumeGrant(business, decision, method) {
|
||||
const grant = this.#approvedGrant(business, decision);
|
||||
if (grant?.method !== method) fail('not-authorized');
|
||||
if (this.#spent(business, decision)) fail('decision-consumed');
|
||||
this.#grantEvent(business, { operation: 'grant.' + method, decision }, decision);
|
||||
return grant;
|
||||
}
|
||||
#hostRecord(business, host) {
|
||||
const r = this.#store.get('SELECT * FROM hosts WHERE business=? AND host=?', business, host);
|
||||
return r && { ...r, sudo: r.sudo === 1, hostKeys: JSON.parse(r.host_keys) };
|
||||
}
|
||||
// r6 X4 first bullet, the Approve record check: the host record and the session's grant record, whose
|
||||
// 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) {
|
||||
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);
|
||||
const spent = g && this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host' AND json_extract(body,'$.decision')=? LIMIT 1`,
|
||||
s.business,
|
||||
g.decision,
|
||||
);
|
||||
// The grant must name this agent and host too, so one approval can't stand behind another record.
|
||||
const grant = spent && this.#approvedGrant(s.business, g.decision);
|
||||
if (grant?.method !== 'host' || grant.agent !== s.role || grant.host !== host) fail('host-not-granted');
|
||||
return { entry: { address: r.address, port: r.port, user: r.user, hostKeys: r.hostKeys }, commands: JSON.parse(g.commands) };
|
||||
}
|
||||
// A grant outcome, from the bus, to the human's inbox and to the raising role, threaded as a
|
||||
// close is.
|
||||
#grantNotice(business, decision, body) {
|
||||
const d = this.#store.get('SELECT raised_by_role,raised_by_run FROM decisions WHERE id=?', decision);
|
||||
const thread = this.#threaded(business, d.raised_by_role)
|
||||
? (this.#store.get(
|
||||
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
d.raised_by_run,
|
||||
)?.thread ?? null)
|
||||
: null;
|
||||
for (const [to, t] of [['human', null], [d.raised_by_role, thread]]) {
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid,
|
||||
this.#now(),
|
||||
business,
|
||||
NOTICE_ROLE,
|
||||
NOTICE_RUN,
|
||||
to,
|
||||
'RESULT',
|
||||
null,
|
||||
decision,
|
||||
null,
|
||||
body,
|
||||
);
|
||||
this.#route(business, mid, to, t);
|
||||
}
|
||||
}
|
||||
#enrollFailed(business, decision, host, code, extra = {}) {
|
||||
this.#store.run('DELETE FROM hosts WHERE business=? AND host=? AND decision=?', business, host, decision);
|
||||
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host, code, ...extra }, host);
|
||||
this.#grantNotice(business, decision, `Enrollment of host ${host} (decision ${decision}) failed: ${code}.`);
|
||||
}
|
||||
// Trusted host API, as reconcileGrants is, and no verb reaches it: the executor's entry for one
|
||||
// decision. Step 1 checks everything again, so a second start for a spent decision ends
|
||||
// decision-consumed, and an unresolved or agent-resolved one ends not-authorized, with nothing
|
||||
// written (r6 G10).
|
||||
runEnrollment(business, decision) {
|
||||
return this.#runEnrollment(business, decision);
|
||||
}
|
||||
// r6 E2, started by the human's cli yes. Never throws: the outcome is in the store and the notices,
|
||||
// and the return value is its code, 'enrolled' or the refusal.
|
||||
async #runEnrollment(business, decision) {
|
||||
let grant;
|
||||
try {
|
||||
// Step 1, one transaction: spend, the not-enrolled check, the registry step and the record.
|
||||
grant = this.#store.transaction(() => {
|
||||
const g = this.#consumeGrant(business, decision, 'host-enroll');
|
||||
if (this.#hostRecord(business, g.host)) {
|
||||
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host: g.host, code: 'host-enrolled' }, g.host);
|
||||
this.#grantNotice(business, decision, `Enrollment of host ${g.host} (decision ${decision}) failed: host-enrolled.`);
|
||||
return null;
|
||||
}
|
||||
this.#grantEvent(business, { action: 'grant.host.enroll.step', grantDecision: decision, host: g.host, step: 'registry' }, g.host);
|
||||
this.#store.run(
|
||||
'INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
business, g.host, g.address, g.port, g.user, Number(g.sudo), JSON.stringify(g.hostKeys), null, 'pending-install', decision, null,
|
||||
);
|
||||
return g;
|
||||
});
|
||||
} 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 (!grant) return 'host-enrolled';
|
||||
const host = grant.host;
|
||||
try {
|
||||
if (!this.#hostGrant) fail('host-not-configured');
|
||||
// Steps 2 and 3: the key pair, and the private half to its file.
|
||||
const key = await this.#hostGrant.enroll(business, host);
|
||||
// Step 4.
|
||||
this.#store.transaction(() => {
|
||||
const r = this.#hostRecord(business, host);
|
||||
if (r?.decision !== decision) fail('host-changed');
|
||||
this.#store.run('UPDATE hosts SET installer_fingerprint=? WHERE business=? AND host=?', key.fingerprint, business, host);
|
||||
this.#grantEvent(
|
||||
business,
|
||||
{
|
||||
action: 'grant.host.enrolled',
|
||||
grantDecision: decision,
|
||||
host,
|
||||
registrySha256: registrySha256(r),
|
||||
installerFingerprint: key.fingerprint,
|
||||
user: r.user,
|
||||
sudo: r.sudo,
|
||||
},
|
||||
host,
|
||||
);
|
||||
this.#grantNotice(
|
||||
business,
|
||||
decision,
|
||||
`Host ${host} is enrolled (decision ${decision}) and pending install. ops-01 writes this line to ` +
|
||||
`/etc/ssh/mosaic-keys/mosaic-installer, with <from> the value in /etc/mosaic-keys/from:\n` +
|
||||
`restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ${key.publicLine} ` +
|
||||
`${INSTALLER_COMMENT}${key.tag.slice('mosaic-'.length)}\n` +
|
||||
`/etc/mosaic-keys/users lists ${r.user}.` +
|
||||
(r.sudo ? ` /etc/mosaic-keys/privileged lists ${r.user}.` : ` /etc/mosaic-keys/privileged doesn't list ${r.user}.`) +
|
||||
`\nThen run: mosaic hosts check ${host}`,
|
||||
);
|
||||
});
|
||||
return 'enrolled';
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'host-failed';
|
||||
try {
|
||||
this.#hostGrant?.unenroll(business, host);
|
||||
} catch {}
|
||||
try {
|
||||
this.#store.transaction(() => this.#enrollFailed(business, decision, host, code));
|
||||
} catch {}
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// r6 X3 and duckworth-0067 N1: credential.mint decisions for `method`, resolved yes by the human via
|
||||
// cli and never spent.
|
||||
#openGrantDecisions(business, method) {
|
||||
return this.#store
|
||||
.all("SELECT id FROM decisions WHERE business=? AND action='credential.mint' AND route_to='human' ORDER BY at,id", business)
|
||||
.map((d) => d.id)
|
||||
.filter((id) => this.#approvedGrant(business, id)?.method === method && !this.#spent(business, id));
|
||||
}
|
||||
// Trusted host API, at start before the socket opens (r6 X3). An enrollment spent with no
|
||||
// grant.host.enrolled or grant.failed died after its registry step: the key file and the record
|
||||
// go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way.
|
||||
// Neither runs late.
|
||||
reconcileGrants() {
|
||||
const ended = [];
|
||||
for (const business of Object.keys(this.#businesses)) {
|
||||
const spent = this.#store.all(
|
||||
`SELECT json_extract(body,'$.decision') AS decision FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host-enroll' ORDER BY seq`,
|
||||
business,
|
||||
);
|
||||
for (const { decision } of spent) {
|
||||
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.host.enrolled','grant.failed') LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
);
|
||||
if (end) continue;
|
||||
const host = this.#raised(business, decision).grant.host;
|
||||
this.#hostGrant?.unenroll(business, host);
|
||||
this.#store.transaction(() =>
|
||||
this.#enrollFailed(business, decision, host, 'interrupted', { tag: this.#hostGrant?.installerTag(business, host) ?? null }),
|
||||
);
|
||||
ended.push(decision);
|
||||
}
|
||||
for (const decision of this.#openGrantDecisions(business, 'host-enroll')) {
|
||||
this.#store.transaction(() => {
|
||||
const g = this.#consumeGrant(business, decision, 'host-enroll');
|
||||
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host: g.host, code: 'interrupted', tag: this.#hostGrant?.installerTag(business, g.host) ?? null }, g.host);
|
||||
this.#grantNotice(business, decision, `Enrollment of host ${g.host} (decision ${decision}) failed: interrupted.`);
|
||||
});
|
||||
ended.push(decision);
|
||||
}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
// r6 E1, R3: the raise checks and the keyscan, then 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');
|
||||
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 });
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.#refused(s, e, { verb: 'decision.raise', args: a });
|
||||
}
|
||||
}
|
||||
#hostsCaller(s, verb) {
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.tester) 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');
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
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.
|
||||
async #requestHosts(s, verb, a) {
|
||||
try {
|
||||
const by = this.#hostsCaller(s, verb);
|
||||
if (verb === 'hosts.status') {
|
||||
keys(a, []);
|
||||
return this.#store
|
||||
.all('SELECT host FROM hosts WHERE business=? ORDER BY host', s.business)
|
||||
.map(({ host }) => {
|
||||
const r = this.#hostRecord(s.business, host);
|
||||
return {
|
||||
host,
|
||||
address: r.address,
|
||||
port: r.port,
|
||||
user: r.user,
|
||||
sudo: r.sudo,
|
||||
state: r.state,
|
||||
hostKeys: r.hostKeys.map(fingerprintOf),
|
||||
installerFingerprint: r.installer_fingerprint,
|
||||
checkedAt: r.checked_at,
|
||||
hostLineLeft: this.#hostLinesLeft(s.business, host),
|
||||
};
|
||||
});
|
||||
}
|
||||
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.remove') {
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#hostRecord(s.business, a.host);
|
||||
if (!r) fail('host-unknown');
|
||||
if (this.#store.get('SELECT 1 FROM host_grants WHERE business=? AND host=? LIMIT 1', s.business, a.host)) fail('host-in-use');
|
||||
if (this.#hostLinesLeft(s.business, a.host).length) fail('host-line-left');
|
||||
this.#store.run('DELETE FROM hosts WHERE business=? AND host=?', s.business, a.host);
|
||||
this.#grantEvent(s.business, { action: 'grant.host.removed', host: a.host, registrySha256: registrySha256(r), by }, a.host);
|
||||
// Last, so a key file that won't go rolls the record and the event back.
|
||||
this.#hostGrant.unenroll(s.business, a.host);
|
||||
return { host: a.host, removed: true };
|
||||
});
|
||||
}
|
||||
const before = this.#hostRecord(s.business, a.host);
|
||||
if (!before) fail('host-unknown');
|
||||
if (!before.installer_fingerprint) fail('host-not-enrolled');
|
||||
const out = await this.#hostGrant.check(s.business, a.host, before);
|
||||
if (out.exit !== 0) fail('host-failed');
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#hostRecord(s.business, a.host);
|
||||
const sha = r && registrySha256(r);
|
||||
if (!r || r.decision !== before.decision || sha !== registrySha256(before) || out.installerFingerprint !== r.installer_fingerprint)
|
||||
fail('host-changed');
|
||||
const at = this.#now();
|
||||
this.#store.run("UPDATE hosts SET state='ready',checked_at=? WHERE business=? AND host=?", at, s.business, a.host);
|
||||
this.#grantEvent(
|
||||
s.business,
|
||||
{ action: 'grant.host.checked', host: a.host, registrySha256: sha, installerFingerprint: r.installer_fingerprint, by },
|
||||
a.host,
|
||||
);
|
||||
return { host: a.host, state: 'ready', checkedAt: at };
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.#refused(s, e, { verb, args: a });
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
#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);
|
||||
}
|
||||
#hardWait(s, action, target) {
|
||||
if (GATED.has(action)) return true;
|
||||
return action === 'role.revoke' && END_PROTECTED.includes(this.#business(s.business).roles[target]?.definition);
|
||||
@@ -1246,6 +1609,7 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && ['git.push.working', MERGE].includes(body?.action)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
|
||||
return this.#event(s, kind, body, subject);
|
||||
});
|
||||
}
|
||||
@@ -1371,6 +1735,9 @@ export class Broker {
|
||||
return this.#requestUsage(s, request.verb, structuredClone(args));
|
||||
if ((request.verb === 'message.receive' || request.verb === 'state') && this.#stops.has(s.business + ':' + s.run))
|
||||
this.#limitStop(s);
|
||||
// Row 234 (#1740, r6 E1): a raise with `grant` runs its keyscan before the raise transaction.
|
||||
if (request.verb === 'decision.raise' && 'grant' in args) return this.#raiseGrant(s, structuredClone(args));
|
||||
if (HOSTS_VERBS.includes(request.verb)) return this.#requestHosts(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'spec.read' || request.verb === 'record.write' ||
|
||||
(request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args)))
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
@@ -1727,21 +2094,18 @@ export class Broker {
|
||||
a = request.args ?? {};
|
||||
keys(a, ['host', 'argv', 'stdin', 'timeout', 'decision'], ['host', 'argv']);
|
||||
this.#secretCheck(a);
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
const b = this.#business(s.business);
|
||||
identifier(a.host);
|
||||
if (!Object.hasOwn(b.hosts ?? {}, a.host)) fail('host-unknown');
|
||||
const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.host) ? b.roles[s.role].hosts[a.host] : null;
|
||||
if (!grant) fail('host-not-granted');
|
||||
// Row 234 (#1740, r6 X4): the store records, not the business file.
|
||||
const access = this.#store.transaction(() => (this.#agent(s), this.#hostAccess(s, a.host)));
|
||||
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
|
||||
if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');
|
||||
if (!commandAllowed(a.argv, access.commands)) fail('command-not-granted');
|
||||
timeout = a.timeout ?? HOST_TIMEOUT_DEFAULT;
|
||||
if (!Number.isInteger(timeout) || timeout < 1 || timeout > HOST_TIMEOUT_MAX) fail('invalid-request');
|
||||
if (a.decision !== undefined) identifier(a.decision);
|
||||
if (!executor) fail('host-not-configured');
|
||||
// A busy host refuses before the decision is spent; the executor checks again under its lock.
|
||||
if (executor.busy(s, a.host)) fail('host-busy');
|
||||
entry = b.hosts[a.host];
|
||||
entry = access.entry;
|
||||
bound = hostRun(a.host, a.argv, a.stdin);
|
||||
this.#store.transaction(() =>
|
||||
this.#consumeAuthority(
|
||||
@@ -1793,7 +2157,7 @@ export class Broker {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
#dispatch(s, verb, a, spec = null) {
|
||||
#dispatch(s, verb, a, spec = null, enroll = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
switch (verb) {
|
||||
@@ -1898,9 +2262,12 @@ export class Broker {
|
||||
'spec_commit',
|
||||
'argv',
|
||||
'stdin',
|
||||
'grant',
|
||||
],
|
||||
['action', 'question', 'options', 'recommendation', 'blocking'],
|
||||
);
|
||||
// Only #raiseGrant passes `enroll`, after its checks and keyscan.
|
||||
if (('grant' in a) !== (enroll !== 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);
|
||||
@@ -1961,18 +2328,29 @@ export class Broker {
|
||||
if (!sudo && (a.argv !== undefined || a.stdin !== undefined)) fail('invalid-request');
|
||||
let question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
|
||||
if (sudo) {
|
||||
if (a.target === undefined || !Object.hasOwn(b.hosts ?? {}, a.target)) fail('host-unknown');
|
||||
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
|
||||
if (a.target === undefined) fail('host-unknown');
|
||||
// The human is asked only about a call host.run would take: the raiser's own grant and prefix (N1).
|
||||
const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.target) ? b.roles[s.role].hosts[a.target] : null;
|
||||
if (!grant) fail('host-not-granted');
|
||||
if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');
|
||||
const h = b.hosts[a.target];
|
||||
// Row 234 (r6 X4): read from the store records, with the Approve record check.
|
||||
const access = this.#hostAccess(s, a.target);
|
||||
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
|
||||
if (!commandAllowed(a.argv, access.commands)) fail('command-not-granted');
|
||||
const h = access.entry;
|
||||
question +=
|
||||
`\n\nHost ${a.target}, ${h.user} at ${h.address} port ${h.port}, runs as root:` +
|
||||
`\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.`);
|
||||
}
|
||||
string(question);
|
||||
const did = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -2010,6 +2388,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 } : {}),
|
||||
},
|
||||
a.task_ref ?? (taskRef(a.target) ? a.target : did),
|
||||
);
|
||||
@@ -2060,6 +2439,10 @@ export class Broker {
|
||||
a.note ?? null,
|
||||
s.human ? 'cli' : 'broker',
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// 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 (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
@@ -2103,7 +2486,7 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, thread);
|
||||
}
|
||||
return { id: d.id, op };
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
|
||||
@@ -98,7 +98,7 @@ export function assertCleanKey(value, key) {
|
||||
if (key.needles.some((n) => encoded.includes(n))) deny('credential-leak');
|
||||
}
|
||||
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
|
||||
const privateDir = (path, { missing = false } = {}) => {
|
||||
export const privateDir = (path, { missing = false } = {}) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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).
|
||||
//
|
||||
// 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,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { 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';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const PATH = '/usr/bin:/bin';
|
||||
const KILL_WITH_PARENT = ['/usr/bin/setpriv', '--pdeathsig', 'KILL', '--'];
|
||||
// E1: the users no enrollment may name. The host writer refuses them again (tools/host/mosaic-keys-write).
|
||||
export const RESERVED_USERS = Object.freeze(['root', 'localadmin', 'mosaic-installer']);
|
||||
const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
|
||||
// E1: the three key types the keyscan asks for, so any other fingerprint can't match.
|
||||
const SCAN_TYPES = 'ed25519,ecdsa,rsa';
|
||||
export const INSTALLER_USER = 'mosaic-installer';
|
||||
const INSTALLER_AGENT = '@installer';
|
||||
const SCAN_MS = 10000;
|
||||
|
||||
// X2 step 2: `mosaic-` and the first 32 hex digits of sha256 over business, agent and host id joined
|
||||
// with NUL. Names match ID_PATTERN or HOST_ID, neither holds NUL, so the join is one to one.
|
||||
export function grantTag(business, agent, hostId) {
|
||||
return 'mosaic-' + createHash('sha256').update([business, agent, hostId].join('\0')).digest('hex').slice(0, 32);
|
||||
}
|
||||
export const installerTag = (business, hostId) => grantTag(business, INSTALLER_AGENT, hostId);
|
||||
|
||||
// The OpenSSH fingerprint of a "<type> <base64>" line: sha256 over the decoded blob.
|
||||
export function fingerprint(line) {
|
||||
const blob = Buffer.from(line.split(' ')[1], 'base64');
|
||||
return 'SHA256:' + createHash('sha256').update(blob).digest('base64').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// E1's binding. Anything off refuses invalid-request; the broker adds the chief, action and enrolled
|
||||
// checks. Returns a fresh object with only the bound keys.
|
||||
export function enrollCheck(grant) {
|
||||
const allowed = ['method', 'host', 'address', 'port', 'user', 'sudo', 'fingerprints'];
|
||||
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-enroll') deny('invalid-request');
|
||||
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
|
||||
try {
|
||||
checkAddress(grant.address, 'grant.address');
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
if (!Number.isInteger(grant.port) || grant.port < 1 || grant.port > 65535) deny('invalid-request');
|
||||
if (typeof grant.user !== 'string' || !USER.test(grant.user) || RESERVED_USERS.includes(grant.user)) deny('invalid-request');
|
||||
if (typeof grant.sudo !== 'boolean') deny('invalid-request');
|
||||
const prints = grant.fingerprints;
|
||||
if (!Array.isArray(prints) || prints.length < 1 || prints.length > 3) deny('invalid-request');
|
||||
if (prints.some((f) => typeof f !== 'string' || !FINGERPRINT.test(f)) || new Set(prints).size !== prints.length) deny('invalid-request');
|
||||
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const keys = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length !== 3) continue;
|
||||
const key = parts[1] + ' ' + parts[2];
|
||||
if (HOST_KEY.test(key) && !keys.includes(key)) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// E1: `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa <address>` from the stack host. It reads public
|
||||
// keys and logs in to nothing. A scan that can't start refuses host-failed. One that runs and gets no key,
|
||||
// as from a host that doesn't answer, returns no lines, so the raise refuses host-key-mismatch
|
||||
// (duckworth-0081 N3).
|
||||
export function keyscan(address, port, { bin = 'ssh-keyscan', limitMs = SCAN_MS } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, '-T', '5', '-p', String(port), '-t', SCAN_TYPES, '--', address], {
|
||||
env: { PATH },
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const out = [];
|
||||
let size = 0;
|
||||
child.stdout.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size <= 65536) out.push(chunk);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}, limitMs);
|
||||
child.on('error', () => (clearTimeout(timer), reject(new BusError('host-failed'))));
|
||||
child.on('close', () => (clearTimeout(timer), resolve(scanLines(Buffer.concat(out).toString('utf8')))));
|
||||
});
|
||||
}
|
||||
|
||||
// E1: the scanned set equals the requested set, or host-key-mismatch.
|
||||
export function sameFingerprints(lines, prints) {
|
||||
const scanned = new Set(lines.map(fingerprint));
|
||||
return scanned.size === prints.length && prints.every((f) => scanned.has(f));
|
||||
}
|
||||
|
||||
const run = (bin, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { env: { PATH }, stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
child.on('error', () => reject(new BusError('host-failed')));
|
||||
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' }) {
|
||||
const keyRoot = join(configDir, 'secrets', 'hosts');
|
||||
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
|
||||
const readKey = (business, hostId) => {
|
||||
const file = keyPath(business, hostId);
|
||||
for (const dir of [keyRoot, dirname(dirname(file)), dirname(file)]) privateDir(dir);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
return parseHostKey(readFileSync(fd, 'utf8'));
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
};
|
||||
return {
|
||||
enrollCheck,
|
||||
sameFingerprints,
|
||||
fingerprint,
|
||||
installerTag,
|
||||
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) {
|
||||
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(runRoot);
|
||||
const dir = mkdtempSync(join(runRoot, 'h-'));
|
||||
try {
|
||||
const tag = installerTag(business, hostId);
|
||||
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', INSTALLER_USER + '-' + tag.slice('mosaic-'.length), '-f', join(dir, 'key')]);
|
||||
const key = parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
|
||||
const file = keyPath(business, hostId);
|
||||
mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(keyRoot);
|
||||
for (const d of [dirname(dirname(file)), dirname(file)]) {
|
||||
if (!privateDir(d, { missing: true })) mkdirSync(d, { mode: 0o700 });
|
||||
privateDir(d);
|
||||
}
|
||||
const tmp = file + '.tmp';
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, key.text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(dirname(file), constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
return { publicLine: key.publicLine, fingerprint: key.fingerprint, tag };
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
// The X3 undo and hosts.remove: the installer key and its two dirs, if present.
|
||||
unenroll(business, hostId) {
|
||||
const file = keyPath(business, hostId);
|
||||
for (const f of [file + '.tmp', file]) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
for (const d of [dirname(file), dirname(dirname(file))]) {
|
||||
try {
|
||||
rmdirSync(d);
|
||||
} catch (e) {
|
||||
if (!['ENOENT', 'ENOTEMPTY'].includes(e.code)) deny('credential-file');
|
||||
}
|
||||
}
|
||||
},
|
||||
// E4: `list <user>` as mosaic-installer with the installer key and the pinned host keys. A wrong
|
||||
// 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 out = await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: `list ${record.user}` });
|
||||
return { installerFingerprint: key.fingerprint, exit: out.exit };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -159,7 +159,9 @@ function runSsh(args, stdin, timeoutMs, signal, limits, onSpawn) {
|
||||
|
||||
// D7: one call. `key` is the parsed entry from credentials.mjs (text and publicLine); `entry` is
|
||||
// the registry's { address, port, user, hostKeys }. `root` is <configDir>/secrets/run.
|
||||
export async function runHost({ root, hostId, entry, key, argv, stdin, timeout, signal = null, limits = HOST_LIMITS }) {
|
||||
// Row 234 (#1740): `command`, when set, is sent as is in place of the sudo argv. The installer key's
|
||||
// forced command reads it (tools/host/mosaic-keys).
|
||||
export async function runHost({ root, hostId, entry, key, argv, stdin, timeout, signal = null, limits = HOST_LIMITS, command = null }) {
|
||||
if (signal?.aborted) deny('credential-unavailable');
|
||||
const dir = mkdtempSync(join(runRoot(root), 'h-'));
|
||||
const sock = join(dir, 'a.sock');
|
||||
@@ -178,7 +180,7 @@ export async function runHost({ root, hostId, entry, key, argv, stdin, timeout,
|
||||
await addKey(sock, key.text, timeout + 30, { limitMs: limits.agentWaitMs, signal });
|
||||
if (signal?.aborted) deny('credential-unavailable');
|
||||
const out = await runSsh(
|
||||
[...sshArgs({ hostId, ...entry, dir }), remoteCommand(argv)],
|
||||
[...sshArgs({ hostId, ...entry, dir }), command ?? remoteCommand(argv)],
|
||||
stdin,
|
||||
timeout * 1000,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Row 234 (#436a row a, #1740): enrolled hosts and host grants live in the store, written only by
|
||||
-- the grant executor, hosts.check, hosts.remove and revoke, each in the transaction of its event.
|
||||
-- A grant event is an action.allowed whose body.action or body.operation is grant.*; the trigger
|
||||
-- refuses one unless the actor is (NULL, 'grant'), the broker's own grant writer (#436b section 7).
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
@@ -16,6 +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 { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
import { createLimits } from './limits.mjs';
|
||||
@@ -95,8 +96,16 @@ export async function startBroker({
|
||||
];
|
||||
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
|
||||
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
store = new Store(dataRoot);
|
||||
// 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.
|
||||
for (const g of store.all('SELECT business, agent, host, rotate_by FROM host_grants ORDER BY business, agent, 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 };
|
||||
}
|
||||
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.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
@@ -112,7 +121,10 @@ export async function startBroker({
|
||||
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
|
||||
for (const id of Object.keys(businesses)) await limits.refresh(id);
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
const broker = new Broker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -181,8 +193,9 @@ export async function startBroker({
|
||||
}
|
||||
const web = await webServices(businesses);
|
||||
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
if (runRoot) sweepRunDir(runRoot);
|
||||
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
|
||||
broker.reconcileGrants();
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
server = await serve({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
@@ -123,7 +123,10 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
// Row138: only project Git effects need an await; preserve every other sync path below.
|
||||
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit')))) {
|
||||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
|
||||
HOSTS_VERBS.includes(r.verb) ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
|
||||
Object.hasOwn(r.args ?? {}, 'grant')))) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
// Local Git may take 100 seconds; keep its result beyond the ordinary idle deadline.
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737';
|
||||
export const SCHEMA_VERSION = '3i';
|
||||
export const SCHEMA_SHA256 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
|
||||
export const SCHEMA_VERSION = '3j';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -56,6 +56,11 @@ const migrations = [{
|
||||
from: '3h',
|
||||
to: '3i',
|
||||
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 234 (#1740): enrolled hosts, host grants and the grant event guard. Only new objects.
|
||||
from: '3i',
|
||||
to: '3j',
|
||||
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -69,6 +69,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
|
||||
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3i');
|
||||
assert.equal(version(path), '3j');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -84,7 +85,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
|
||||
const old = ' account TEXT CHECK (account IS NULL),\n';
|
||||
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
|
||||
assert.deepEqual(master(path), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -122,11 +125,11 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3i');
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves it at 3i', async t => {
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
const { root, path } = source3h(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = snapshot(path), was = master(path);
|
||||
@@ -134,14 +137,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3i');
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
|
||||
@@ -606,7 +606,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -630,7 +630,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3i', to: '3j', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3i ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3j ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
@@ -0,0 +1,489 @@
|
||||
// Row 234 (#1740, #436a row a): host enrollment through a credential.mint raise with a `grant` binding
|
||||
// (r6 E1, E2, E4, X1, X3). The keyscan is a fake that returns key lines this test makes, ssh-keygen is
|
||||
// real, and the key files go under a scratch config dir. Nothing connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError, writeGrantEvent, registrySha256 } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, fingerprint, grantTag, installerTag } from '../src/host-grant.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keyLine = (dir, name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
|
||||
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
|
||||
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
function setup(t, { keygen, check, hang = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'g234-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
|
||||
const scans = [];
|
||||
const scan = { lines: hostKeys };
|
||||
const configDir = join(root, 'config');
|
||||
const real = hostGrant({
|
||||
configDir,
|
||||
runRoot: join(configDir, 'secrets', 'run'),
|
||||
scan: async (address, port) => (scans.push([address, port]), scan.lines),
|
||||
...(keygen ? { keygen } : {}),
|
||||
});
|
||||
const checks = [];
|
||||
// `hang`: steps 2 and 3 run, then the call never returns, as when the child dies before step 4.
|
||||
const enroll = hang ? async (...args) => (await real.enroll(...args), new Promise(() => {})) : real.enroll;
|
||||
const hg = { ...real, enroll, check: async (...args) => (checks.push(args), check ? check(...args) : { installerFingerprint: args[2].installer_fingerprint, exit: 0 }) };
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const make = (h = hg) => new Broker({ store, businesses: { demo: business }, hostGrant: h });
|
||||
const broker = make();
|
||||
const caps = {};
|
||||
const bind = (b) => {
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run-' + Math.random(), harness: 'pi', address: role });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
caps.reader = b.bindReader({ business: 'demo' });
|
||||
};
|
||||
bind(broker);
|
||||
const grant = (over = {}) => ({
|
||||
method: 'host-enroll',
|
||||
host: 'web1',
|
||||
address: '192.0.2.10',
|
||||
port: 22,
|
||||
user: 'jarvis',
|
||||
sudo: true,
|
||||
fingerprints: hostKeys.map(fingerprint),
|
||||
...over,
|
||||
});
|
||||
const call = (who, verb, args = {}, b = broker) => b.request(caps[who], { verb, args });
|
||||
const raise = (over = {}, who = 'chief', extra = {}) =>
|
||||
call(who, 'decision.raise', {
|
||||
action: 'credential.mint',
|
||||
question: 'Enroll web1?',
|
||||
options,
|
||||
recommendation: 'yes',
|
||||
blocking: false,
|
||||
grant: grant(over),
|
||||
...extra,
|
||||
});
|
||||
const events = () =>
|
||||
store
|
||||
.all("SELECT kind, actor_role, actor_run, body FROM events WHERE business='demo' ORDER BY seq")
|
||||
.map((e) => ({ kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, ...JSON.parse(e.body) }));
|
||||
const grantEvents = () => events().filter((e) => e.actor_run === 'grant');
|
||||
const settle = async (decision) => {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (grantEvents().some((e) => e.grantDecision === decision && ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('enrollment did not end');
|
||||
};
|
||||
const keyFile = (host = 'web1') => join(configDir, 'secrets', 'hosts', 'demo', host, 'installer');
|
||||
const hostRow = (host = 'web1') => store.get("SELECT * FROM hosts WHERE business='demo' AND host=?", host);
|
||||
const messages = () => store.all("SELECT from_role, to_role, class, decision, body FROM messages WHERE business='demo' ORDER BY rowid");
|
||||
return { root, store, broker, make, bind, caps, call, raise, grant, hostKeys, scan, scans, checks, events, grantEvents, settle, keyFile, hostRow, messages, configDir };
|
||||
}
|
||||
const approve = async (f, over) => {
|
||||
const d = await f.raise(over);
|
||||
const r = f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await f.settle(d.id);
|
||||
return { d, r };
|
||||
};
|
||||
|
||||
test('G1, A1: only the chief raises a grant, and a refused raise stores nothing and runs no keyscan', async (t) => {
|
||||
const f = setup(t);
|
||||
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
|
||||
await refusesWith(f.call('human', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'agent-required');
|
||||
await refusesWith(f.call('tester', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'tester-refused');
|
||||
assert.equal(f.scans.length, 0);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
assert.deepEqual(f.events().filter((e) => e.kind === 'action.refused').map((e) => e.code), ['grant-chief-only', 'agent-required', 'tester-refused']);
|
||||
});
|
||||
|
||||
test('G1: grant goes only with credential.mint, row a takes only host-enroll, and E1 refuses each bad field', async (t) => {
|
||||
const f = setup(t);
|
||||
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
|
||||
for (const over of [
|
||||
{ method: 'host' },
|
||||
{ user: 'root' },
|
||||
{ user: 'localadmin' },
|
||||
{ user: 'mosaic-installer' },
|
||||
{ user: 'Bad' },
|
||||
{ sudo: 'yes' },
|
||||
{ port: 0 },
|
||||
{ port: 65536 },
|
||||
{ host: 'Web_1' },
|
||||
{ address: '127.0.0.1' },
|
||||
{ address: 'localhost' },
|
||||
{ fingerprints: [] },
|
||||
{ fingerprints: ['SHA256:short'] },
|
||||
{ fingerprints: [f.grant().fingerprints[0], f.grant().fingerprints[0]] },
|
||||
{ extra: 1 },
|
||||
])
|
||||
await refusesWith(f.raise(over), 'invalid-request');
|
||||
const { method, ...noMethod } = f.grant();
|
||||
await refusesWith(
|
||||
f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: noMethod }),
|
||||
'invalid-request',
|
||||
);
|
||||
assert.equal(f.scans.length, 0);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('G5, A5, A6: the scanned key set must equal the requested set, or host-key-mismatch and nothing stored', async (t) => {
|
||||
const f = setup(t);
|
||||
const other = keyLine(f.root, 'hk3');
|
||||
for (const lines of [[f.hostKeys[0]], [...f.hostKeys, other], [f.hostKeys[0], other], []]) {
|
||||
f.scan.lines = lines;
|
||||
await refusesWith(f.raise(), 'host-key-mismatch');
|
||||
}
|
||||
f.scan.lines = f.hostKeys;
|
||||
await refusesWith(f.raise({ fingerprints: [fingerprint(f.hostKeys[0])] }), 'host-key-mismatch');
|
||||
assert.deepEqual(f.scans.at(-1), ['192.0.2.10', 22]);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('G4 (host-enroll block, M1): the broker appends the keys, fingerprints and sudo line, and the cap counts the block', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to ?? f.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, 'human');
|
||||
const q = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
|
||||
assert.ok(q.startsWith('Enroll web1?\n\nEnroll host web1: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
for (const k of f.hostKeys) assert.ok(q.includes(`\n${fingerprint(k)} ${k}`));
|
||||
assert.ok(q.endsWith('\njarvis has sudo on this host. Approving lets later grants for jarvis be installed, and section 9\'s first limit applies to jarvis.'));
|
||||
const raiseBody = f.events().find((e) => e.operation === 'decision.raise');
|
||||
assert.deepEqual(raiseBody.grant, { ...f.grant(), hostKeys: f.hostKeys });
|
||||
// A question that carries a forged block still gets the broker's block after it.
|
||||
const forged = 'x\n\nEnroll host web1: user other at 192.0.2.99 port 22.\nother has no sudo on this host.';
|
||||
const d2 = await f.raise({ host: 'web2', sudo: false }, 'chief', { question: forged });
|
||||
const q2 = f.store.get('SELECT question FROM decisions WHERE id=?', d2.id).question;
|
||||
assert.ok(q2.startsWith(forged + '\n\nEnroll host web2: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
assert.ok(q2.endsWith('\njarvis has no sudo on this host.'));
|
||||
// The 4096 cap holds with the block appended.
|
||||
await refusesWith(f.raise({ host: 'web3' }, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions").n, 2);
|
||||
});
|
||||
|
||||
test('G6, X1: the human\'s cli yes starts enrollment, which writes the record, the key file and the events in order', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d, r } = await approve(f);
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', grant: 'started' });
|
||||
const row = f.hostRow();
|
||||
assert.equal(row.state, 'pending-install');
|
||||
assert.equal(row.decision, d.id);
|
||||
assert.equal(row.user, 'jarvis');
|
||||
assert.equal(row.sudo, 1);
|
||||
assert.deepEqual(JSON.parse(row.host_keys), f.hostKeys);
|
||||
assert.match(row.installer_fingerprint, /^SHA256:/);
|
||||
// E2 step 3: 0600 in 0700 dirs.
|
||||
assert.equal(statSync(f.keyFile()).mode & 0o777, 0o600);
|
||||
for (const dir of [join(f.keyFile(), '..'), join(f.keyFile(), '..', '..')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
|
||||
const g = f.grantEvents();
|
||||
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.host.enroll.step', 'grant.host.enrolled']);
|
||||
for (const e of g) assert.deepEqual([e.kind, e.actor_role, e.actor_run], ['action.allowed', null, 'grant']);
|
||||
assert.deepEqual(g[0], { kind: 'action.allowed', actor_role: null, actor_run: 'grant', operation: 'grant.host-enroll', decision: d.id });
|
||||
assert.equal(g[1].grantDecision, d.id);
|
||||
assert.equal(g[1].step, 'registry');
|
||||
const enrolled = g[2];
|
||||
assert.equal('decision' in enrolled, false);
|
||||
assert.equal(enrolled.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
|
||||
assert.equal(enrolled.installerFingerprint, row.installer_fingerprint);
|
||||
assert.deepEqual([enrolled.user, enrolled.sudo], ['jarvis', true]);
|
||||
// E2 step 4's message to the human and the chief: the installer line, public half only.
|
||||
const tag = installerTag('demo', 'web1');
|
||||
const pub = execFileSync('ssh-keygen', ['-y', '-f', f.keyFile()], { encoding: 'utf8' }).trim().split(' ').slice(0, 2).join(' ');
|
||||
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
|
||||
assert.deepEqual(notes.map((m) => [m.to_role, m.class]), [['human', 'RESULT'], ['chief', 'RESULT']]);
|
||||
for (const m of notes)
|
||||
assert.ok(m.body.includes(`\nrestrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ${pub} mosaic-installer-${tag.slice(7)}\n`));
|
||||
// A7: no part of the private key reaches the store.
|
||||
const secret = readFileSync(f.keyFile(), 'utf8').split('\n').filter((l) => l.length >= 24 && !l.startsWith('-----'));
|
||||
const dump = JSON.stringify([f.store.all('SELECT body FROM events'), f.store.all('SELECT body FROM messages'), f.store.all('SELECT * FROM hosts')]);
|
||||
for (const l of secret) assert.equal(dump.includes(l), false);
|
||||
// The tmp dir under the run root is gone.
|
||||
assert.deepEqual(readdirSync(join(f.configDir, 'secrets', 'run')), []);
|
||||
});
|
||||
|
||||
test('A2, A4: a credential.mint without grant, and a no, start nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const plain = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'Hand-made key?', options, recommendation: 'yes', blocking: false });
|
||||
assert.deepEqual(f.call('human', 'decision.resolve', { id: plain.id, choice: 'yes' }), { id: plain.id, op: 'resolved' });
|
||||
const d = await f.raise();
|
||||
assert.deepEqual(f.call('human', 'decision.resolve', { id: d.id, choice: 'no' }), { id: d.id, op: 'resolved' });
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
});
|
||||
|
||||
test('A3, X1: a resolve by the tester or an agent starts nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
for (const who of ['tester', 'pm', 'chief']) {
|
||||
try {
|
||||
f.call(who, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
} catch {}
|
||||
}
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
});
|
||||
|
||||
test('G6 (duckworth-0081 B3): a withdraw by the chief or the human, and an arbiter resolve, start nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
for (const who of ['chief', 'human']) {
|
||||
const d = await f.raise();
|
||||
assert.deepEqual(f.call(who, 'decision.withdraw', { id: d.id }), { id: d.id, op: 'withdrawn' });
|
||||
}
|
||||
// A grant raise always routes to the human, so the fixture writes an open one routed to the pm
|
||||
// arbiter, which then resolves it yes through the verb.
|
||||
const d = { id: grantDecision(f.store, { route: 'pm', grant: f.grant(), close: null }) };
|
||||
assert.deepEqual(f.call('pm', 'decision.resolve', { id: d.id, choice: 'yes' }), { id: d.id, op: 'resolved' });
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.deepEqual(f.broker.reconcileGrants(), []);
|
||||
assert.equal(await f.broker.runEnrollment('demo', d.id), 'not-authorized');
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
});
|
||||
|
||||
test('G10 (duckworth-0081 B1): a second start for a spent decision refuses decision-consumed, and an unresolved or agent-resolved one not-authorized', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
const before = { events: f.grantEvents(), row: f.hostRow(), messages: f.messages().length };
|
||||
assert.equal(await f.broker.runEnrollment('demo', d.id), 'decision-consumed');
|
||||
assert.deepEqual(f.grantEvents(), before.events);
|
||||
assert.deepEqual(f.hostRow(), before.row);
|
||||
assert.equal(f.messages().length, before.messages);
|
||||
// Unresolved, then resolved by an agent: written by SQL, since the verb refuses the agent first.
|
||||
const open = await f.raise({ host: 'web2' });
|
||||
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
|
||||
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', open.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'broker');
|
||||
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
|
||||
// A cli yes by someone other than the human, so the by check alone refuses it (duckworth-0083 N8, A3b).
|
||||
const other = await f.raise({ host: 'web3' });
|
||||
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', other.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'cli');
|
||||
assert.equal(await f.broker.runEnrollment('demo', other.id), 'not-authorized');
|
||||
assert.deepEqual(f.broker.reconcileGrants(), []);
|
||||
assert.deepEqual(f.grantEvents(), before.events);
|
||||
assert.equal(f.hostRow('web2'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('web2')), false);
|
||||
assert.equal(f.hostRow('web3'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('web3')), false);
|
||||
});
|
||||
|
||||
test('A8, S1: two approved enrollments for one host: the second ends host-enrolled and the pin stays', async (t) => {
|
||||
const f = setup(t);
|
||||
const d1 = await f.raise();
|
||||
const d2 = await f.raise({ user: 'melanie', sudo: false });
|
||||
f.call('human', 'decision.resolve', { id: d1.id, choice: 'yes' });
|
||||
await f.settle(d1.id);
|
||||
const before = f.hostRow();
|
||||
f.call('human', 'decision.resolve', { id: d2.id, choice: 'yes' });
|
||||
await f.settle(d2.id);
|
||||
assert.deepEqual(f.hostRow(), before);
|
||||
const failed = f.grantEvents().find((e) => e.grantDecision === d2.id && e.action === 'grant.failed');
|
||||
assert.equal(failed.code, 'host-enrolled');
|
||||
assert.ok(f.grantEvents().some((e) => e.operation === 'grant.host-enroll' && e.decision === d2.id));
|
||||
assert.equal(f.grantEvents().filter((e) => e.grantDecision === d2.id && e.action === 'grant.host.enroll.step').length, 0);
|
||||
// A raise for an enrolled host refuses before the keyscan.
|
||||
const scans = f.scans.length;
|
||||
await refusesWith(f.raise(), 'host-enrolled');
|
||||
assert.equal(f.scans.length, scans);
|
||||
});
|
||||
|
||||
test('A9: the hosts primary key refuses a second record for (business, host)', (t) => {
|
||||
const f = setup(t);
|
||||
const d = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false });
|
||||
const insert = () =>
|
||||
f.store.run(
|
||||
"INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,state,decision) VALUES('demo','web1','192.0.2.1',22,'u',0,'[\"k\"]','pending-install',?)",
|
||||
d.id,
|
||||
);
|
||||
insert();
|
||||
assert.throws(insert, /UNIQUE|PRIMARY/);
|
||||
});
|
||||
|
||||
test('X3: a failure at step 2 deletes the record again and ends grant.failed, with no key file', async (t) => {
|
||||
const f = setup(t, { keygen: '/nonexistent/ssh-keygen' });
|
||||
const { d } = await approve(f);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
const failed = f.grantEvents().find((e) => e.action === 'grant.failed');
|
||||
assert.deepEqual([failed.grantDecision, failed.host, failed.code], [d.id, 'web1', 'host-failed']);
|
||||
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
|
||||
assert.deepEqual(notes.map((m) => m.to_role), ['human', 'chief']);
|
||||
assert.ok(notes[0].body.includes('failed: host-failed'));
|
||||
});
|
||||
|
||||
test('A10, X3: at start a resolved, unspent enrollment is spent and ends interrupted, and never runs late', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
// The resolve commits, and the child dies before step 1: the resolve row alone, written here.
|
||||
f.store.run("INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)", d.id, new Date().toISOString(), 'resolved', 'jason', 'yes', 'cli');
|
||||
const restarted = f.make();
|
||||
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
|
||||
const g = f.grantEvents();
|
||||
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.failed']);
|
||||
assert.deepEqual([g[1].code, g[1].host, g[1].tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.deepEqual(restarted.reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('X3: at start an enrollment that stopped after its registry step loses its record and key file', async (t) => {
|
||||
const f = setup(t, { hang: true });
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
for (let i = 0; i < 250 && !existsSync(f.keyFile()); i++) await sleep(20);
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
assert.ok(existsSync(f.keyFile()));
|
||||
const restarted = f.make();
|
||||
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
const failed = f.grantEvents().find((e) => e.grantDecision === d.id && e.action === 'grant.failed');
|
||||
assert.deepEqual([failed.code, failed.host, failed.tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
|
||||
assert.deepEqual(f.messages().filter((m) => m.decision === d.id && m.from_role === '_bus').map((m) => m.to_role), ['human', 'chief']);
|
||||
assert.deepEqual(restarted.reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('G24 first sentence, M2, A12: tags are mosaic- and 32 hex, and differ by business, agent and host', () => {
|
||||
const long = 'a'.repeat(30) + '.' + '_'.repeat(2) + 'b'.repeat(31);
|
||||
assert.equal(long.length, 64);
|
||||
const tags = [grantTag('demo', long, 'web1'), grantTag('demo', long + 'x', 'web1'), grantTag('other', long, 'web1'), grantTag('demo', long, 'web2'), installerTag('demo', 'web1'), installerTag('other', 'web1')];
|
||||
for (const t of tags) assert.match(t, /^mosaic-[0-9a-f]{32}$/);
|
||||
assert.equal(new Set(tags).size, tags.length);
|
||||
});
|
||||
|
||||
test('E4: hosts.check sets the record ready and records grant.host.checked; a non-zero list leaves it pending', async (t) => {
|
||||
const exits = [1, 0];
|
||||
const f = setup(t, { check: (b, h, r) => ({ installerFingerprint: r.installer_fingerprint, exit: exits.shift() }) });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-failed');
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
await refusesWith(f.call('pm', 'hosts.check', { host: 'web1' }), 'grant-chief-only');
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'nope' }), 'host-unknown');
|
||||
const r = await f.call('chief', 'hosts.check', { host: 'web1' });
|
||||
assert.equal(r.state, 'ready');
|
||||
assert.equal(f.hostRow().state, 'ready');
|
||||
assert.equal(f.hostRow().checked_at, r.checkedAt);
|
||||
const checked = f.grantEvents().find((e) => e.action === 'grant.host.checked');
|
||||
assert.deepEqual([checked.host, checked.by, checked.installerFingerprint], ['web1', 'chief', f.hostRow().installer_fingerprint]);
|
||||
assert.equal(checked.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
|
||||
const status = await f.call('human', 'hosts.status');
|
||||
assert.deepEqual(status.map((h) => [h.host, h.state, h.hostKeys, h.hostLineLeft]), [['web1', 'ready', f.hostKeys.map(fingerprint), []]]);
|
||||
});
|
||||
|
||||
test('G20 (duckworth-0081 B3): the human\'s hosts.check marks the host ready, and the reader refuses', async (t) => {
|
||||
const f = setup(t);
|
||||
await approve(f);
|
||||
await refusesWith(f.call('reader', 'hosts.check', { host: 'web1' }), 'read-only');
|
||||
const r = await f.call('human', 'hosts.check', { host: 'web1' });
|
||||
assert.equal(r.state, 'ready');
|
||||
assert.equal(f.hostRow().state, 'ready');
|
||||
const checked = f.grantEvents().filter((e) => e.action === 'grant.host.checked');
|
||||
assert.deepEqual(checked.map((e) => e.by), ['jason']);
|
||||
});
|
||||
|
||||
test('G18 (duckworth-0081 B2), broker half: a check refused host-key-mismatch leaves the host pending-install with no grant.host.checked', async (t) => {
|
||||
const f = setup(t, { check: () => { throw new BusError('host-key-mismatch'); } });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-key-mismatch');
|
||||
assert.equal(f.checks.length, 1);
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
assert.equal(f.hostRow().checked_at, null);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.host.checked'), []);
|
||||
});
|
||||
|
||||
test('E4: a check whose installer fingerprint differs from the record refuses host-changed', async (t) => {
|
||||
const f = setup(t, { check: () => ({ installerFingerprint: 'SHA256:' + 'A'.repeat(43), exit: 0 }) });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('tester', 'hosts.check', { host: 'web1' }), 'host-changed');
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
});
|
||||
|
||||
test('E4, G21: hosts.remove is the human\'s or the tester\'s, deletes the key and the record, and records grant.host.removed', async (t) => {
|
||||
const f = setup(t);
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.remove', { host: 'web1' }), 'human-required');
|
||||
await refusesWith(f.call('reader', 'hosts.remove', { host: 'web1' }), 'read-only');
|
||||
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
assert.equal(existsSync(join(f.keyFile(), '..')), false);
|
||||
const removed = f.grantEvents().find((e) => e.action === 'grant.host.removed');
|
||||
assert.deepEqual([removed.host, removed.by], ['web1', 'jason']);
|
||||
await refusesWith(f.call('tester', 'hosts.remove', { host: 'web1' }), 'host-unknown');
|
||||
});
|
||||
|
||||
test('G21, F1, F2, M4, A22: hosts.remove refuses host-line-left on a guarded hostLineLeft, and ignores look-alikes', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
const at = new Date().toISOString();
|
||||
// Look-alikes: the same body under action.refused, and under another actor of another kind.
|
||||
const lookAlike = JSON.stringify({ action: 'grant.failed', grantDecision: d.id, host: 'web1', hostLineLeft: installerTag('demo', 'web1') });
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la1',?,'demo','action.refused',NULL,'grant','web1',?)", at, lookAlike);
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la2',?,'demo','action.refused','chief','chief-run','web1',?)", at, lookAlike);
|
||||
// The trigger refuses the look-alike as an action.allowed from a session.
|
||||
assert.throws(() =>
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la3',?,'demo','action.allowed','chief','chief-run','web1',?)", at, lookAlike),
|
||||
);
|
||||
assert.equal((await f.call('human', 'hosts.status'))[0].hostLineLeft.length, 0);
|
||||
// F1: the real shape, through the grant writer. F2: any guarded body, not only grant.failed.
|
||||
writeGrantEvent(f.store, at, 'demo', { action: 'grant.revoke.closed', tag: 'mosaic-' + '0'.repeat(32), host: 'web1', hostLineLeft: 'mosaic-' + '0'.repeat(32) }, 'web1');
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, ['mosaic-' + '0'.repeat(32)]);
|
||||
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-line-left');
|
||||
assert.ok(f.hostRow());
|
||||
assert.ok(existsSync(f.keyFile()));
|
||||
});
|
||||
|
||||
test('hosts.remove refuses host-in-use while a grant record names the host', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
f.store.run(
|
||||
"INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES('demo','pm','web1','[[\"true\"]]','2099-01-01',?,?)",
|
||||
grantTag('demo', 'pm', 'web1'),
|
||||
d.id,
|
||||
);
|
||||
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-in-use');
|
||||
assert.ok(f.hostRow());
|
||||
});
|
||||
|
||||
test('the grant writer refuses a body outside section 7\'s shape', (t) => {
|
||||
const f = setup(t);
|
||||
const at = new Date().toISOString();
|
||||
for (const body of [
|
||||
{ action: 'host.sudo' },
|
||||
{ action: 'grant.failed', decision: 'd' },
|
||||
{ operation: 'grant.host-enroll', decision: 'd', host: 'web1' },
|
||||
{ operation: 'grant.host-enroll' },
|
||||
{ operation: 'decision.raise', decision: 'd' },
|
||||
])
|
||||
assert.throws(() => writeGrantEvent(f.store, at, 'demo', body), /grant event shape/);
|
||||
});
|
||||
|
||||
test('recordEvent refuses reserved-event for a grant.* action or operation', (t) => {
|
||||
const f = setup(t);
|
||||
for (const body of [{ action: 'grant.failed' }, { operation: 'grant.host-enroll', decision: 'x' }])
|
||||
refusesSync(() => f.broker.recordEvent(f.caps.chief, { kind: 'action.allowed', body }), 'reserved-event');
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE json_extract(body,'$.action') GLOB 'grant.*'").n, 0);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
// Row 234 (#1740, #436a row a): the 3i→3j step adds the hosts and host_grants tables and the grant
|
||||
// event guard (#436b section 7 at fa5658941). It runs on a 3i store built here from the pinned 3i
|
||||
// schema (8fede5fc6's schema.sql) and fixture rows. No live store is read or copied.
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const TAG = 'mosaic-' + 'a'.repeat(32);
|
||||
const ddl3i = readFileSync(new URL('./fixtures/schema-3i.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
assert.equal(createHash('sha256').update(ddl3i).digest('hex'), '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737');
|
||||
db.exec(ddl3i);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3i');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', 'coder', 'run1', '{"action":"host.sudo"}');
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
|
||||
};
|
||||
// A copy of the bus module whose store.mjs, schema.sql and 3i→3j step the caller edits.
|
||||
async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
const moduleRoot = join(root, name);
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
|
||||
if (store) assert.notEqual(edited, source);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-store-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
return store;
|
||||
}
|
||||
const event = (store, actor, body, kind = 'action.allowed') => store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)',
|
||||
'e' + Math.random().toString(16).slice(2), AT, 'demo', kind, actor[0], actor[1], JSON.stringify(body));
|
||||
const host = (store, values = {}) => store.run(
|
||||
`INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at)
|
||||
VALUES(:business,:host,:address,:port,:user,:sudo,:host_keys,:fp,:state,:decision,:checked)`,
|
||||
{ business: 'demo', host: 'web1', address: '192.0.2.10', port: 22, user: 'deploy', sudo: 0,
|
||||
host_keys: '["ssh-ed25519 AAAA"]', fp: null, state: 'pending-install', decision: 'd1', checked: null, ...values });
|
||||
|
||||
test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the two tables, their keys and the guard', t => {
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(rows(path), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM hosts').n, 0);
|
||||
// An event row 210 wrote before the step stays, and a session can still write host.sudo.
|
||||
event(store, ['coder', 'run2'], { action: 'host.sudo', phase: 'requested' });
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a 3i→3j step that fails partway leaves the store at 3i with every row and the schema intact', async t => {
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
// The real step plus a statement that fails only when events has a row, so the empty replay at
|
||||
// import passes and the store's run fails after both tables and the trigger exist.
|
||||
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
|
||||
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
|
||||
const { root, path } = source3i(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
const Store3i = await engine(root, 'engine-3i', {
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
test('A22b, sage-1703: a session-written grant.* row from before the step survives it, and the grant readers ignore it', async t => {
|
||||
// The step adds the guard but leaves old rows. At 3i recordEvent had no reserved-event refusal, so a
|
||||
// session could write these, and after the step only GRANT_EVIDENCE's actor filter keeps them out.
|
||||
const { root, path } = source3i(t);
|
||||
const db = new DatabaseSync(path);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
const legacy = [{ action: 'grant.host.removed', host: 'web1', hostLineLeft: TAG }, { operation: 'grant.host-enroll', decision: 'd1' }];
|
||||
for (const [i, body] of legacy.entries())
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'old' + i, AT, 'demo', 'action.allowed', 'chief', 'run1', JSON.stringify(body));
|
||||
db.close();
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const store = new Store(root);
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE id IN ('old0','old1')").n, 2);
|
||||
host(store);
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: {},
|
||||
};
|
||||
const unenrolled = [];
|
||||
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { unenroll: (b, h) => unenrolled.push([b, h]), installerTag: () => null } });
|
||||
// The enrollment look-alike is not a spent decision, so nothing ends interrupted.
|
||||
assert.deepEqual(broker.reconcileGrants(), []);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0);
|
||||
// The hostLineLeft look-alike is not a line left on the host.
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.deepEqual(await broker.request(human, { verb: 'hosts.remove', args: { host: 'web1' } }), { host: 'web1', removed: true });
|
||||
assert.deepEqual(unenrolled, [['demo', 'web1']]);
|
||||
});
|
||||
|
||||
test('the guard: a grant.* action or operation on action.allowed needs the (NULL, grant) actor', t => {
|
||||
const store = fresh(t);
|
||||
const refused = /only the grant writer records grant events/;
|
||||
for (const body of [{ action: 'grant.step' }, { action: 'grant.host.enrolled' }, { operation: 'grant.host-enroll', decision: 'd1' }]) {
|
||||
assert.throws(() => event(store, ['chief', 'run1'], body), refused, JSON.stringify(body));
|
||||
assert.throws(() => event(store, ['tester', 'run1'], body), refused);
|
||||
assert.throws(() => event(store, [null, null], body), refused, 'the human and session-less pair is not the grant actor');
|
||||
assert.throws(() => event(store, [null, 'run1'], body), refused);
|
||||
assert.throws(() => event(store, ['grant', 'grant'], body), refused);
|
||||
event(store, [null, 'grant'], body);
|
||||
}
|
||||
// Row 210's host.sudo and host.run stay outside the namespace, and so does any other kind.
|
||||
event(store, ['coder', 'run1'], { action: 'host.sudo', phase: 'completed' });
|
||||
event(store, ['coder', 'run1'], { action: 'grant.step' }, 'action.refused');
|
||||
event(store, ['coder', 'run1'], { action: 'granted.step', operation: 'grantx' });
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 3);
|
||||
});
|
||||
|
||||
test('hosts: one record per business and host, a ready host has its installer and check, grants need the host', t => {
|
||||
const store = fresh(t);
|
||||
host(store);
|
||||
assert.throws(() => host(store, { address: '192.0.2.11' }), /UNIQUE constraint failed: hosts\.business, hosts\.host/);
|
||||
host(store, { business: 'other' });
|
||||
assert.throws(() => host(store, { host: 'web2', state: 'ready' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x' }), /CHECK constraint failed/);
|
||||
host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x', checked: AT });
|
||||
assert.throws(() => host(store, { host: 'web3', state: 'live' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web3', host_keys: '[]' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web3', decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
const grant = (values = {}) => store.run(
|
||||
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(:business,:agent,:host,:commands,:rotate,:tag,:decision)',
|
||||
{ business: 'demo', agent: 'coder', host: 'web2', commands: '[["true"]]', rotate: '2026-12-01', tag: TAG, decision: 'd1', ...values });
|
||||
assert.throws(() => grant({ host: 'web9' }), /FOREIGN KEY constraint failed/);
|
||||
for (const tag of ['mosaic-' + 'A'.repeat(32), 'mosaic-' + 'a'.repeat(31), 'mosaix-' + 'a'.repeat(32), 'mosaic-' + 'a'.repeat(31) + 'g'])
|
||||
assert.throws(() => grant({ tag }), /CHECK constraint failed/, tag);
|
||||
assert.throws(() => grant({ commands: '[]' }), /CHECK constraint failed/);
|
||||
grant();
|
||||
assert.throws(() => grant({ tag: 'mosaic-' + 'b'.repeat(32) }), /UNIQUE constraint failed/);
|
||||
assert.throws(() => store.run("DELETE FROM hosts WHERE business='demo' AND host='web2'"), /FOREIGN KEY constraint failed/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
|
||||
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
|
||||
|
||||
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
|
||||
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
|
||||
@@ -37,10 +38,11 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
|
||||
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder',
|
||||
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] },
|
||||
hosts: { web1: { commands: [['systemctl', 'status']] } } } },
|
||||
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
|
||||
hosts: { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } } });
|
||||
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
|
||||
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
|
||||
// Row 234 (#1740): both are store records.
|
||||
hostRecord(f.store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands: [['systemctl', 'status']] });
|
||||
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
|
||||
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
|
||||
commit: 'a'.repeat(40), previous: null });
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Row 234 (#1740, r6 X4): host and grant store records for tests. Row a has no way to make a host_grants
|
||||
// record outside a fixture, because row b's grant executor writes them. The decision, its raise event and
|
||||
// its close go in by SQL. The grant evidence goes through writeGrantEvent (F1), so the trigger sees the
|
||||
// real shape.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { registrySha256, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { grantTag } from '../src/host-grant.mjs';
|
||||
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const OPTIONS = JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]);
|
||||
export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli' };
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
);
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
return id;
|
||||
}
|
||||
|
||||
// A ready host record from an approved, spent and finished enrollment. grant.host.enrolled keeps the
|
||||
// start reconcile from reading the enrollment as interrupted.
|
||||
export function hostRecord(store, { business = 'demo', host = 'web1', entry, sudo = true }) {
|
||||
const grant = { method: 'host-enroll', host, address: entry.address, port: entry.port, user: entry.user, sudo, fingerprints: [] };
|
||||
const decision = grantDecision(store, { business, grant });
|
||||
writeGrantEvent(store, AT, business, { operation: 'grant.host-enroll', decision }, decision);
|
||||
const installerFingerprint = 'SHA256:' + 'A'.repeat(43);
|
||||
writeGrantEvent(store, AT, business, {
|
||||
action: 'grant.host.enrolled', grantDecision: decision, host, installerFingerprint, user: entry.user, sudo,
|
||||
registrySha256: registrySha256({ ...entry, sudo }),
|
||||
}, decision);
|
||||
store.run(
|
||||
'INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
business, host, entry.address, entry.port, entry.user, sudo ? 1 : 0, JSON.stringify(entry.hostKeys), installerFingerprint, 'ready', decision, AT,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
// One agent's grant record on a host. By default its decision is a `host` grant for the same agent and
|
||||
// host, resolved yes by the human via cli and spent as grant.host. Each option breaks one part.
|
||||
export function hostGrantRecord(store, { business = 'demo', agent, host = 'web1', commands, rotateBy = '2099-01-01', grant = {}, close = HUMAN_YES, spend = 'grant.host', action }) {
|
||||
const decision = grantDecision(store, {
|
||||
business, close, action,
|
||||
grant: grant === null ? undefined : { method: 'host', agent, host, commands, rotateBy, ...grant },
|
||||
});
|
||||
if (spend) writeGrantEvent(store, AT, business, { operation: spend, decision }, decision);
|
||||
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,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Credentials, parseHostKey } from '../src/credentials.mjs';
|
||||
import { addKey, hostExecutor, runHost, sshArgs, startTime, sweepRunDir, quote, remoteCommand } from '../src/host-run.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { HUMAN_YES, hostGrantRecord, hostRecord } from './host-fixtures.mjs';
|
||||
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant } from '../src/host-grant.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
@@ -108,13 +110,12 @@ async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey =
|
||||
const executor = hostExecutor({ credentials, root });
|
||||
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, wrongHostKey ? 'otherhost' : 'hostkey'))] };
|
||||
const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] };
|
||||
const f = await fixture(t, {
|
||||
roles: {
|
||||
coder: { definition: 'coder', authority, hosts: { web1: { commands }, ...(coderWeb2 ? { web2: { commands } } : {}) } },
|
||||
reviewer: { definition: 'reviewer', authority: { withinRole: ['message.send'], crossRole: [] }, ...(reviewerHosts ? { hosts: reviewerHosts } : {}) },
|
||||
},
|
||||
hosts: { web1: entry, web2: { ...entry } },
|
||||
});
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority } } });
|
||||
// Row 234 (#1740, r6 X4): host.run reads store records, so the hosts and grants are fixtures there.
|
||||
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
|
||||
if (coderWeb2) hostGrantRecord(f.store, { agent: 'coder', host: 'web2', commands });
|
||||
for (const [host, g] of Object.entries(reviewerHosts ?? {})) hostGrantRecord(f.store, { agent: 'reviewer', host, commands: g.commands });
|
||||
const approve = async (argv, stdin, host = 'web1') => {
|
||||
const d = await f.raise({ action: 'host.sudo', target: host, argv, ...(stdin !== undefined ? { stdin } : {}) });
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
@@ -291,6 +292,26 @@ test('T14: a host key not in the registry refuses and nothing runs', async (t) =
|
||||
assert.deepEqual(f.argvSeen(), []);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, r6 G18, duckworth-0081 B2): hosts.check's `list` goes through runHost with the record's
|
||||
// pinned keys. A stub `list` on the scratch sshd's PATH records each run.
|
||||
test('G18: hosts.check with a wrong pin refuses host-key-mismatch and runs nothing on the host', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
writeFileSync(join(f.dir, 'fake', 'list'), `#!/bin/sh\necho "$@" >> '${f.dir}/list.log'\n`, { mode: 0o755 });
|
||||
const configDir = join(f.dir, 'cfg');
|
||||
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
|
||||
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
||||
for (const d of [join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
|
||||
writeFileSync(join(keyDir, 'installer'), readFileSync(f.keyFile), { mode: 0o600 });
|
||||
const hg = hostGrant({ configDir, runRoot: f.root });
|
||||
const record = { address: '127.0.0.1', port: f.port, user: 'jarvis', hostKeys: [pubLine(join(f.dir, 'otherhost'))] };
|
||||
await refusesWith(hg.check('demo', 'web1', record), 'host-key-mismatch');
|
||||
assert.equal(existsSync(join(f.dir, 'list.log')), false);
|
||||
// Control: with the right pin the same command reaches the stub.
|
||||
const out = await runHost({ root: f.root, hostId: 'web1', entry: f.entry, key: f.key, timeout: 10, command: 'list jarvis' });
|
||||
assert.equal(out.exit, 0);
|
||||
assert.equal(readFileSync(join(f.dir, 'list.log'), 'utf8'), 'jarvis\n');
|
||||
});
|
||||
|
||||
test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => {
|
||||
const f = await hostFixture(t, { commands: [['fail255']] });
|
||||
const out = await f.call(['fail255']);
|
||||
@@ -532,3 +553,90 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
|
||||
// A bus that starts anyway is closed, so a failure here ends the file instead of holding it open.
|
||||
await assert.rejects(async () => { const again = await start(); await again.close(); }, /credential-file/);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, duckworth-0079 M3, r6 X4 first bullet): host.run takes a grant record only behind the
|
||||
// Approve record check. Each case breaks one part of it. The refusals come before the executor, so none
|
||||
// needs sshd. The control passes the check and stops at host-not-configured.
|
||||
test('M3: host.run refuses host-not-granted unless the grant record has an approved, spent host grant', async (t) => {
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const entry = { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] };
|
||||
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
|
||||
const run = (cap = f.coder, host = 'web1') => f.broker.requestHost(cap, { verb: 'host.run', args: { host, argv: ['true'] } }, null);
|
||||
const commands = [['true']];
|
||||
const cases = {
|
||||
'no record': null,
|
||||
unresolved: { close: null },
|
||||
'resolved no': { close: { ...HUMAN_YES, choice: 'no' } },
|
||||
withdrawn: { close: { op: 'withdrawn', by: 'jason', via: 'cli' } },
|
||||
'an arbiter': { close: { ...HUMAN_YES, by: 'reviewer', via: 'broker' } },
|
||||
'the tester': { close: { ...HUMAN_YES, by: 'tester' } },
|
||||
'an agent': { close: { ...HUMAN_YES, by: 'coder', via: 'broker' } },
|
||||
'not via cli': { close: { ...HUMAN_YES, via: 'web' } },
|
||||
'consumed as grant.host-enroll': { spend: 'grant.host-enroll' },
|
||||
unspent: { spend: null },
|
||||
'not a credential.mint': { action: 'deploy' },
|
||||
'no grant in the raise': { grant: null },
|
||||
'a host-enroll grant': { grant: { method: 'host-enroll' } },
|
||||
'a grant for another agent': { grant: { agent: 'reviewer' } },
|
||||
'a grant for another host': { grant: { host: 'web2' } },
|
||||
};
|
||||
for (const [name, opts] of Object.entries(cases)) {
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
if (opts) hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, ...opts });
|
||||
await refusesWith(run(), 'host-not-granted').catch((e) => assert.fail(`${name}: ${e.message}`));
|
||||
}
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
|
||||
await refusesWith(run(), 'host-not-configured');
|
||||
// The record is per agent: the reviewer holds none on web1, and web9 has no host record.
|
||||
await refusesWith(run(f.reviewer), 'host-not-granted');
|
||||
await refusesWith(run(f.coder, 'web9'), 'host-unknown');
|
||||
// The host.sudo raise takes the same check (N1).
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, spend: null });
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] }), /host-not-granted/);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, M3, r6 X4 third bullet): at start each host_grants record gives Credentials a store
|
||||
// reference to host.<id>, so a restart finds the key the grant executor wrote.
|
||||
test('M3: a host_grants record gives Credentials a store reference at start, and a restart reads its key', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'h234-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
let bus = await start();
|
||||
assert.deepEqual(hostKeys(bus), []);
|
||||
await bus.close();
|
||||
const store = new Store(join(root, 'data'));
|
||||
hostRecord(store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
|
||||
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['true']], rotateBy: '2099-02-03' });
|
||||
store.close();
|
||||
bus = await start();
|
||||
try {
|
||||
assert.deepEqual(hostKeys(bus).map(({ instance, service, date, state }) => ({ instance, service, date, state })), [
|
||||
{ instance: 'demo/coder', service: 'host.web1', date: '2099-02-03', state: 'not-issued' },
|
||||
]);
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
const dir = join(root, 'secrets', 'agents', 'demo', 'coder');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets', 'agents', 'demo'), 0o700);
|
||||
keygen(join(dir, 'host.web1'));
|
||||
bus = await start();
|
||||
try {
|
||||
const [key] = hostKeys(bus);
|
||||
assert.equal(key.publicKey, pubLine(join(dir, 'host.web1')));
|
||||
assert.notEqual(key.state, 'not-issued');
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// Row 234 (#1740, #436a row a), design r6 E3: the four host files in tools/host, as G16 and G25.
|
||||
// The writer runs as the test user through a harness that loads it by path and passes its own dirs,
|
||||
// owner uid and user, group and sudo lookups, so nothing runs as root and nothing under /etc is read.
|
||||
// mosaic-keys runs from a copy whose sudo and logger paths point at stubs in a scratch dir.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const TOOLS = join(REPO, 'tools', 'host');
|
||||
const WRITER = join(TOOLS, 'mosaic-keys-write');
|
||||
const REFUSED_GROUPS = ['sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin'];
|
||||
const tag = (n) => 'mosaic-' + createHash('sha256').update(String(n)).digest('hex').slice(0, 32);
|
||||
const u32 = (n) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
};
|
||||
const keyBlob = () => Buffer.concat([u32(11), Buffer.from('ssh-ed25519'), u32(32), randomBytes(32)]).toString('base64');
|
||||
const fingerprint = (b64) => 'SHA256:' + createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64').replace(/=+$/, '');
|
||||
const notAllowed = (u) => `User ${u} is not allowed to run sudo on testhost.\n`;
|
||||
|
||||
const HARNESS = `import importlib.machinery, importlib.util, json, os, sys
|
||||
sys.dont_write_bytecode = True
|
||||
cfg = json.loads(sys.argv[1])
|
||||
loader = importlib.machinery.SourceFileLoader('mosaic_keys_write', cfg['writer'])
|
||||
m = importlib.util.module_from_spec(importlib.util.spec_from_loader('mosaic_keys_write', loader))
|
||||
loader.exec_module(m)
|
||||
users = cfg['users']
|
||||
def log(msg):
|
||||
with open(cfg['log'], 'a') as f:
|
||||
f.write(msg + '\\n')
|
||||
def sudo(u):
|
||||
with open(cfg['sudoLog'], 'a') as f:
|
||||
f.write(u + '\\n')
|
||||
return users[u]['sudo'] if 'sudo' in users[u] else 'User %s is not allowed to run sudo on testhost.\\n' % u
|
||||
host = m.Host(key_dir=cfg['keyDir'], conf_dir=cfg['confDir'], owner=os.getuid(),
|
||||
passwd=lambda u: users[u]['uid'] if u in users else None,
|
||||
groups=lambda u: users[u].get('groups', []), sudo=sudo, hostname='testhost', log=log)
|
||||
sys.exit(m.main(sys.argv[2:], host))
|
||||
`;
|
||||
|
||||
// The users the harness's lookups know. users lists all of them but dave and eve. bob and dave are
|
||||
// privileged. carol has a sudo line. Each gX is in one refused group.
|
||||
function userTable() {
|
||||
const users = {
|
||||
alice: { uid: 1500 },
|
||||
bob: { uid: 1501, groups: ['sudo', 'docker'], sudo: 'User bob may run the following commands on testhost:\n (ALL) ALL\n' },
|
||||
carol: { uid: 1502, sudo: 'User carol may run the following commands on testhost:\n (root) /usr/bin/true\n' },
|
||||
dave: { uid: 1503 },
|
||||
eve: { uid: 1504 },
|
||||
frank: { uid: 1505, groups: ['users', 'adm'] },
|
||||
'../x': { uid: 1506 },
|
||||
root: { uid: 0 },
|
||||
toor: { uid: 0 },
|
||||
daemon2: { uid: 999 },
|
||||
localadmin: { uid: 1000 },
|
||||
'mosaic-installer': { uid: 1001 },
|
||||
};
|
||||
for (const [i, g] of REFUSED_GROUPS.entries()) users['g' + g] = { uid: 1600 + i, groups: ['users', g] };
|
||||
return users;
|
||||
}
|
||||
|
||||
function hostDirs() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
const keyDir = join(root, 'keys');
|
||||
const confDir = join(root, 'conf');
|
||||
mkdirSync(keyDir, { mode: 0o755 });
|
||||
mkdirSync(confDir, { mode: 0o755 });
|
||||
chmodSync(keyDir, 0o755);
|
||||
const users = userTable();
|
||||
const conf = (name, text) => {
|
||||
writeFileSync(join(confDir, name), text, { mode: 0o644 });
|
||||
chmodSync(join(confDir, name), 0o644);
|
||||
};
|
||||
conf('users', '# users the writer may touch\n' + Object.keys(users).filter((u) => !['dave', 'eve'].includes(u)).join('\n') + '\n');
|
||||
conf('privileged', 'bob\ndave\n');
|
||||
conf('from', '10.1.2.3/32\n');
|
||||
const harness = join(root, 'harness.py');
|
||||
writeFileSync(harness, HARNESS);
|
||||
const cfg = { writer: WRITER, keyDir, confDir, users, log: join(root, 'syslog'), sudoLog: join(root, 'sudo.log') };
|
||||
const run = (...args) => {
|
||||
const r = spawnSync('python3', ['-B', '-I', harness, JSON.stringify(cfg), ...args], { encoding: 'utf8' });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
};
|
||||
const read = (file) => readFileSync(join(keyDir, file), 'utf8');
|
||||
const lastLog = () => readFileSync(cfg.log, 'utf8').trim().split('\n').at(-1);
|
||||
const sudoCalls = () => (existsSync(cfg.sudoLog) ? readFileSync(cfg.sudoLog, 'utf8').trim().split('\n') : []);
|
||||
return { root, keyDir, confDir, conf, run, read, lastLog, sudoCalls, done: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
const refused = (r, code) => {
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, `mosaic-keys-write: refused ${code}\n`);
|
||||
};
|
||||
|
||||
test('G16: the writer refuses each user, tag and key the design names, before any key file', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const key = keyBlob();
|
||||
const t = tag(1);
|
||||
const add = (user) => h.run('add', user, 'ssh-ed25519', key, t);
|
||||
// The user pattern stops `../x` before any path, though the lookups and users know it.
|
||||
for (const user of ['../x', 'Alice', 'a'.repeat(33), '1alice', 'al ice', '']) {
|
||||
refused(add(user), 'user-pattern');
|
||||
refused(h.run('list', user), 'user-pattern');
|
||||
}
|
||||
// users is always required: privileged alone allows nothing.
|
||||
refused(add('dave'), 'user-not-listed');
|
||||
refused(add('eve'), 'user-not-listed');
|
||||
for (const user of ['root', 'localadmin', 'mosaic-installer']) refused(add(user), 'user-not-listed');
|
||||
refused(add('toor'), 'user-system');
|
||||
refused(add('daemon2'), 'user-system');
|
||||
refused(add('ghost'), 'user-not-listed');
|
||||
for (const g of REFUSED_GROUPS) {
|
||||
refused(add('g' + g), 'user-group');
|
||||
refused(h.run('remove', 'g' + g, t), 'user-group');
|
||||
}
|
||||
refused(add('carol'), 'user-sudo');
|
||||
refused(h.run('list', 'carol'), 'user-sudo');
|
||||
assert.equal(h.lastLog(), 'result=refused user-sudo user=carol');
|
||||
for (const bad of ['mosaic-' + 'a'.repeat(31), 'mosaic-' + 'A'.repeat(32), 'mosaic-installer-' + 'a'.repeat(32), 'mosaic-' + 'g'.repeat(32)]) {
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key, bad), 'tag-pattern');
|
||||
refused(h.run('remove', 'alice', bad), 'tag-pattern');
|
||||
}
|
||||
refused(h.run('add', 'alice', 'ssh-dss', key, t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key.slice(0, -2) + '!!', t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', 'A', t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key + ' x', t), 'key-format');
|
||||
for (const args of [[], ['list'], ['list', 'alice', 'bob'], ['add', 'alice', 'ssh-ed25519', key], ['remove', 'alice'], ['rm', 'alice', t]])
|
||||
refused(h.run(...args), 'request');
|
||||
// No refusal reached the key dir, so not even the lock exists.
|
||||
assert.deepEqual(readdirSync(h.keyDir), []);
|
||||
// Only users past the groups check reach sudo -l, and the privileged never do.
|
||||
assert.deepEqual([...new Set(h.sudoCalls())], ['carol']);
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G16: from= comes from the host file, a privileged user skips the group and sudo checks, and the line is exact', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k1 = keyBlob();
|
||||
const k2 = keyBlob();
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k1, tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=added user=alice tag=${tag(1)} key=${fingerprint(k1)}`);
|
||||
h.conf('from', '# the stack host\n10.9.9.9,192.168.1.0/24\n');
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(2)).status, 0);
|
||||
assert.equal(
|
||||
h.read('alice'),
|
||||
`from="10.1.2.3/32",restrict ssh-ed25519 ${k1} ${tag(1)}\nfrom="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k2} ${tag(2)}\n`,
|
||||
);
|
||||
// A tag already there changes nothing and exits 0.
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=present user=alice tag=${tag(1)} key=${fingerprint(k2)}`);
|
||||
assert.equal(h.read('alice').split('\n').length, 3);
|
||||
// bob is in sudo and docker with a sudo line, and privileged.
|
||||
assert.equal(h.run('add', 'bob', 'ssh-ed25519', k1, tag(3)).status, 0);
|
||||
// frank's groups are none of the refused ones.
|
||||
assert.equal(h.run('add', 'frank', 'ssh-ed25519', k1, tag(4)).status, 0);
|
||||
assert.deepEqual(h.sudoCalls(), ['alice', 'alice', 'alice', 'frank']);
|
||||
// A from value that could close the quote, two values, or none, refuses and writes nothing.
|
||||
for (const text of ['10.0.0.1",command="/bin/sh\n', '10.0.0.1\n10.0.0.2\n', '# none\n', '10.0.0.1 10.0.0.2\n']) {
|
||||
h.conf('from', text);
|
||||
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
|
||||
}
|
||||
rmSync(join(h.confDir, 'from'));
|
||||
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
|
||||
assert.ok(!h.read('frank').includes(tag(5)));
|
||||
// list and remove don't need from.
|
||||
assert.equal(h.run('list', 'frank').stdout, `from="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k1} ${tag(4)}\n`);
|
||||
for (const name of ['users', 'privileged']) {
|
||||
chmodSync(join(h.confDir, name), 0o664);
|
||||
refused(h.run('list', 'alice'), 'not-root-owned');
|
||||
chmodSync(join(h.confDir, name), 0o644);
|
||||
}
|
||||
rmSync(join(h.confDir, 'users'));
|
||||
refused(h.run('list', 'alice'), 'config-users');
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G16: list shows tagged lines only, and add and remove never touch a line outside the tag pattern', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const hand = [
|
||||
'# written by hand',
|
||||
`ssh-ed25519 ${k} mosaic-installer-${'a'.repeat(32)}`,
|
||||
'',
|
||||
`ssh-ed25519 ${k} ${tag(9).toUpperCase()}`,
|
||||
`ssh-ed25519 ${k} ${tag(1)} trailing`,
|
||||
];
|
||||
const tagged = `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(7)}`;
|
||||
writeFileSync(join(h.keyDir, 'alice'), [hand[0], tagged, ...hand.slice(1)].join('\n') + '\n', { mode: 0o644 });
|
||||
assert.equal(h.run('list', 'alice').stdout, tagged + '\n');
|
||||
// The last field is the tag, so `... mosaic-<1> trailing` isn't tag 1.
|
||||
assert.equal(h.run('remove', 'alice', tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=absent user=alice tag=${tag(1)}`);
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k, tag(8)).status, 0);
|
||||
assert.equal(h.run('remove', 'alice', tag(7)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=removed user=alice tag=${tag(7)}`);
|
||||
assert.equal(h.read('alice'), [...hand, `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(8)}`].join('\n') + '\n');
|
||||
assert.equal(h.run('remove', 'alice', tag(8)).status, 0);
|
||||
assert.equal(h.read('alice'), hand.join('\n') + '\n');
|
||||
// A missing file lists nothing and a remove there changes nothing.
|
||||
assert.equal(h.run('list', 'frank').stdout, '');
|
||||
assert.equal(h.run('remove', 'frank', tag(1)).status, 0);
|
||||
assert.ok(!existsSync(join(h.keyDir, 'frank')));
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G25: the writer refuses a symlinked dir or user file, a group-writable dir and an odd tmp, and writes 0644', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const add = (n, user = 'alice') => h.run('add', user, 'ssh-ed25519', k, tag(n));
|
||||
// A first add creates the file, regular, 0644, owned by the owner uid, with no tmp left.
|
||||
assert.equal(add(1).status, 0);
|
||||
const st = statSync(join(h.keyDir, 'alice'));
|
||||
assert.equal(st.mode & 0o777, 0o644);
|
||||
assert.equal(st.uid, process.getuid());
|
||||
assert.deepEqual(readdirSync(h.keyDir).sort(), ['.lock', 'alice']);
|
||||
// A leftover regular tmp is unlinked and the write goes ahead.
|
||||
writeFileSync(join(h.keyDir, '.alice.tmp'), 'half a write');
|
||||
assert.equal(add(2).status, 0);
|
||||
assert.ok(h.read('alice').includes(tag(2)));
|
||||
assert.ok(!existsSync(join(h.keyDir, '.alice.tmp')));
|
||||
// A tmp that is a symlink, a dir or a fifo refuses, and nothing is written.
|
||||
const decoy = join(h.root, 'decoy');
|
||||
writeFileSync(decoy, 'decoy\n');
|
||||
const before = h.read('alice');
|
||||
for (const make of [
|
||||
(p) => symlinkSync(decoy, p),
|
||||
(p) => mkdirSync(p),
|
||||
(p) => execFileSync('mkfifo', [p]),
|
||||
]) {
|
||||
make(join(h.keyDir, '.alice.tmp'));
|
||||
refused(add(3), 'tmp-file');
|
||||
assert.equal(h.read('alice'), before);
|
||||
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
|
||||
rmSync(join(h.keyDir, '.alice.tmp'), { recursive: true });
|
||||
}
|
||||
// A symlinked user file refuses, though its target is a regular file the owner owns.
|
||||
symlinkSync(decoy, join(h.keyDir, 'frank'));
|
||||
refused(add(4, 'frank'), 'user-file');
|
||||
refused(h.run('list', 'frank'), 'user-file');
|
||||
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
|
||||
assert.ok(lstatSync(join(h.keyDir, 'frank')).isSymbolicLink());
|
||||
// A user "file" that is a dir refuses.
|
||||
mkdirSync(join(h.keyDir, 'bob'));
|
||||
refused(add(5, 'bob'), 'not-root-owned');
|
||||
// A group-writable dir refuses.
|
||||
chmodSync(h.keyDir, 0o775);
|
||||
refused(add(6), 'not-root-owned');
|
||||
chmodSync(h.keyDir, 0o755);
|
||||
// A symlinked key dir refuses, and its target is untouched.
|
||||
const real = join(h.root, 'real-keys');
|
||||
mkdirSync(real, { mode: 0o755 });
|
||||
rmSync(h.keyDir, { recursive: true });
|
||||
symlinkSync(real, h.keyDir);
|
||||
refused(add(7), 'key-dir');
|
||||
assert.deepEqual(readdirSync(real), []);
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
// mosaic-keys from a copy with its sudo and logger paths on stubs. The stub sudo records its argv,
|
||||
// prints a line and exits 3, so the test sees the exit and output pass through.
|
||||
function forcedCommand() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
const sudo = join(root, 'sudo');
|
||||
const logger = join(root, 'logger');
|
||||
writeFileSync(sudo, `#!/bin/bash\nprintf '%s\\n' "$@" > ${root}/sudo.argv\necho from-writer\nexit 3\n`, { mode: 0o755 });
|
||||
writeFileSync(logger, `#!/bin/bash\nprintf '%s\\n' "$*" >> ${root}/logger.log\n`, { mode: 0o755 });
|
||||
const source = readFileSync(join(TOOLS, 'mosaic-keys'), 'utf8');
|
||||
assert.equal(source.split('/usr/bin/sudo').length, 2);
|
||||
assert.equal(source.split('/usr/bin/logger').length, 2);
|
||||
const copy = join(root, 'mosaic-keys');
|
||||
writeFileSync(copy, source.replace('/usr/bin/sudo', sudo).replace('/usr/bin/logger', logger), { mode: 0o755 });
|
||||
const run = (request) => {
|
||||
rmSync(join(root, 'sudo.argv'), { force: true });
|
||||
const env = { PATH: '/usr/bin:/bin', SSH_CONNECTION: '10.1.2.3 50000 10.1.2.9 22' };
|
||||
if (request !== undefined) env.SSH_ORIGINAL_COMMAND = request;
|
||||
const r = spawnSync(copy, [], { encoding: 'utf8', env });
|
||||
const argv = existsSync(join(root, 'sudo.argv')) ? readFileSync(join(root, 'sudo.argv'), 'utf8').trim().split('\n') : null;
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, argv };
|
||||
};
|
||||
const logged = () => readFileSync(join(root, 'logger.log'), 'utf8').trim().split('\n');
|
||||
return { run, logged, done: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('G16: mosaic-keys passes the three requests to the writer through sudo and refuses anything else without it', () => {
|
||||
const f = forcedCommand();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const t = tag(1);
|
||||
const writer = ['-n', '/usr/local/sbin/mosaic-keys-write'];
|
||||
for (const words of [['list', 'alice'], ['add', 'alice', 'ssh-ed25519', k, t], ['add', 'alice', 'ecdsa-sha2-nistp256', 'AAAA', t], ['remove', 'alice', t]]) {
|
||||
const r = f.run(words.join(' '));
|
||||
assert.deepEqual(r.argv, [...writer, ...words]);
|
||||
assert.equal(r.status, 3);
|
||||
assert.equal(r.stdout, 'from-writer\n');
|
||||
}
|
||||
const bad = [
|
||||
undefined, '', 'list', 'list ../x', 'list Alice', 'list alice;id', 'list alice ', ' list alice', 'list alice', 'list\talice',
|
||||
'list alice\nlist bob', 'list $(id)', 'list alice`id`', 'list *', 'rm alice', 'list ' + 'a'.repeat(33),
|
||||
`add alice ssh-dss ${k} ${t}`, `add alice ssh-ed25519 ${k}! ${t}`, `add alice ssh-ed25519 ${k}`, `add alice ssh-ed25519 ${k} ${t} x`,
|
||||
`remove alice mosaic-${'a'.repeat(31)}`, `remove alice ${t.toUpperCase()}`, `remove alice ${t}\n`, `remove alice ${t}; rm -rf /`,
|
||||
];
|
||||
for (const request of bad) {
|
||||
const r = f.run(request);
|
||||
assert.equal(r.argv, null, JSON.stringify(request));
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(r.stderr, 'mosaic-keys: refused\n');
|
||||
}
|
||||
// Two bad requests hold a newline, so count the logger calls, not the lines.
|
||||
const logged = f.logged().filter((l) => l.startsWith('-t mosaic-keys -- '));
|
||||
assert.equal(logged.length, 4 + bad.length);
|
||||
assert.equal(logged[0], '-t mosaic-keys -- connection=10.1.2.3 50000 10.1.2.9 22 request=list alice');
|
||||
} finally {
|
||||
f.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('E3: the sudoers line and the sshd drop-in are the design lines and parse', () => {
|
||||
const lines = (file) => readFileSync(join(TOOLS, file), 'utf8').split('\n').filter((l) => l && !l.startsWith('#'));
|
||||
assert.deepEqual(lines('mosaic-keys.sudoers'), ['mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write']);
|
||||
assert.deepEqual(lines('mosaic-keys.sshd.conf'), ['AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
|
||||
assert.ok(existsSync('/usr/bin/visudo'), 'visudo on the test host');
|
||||
execFileSync('/usr/bin/visudo', ['-c', '-q', '-f', join(TOOLS, 'mosaic-keys.sudoers')]);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
try {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hk')]);
|
||||
writeFileSync(join(dir, 'cfg'), `HostKey ${join(dir, 'hk')}\nInclude ${join(TOOLS, 'mosaic-keys.sshd.conf')}\n`);
|
||||
const out = execFileSync('/usr/bin/sshd', ['-T', '-f', join(dir, 'cfg'), '-C', 'user=alice,host=x,addr=127.0.0.1'], { encoding: 'utf8' });
|
||||
// sshd -T prints this key as AuthorizedKeysFile, unlike most.
|
||||
const line = out.split('\n').filter((l) => /^authorizedkeysfile /i.test(l));
|
||||
assert.deepEqual(line.map((l) => l.split(' ').slice(1).join(' ')), ['.ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const file of ['mosaic-keys', 'mosaic-keys-write']) assert.equal(statSync(join(TOOLS, file)).mode & 0o111, 0o111, file);
|
||||
assert.equal(readFileSync(WRITER, 'utf8').split('\n')[0], '#!/usr/bin/python3 -I');
|
||||
});
|
||||
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -25,7 +25,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
|
||||
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
|
||||
// Row 210 (#1707) reads credentialKind and the D6 argv check, validArgv and commandAllowed, from it.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed } from '../../business/src/vocabulary.mjs';",
|
||||
// Row 234 (#1740) reads HOST_ID for the hosts verbs.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -519,7 +519,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
|
||||
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
||||
// Row 124 adds usage_records and its three append-only triggers.
|
||||
assert.equal(count('table') - 1, 16);
|
||||
assert.equal(count('trigger'), 70);
|
||||
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 18);
|
||||
assert.equal(count('trigger'), 71);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, hosts } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' } } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -17,7 +17,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
...roles,
|
||||
}, ...(hosts ? { hosts } : {}) };
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
|
||||
readSpec: context => readSpec({ ...context, dataRoot: root }),
|
||||
|
||||
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3i ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3j ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h to 3i preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -10,8 +10,7 @@ import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkPlugins } from "../../plugins/src/kinds.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, HOST_PREFIX } from "./vocabulary.mjs";
|
||||
import { checkHosts, checkHostGrants } from "./hosts.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE } from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -103,7 +102,11 @@ function checkTracker(tracker, file) {
|
||||
return { sync, labels };
|
||||
}
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business, hosts) {
|
||||
// Row 234 (#1740, r6 S1): hosts and host grants are bus store records made by a human-approved grant,
|
||||
// so the business file can't name them. The key stays known so the refusal says where they went.
|
||||
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -121,14 +124,9 @@ function checkRoles(roles, file, rolesDir, business, hosts) {
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
// Row 210 (#1707, design D3 steps 1 and 3): a host grant needs a definition that declares the
|
||||
// host credential, and each granted host needs its own host.<id> key reference.
|
||||
let hostGrants = null;
|
||||
if (entry.hosts !== undefined) {
|
||||
if (!services.includes(HOST_SERVICE)) refuse(`${at}.hosts needs a role definition that declares the host credential`);
|
||||
hostGrants = checkHostGrants(entry.hosts, hosts, `${at}.hosts`);
|
||||
}
|
||||
const kinds = [...services.filter((s) => s !== HOST_SERVICE), ...Object.keys(hostGrants ?? {}).map((id) => HOST_PREFIX + id)];
|
||||
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
|
||||
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
|
||||
const kinds = services.filter((s) => s !== HOST_SERVICE);
|
||||
let tracker = null;
|
||||
if (services.includes("vikunja")) {
|
||||
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
@@ -151,7 +149,6 @@ function checkRoles(roles, file, rolesDir, business, hosts) {
|
||||
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
|
||||
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
|
||||
// names are checked against projects once those are read.
|
||||
if (hostGrants) out[instance].hosts = hostGrants;
|
||||
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
|
||||
if (entry.publish !== undefined) {
|
||||
// N10: one message for both, naming the grant.
|
||||
@@ -207,8 +204,8 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
}
|
||||
const human = requireId(document.human, "business human");
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
const hosts = checkHosts(document.hosts, `${file} hosts`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hosts);
|
||||
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -268,7 +265,6 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(document.hosts === undefined ? {} : { hosts }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// Row 210 (#1707, design D3 and D6): the host registry in the business file, an agent's host
|
||||
// grants, and the argv allowlist the broker matches each host.run against. Hand edits only: the
|
||||
// chief's organization.set and the human's instances.set take neither key.
|
||||
// Row 210 (#1707, design D3 and D6): the address, user and host key checks, and the argv allowlist the
|
||||
// broker matches each host.run against. Row 234 (#1740, r6 S1) moved the host registry and the grants
|
||||
// out of the business file into bus store records. The enrollment raise checks its fields with these,
|
||||
// and row b's host grant raise checks its commands with checkCommands.
|
||||
|
||||
import { isIP } from "node:net";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { HOST_ID, ARGV_MAX } from "./vocabulary.mjs";
|
||||
import { ARGV_MAX } from "./vocabulary.mjs";
|
||||
// Row 210 r4 (#1707): the argv check lives in vocabulary.mjs, which the worker sandbox mounts
|
||||
// and which imports nothing, so the broker can read it. Re-exported so the D6 checks keep one
|
||||
// business home here.
|
||||
export { ARGV_MAX, ARG_BYTES, ARGV_BYTES, validArgv, commandAllowed } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, isPlainObject } from "./util.mjs";
|
||||
|
||||
const DNS = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
|
||||
const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
|
||||
export const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
export const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
// D6: a prefix starting with one of these, by basename, would turn the grant back into "any".
|
||||
// The list can't be complete. Under PRD 1.7 the control is the human's decision on each call (D4).
|
||||
@@ -49,7 +49,7 @@ export function ownMachine(address) {
|
||||
return address === "localhost" || address.endsWith(".localhost");
|
||||
}
|
||||
|
||||
function checkAddress(address, where) {
|
||||
export function checkAddress(address, where) {
|
||||
if (typeof address !== "string") refuse(`${where} must be an IP address or a DNS name`);
|
||||
const version = isIP(address);
|
||||
if (version === 6 && address.includes("%")) refuse(`${where} must not carry an IPv6 zone`);
|
||||
@@ -63,32 +63,6 @@ function checkAddress(address, where) {
|
||||
return address;
|
||||
}
|
||||
|
||||
// The top-level "hosts" key: { <hostId>: { address, port, user, hostKeys } }.
|
||||
export function checkHosts(hosts, where) {
|
||||
if (hosts === undefined) return {};
|
||||
requireObject(hosts, where);
|
||||
const out = {};
|
||||
for (const [id, entry] of Object.entries(hosts)) {
|
||||
if (!HOST_ID.test(id)) refuse(`${where} host id ${JSON.stringify(id)} must match ${HOST_ID}`);
|
||||
const at = `${where}.${id}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["address", "port", "user", "hostKeys"], at);
|
||||
const address = checkAddress(entry.address, `${at}.address`);
|
||||
if (!Number.isInteger(entry.port) || entry.port < 1 || entry.port > 65535) refuse(`${at}.port must be 1 to 65535`);
|
||||
if (typeof entry.user !== "string" || !USER.test(entry.user)) refuse(`${at}.user must match ${USER}`);
|
||||
if (!Array.isArray(entry.hostKeys) || entry.hostKeys.length === 0) refuse(`${at}.hostKeys must be a non-empty list`);
|
||||
const keys = entry.hostKeys.map((key, i) => {
|
||||
if (typeof key !== "string" || !HOST_KEY.test(key)) {
|
||||
refuse(`${at}.hostKeys[${i}] must be "<type> <base64>" with type ssh-ed25519, ecdsa-sha2-nistp256 or ssh-rsa, and no comment`);
|
||||
}
|
||||
return key;
|
||||
});
|
||||
if (new Set(keys).size !== keys.length) refuse(`${at}.hostKeys lists a key twice`);
|
||||
out[id] = { address, port: entry.port, user: entry.user, hostKeys: keys };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// D6: an argv allowlist and nothing else. No wildcard: "any", "*", {} and [] all refuse.
|
||||
export function checkCommands(commands, where) {
|
||||
if (!Array.isArray(commands)) refuse(`${where} must be a list of argv prefixes; there is no wildcard grant`);
|
||||
@@ -110,18 +84,3 @@ export function checkCommands(commands, where) {
|
||||
return [...prefix];
|
||||
});
|
||||
}
|
||||
|
||||
// A role entry's "hosts": { <hostId>: { commands } }, each id in the registry.
|
||||
export function checkHostGrants(grants, registry, where) {
|
||||
requireObject(grants, where);
|
||||
if (Object.keys(grants).length === 0) refuse(`${where} must name at least one host`);
|
||||
const out = {};
|
||||
for (const [id, grant] of Object.entries(grants)) {
|
||||
const at = `${where}.${id}`;
|
||||
if (!Object.hasOwn(registry, id)) refuse(`${at}: host ${id} isn't in the business file's hosts`);
|
||||
if (!isPlainObject(grant)) refuse(`${at} must be an object`);
|
||||
rejectUnknownKeys(grant, ["commands"], at);
|
||||
out[id] = { commands: checkCommands(grant.commands, `${at}.commands`) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Row 210 (#1707, design D3 and D6): the host registry, an agent's host grant and its argv
|
||||
// allowlist, checked at business load. Tests T1, T2 and T26 of the design.
|
||||
// Row 210 (#1707, design D3 and D6): the address checks and the argv allowlist. Row 234 (#1740, r6 S1)
|
||||
// moved the registry and the grants to bus store records, so the business file refuses them.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { REFUSED_COMMANDS, commandAllowed, ownMachine, validArgv } from "../src/hosts.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, tokenFile, FAR_DATE } from "./helpers.mjs";
|
||||
import { HOST_KEY as HOST_KEY_PATTERN, REFUSED_COMMANDS, USER, checkAddress, checkCommands, commandAllowed, ownMachine, validArgv } from "../src/hosts.mjs";
|
||||
import { businessDoc, rolesCopy, tmp, tokenFile, FAR_DATE } from "./helpers.mjs";
|
||||
|
||||
const HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOv6Rq3Mvo9w2j3eYvYbqX0J1pJ0x9m8Zr0w1kJ8p0Qn";
|
||||
|
||||
@@ -19,8 +18,7 @@ function refuses(fn, pattern) {
|
||||
});
|
||||
}
|
||||
|
||||
// The fixture with coder's definition declaring the host credential, one registered host, and
|
||||
// coder granted it with one command.
|
||||
// The fixture with coder's definition declaring the host credential and no hosts.
|
||||
function check(mutate, { declare = true } = {}) {
|
||||
const root = tmp();
|
||||
const rolesDir = rolesCopy(root);
|
||||
@@ -31,62 +29,44 @@ function check(mutate, { declare = true } = {}) {
|
||||
writeFileSync(file, JSON.stringify(coder));
|
||||
}
|
||||
const doc = businessDoc(root);
|
||||
doc.hosts = { web1: { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] } };
|
||||
doc.roles.coder.hosts = { web1: { commands: [["systemctl", "status"]] } };
|
||||
doc.roles.coder.credentials["host.web1"] = { file: tokenFile(join(root, "secrets"), "coder-host.key"), rotateBy: FAR_DATE };
|
||||
mutate?.(doc, root);
|
||||
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir });
|
||||
}
|
||||
|
||||
test("T1: a registry and a grant load, and the resolved session credentials leave the host key out", () => {
|
||||
const business = check();
|
||||
assert.deepEqual(business.hosts.web1, { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] });
|
||||
assert.deepEqual(business.roles.coder.hosts, { web1: { commands: [["systemctl", "status"]] } });
|
||||
assert.ok(business.roles.coder.credentials["host.web1"]);
|
||||
assert.equal(check((d) => { d.hosts.web1.address = "host.example"; }).hosts.web1.address, "host.example");
|
||||
// A file without hosts loads with no hosts key at all.
|
||||
assert.equal("hosts" in check((d) => { delete d.hosts; delete d.roles.coder.hosts; delete d.roles.coder.credentials["host.web1"]; }), false);
|
||||
// Row 234 (#1740, r6 S1): hosts and grants are bus store records, so the business file refuses both keys
|
||||
// and any host.<id> key reference. The control loads.
|
||||
test("S1: the business file refuses hosts at the top level and in a role, and host.<id> references", () => {
|
||||
assert.equal("hosts" in check(), false);
|
||||
assert.equal("hosts" in check().roles.coder, false);
|
||||
const entry = { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] };
|
||||
refuses(() => check((d) => { d.hosts = { web1: entry }; }), /acme\.json hosts lives in the bus store/);
|
||||
refuses(() => check((d) => { d.hosts = {}; }), /hosts lives in the bus store/);
|
||||
refuses(() => check((d) => { d.roles.coder.hosts = { web1: { commands: [["systemctl", "status"]] } }; }), /roles\.coder\.hosts lives in the bus store/);
|
||||
refuses(() => check((d) => { d.roles.coder.hosts = {}; }, { declare: false }), /roles\.coder\.hosts lives in the bus store/);
|
||||
const key = (d, root) => { d.roles.coder.credentials["host.web1"] = { file: tokenFile(join(root, "secrets"), "coder-host.key"), rotateBy: FAR_DATE }; };
|
||||
refuses(() => check(key), /host\.web1/);
|
||||
});
|
||||
|
||||
test("T1: registry field checks name the key path", () => {
|
||||
refuses(() => check((d) => { d.hosts = []; }), /hosts/);
|
||||
refuses(() => check((d) => { d.hosts = { "Web 1": d.hosts.web1 }; }), /host id/);
|
||||
refuses(() => check((d) => { d.hosts.web1.extra = 1; }), /hosts\.web1/);
|
||||
for (const port of [0, 65536, "22", 1.5]) refuses(() => check((d) => { d.hosts.web1.port = port; }), /hosts\.web1\.port/);
|
||||
for (const user of ["Root", "a b", "", 7]) refuses(() => check((d) => { d.hosts.web1.user = user; }), /hosts\.web1\.user/);
|
||||
refuses(() => check((d) => { d.hosts.web1.hostKeys = []; }), /hosts\.web1\.hostKeys/);
|
||||
refuses(() => check((d) => { d.hosts.web1.hostKeys = [`${HOST_KEY} root@web1`]; }), /hosts\.web1\.hostKeys\[0\]/);
|
||||
refuses(() => check((d) => { d.hosts.web1.hostKeys = ["ssh-dss AAAA"]; }), /hosts\.web1\.hostKeys\[0\]/);
|
||||
refuses(() => check((d) => { d.hosts.web1.hostKeys = [HOST_KEY, HOST_KEY]; }), /twice/);
|
||||
for (const address of ["bad_name", "a..b", "-a.b", "fe80::1%eth0", 7])
|
||||
refuses(() => check((d) => { d.hosts.web1.address = address; }), /hosts\.web1\.address/);
|
||||
});
|
||||
|
||||
test("T1: every own-machine form refuses, and the controls load", () => {
|
||||
test("T1: address, user and host key checks, which the enrollment raise uses", () => {
|
||||
const own = ["127.0.0.2", "0.0.0.0", "::1", "::", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1", "::ffff:0.0.0.0", "localhost", "a.localhost",
|
||||
"127.1", "2130706433", "0177.0.0.1", "0x7f.1"];
|
||||
for (const address of own) refuses(() => check((d) => { d.hosts.web1.address = address; }), /hosts\.web1\.address/);
|
||||
for (const address of ["10.1.1.53", "host.example", "2001:db8::1"]) assert.equal(check((d) => { d.hosts.web1.address = address; }).hosts.web1.address, address);
|
||||
"127.1", "2130706433", "0177.0.0.1", "0x7f.1", "bad_name", "a..b", "-a.b", "fe80::1%eth0", 7];
|
||||
for (const address of own) refuses(() => checkAddress(address, "grant.address"), /grant\.address/);
|
||||
for (const address of ["10.1.1.53", "host.example", "2001:db8::1"]) assert.equal(checkAddress(address, "grant.address"), address);
|
||||
assert.equal(ownMachine("::ffff:7f00:1"), true);
|
||||
assert.equal(ownMachine("::ffff:10.0.0.1"), false);
|
||||
});
|
||||
|
||||
test("T2: a grant naming an unknown host, or on a definition without host, refuses at roles.<agent>.hosts", () => {
|
||||
refuses(() => check((d) => { d.roles.coder.hosts = { web2: { commands: [["ls"]] } }; }), /roles\.coder\.hosts\.web2: host web2 isn't/);
|
||||
refuses(() => check(null, { declare: false }), /roles\.coder\.hosts needs a role definition that declares the host credential/);
|
||||
refuses(() => check((d) => { d.roles.coder.hosts = {}; }), /roles\.coder\.hosts must name at least one host/);
|
||||
refuses(() => check((d) => { d.roles.coder.hosts.web1.extra = 1; }), /roles\.coder\.hosts\.web1/);
|
||||
// The key reference is for a granted host only.
|
||||
refuses(() => check((d) => { d.roles.coder.credentials["host.web2"] = d.roles.coder.credentials["host.web1"]; }), /host\.web2/);
|
||||
for (const user of ["Root", "a b", ""]) assert.equal(USER.test(user), false);
|
||||
assert.equal(USER.test("localadmin"), true);
|
||||
for (const key of [`${HOST_KEY} root@web1`, "ssh-dss AAAA"]) assert.equal(HOST_KEY_PATTERN.test(key), false);
|
||||
assert.equal(HOST_KEY_PATTERN.test(HOST_KEY), true);
|
||||
});
|
||||
|
||||
test("T26: no wildcard and no shell, runner or interpreter as a prefix", () => {
|
||||
const at = /roles\.coder\.hosts\.web1\.commands/;
|
||||
const at = /grant\.commands/;
|
||||
for (const commands of ["any", "*", {}, [], [[]], [[7]], [["ls", ""]], [["ls"], ["ls"]], [["ls", "*"]], [["ls", "a?"]], [["ls", "[a]"]], [["bin/ls"]], [["ls\0"]]])
|
||||
refuses(() => check((d) => { d.roles.coder.hosts.web1.commands = commands; }), at);
|
||||
refuses(() => checkCommands(commands, "grant.commands"), at);
|
||||
const names = [...REFUSED_COMMANDS, "python", "python3", "python3.12", "awk", "gawk", "mawk", "nawk", "php", "php8.2", "/bin/bash", "/usr/bin/env"];
|
||||
for (const name of names) refuses(() => check((d) => { d.roles.coder.hosts.web1.commands = [[name, "x"]]; }), at);
|
||||
for (const name of ["systemctl", "/usr/bin/journalctl", "tee"]) assert.ok(check((d) => { d.roles.coder.hosts.web1.commands = [[name]]; }));
|
||||
for (const name of names) refuses(() => checkCommands([[name, "x"]], "grant.commands"), at);
|
||||
for (const name of ["systemctl", "/usr/bin/journalctl", "tee"]) assert.deepEqual(checkCommands([[name]], "grant.commands"), [[name]]);
|
||||
});
|
||||
|
||||
test("D6: argv shape and the literal prefix match", () => {
|
||||
@@ -101,11 +81,3 @@ test("D6: argv shape and the literal prefix match", () => {
|
||||
assert.equal(commandAllowed(["systemctl", "restart", "nginx"], commands), false);
|
||||
assert.equal(commandAllowed(["systemctl", "status*"], commands), false);
|
||||
});
|
||||
|
||||
test("D5: resolveInstance leaves host.<id> out of the session's credentials", () => {
|
||||
let root;
|
||||
const business = check((d, r) => { root = r; });
|
||||
const resolved = resolveInstance({ system: systemFor(root), business, instance: "coder" });
|
||||
assert.equal("host.web1" in resolved.credentials, false);
|
||||
assert.ok(resolved.credentials.gitea);
|
||||
});
|
||||
|
||||
@@ -1046,6 +1046,15 @@ entries; `delete` refuses `agent-running` while the agent holds a claim. With
|
||||
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
|
||||
the old token in Gitea or Vikunja yourself; the bus only forgets it.
|
||||
|
||||
## hosts (row 234)
|
||||
|
||||
`mosaic hosts [status] [--json] [--tester] [--business ID]` prints one tab-separated line per
|
||||
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.
|
||||
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.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -18,6 +18,7 @@ import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TAL
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
@@ -40,6 +41,7 @@ export const USAGE = `usage:
|
||||
${LIMITS_USAGE}
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${HOSTS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -580,6 +582,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 199 (#1697): issue and rotate read the token on stdin.
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// `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.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const HOSTS_USAGE = "mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove HOST [--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") {
|
||||
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);
|
||||
else out.positional.push(arg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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);
|
||||
try {
|
||||
const call = callFor(parsed);
|
||||
if (verb === "status") {
|
||||
const rows = await call("hosts.status", {});
|
||||
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");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`. 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";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { io, tmp } from "./helpers.mjs";
|
||||
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
const ROWS = [
|
||||
{ host: "web1", address: "10.1.1.53", port: 22, user: "deploy", sudo: true, state: "ready", hostKeys: ["SHA256:a"],
|
||||
installerFingerprint: "SHA256:b", checkedAt: "2026-10-21T00:00:00.000Z", hostLineLeft: [] },
|
||||
{ host: "web2", address: "10.1.1.54", port: 2222, user: "app", sudo: false, state: "pending-install", hostKeys: ["SHA256:c"],
|
||||
installerFingerprint: "SHA256:d", checkedAt: null, hostLineLeft: ["mosaic-" + "0".repeat(32)] },
|
||||
];
|
||||
|
||||
function deps(calls) {
|
||||
const answer = (who) => (business) => async (verb, args) => {
|
||||
calls.push({ who, business, verb, args });
|
||||
if (verb === "hosts.status") return ROWS;
|
||||
if (verb === "hosts.check") return { host: args.host, state: "ready", checkedAt: "2026-10-21T00:00:00.000Z" };
|
||||
return { host: args.host, removed: true };
|
||||
};
|
||||
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
|
||||
}
|
||||
|
||||
test("row 234: hosts status prints one line per host, and --json the rows", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["hosts", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "hosts.status", args: {} }]);
|
||||
assert.equal(x.out.text,
|
||||
"web1\tready\[email protected]:22\tsudo\t2026-10-21T00:00:00.000Z\n" +
|
||||
`web2\tpending-install\t[email protected]:2222\tno-sudo\tunchecked\tline left: mosaic-${"0".repeat(32)}\n`);
|
||||
const y = io();
|
||||
await main(["hosts", "status", "--json", "--tester", "--business", "acme"], y, deps(calls));
|
||||
assert.deepEqual(JSON.parse(y.out.text), ROWS);
|
||||
assert.equal(calls[1].who, "tester");
|
||||
});
|
||||
|
||||
test("row 234: hosts check and remove send the host id, as the human or the tester", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["hosts", "check", "web1", "--business", "acme"], x, deps(calls));
|
||||
await main(["hosts", "remove", "web2", "--tester", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
|
||||
{ who: "human", verb: "hosts.check", args: { host: "web1" } },
|
||||
{ who: "tester", verb: "hosts.remove", args: { host: "web2" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("row 234: a malformed hosts command is a usage error and calls nothing", async () => {
|
||||
const calls = [];
|
||||
for (const argv of [["hosts", "check"], ["hosts", "remove"], ["hosts", "status", "web1"], ["hosts", "check", "web1", "web2"],
|
||||
["hosts", "purge", "web1"], ["hosts", "check", "web1", "--json"], ["hosts", "--business"], ["hosts", "--force"]])
|
||||
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 234: a bus refusal exits 2 with its code", async () => {
|
||||
const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("host-in-use"); } };
|
||||
await assert.rejects(main(["hosts", "remove", "web1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /host-in-use/), true));
|
||||
});
|
||||
|
||||
test("row 234: scripts/mosaic sends hosts to packages/cli, not the seat CLI", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-hosts-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const r = spawnSync(MOSAIC, ["hosts", "purge", "web1"], { env, encoding: "utf8" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /^mosaic: mosaic hosts \[status\]/);
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </);
|
||||
});
|
||||
@@ -916,6 +916,8 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
|
||||
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
|
||||
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
|
||||
const untimed={
|
||||
host_grants:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
hosts:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
meta:'key and value only; its one UPDATE is the migration step',
|
||||
message_threads:'no time column; each row goes with a messages row, which is counted',
|
||||
sqlite_sequence:'SQLite\'s own AUTOINCREMENT table',
|
||||
|
||||
+1
-1
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Row 234 (#1740, #436a row a), design r6 E3: the installer key's forced command.
|
||||
#
|
||||
# Installed as /usr/local/sbin/mosaic-keys, root, 0755, and named by mosaic-installer's own line in
|
||||
# /etc/ssh/mosaic-keys/mosaic-installer:
|
||||
# restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ssh-ed25519 <base64> mosaic-installer-<tag>
|
||||
#
|
||||
# It matches SSH_ORIGINAL_COMMAND against three fixed patterns and hands the words to
|
||||
# mosaic-keys-write through sudo. Nothing else runs. sudo's env_reset drops SSH_CONNECTION, so the
|
||||
# connection and the request go to syslog here, before sudo. The writer checks everything again.
|
||||
set -u
|
||||
export PATH=/usr/bin:/bin LC_ALL=C
|
||||
|
||||
request=${SSH_ORIGINAL_COMMAND-}
|
||||
/usr/bin/logger -t mosaic-keys -- "connection=${SSH_CONNECTION-} request=${request:0:1024}"
|
||||
|
||||
user='[a-z_][a-z0-9_-]{0,31}'
|
||||
tag='mosaic-[0-9a-f]{32}'
|
||||
key='(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}'
|
||||
list_re="^list $user\$"
|
||||
add_re="^add $user $key $tag\$"
|
||||
remove_re="^remove $user $tag\$"
|
||||
|
||||
if [[ $request =~ $list_re || $request =~ $add_re || $request =~ $remove_re ]]; then
|
||||
# The patterns hold no space, glob or quote characters, so a split on spaces gives the words back.
|
||||
read -r -a words <<<"$request"
|
||||
exec /usr/bin/sudo -n /usr/local/sbin/mosaic-keys-write "${words[@]}"
|
||||
fi
|
||||
echo 'mosaic-keys: refused' >&2
|
||||
exit 1
|
||||
Executable
+285
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/python3 -I
|
||||
# Row 234 (#1740, #436a row a), design r6 E3: the root half of host enrollment.
|
||||
#
|
||||
# Installed as /usr/local/sbin/mosaic-keys-write, root, 0755. mosaic-keys (the installer key's forced
|
||||
# command) runs it through `sudo -n` with one request:
|
||||
#
|
||||
# list <user> each line of /etc/ssh/mosaic-keys/<user> with a mosaic- tag
|
||||
# add <user> <type> <base64> <tag> appends from="<from>",restrict <type> <base64> <tag>, unless the tag exists
|
||||
# remove <user> <tag> drops the line with that exact tag; a missing tag changes nothing
|
||||
#
|
||||
# It checks the request again, whatever mosaic-keys did. Host config is root-owned and written by ops:
|
||||
# /etc/mosaic-keys/users (the users it may touch), /etc/mosaic-keys/privileged (users with sudo that
|
||||
# Jason approved by name) and /etc/mosaic-keys/from (the from= value). Standard library only. Exit 0
|
||||
# on success, 1 on any refusal, with the reason on stderr and in syslog.
|
||||
import base64
|
||||
import fcntl
|
||||
import grp
|
||||
import hashlib
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import syslog
|
||||
|
||||
KEY_DIR = '/etc/ssh/mosaic-keys'
|
||||
CONF_DIR = '/etc/mosaic-keys'
|
||||
USER = re.compile(r'[a-z_][a-z0-9_-]{0,31}')
|
||||
TAG = re.compile(r'mosaic-[0-9a-f]{32}')
|
||||
KEY_TYPE = re.compile(r'ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa')
|
||||
BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
|
||||
FROM = re.compile(r'[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*')
|
||||
# In the code, not in a file: no request may name these, whatever users says.
|
||||
RESERVED_USERS = ('root', 'localadmin', 'mosaic-installer')
|
||||
MIN_UID = 1000
|
||||
# Each is root by another route. disk reads and writes raw block devices, libvirt and incus-admin start
|
||||
# guests with host mounts (r6 N5). privileged lifts this check and nothing else.
|
||||
REFUSED_GROUPS = ('sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin')
|
||||
LOCK = '.lock'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _passwd(user):
|
||||
try:
|
||||
return pwd.getpwnam(user).pw_uid
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _groups(user):
|
||||
entry = pwd.getpwnam(user)
|
||||
names = set()
|
||||
for gid in os.getgrouplist(user, entry.pw_gid):
|
||||
try:
|
||||
names.add(grp.getgrgid(gid).gr_name)
|
||||
except KeyError:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def _sudo(user):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
['/usr/bin/sudo', '-l', '-U', user],
|
||||
env={'LC_ALL': 'C', 'PATH': '/usr/bin:/bin'},
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# No answer is not the expected line, so the caller refuses user-sudo and logs it (duckworth-0081 N2).
|
||||
return ''
|
||||
return out.stdout.decode('utf-8', 'replace')
|
||||
|
||||
|
||||
def _log(message):
|
||||
syslog.openlog('mosaic-keys-write', 0, syslog.LOG_AUTH)
|
||||
syslog.syslog(syslog.LOG_INFO, message)
|
||||
|
||||
|
||||
class Host:
|
||||
# What the writer reads from the machine. Tests pass their own dirs, owner and lookups.
|
||||
def __init__(self, key_dir=KEY_DIR, conf_dir=CONF_DIR, owner=0, passwd=_passwd, groups=_groups,
|
||||
sudo=_sudo, hostname=None, log=_log):
|
||||
self.key_dir = key_dir
|
||||
self.conf_dir = conf_dir
|
||||
self.owner = owner
|
||||
self.passwd = passwd
|
||||
self.groups = groups
|
||||
self.sudo = sudo
|
||||
self.hostname = hostname if hostname is not None else socket.gethostname().split('.')[0]
|
||||
self.log = log
|
||||
|
||||
|
||||
def fingerprint(blob64):
|
||||
digest = hashlib.sha256(base64.b64decode(blob64, validate=True)).digest()
|
||||
return 'SHA256:' + base64.b64encode(digest).decode().rstrip('=')
|
||||
|
||||
|
||||
def parse(argv):
|
||||
# The request's shape and every pattern, before any name reaches a path.
|
||||
if not argv:
|
||||
raise Refused('request')
|
||||
verb, args = argv[0], argv[1:]
|
||||
shapes = {'list': 1, 'add': 4, 'remove': 2}
|
||||
if verb not in shapes or len(args) != shapes[verb]:
|
||||
raise Refused('request')
|
||||
if not USER.fullmatch(args[0]):
|
||||
raise Refused('user-pattern')
|
||||
req = {'verb': verb, 'user': args[0]}
|
||||
if verb == 'add':
|
||||
if not KEY_TYPE.fullmatch(args[1]) or not BASE64.fullmatch(args[2]):
|
||||
raise Refused('key-format')
|
||||
try:
|
||||
req['fingerprint'] = fingerprint(args[2])
|
||||
except ValueError:
|
||||
raise Refused('key-format')
|
||||
req['type'], req['base64'] = args[1], args[2]
|
||||
if verb in ('add', 'remove'):
|
||||
if not TAG.fullmatch(args[-1]):
|
||||
raise Refused('tag-pattern')
|
||||
req['tag'] = args[-1]
|
||||
return req
|
||||
|
||||
|
||||
def _owned(st, host, kind):
|
||||
if st.st_uid != host.owner or st.st_mode & 0o022 or not kind(st.st_mode):
|
||||
raise Refused('not-root-owned')
|
||||
|
||||
|
||||
def read_conf(host, name):
|
||||
# A root-owned regular file, not group or world writable, opened without following a symlink.
|
||||
try:
|
||||
fd = os.open(os.path.join(host.conf_dir, name), os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
raise Refused('config-' + name)
|
||||
try:
|
||||
_owned(os.fstat(fd), host, stat.S_ISREG)
|
||||
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
finally:
|
||||
os.close(fd)
|
||||
return [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith('#')]
|
||||
|
||||
|
||||
def check_user(req, host):
|
||||
user = req['user']
|
||||
users = read_conf(host, 'users')
|
||||
privileged = read_conf(host, 'privileged')
|
||||
if user in RESERVED_USERS or user not in users:
|
||||
raise Refused('user-not-listed')
|
||||
uid = host.passwd(user)
|
||||
if uid is None:
|
||||
raise Refused('user-unknown')
|
||||
if uid == 0 or uid < MIN_UID:
|
||||
raise Refused('user-system')
|
||||
if user in privileged:
|
||||
return
|
||||
if set(REFUSED_GROUPS) & set(host.groups(user)):
|
||||
raise Refused('user-group')
|
||||
expected = 'User %s is not allowed to run sudo on %s.' % (user, host.hostname)
|
||||
out = host.sudo(user)
|
||||
if out not in (expected, expected + '\n'):
|
||||
raise Refused('user-sudo')
|
||||
|
||||
|
||||
def read_from(host):
|
||||
lines = read_conf(host, 'from')
|
||||
if len(lines) != 1 or not FROM.fullmatch(lines[0]):
|
||||
raise Refused('config-from')
|
||||
return lines[0]
|
||||
|
||||
|
||||
def tag_of(line):
|
||||
parts = line.split()
|
||||
return parts[-1] if parts and TAG.fullmatch(parts[-1]) else None
|
||||
|
||||
|
||||
def read_lines(dir_fd, user, host):
|
||||
# ENOENT means an empty list: a first add finds no file (r6 N6).
|
||||
try:
|
||||
fd = os.open(user, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dir_fd)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except OSError:
|
||||
raise Refused('user-file')
|
||||
try:
|
||||
_owned(os.fstat(fd), host, stat.S_ISREG)
|
||||
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
|
||||
return f.read().splitlines()
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def write_lines(dir_fd, user, lines, host):
|
||||
tmp = '.' + user + '.tmp'
|
||||
try:
|
||||
st = os.stat(tmp, dir_fd=dir_fd, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
st = None
|
||||
if st is not None:
|
||||
# Under the flock a leftover tmp can only come from a writer that crashed.
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != host.owner:
|
||||
raise Refused('tmp-file')
|
||||
os.unlink(tmp, dir_fd=dir_fd)
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=dir_fd)
|
||||
try:
|
||||
os.fchmod(fd, 0o644)
|
||||
os.write(fd, ''.join(line + '\n' for line in lines).encode('utf-8'))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.rename(tmp, user, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
os.fsync(dir_fd)
|
||||
|
||||
|
||||
def run(req, host, out):
|
||||
check_user(req, host)
|
||||
line_from = read_from(host) if req['verb'] == 'add' else None
|
||||
try:
|
||||
dir_fd = os.open(host.key_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
raise Refused('key-dir')
|
||||
try:
|
||||
_owned(os.fstat(dir_fd), host, stat.S_ISDIR)
|
||||
lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=dir_fd)
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
user = req['user']
|
||||
lines = read_lines(dir_fd, user, host)
|
||||
if req['verb'] == 'list':
|
||||
for line in lines:
|
||||
if tag_of(line):
|
||||
out.write(line + '\n')
|
||||
return 'listed'
|
||||
if req['verb'] == 'add':
|
||||
if any(tag_of(line) == req['tag'] for line in lines):
|
||||
return 'present'
|
||||
entry = 'from="%s",restrict %s %s %s' % (line_from, req['type'], req['base64'], req['tag'])
|
||||
write_lines(dir_fd, user, lines + [entry], host)
|
||||
return 'added'
|
||||
kept = [line for line in lines if tag_of(line) != req['tag']]
|
||||
if len(kept) == len(lines):
|
||||
return 'absent'
|
||||
write_lines(dir_fd, user, kept, host)
|
||||
return 'removed'
|
||||
finally:
|
||||
os.close(lock)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def main(argv, host, out=sys.stdout, err=sys.stderr):
|
||||
req = None
|
||||
try:
|
||||
req = parse(argv)
|
||||
result = run(req, host, out)
|
||||
code = 0
|
||||
except Refused as e:
|
||||
result = 'refused ' + str(e)
|
||||
code = 1
|
||||
except OSError as e:
|
||||
result = 'refused os-' + (e.__class__.__name__)
|
||||
code = 1
|
||||
fields = ['result=' + result]
|
||||
if req:
|
||||
fields.append('user=' + req['user'])
|
||||
if 'tag' in req:
|
||||
fields.append('tag=' + req['tag'])
|
||||
if 'fingerprint' in req:
|
||||
fields.append('key=' + req['fingerprint'])
|
||||
host.log(' '.join(fields))
|
||||
if code:
|
||||
err.write('mosaic-keys-write: ' + result + '\n')
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:], Host()))
|
||||
@@ -0,0 +1,3 @@
|
||||
# Row 234 (#1740), design r6 E3. Installed as /etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf, root, 0644.
|
||||
# Keeps both paths the hosts use today and adds the root-owned per-user files mosaic-keys-write keeps.
|
||||
AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u
|
||||
@@ -0,0 +1,2 @@
|
||||
# Row 234 (#1740), design r6 E3. Installed as /etc/sudoers.d/mosaic-keys, root, 0440.
|
||||
mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write
|
||||
Reference in New Issue
Block a user