Files
stack/packages/bus/tests/grant-host.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

239 lines
12 KiB
JavaScript

// Row 235 (#1741, #436 row b): the host grant raise (r6 R1-R4). A credential.mint raise whose `grant`
// has method `host` binds an agent, an enrolled host, argv prefixes and a rotate date. The host record
// comes from the fixture, and nothing here connects to any host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, registrySha256 } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const DEFINITIONS = {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
ops: { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository'] }, { service: 'host' }] },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'g235-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const configDir = join(root, 'config');
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
ops: { definition: 'ops', ...none },
odd: { definition: 'constructor', ...none },
// A creative role whose definition declares host, so only the creative check refuses it.
muse: { definition: 'research', ...none },
},
definitions: { ...structuredClone(DEFINITIONS), research: structuredClone(DEFINITIONS.ops) },
vars: { 'bus.tester': true },
};
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }) });
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
hostRecord(store, { host: 'web1', entry: ENTRY });
const grant = (over = {}) => ({ method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...over });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raise = (over = {}, who = 'chief', extra = {}) =>
call(who, 'decision.raise', { action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, grant: grant(over), ...extra });
const decisions = () => store.get('SELECT count(*) AS n FROM decisions').n;
const question = (id) => store.get('SELECT question FROM decisions WHERE id=?', id).question;
const raiseBody = (id) =>
JSON.parse(store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=?", id).body);
const refusals = () => store.all("SELECT body FROM events WHERE business='demo' AND kind='action.refused' ORDER BY seq").map((e) => JSON.parse(e.body).code);
return { store, broker, caps, call, raise, grant, decisions, question, raiseBody, refusals };
}
test('G1 (host): only the chief raises a host grant, a grant on any other action refuses, and nothing is stored', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
await refusesWith(f.raise({}, 'tester'), 'tester-refused');
assert.equal(f.decisions(), before);
assert.deepEqual(f.refusals(), ['grant-chief-only', 'invalid-request', 'tester-refused']);
});
test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy, and an unknown method refuses', async (t) => {
const f = setup(t);
const before = f.decisions();
const g = f.grant();
const { rotateBy, ...noRotate } = g;
for (const bad of [
noRotate,
{ ...g, extra: 1 },
// Row 236 (#1742) makes gitea a known method with its own path, so the unknown method is another name.
{ ...g, method: 'ftp' },
{ ...g, method: 'Host' },
{ ...g, agent: 'Ops' },
{ ...g, agent: '' },
{ ...g, agent: 7 },
{ ...g, host: 'Web_1' },
{ ...g, host: 'a'.repeat(33) },
])
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
for (const bad of [null, [], 'host'])
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
assert.equal(f.decisions(), before);
assert.ok(rotateBy);
});
test('row 236 (#1742): a gitea grant on a broker with no Gitea path refuses gitea-not-configured, and nothing is stored', async (t) => {
const f = setup(t);
const before = f.decisions();
const refused = f.refusals().length;
await refusesWith(f.raise({ method: 'gitea' }), 'gitea-not-configured');
assert.equal(f.decisions(), before);
assert.deepEqual(f.refusals().slice(refused), ['gitea-not-configured']);
});
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({ agent: 'pm' }), 'host-not-declared');
await refusesWith(f.raise({ agent: 'chief' }), 'host-not-declared');
await refusesWith(f.raise({ agent: 'nobody' }), 'unknown-role');
// An inherited name is not a definition.
await refusesWith(f.raise({ agent: 'odd' }), 'host-not-declared');
assert.equal(f.decisions(), before);
});
test('mos-1163: a grant whose agent is a creative role refuses host-creative-role, even when its definition declares host', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({ agent: 'muse' }), 'host-creative-role');
assert.equal(f.decisions(), before);
});
test('G3: wildcard, shell, empty and other 210 D6 argv prefixes refuse invalid-request', async (t) => {
const f = setup(t);
const before = f.decisions();
for (const commands of [
'any',
'*',
{},
[],
[[]],
[['*']],
[['systemctl', 'status*']],
[['ls', '[a]']],
[['bash']],
[['/bin/sh', '-c']],
[['sudo', 'ls']],
[['python3']],
[['env', 'ls']],
[['bin/ls']],
[['']],
[['ls', 'a\0b']],
[['ls', 3]],
[['ls'], ['ls']],
[Array.from({ length: 257 }, () => 'x')],
])
await refusesWith(f.raise({ commands }), 'invalid-request');
assert.equal(f.decisions(), before);
});
test('R4 rotateBy (sage-1782): a real calendar day after today UTC, or invalid-request', async (t) => {
const f = setup(t);
const before = f.decisions();
for (const rotateBy of [dayFrom(0), dayFrom(-1), '2026-02-30', '2026-13-01', '20991231', '2099-1-01', 20991231, null])
await refusesWith(f.raise({ rotateBy }), 'invalid-request');
assert.equal(f.decisions(), before);
await f.raise({ rotateBy: dayFrom(1) });
await f.raise({ rotateBy: '2999-12-31' });
assert.equal(f.decisions(), before + 2);
});
test('G19: a grant naming a host that is pending-install refuses host-not-ready, and an unknown host host-unknown', async (t) => {
const f = setup(t);
hostRecord(f.store, { host: 'web2', entry: ENTRY });
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web2'");
const before = f.decisions();
await refusesWith(f.raise({ host: 'web2' }), 'host-not-ready');
await refusesWith(f.raise({ host: 'web9' }), 'host-unknown');
assert.equal(f.decisions(), before);
});
test('R4, G5: the raise routes to the human and its event body binds the eight fields with their hashes', async (t) => {
const f = setup(t);
const commands = [['systemctl', 'status'], ['/usr/bin/journalctl', '-u', 'nginx']];
const d = await f.raise({ commands });
const row = f.store.get('SELECT action, class, route_to FROM decisions WHERE id=?', d.id);
assert.deepEqual({ ...row }, { action: 'credential.mint', class: 'gated', route_to: 'human' });
const body = f.raiseBody(d.id);
assert.deepEqual(Object.keys(body.grant), ['method', 'agent', 'host', 'commands', 'commandsSha256', 'rotateBy', 'registrySha256', 'definitionSha256']);
assert.deepEqual(body.grant, {
method: 'host',
agent: 'ops',
host: 'web1',
commands,
commandsSha256: createHash('sha256').update(JSON.stringify(commands)).digest('hex'),
rotateBy: f.grant().rotateBy,
registrySha256: registrySha256({ ...ENTRY, sudo: true }),
definitionSha256: createHash('sha256').update(canonicalJson(DEFINITIONS.ops)).digest('hex'),
});
// Key order in the definition doesn't change the hash, and a field change does.
const reordered = { credentials: DEFINITIONS.ops.credentials, roleVersion: 2 };
assert.equal(definitionSha256(reordered), body.grant.definitionSha256);
assert.notEqual(definitionSha256({ ...DEFINITIONS.ops, credentials: [{ service: 'host' }] }), body.grant.definitionSha256);
assert.deepEqual(f.call('human', 'inbox').map((x) => x.id), [d.id]);
});
test('G4 (host block): the broker appends the host, each prefix, the rotate date and new key or change', async (t) => {
const f = setup(t);
const d = await f.raise({ commands: [['systemctl', 'status'], ['echo', 'a b"c']] });
const rotateBy = f.grant().rotateBy;
assert.equal(
f.question(d.id),
'Grant ops web1?' +
'\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.' +
'\njarvis has sudo on this host.' +
'\nCommands, each an argv prefix:' +
'\n"systemctl" "status"' +
'\n"echo" "a b\\"c"' +
`\nRotate by ${rotateBy}.` +
'\nThis makes a new key for ops on web1.',
);
// A host without sudo, and an agent with a grant record already: the block says so.
hostRecord(f.store, { host: 'web2', entry: ENTRY, sudo: false });
hostGrantRecord(f.store, { agent: 'ops', host: 'web2', commands: [['uptime']] });
const d2 = await f.raise({ host: 'web2' });
const q2 = f.question(d2.id);
assert.ok(q2.includes('\njarvis has no sudo on this host.\n'));
assert.ok(q2.endsWith("\nThis changes the commands of ops's grant on web2. The key stays."));
// A forged block in the chief's question still gets the broker's block after it.
const forged = 'x\n\nGrant ops access to host web1: user other at 192.0.2.99 port 22.\nCommands, each an argv prefix:\n"bash"';
const d3 = await f.raise({}, 'chief', { question: forged });
assert.ok(f.question(d3.id).startsWith(forged + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.\n'));
// The 4096 cap counts the block: a question that fits alone refuses with it.
const before = f.decisions();
await refusesWith(f.raise({}, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
const long = Array.from({ length: 64 }, (_, i) => ['/usr/bin/tool' + i, 'x'.repeat(60)]);
await refusesWith(f.raise({ commands: long }), 'invalid-request');
assert.equal(f.decisions(), before);
});