The credentials read returns service, account, role, date kind, date and state for the capability's business, to human and reader sessions only. It never returns a token, file path or variable name. Author Filbert, reviewers Dewey (27558) and Darkwing (27575), candidate manifest 13541657. Co-Authored-By: Claude Opus 5.5 <[email protected]>
213 lines
9.5 KiB
JavaScript
213 lines
9.5 KiB
JavaScript
// Row 60 (#1549): `credentials`, the broker's credential status read. Human and reader
|
|
// sessions read service, account, role, date and state for their own business; a role
|
|
// session can't. No token, file path or variable name reaches the output or the host log.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { fork } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { once } from 'node:events';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { Client } from '../src/client.mjs';
|
|
|
|
const day = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
const authority = { withinRole: ['message.send'], crossRole: [] };
|
|
const TOKENS = {
|
|
pmGitea: 'fixture_pm_gitea_0123456789',
|
|
pmVikunja: 'fixture_pm_vikunja_0123456789',
|
|
coderGitea: 'fixture_coder_gitea_0123456789',
|
|
coderVikunja: 'fixture_coder_vikunja_0123456789',
|
|
sync: 'fixture_sync_vikunja_0123456789',
|
|
other: 'fixture_other_gitea_0123456789',
|
|
};
|
|
const ENV = 'FIXTURE_ROW60_PM_VIKUNJA';
|
|
|
|
// Two businesses, `demo` and `demox`: an instance prefix without its slash would leak one into the other.
|
|
function seed(root) {
|
|
const secrets = join(root, 'secrets');
|
|
mkdirSync(secrets);
|
|
const file = (name) => {
|
|
const path = join(secrets, name);
|
|
writeFileSync(path, TOKENS[name] + '\n', { mode: 0o600 });
|
|
return path;
|
|
};
|
|
const paths = { pmGitea: file('pmGitea'), coderGitea: file('coderGitea'), coderVikunja: file('coderVikunja') };
|
|
paths.sync = file('sync');
|
|
paths.other = file('other');
|
|
const businesses = {
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'cto', delivery: 'pm' },
|
|
roles: {
|
|
pm: {
|
|
authority,
|
|
tracker: { bot: 'bot-pm' },
|
|
credentials: {
|
|
gitea: { file: paths.pmGitea, rotateBy: '2099-01-01' },
|
|
vikunja: { env: ENV, expires: day(3) },
|
|
},
|
|
},
|
|
coder: {
|
|
authority,
|
|
tracker: { bot: 'bot-coder' },
|
|
credentials: {
|
|
gitea: { file: paths.coderGitea, rotateBy: '2000-01-01' },
|
|
vikunja: { file: paths.coderVikunja, expires: '2000-01-01' },
|
|
},
|
|
},
|
|
cto: { authority },
|
|
},
|
|
tracker: { sync: { bot: 'bot-sync', botId: 7, credentials: { vikunja: { file: paths.sync, expires: '2099-01-01' } } } },
|
|
},
|
|
demox: {
|
|
id: 'demox',
|
|
human: 'jason',
|
|
arbiters: { technical: 'cto', delivery: 'cto' },
|
|
roles: { cto: { authority, credentials: { gitea: { file: paths.other, rotateBy: '2099-01-01' } } } },
|
|
},
|
|
};
|
|
return { businesses, paths, secrets, env: { [ENV]: TOKENS.pmVikunja } };
|
|
}
|
|
// What the runtime builds: one reference per business/instance, plus business/@sync.
|
|
function references(businesses) {
|
|
const out = {};
|
|
for (const [business, b] of Object.entries(businesses)) {
|
|
for (const [role, r] of Object.entries(b.roles)) if (r.credentials) out[business + '/' + role] = r.credentials;
|
|
if (b.tracker?.sync?.credentials) out[business + '/@sync'] = b.tracker.sync.credentials;
|
|
}
|
|
return out;
|
|
}
|
|
const DEMO = [
|
|
{ service: 'gitea', account: null, role: 'pm', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
{ service: 'vikunja', account: 'bot-pm', role: 'pm', dateKind: 'expires', date: day(3), state: 'expiring' },
|
|
{ service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' },
|
|
{ service: 'vikunja', account: 'bot-coder', role: 'coder', dateKind: 'expires', date: '2000-01-01', state: 'expired' },
|
|
{ service: 'vikunja', account: 'bot-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' },
|
|
];
|
|
// Nothing that names or holds a credential: the tokens, the token files and their directory, and the env name.
|
|
const forbidden = (secrets, paths) => [...Object.values(TOKENS), ...Object.values(paths), secrets, ENV];
|
|
const clean = (text, words) => words.filter((w) => text.includes(w));
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-status-'));
|
|
const { businesses, paths, secrets, env } = seed(root);
|
|
const credentials = new Credentials({ references: references(businesses), repoRoots: [], dataRoot: '/nonexistent-data', env });
|
|
mkdirSync(join(root, 'data'));
|
|
const store = new Store(join(root, 'data'));
|
|
const b = new Broker({
|
|
store,
|
|
businesses,
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
credentialStatus: () => credentials.status(),
|
|
});
|
|
t.after(() => {
|
|
store.close();
|
|
credentials.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
return { b, secrets, paths };
|
|
}
|
|
|
|
test('credentials gives human and reader sessions service, account, role, date and state, and nothing else', (t) => {
|
|
const { b, secrets, paths } = setup(t);
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
for (const cap of [human, reader]) {
|
|
const rows = b.request(cap, { verb: 'credentials' });
|
|
assert.deepEqual(rows, DEMO);
|
|
for (const row of rows) assert.deepEqual(Object.keys(row), ['service', 'account', 'role', 'dateKind', 'date', 'state']);
|
|
assert.deepEqual(clean(JSON.stringify(rows), forbidden(secrets, paths)), []);
|
|
}
|
|
assert.deepEqual(b.request(b.bindReader({ business: 'demox' }), { verb: 'credentials' }), [
|
|
{ service: 'gitea', account: null, role: 'cto', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
]);
|
|
});
|
|
|
|
test('credentials refuses a role session and any argument', (t) => {
|
|
const { b } = setup(t);
|
|
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
|
b.request(coder, { verb: 'role.claim' });
|
|
assert.throws(() => b.request(coder, { verb: 'credentials' }), { code: 'human-required' });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
assert.throws(() => b.request(reader, { verb: 'credentials', args: { role: 'coder' } }), { code: 'invalid-request' });
|
|
assert.throws(() => b.request(reader, { verb: 'credentials', args: { business: 'demox' } }), { code: 'invalid-request' });
|
|
});
|
|
|
|
test('credentials reads the status on each request, and a broker without one lists none', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-live-'));
|
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority } } } };
|
|
const stores = ['a', 'b'].map((d) => (mkdirSync(join(root, d)), new Store(join(root, d))));
|
|
t.after(() => {
|
|
for (const s of stores) s.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
let state = 'valid';
|
|
const b = new Broker({
|
|
store: stores[0],
|
|
businesses,
|
|
credentialStatus: () => [{ instance: 'demo/cto', service: 'gitea', date: '2099-01-01', state }],
|
|
});
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'valid');
|
|
state = 'rotation-due';
|
|
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'rotation-due');
|
|
const bare = new Broker({ store: stores[1], businesses });
|
|
assert.deepEqual(bare.request(bare.bindReader({ business: 'demo' }), { verb: 'credentials' }), []);
|
|
});
|
|
|
|
// The brief's acceptance: the broker process on a seeded config holding a file token, an env
|
|
// value and their paths and names. The op's output, the host log and the store hold none of them.
|
|
test('the broker process answers credentials without a token, path or variable in its output, log or store', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-process-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const { businesses, paths, secrets, env } = seed(root);
|
|
const data = join(root, 'data');
|
|
mkdirSync(data);
|
|
const child = fork(new URL('../src/process.mjs', import.meta.url), [], {
|
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
env: { ...process.env, ...env },
|
|
});
|
|
let logs = '';
|
|
child.stdout.on('data', (b) => (logs += b));
|
|
child.stderr.on('data', (b) => (logs += b));
|
|
t.after(() => {
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
});
|
|
const reply = once(child, 'message');
|
|
child.send({
|
|
op: 'boot',
|
|
config: {
|
|
dataRoot: data,
|
|
businesses,
|
|
launches: [{ business: 'demo', role: 'coder', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }],
|
|
readers: ['demo'],
|
|
},
|
|
});
|
|
const ready = (await reply)[0];
|
|
assert.equal(ready.ok, true);
|
|
const reader = new Client({ path: ready.path, cap: ready.readers[0].cap });
|
|
const rows = await reader.call('credentials');
|
|
assert.deepEqual(rows, DEMO);
|
|
const agent = new Client({ path: ready.path, cap: ready.launches[0].cap });
|
|
await agent.call('role.claim');
|
|
await assert.rejects(agent.call('credentials'), /human-required/);
|
|
const exit = once(child, 'exit');
|
|
child.send({ op: 'close' });
|
|
assert.equal((await exit)[0], 0);
|
|
|
|
const words = forbidden(secrets, paths);
|
|
assert.deepEqual(clean(JSON.stringify(rows), words), []);
|
|
assert.deepEqual(clean(logs, words), []);
|
|
const bus = join(data, 'bus');
|
|
const stored = readdirSync(bus)
|
|
.filter((f) => f.startsWith('bus.sqlite'))
|
|
.map((f) => readFileSync(join(bus, f), 'latin1'))
|
|
.join('');
|
|
assert.ok(stored.includes('human-required'), 'the refusal is in the store');
|
|
assert.deepEqual(clean(stored, words), []);
|
|
});
|