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.
239 lines
12 KiB
JavaScript
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);
|
|
});
|