Files
stack/packages/bus/tests/credential-verbs.test.mjs
jason.woltje 6d0e9f89be fix(bus): sweep ungranted copy slots at start, clean a failed stage (#1873)
Security review P2: a hard crash after the stage left the staged token
on disk. sweepCopySlots runs at start, before Credentials loads, and
removes each copy service slot whose binding holds no grant, plus any
staged temp file. It never touches a role's slots, a slot a loaded
reference names, or an organization whose document cannot be read. A
stage write that fails removes its temp file, and its slot when the
rename has already happened.

Build review P3s: push is held to the binding's approved level, not the
token's. Tests now cover the boot-copy pre-checks, the format check
running before the stage, and stageCopy's reference refusal.
2026-11-11 17:13:04 -06:00

217 lines
16 KiB
JavaScript

// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
import { readTemplate } from '../../organization/src/definitions.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
edit(doc, rolesDir);
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
before(store);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
};
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = bus.broker.bindTester({ business: 'demo' });
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
const receipts = () => {
const log = join(root, 'businesses/.history/demo/changes.jsonl');
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
};
const committed = () => receipts().filter((r) => r.state === 'committed');
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
}
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
const s = new Store(root);
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const reader = b.bindReader({ business: 'demo' });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const args = {
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
'credentials.revoke': { agent: 'coder', reason: 'fired' },
'credentials.delete': { agent: 'coder', reason: 'fired' },
};
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
for (const verb of CREDENTIAL_VERBS) {
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
}
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
assert.equal(calls.length, 0);
// A refusal records its code alone, never the token.
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
assert.ok(refused.length >= 10);
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
// delete waits for the agent's claim to end.
b.request(coder, { verb: 'role.claim' });
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
b.request(coder, { verb: 'role.release' });
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
const c = await setup(t);
const coder = { business: 'demo', role: 'coder' };
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
// issue: the file, then the business file's reference, then memory.
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(await read('gitea'), 'issued');
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
let last = c.committed().at(-1);
assert.equal(last.actor, 'jason');
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
// The same token again is one the bus holds: the secret check refuses it before any write.
const count = c.receipts().length;
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
assert.equal(c.receipts().length, count);
// rotate: an issue over an issued entry, used at once.
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
assert.equal(rotated.verb, 'rotate');
assert.equal(await read('gitea'), 'rotated');
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
await assert.rejects(read('gitea'), /credential-unavailable/);
assert.equal(existsSync(c.store('coder', 'gitea')), false);
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
last = c.committed().at(-1);
assert.equal(last.actor, 'tester');
assert.deepEqual(last.changes, []);
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
const outside = c.file().roles.coder.credentials.vikunja.file;
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
await assert.rejects(read('vikunja'), /credential-unavailable/);
assert.equal(existsSync(outside), true);
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
// delete: every kind revoked, the agent's dir gone, both references store.
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
// The status read never carries a value.
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
// T4: no token in any file under the root (receipts, business file, events, logs).
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
const c = await setup(t, { storeRoot: false });
const dir = join(c.root, 'secrets', 'agents');
assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
});
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
const pending = (doc, rolesDir) => {
doc.copyBindings = { rev: {
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
decision: '11111111-1111-4111-8111-111111111111' } };
};
const staged = (store) => {
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
};
const c = await setup(t, { edit: pending, before: staged });
assert.equal(c.file().copyBindings.rev.state, 'pending');
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
// A role's own store slot is the store's, not the sweep's.
assert.equal(existsSync(c.store('coder', 'gitea')), true);
});