diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 7ba046911..a6700ebc1 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -103,6 +103,11 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm // Row 175: the organization verbs a tester may call. Its writes run every check the human's do // and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it. export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']); +// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may +// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it +// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb. +export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']); +const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']); const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); const POLL_KINDS = new Set([ 'task.changed.external', @@ -199,6 +204,7 @@ export class Broker { #credentialStatus; #requirements; #organization; + #credentialVerbs; #project; #usage; #limits; @@ -207,13 +213,15 @@ export class Broker { #stops = new Map(); #clock = 0; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); this.#businesses = structuredClone(businesses); this.#store = store; this.#secretCheck = secretCheck; this.#credentialStatus = credentialStatus; this.#organization = organization; + // Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker. + this.#credentialVerbs = credentialVerbs; this.#project = project; this.#usage = usage; this.#limits = limits; @@ -1341,6 +1349,7 @@ export class Broker { return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) }); if (PROJECT_FILE_VERBS.includes(request.verb)) return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) }); + if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args)); if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) { // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose // file lists organization.change. It takes the same keys, runs the same write path and @@ -1412,6 +1421,35 @@ export class Broker { throw this.#refused(s, e, request); } } + // Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the + // secret check above has refused one the bus already holds, a refusal records its code alone, and + // nothing returned carries it. Outside the store transaction, as the organization branch. + #requestCredential(s, verb, a) { + if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required'); + if (!this.#credentialVerbs) fail('not-configured'); + if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']); + else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']); + string(a.agent, 64); + if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request'); + if (a.token !== undefined) string(a.token, 16384); + if (a.date !== undefined) string(a.date, 10); + if (a.reason !== undefined) string(a.reason, 500); + if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown'); + // A running agent keeps its tokens until its runs end: delete waits for every claim to end. + if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length)) + fail('agent-running'); + let result; + try { + result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human); + } catch (e) { + if (e instanceof BusError && e.code.startsWith('credential-')) throw e; + if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code); + if (e.name === 'BusinessError') fail('invalid-request'); + throw new BusError('outcome-unknown'); + } + this.#secretCheck(result); + return result; + } // Only the runner's bound session and delivered message identify a turn; no host path crosses the wire. // Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin // needs the decision held for the run's role; its end needs that begin, since the landing may diff --git a/packages/bus/src/credential-verbs.mjs b/packages/bus/src/credential-verbs.mjs new file mode 100644 index 000000000..7ad2a976e --- /dev/null +++ b/packages/bus/src/credential-verbs.mjs @@ -0,0 +1,53 @@ +import { BusError } from './broker.mjs'; +// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for +// the broker's human-only verbs. The broker checks the session, the keys and a running claim first. +// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the +// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token. +const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' }); +const deny = (code) => { + throw new BusError(code); +}; +export function credentialVerbs({ credentials, writer }) { + const state = (instance, service) => + credentials.status().find((e) => e.instance === instance && e.service === service)?.state; + return (id, verb, args, actor) => { + const { agent } = args; + const instance = id + '/' + agent; + const { document, revision } = writer.read(id); + const current = document.roles?.[agent]?.credentials; + if (!current) deny('credential-unknown'); + if (verb === 'credentials.issue') { + const { service, token, date } = args; + if (!current[service]) deny('credential-unknown'); + // rotate is an issue over an entry the bus holds now; the old value is gone when this returns. + const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate'; + const next = structuredClone(document); + next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date }; + const detail = { verb: kind, agent, kind: [service], date }; + const status = credentials.issue(instance, service, token, date, () => + writer.write(id, next, { revision, actor, detail }), + ); + return { agent, verb: kind, service, date: status.date, state: status.state }; + } + const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current); + if (services.some((service) => !current[service])) deny('credential-unknown'); + // Memory first, then the store file (Credentials.revoke), then the business file. A reference + // outside the store becomes `store` with its date, so a restart loads nothing for it. When that + // write fails, the entry stays revoked in memory and the refusal says to run the verb again. + if (verb === 'credentials.delete') credentials.remove(instance); + else for (const service of services) credentials.revoke(instance, service); + const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason }; + const moved = services.filter((service) => current[service].store !== true); + if (moved.length) { + const next = structuredClone(document); + for (const service of moved) + next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] }; + writer.write(id, next, { revision, actor, detail }); + } else writer.record(id, { actor, detail }); + return { + agent, + verb: detail.verb, + services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })), + }; + }; +} diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 228e0f92f..7025c65af 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -11,6 +11,8 @@ import { readSessions } from '../../seat/src/session.mjs'; import { createLoader, hasModule } from '../../plugins/src/loader.mjs'; import { pluginSettings } from '../../business/src/plugin-settings.mjs'; import { instanceSettings } from '../../business/src/instance-settings.mjs'; +import { organizationWriter } from '../../business/src/organization.mjs'; +import { credentialVerbs } from './credential-verbs.mjs'; import { webService } from './web.mjs'; import { verifyHuman } from './human.mjs'; import { createUsage } from './usage.mjs'; @@ -135,6 +137,11 @@ export async function startBroker({ }:null, secretCheck: (value) => credentials.assertClean(value), credentialStatus: () => credentials.status(), + // Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each + // rewrites or records through the organization writer, so changes.jsonl holds the receipt. + credentialVerbs: organization + ? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) + : null, // Row 65: a new launch binds only on a harness its business switches on, read from the file // as it stands now; an unreadable file switches none on. harnessOn: (id, name) => { diff --git a/packages/bus/tests/credential-verbs.test.mjs b/packages/bus/tests/credential-verbs.test.mjs new file mode 100644 index 000000000..c60af514a --- /dev/null +++ b/packages/bus/tests/credential-verbs.test.mjs @@ -0,0 +1,163 @@ +// 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 { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } 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 '../../business/tests/helpers.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) { + 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'] } }; + doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE }; + writeJson(join(root, 'businesses/demo.json'), doc); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 }); + const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service); + // The bus's resolved business carries the same references; a store one names its derived file. + const demo = { + id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, + roles: { + 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: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { 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: 'coder', delivery: 'coder' }, roles: { 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); +}); diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs index 2649d37a7..6256af1dd 100644 --- a/packages/business/src/organization.mjs +++ b/packages/business/src/organization.mjs @@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { } } - function write(id, document, { revision, actor }) { + // Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind, + // date, reason), never a value; the other setters pass none. + function write(id, document, { revision, actor, detail }) { requireId(actor, "authenticated actor"); requireId(id, "organization id"); return withLock(id, ({ home, parent }) => { @@ -201,7 +203,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { syncDir(tx); const event = { transaction, actor, business: id, before, after, - changes: changes(old, written), at: new Date().toISOString(), + changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(), }; log(id, { ...event, state: "prepared" }); syncDir(home); @@ -219,5 +221,22 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { }); } - return { logFile, read, write, recover: id => withLock(id, () => recover(id)) }; + // Row 199: a receipt with no document change, for a credential verb that changes only the agents' + // store (a revoke or delete of store references). Committed at once; there is nothing to recover. + function record(id, { actor, detail }) { + requireId(actor, "authenticated actor"); + requireId(id, "organization id"); + return withLock(id, () => { + recover(id); + const hash = sha256(regular(businessFilePath(id, dir))); + const transaction = randomUUID(); + log(id, { + transaction, actor, business: id, before: hash, after: hash, changes: [], detail, + at: new Date().toISOString(), state: "committed", + }); + return { revision: hash, transaction }; + }); + } + + return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) }; }