490 lines
27 KiB
JavaScript
490 lines
27 KiB
JavaScript
// 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', 'decision.resolve.delivery'], 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);
|
|
});
|