diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index bb015b6d7..333c7b526 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6782,21 +6782,25 @@ export class Broker { for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business)) this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true); } - // #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it); - // any other applied decision may have changed, retired or shadowed a copy, which the sweep finds. + // #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it); a + // fired copy's binding is revoked now that the fire is written (cause `retire`; the root agents + // file a root fire changes is not one the gate reads until the next start). Any other applied + // decision may have changed, retired or shadowed a copy, which the sweep finds. #copyAfterHire(business,p) { if(!this.#copyRevoker)return; const instance=p.binding?.instance; if(typeof instance==='string' && this.#copyRevoker.binding(business,instance)?.state==='revoking') this.#copyRevoker.start(business,instance,{cause:'unbind',actor:'bus',owner:true}); + const fired=p.op==='fire' && p.level!=='org' ? (p.level==='project'?`${p.name}.${p.project}`:p.name) : null; + if(fired && this.#copyRevoker.binding(business,fired))this.#copyRevoker.start(business,fired,{cause:'retire',actor:'bus'}); this.#copyRevoker.sweep(business); } async #stopCopyApproved(s,p) { const instance=p.level==='project'?`${p.name}.${p.project}`:p.name; const b=this.#business(s.business); - // #1873 CB4: a fired copy's binding is revoked, its runs stopped, before the fire is written. - if(this.#copyRevoker?.binding(s.business,instance) && (!Object.hasOwn(b.roles,instance) || b.roles[instance]?.copyBound)) - this.#copyRevoker.start(s.business,instance,{cause:'retire',actor:'bus'}); + // #1873 CB4: a fired copy's runs are stopped here, before the fire is written; #copyAfterHire + // revokes its binding after the write. A revoke begun here would write the organization file + // under the fire's own revision check and refuse the fire decision-stale. if(!b.roles[instance])return; if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress'); let running=false; diff --git a/packages/bus/src/copy-revoke.mjs b/packages/bus/src/copy-revoke.mjs index 3c30ead2e..7d0ce0d17 100644 --- a/packages/bus/src/copy-revoke.mjs +++ b/packages/bus/src/copy-revoke.mjs @@ -25,6 +25,11 @@ import { BusError } from './broker.mjs'; import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs'; import { copyBotLogin } from '../../organization/src/copy-bindings.mjs'; import { boundRepositories } from './copy-grant.mjs'; +import { giteaGrant } from './gitea-grant.mjs'; +import { organizationWriter } from '../../organization/src/organization.mjs'; +import { readOrgEntrySync } from '../../organization/src/org-registry.mjs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; export const REVOKE_POINTS = Object.freeze([ 'beforePublication', 'afterPublication', 'beforeStop', 'afterStop', 'beforeSecret', 'afterSecret', @@ -177,7 +182,8 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote const d = doc(business); if (d === null) return []; const pending = Object.entries(d.copyBindings ?? {}).filter(([, r]) => r?.state === 'revoking'); - for (const [instance] of pending) gateOf(business)?.close(business, instance); + // A pass that already failed a step keeps its failure code while it runs again. + for (const [instance, r] of pending) gateOf(business)?.close(business, instance, r.revoke?.failed ? 'credential-revoke-failed' : undefined); return Promise.allSettled(pending.map(([instance, r]) => revoker.run(business, instance, { cause: r.revoke.cause, actor: 'bus', owner: true }))); }, }; @@ -188,9 +194,11 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote // copy's token is deleted by id from its bot user. 204 is `revoked`, 404 `absent`. With no admin slot, // or one on another server than the binding's projects, the organization does not manage the copy's // Gitea and the step is `not-managed`: the record says so and never claims the token is gone. Any -// other answer, an admin slot that fails its checks, or a server that cannot be reached is `failed`, +// other answer, an admin slot that fails its checks, a server that cannot be reached, bound projects +// whose server can no longer be read from the organization file, or a record whose organization +// incarnation is not the live organization's (`incarnation`, business => string|null) is `failed`, // and the copy stays fenced until a later pass revokes it. -export function giteaCopyRemote({ grant, read, today = () => new Date().toISOString().slice(0, 10) }) { +export function giteaCopyRemote({ grant, read, incarnation = null, today = () => new Date().toISOString().slice(0, 10) }) { return async (business, instance, record, service) => { if (service !== 'gitea' || !grant) return 'not-managed'; const m = /^gitea:(\d+):(\d+)$/.exec(record.services?.gitea?.grantId ?? ''); @@ -199,7 +207,14 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr try { origin = boundRepositories(read(business), record.services.gitea.resources?.projects).origin; } catch { - return 'not-managed'; + return 'failed'; + } + if (typeof incarnation === 'function') { + let live = null; + try { + live = incarnation(business); + } catch {} + if (typeof live !== 'string' || live !== record.orgIncarnation) return 'failed'; } let admin; try { @@ -226,3 +241,16 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr } }; } + +// The managed remote step outside a running bus, for archive and delete from the CLI or the Console: +// the organization's Gitea admin slot, its file and its registry incarnation, each read when the +// step runs. `transport` and `ca` are the scratch tests' (giteaGrant's own options). +const ROLES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles'); +export function managedCopyRemote({ configDir, dataRoot, transport = undefined, ca = undefined }) { + const writer = organizationWriter({ dir: configDir, rolesDir: ROLES_DIR, dataRoot }); + return giteaCopyRemote({ + grant: giteaGrant({ configDir, ...(transport ? { transport, ca } : {}) }), + read: (id) => writer.read(id).document, + incarnation: (id) => readOrgEntrySync(configDir, id)?.incarnation ?? null, + }); +} diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 8334e2908..852dec1ac 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -49,6 +49,7 @@ import { COPY_BINDABLE, loadWithCopies, promoteCopies } from '../../organization import { copyGrant, sweepCopySlots } from './copy-grant.mjs'; import { copyLaunchGate } from './copy-launch.mjs'; import { copyRevoker as makeCopyRevoker, giteaCopyRemote } from './copy-revoke.mjs'; +import { readOrgEntrySync } from '../../organization/src/org-registry.mjs'; import { loadRole } from '../../organization/src/role.mjs'; import { resolveInstance } from '../../organization/src/resolve.mjs'; import { canonicalJson } from '../../organization/src/util.mjs'; @@ -471,7 +472,7 @@ export async function startBroker({ const b = await brokerReady; return b ? b.stopCopyRuns(id, instance, opts) : { confirmed: false }; }, - remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document }), + remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document, incarnation: (id) => readOrgEntrySync(organization.dir, id)?.incarnation ?? null }), definitions: (id) => (bootCopies?.org === id ? bootCopies.definitions ?? {} : {}), }); } diff --git a/packages/bus/tests/copy-revoke.test.mjs b/packages/bus/tests/copy-revoke.test.mjs index b6de7ad75..a0aa4ecac 100644 --- a/packages/bus/tests/copy-revoke.test.mjs +++ b/packages/bus/tests/copy-revoke.test.mjs @@ -16,14 +16,14 @@ import { Credentials } from '../src/credentials.mjs'; import { REFUSALS } from '../src/refusals.mjs'; import { copyGrant } from '../src/copy-grant.mjs'; import { copyLaunchGate } from '../src/copy-launch.mjs'; -import { copyRevoker, giteaCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs'; +import { copyRevoker, giteaCopyRemote, managedCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs'; import { giteaGrant, send } from '../src/gitea-grant.mjs'; import { fakeGiteaApi } from './fake-gitea-api.mjs'; import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs'; import { checkRootAgents } from '../../organization/src/agent-levels.mjs'; import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs'; -import { activateOrg, listOrgs, reserveOrg } from '../../organization/src/org-registry.mjs'; +import { activateOrg, listOrgs, readOrgEntrySync, reserveOrg } from '../../organization/src/org-registry.mjs'; import * as orgService from '../../organization/src/org-service.mjs'; import { copyBotLogin } from '../../organization/src/copy-bindings.mjs'; import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; @@ -121,7 +121,7 @@ async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'r const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(f.clock), onLoss: (business, instance, cause) => revoker?.start(business, instance, { cause, actor: 'bus' }) }); revoker = copyRevoker({ writer, gate, credentials, hooks: f.hooks, stopRuns: (...a) => f.stopRuns(...a), - remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document }), definitions: () => snapshot.definitions ?? {} }); + remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document, incarnation: (x) => readOrgEntrySync(root, x)?.incarnation ?? null }), definitions: () => snapshot.definitions ?? {} }); let promoted = snapshot; const business = () => { const d = loadWithCopies(id, { dir: root, rolesDir, snapshot: promoted }); @@ -336,13 +336,22 @@ test('CB4 fire: a copy whose root agent is gone at the next start (the fire land await assert.rejects(f.prepare(), refuses('copy-unavailable')); }); -test('CB4 fire: the bus revokes a fired copy\'s binding before the fire is written, with its runs stopped', async (t) => { +test('CB4 fire through the owner\'s decision: the fire is written, the copy\'s runs are stopped and its binding is revoked after the write', async (t) => { const w = await world(t); - const { f, r } = await live(t, w); - // The fire path (#stopCopyApproved) starts the retire revoke; the revoker is the one it calls. - await f.revoker.run(f.id, f.name, { cause: 'retire', actor: 'bus' }); - // The root agent is still in this boot's file, so the copy itself still exists: unbound. - await assertRevoked(f, r, { cause: 'retire', code: 'credential-unbound' }); + const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'research' } } }); + f.rootAgents = {}; + f.start(); + await f.bind({ level: 'project', project: 'stack', name: 'rev' }); + f.start(); + await f.boot(); + const r = await f.launch('run-1'); + assert.equal((await f.use(r.cap)).status, 200); + // The fire decision itself must not be refused decision-stale by a revoke written under it. + const out = await f.call(f.owner, 'hire.start.console', { op: 'fire', level: 'project', project: 'stack', name: 'rev', reason: 'Fire the copy' }); + assert.ok(out, 'the fire applied'); + assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).projects.stack.agents?.rev, undefined, 'the fire is written'); + assert.ok(f.killed().includes(r.run), 'the run was told to stop before the write'); + await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' }); }); test('CB4 a project copy removed from its project while the bus runs (no reload): the next call refuses and the binding is revoked', async (t) => { @@ -521,16 +530,17 @@ for (const point of REVOKE_POINTS) { }); } -test('CB4 archive discards the copy credential: tombstone, slot gone, receipt honest about the remote, restore brings nothing back', async (t) => { +test('CB4 archive with no remote adapter: slot gone, the copy stays revoking and fenced, the receipt claims no revoke, and the bus finishes it after restore', async (t) => { const w = await world(t); const { f, r } = await live(t, w); const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision; const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) }); - assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'not-attempted' } }]); - assert.ok(tombstoned(f)); + assert.deepEqual(out.copies, [{ agent: f.name, state: 'revoking', slot: 'removed', remote: { gitea: 'not-attempted' } }]); + assert.equal(f.rec().state, 'revoking'); assert.equal(f.rec().revoke.cause, 'archive'); + assert.deepEqual(f.rec().revoke.failed, ['remote']); assert.ok(!existsSync(f.slot)); - // Not wired to the managed adapter here, so the token is still at the service and the record says so. + // Not attempted at the service: the token is still there and nothing says otherwise. assert.equal(f.bot.tokens.length, 1); const log = readFileSync(join(w.data, 'org-lifecycle.log'), 'utf8').trim().split('\n').map(JSON.parse); assert.deepEqual(log.at(-1).copies, out.copies); @@ -541,16 +551,21 @@ test('CB4 archive discards the copy credential: tombstone, slot gone, receipt ho f.start(); await f.boot(); assert.deepEqual(f.gate.bound(), {}); - await assert.rejects(f.prepare(), refuses('credential-unbound')); + await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoke-failed'].includes(e.code)); + // The bus's resume at its start runs the remote step through the managed adapter. + await f.revoker.resume(f.id); + await assertRevoked(f, null, { cause: 'archive', code: 'credential-unbound' }); }); test('CB4 archive with the managed adapter wired revokes the token at Gitea', async (t) => { const w = await world(t); const { f } = await live(t, w); const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision; - const copyRemote = giteaCopyRemote({ grant: f.grant, read: (x) => f.writer.read(x).document }); + // The adapter the CLI and Console wire (org-bus.mjs copyRemoteFor), on the fake Gitea. + const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca }); const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote }); - assert.deepEqual(out.copies[0].remote, { gitea: 'revoked' }); + assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'revoked' } }]); + assert.ok(tombstoned(f)); assert.deepEqual(f.bot.tokens, []); }); @@ -561,7 +576,8 @@ test('CB4 archive rerun after a crash joins the pending op', async (t) => { const begun = f.writer.revokeCopyBinding(f.id, { phase: 'begin', instance: f.name, cause: 'archive' }, { actor: 'bus', owner: true }); assert.equal(begun.state, 'revoking'); const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision; - const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) }); + const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca }); + const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote }); assert.equal(out.copies[0].state, 'unbound'); assert.equal(f.rec().revoke.id, begun.op); assert.ok(!existsSync(f.slot)); @@ -585,6 +601,89 @@ test('CB4 delete quarantines the copy slot and its receipt never claims a remote await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'copy-unavailable', 'credential-unbound'].includes(e.code)); }); +test('CB4 delete with the managed adapter revokes each copy token at Gitea and says so in the receipt', async (t) => { + const w = await world(t); + const { f } = await live(t, w); + const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision; + const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca }); + const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote }); + assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'revoked' }]); + assert.equal(out.copiesUnreadable, undefined); + assert.deepEqual(f.bot.tokens, []); +}); + +test('CB4 delete with an unreadable organization file says so, never an empty copy list', async (t) => { + const w = await world(t); + const { f } = await live(t, w); + const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision; + // The file fails the private read when the copies step reads it (busStop runs just before). + const busStop = async () => { chmodSync(f.file, 0o644); return { stopped: true, reason: 'stopped' }; }; + const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop }); + assert.equal(out.copiesUnreadable, true); + assert.deepEqual(out.copies, []); + assert.match(out.note, /copiesUnreadable/); +}); + +test('CB4 an owner update cannot change the repository of a project a copy binding still grants', async (t) => { + const w = await world(t); + const { f } = await live(t, w); + const { revision } = (await listOrgs(f.ctx)).find((o) => o.id === f.id); + const doc = { ...JSON.parse(readFileSync(f.file, 'utf8')), human: f.ctx.principal.sub }; + const moved = structuredClone(doc); moved.projects.stack.remote = `${w.api.url}/elsewhere/stack`; + await assert.rejects(orgService.updateOrg(f.ctx, f.id, moved, { ifRevision: revision }), refuses('project-repo-bound')); + // Removed (with another project left, so the document itself is valid). + const dropped = structuredClone(doc); + dropped.projects.other = { ...structuredClone(doc.projects.stack), root: join(w.data, 'org-runtime', f.id, 'projects', 'other'), remote: `${w.api.url}/owner/other` }; + delete dropped.projects.other.agents; delete dropped.projects.stack; + await assert.rejects(orgService.updateOrg(f.ctx, f.id, dropped, { ifRevision: revision }), refuses('project-repo-bound')); + // An update that leaves the bound project's repository as it is still goes through. + const other = structuredClone(doc); other.vars = { ...other.vars, 'bus.tester': false }; + await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision }); +}); + +test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => { + const w = await world(t); + const { f } = await live(t, w); + const rec = f.rec(); + const base = { grant: f.grant, read: (x) => f.writer.read(x).document }; + // The bound project is not in the file any more: no origin to revoke against, never not-managed. + const gone = giteaCopyRemote({ ...base, read: (x) => { const d = structuredClone(f.writer.read(x).document); delete d.projects.stack; return d; } }); + assert.equal(await gone(f.id, f.name, rec, 'gitea'), 'failed'); + // Another incarnation, or none: the admin slot is never asked to delete. + const sent = f.api.calls().length; + for (const incarnation of [() => 'f'.repeat(32), () => null, () => { throw new Error('unreadable'); }]) + assert.equal(await giteaCopyRemote({ ...base, incarnation })(f.id, f.name, rec, 'gitea'), 'failed'); + assert.equal(f.bot.tokens.length, 1); + assert.equal(f.api.calls().length, sent, 'no request reached the server'); + // Through the revoker: the record stays revoking with the remote step failed, and fenced. + const revoker = copyRevoker({ writer: f.writer, gate: f.gate, credentials: f.credentials, stopRuns: (...a) => f.stopRuns(...a), + remote: gone, definitions: () => f.snapshot.definitions ?? {} }); + const done = await revoker.run(f.id, f.name, { cause: 'revoke', actor: 'jason', owner: true }); + assert.equal(done.state, 'revoking'); + assert.deepEqual(f.rec().revoke.failed, ['remote']); + assert.equal(f.bot.tokens.length, 1); + await assert.rejects(f.prepare(), refuses('credential-revoke-failed')); +}); + +// Build review P3-4: when the begin write refuses before any change, only an owner's verb on a copy +// that was open reopens it (to its parked proof); a loss the bus found, or a copy already closed, +// stays closed. +test('CB4 a refused begin reopens the gate only for an owner verb on an open copy; a loss the bus found stays closed', async () => { + const run = async ({ cause, closed }) => { + const calls = []; + const gate = { closed: () => closed, close: (b, i, c) => calls.push(['close', i, c ?? null]), open: (b, i, parked) => calls.push(['open', i, parked === true]), drain: async () => {} }; + const writer = { read: () => ({ document: { copyBindings: {} } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } }; + const stopRuns = async () => { throw new Error('stop must not run'); }; + const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => { throw new Error('slot must not go'); } }, stopRuns }); + await assert.rejects(revoker.run('acme', 'rev', { cause, owner: cause === 'revoke' }), { code: 'org-mismatch' }); + return calls; + }; + for (const cause of ['expired', 'unavailable', 'retire', 'shadowed', 'upstream']) + assert.deepEqual(await run({ cause, closed: false }), [['close', 'rev', null]], cause); + assert.deepEqual(await run({ cause: 'revoke', closed: true }), [['close', 'rev', null]]); + assert.deepEqual(await run({ cause: 'revoke', closed: false }), [['close', 'rev', null], ['open', 'rev', true]]); +}); + test('CB4 a second organization launches and calls throughout the first one\'s revoke, archive and delete', async (t) => { const w = await world(t); const { f: demo, r: dr } = await live(t, w); diff --git a/packages/cli/README.md b/packages/cli/README.md index 9042534a9..8ade359a5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1295,11 +1295,16 @@ organization bus, moves its local organization document, history, runtime and organization-specific secret directories into the instance's private `deleted/` directory, and removes it from the registry. The ID may then be reused. The deleted folder contains a receipt; `deleted/deleted.log` is an -append-only index. Remote repositories and remote credentials are untouched. -Purging deleted data and revoking remote credentials are separate operations and -are not provided by this command. The receipt's `copies` lists each agent copy -that held a service grant, with its slot `quarantined` and its remote outcome -`not-attempted`: the copy's token at the service still has to be revoked there. +append-only index. Remote repositories are untouched. Purging deleted data is a +separate operation and is not provided by this command. The receipt's `copies` +lists each agent copy that held a service grant (#1873 CB4), with its slot +`quarantined` and its remote outcome: `revoked` or `absent` when the +organization's managed Gitea admin slot deleted the copy's token, `not-managed` +when the organization has no admin slot on that server, and `failed` when the +service could not be reached or refused. For any outcome but `revoked` and +`absent`, the copy's token at the service still has to be revoked there. When +the organization file could not be read, the receipt says `copiesUnreadable: +true` instead of listing copies; check the service for copy tokens by hand. If deletion stops, the organization remains in `deleting` state and cannot start a bus. Inspect the reported receipt and correct the refusal, then resume @@ -1313,10 +1318,13 @@ require the source and deleted folder to be on the same filesystem. `bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after their usage checks and before any config is read. After the bus stops, `mosaic org archive` discards every agent copy's credential (#1873 CB4): each -copy's binding becomes an unbound tombstone and its slot file is removed. The -receipt's `copies` lists each one; its remote outcome is `not-attempted`, so a -copy's token at the service still has to be revoked there. A restore brings no -copy credential back. +copy's slot file is removed and its token is revoked at the service through +the organization's managed Gitea admin slot. The receipt's `copies` lists each +one with its state and remote outcome. A copy whose token was revoked, was +already gone, or is on a server the organization does not manage (`not-managed`) +becomes an unbound tombstone. A copy whose remote revoke `failed` stays +`revoking`, fenced, and the bus finishes the revoke when it starts after a +restore. A restore brings no copy credential back. `mosaic org restore ID --if-revision N` makes the organization active and starts its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It diff --git a/packages/cli/src/org-bus.mjs b/packages/cli/src/org-bus.mjs index 5f5ed191e..be6eec6dc 100644 --- a/packages/cli/src/org-bus.mjs +++ b/packages/cli/src/org-bus.mjs @@ -77,6 +77,12 @@ export async function busStop(ctx, id) { checkedState(org); return stopHost(org.root, { expectedBusiness: id }); } +// #1873 CB4: the remote step archive and delete give each copy's grant: the organization's managed +// Gitea adapter (its admin slot), the one the bus's own revoke uses. Loaded only when needed. +export async function copyRemoteFor(ctx) { + const { managedCopyRemote } = await import('../../bus/src/copy-revoke.mjs'); + return managedCopyRemote({ configDir: ctx.configDir, dataRoot: ctx.dataRoot }); +} // Deletion refuses stale or ambiguous host state and any listening socket. export async function busStopForDelete(ctx, id) { const org = await target(ctx, id, true); diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index 20dad706b..d86138f9b 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -6,7 +6,7 @@ import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { loadSystem } from './config.mjs'; import { CliError } from './errors.mjs'; -import { busStart, busStopForDelete } from './org-bus.mjs'; +import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs'; import { assertBuildStart, assertDevInstance } from './deployment.mjs'; import { consoleBusRestart } from './org-console.mjs'; import { refuseInsideAgent } from './transport.mjs'; @@ -52,7 +52,7 @@ export async function orgCommand(args, io, deps = {}) { else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); } else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); } else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); } - else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete }); + else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) }); else if (words[0] === 'restore' && words.length === 2) { const startBus = !p.flags.has('--no-bus'); if (startBus) restoreBusCheck(system, io.env); @@ -70,7 +70,7 @@ export async function orgCommand(args, io, deps = {}) { } } } - else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle }); + else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx), ...lifecycle }); // U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which // checks the owner and writes the action trail (org-console.mjs consoleBusRestart). else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); } diff --git a/packages/cli/tests/org-delete.test.mjs b/packages/cli/tests/org-delete.test.mjs index c0151112f..9c30159fa 100644 --- a/packages/cli/tests/org-delete.test.mjs +++ b/packages/cli/tests/org-delete.test.mjs @@ -2,8 +2,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { fixture, tmp } from './helpers.mjs'; -import { busStart, busStop, busStopForDelete } from '../src/org-bus.mjs'; +import { fixture, orgFixture, tmp } from './helpers.mjs'; +import { busStart, busStop, busStopForDelete, copyRemoteFor } from '../src/org-bus.mjs'; import { createOrg, deleteOrg, templateRender } from '../../organization/src/org-service.mjs'; test('deletion stops the recorded running bus before moving its runtime', { timeout: 60000 }, async t => { @@ -19,3 +19,17 @@ test('deletion stops the recorded running bus before moving its runtime', { time assert.equal(receipt.bus.stopped, true); assert.equal(receipt.bus.pid, started.pid); assert.ok(receipt.moves.some(move => move.from === started.root)); }); + +// #1873 CB4: archive and delete from the CLI get the organization's managed Gitea remote step. With no +// admin slot and no bound repository it never claims a revoke: a malformed grant fails, another +// service is not managed, and a grant whose bound projects have no server fails (the copy stays fenced). +test('CB4 copyRemoteFor gives the managed remote step, which never claims a revoke it did not make', async t => { + const { root, f } = await orgFixture(t, 'a'); + const ctx = { configDir: join(root, '.config', 'mosaic-test'), dataRoot: f.dataRoot, instance: 'test' }; + const remote = await copyRemoteFor(ctx); + assert.equal(typeof remote, 'function'); + const record = (grantId) => ({ orgIncarnation: 'x', copyId: 'c', services: { gitea: { grantId, resources: { projects: ['work'] } } } }); + assert.equal(await remote('a', 'rev', record('nope'), 'gitea'), 'failed'); + assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'other'), 'not-managed'); + assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'gitea'), 'failed'); +}); diff --git a/packages/organization/src/org-service.mjs b/packages/organization/src/org-service.mjs index 83ba49566..08a359b14 100644 --- a/packages/organization/src/org-service.mjs +++ b/packages/organization/src/org-service.mjs @@ -195,6 +195,13 @@ export async function updateOrg(ctx, id, input, { ifRevision } = {}) { authorize(ctx); return withRegistry(ctx, async registry => { const current = await readCurrentUnlocked(ctx, id); if (!Number.isSafeInteger(ifRevision) || ifRevision !== current.revision) fail('revision-conflict', 'organization revision changed'); const doc = normalizeDoc(ctx, id, input, current.doc); if (doc.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner'); await checked(doc); + // #1873 CB4 (F7): a project a copy binding still grants keeps its repository: its remote, its git + // binding and its presence change only after that binding is revoked, as in project repo set. + for (const [project, entry] of Object.entries(current.doc.projects ?? {})) { + const bound = projectBindings(current.doc, project); if (!bound.length) continue; + const next = doc.projects?.[project]; + if (!next || canonicalJson(next.remote ?? null) !== canonicalJson(entry.remote ?? null) || canonicalJson(next.git ?? null) !== canonicalJson(entry.git ?? null)) fail(REPO_BOUND, repoBoundMessage(project, bound)); + } const raw = encode(doc), revision = current.revision + 1; await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision; return receipt(current.root, 'org.update', id, ctx.principal, { root: current.root, revision }); }); @@ -306,12 +313,23 @@ export async function projectRepoShow(ctx, id, project) { return showSetup(ctx, // The bus has stopped, so no run of a copy is left; each binding takes the same two writes as the // bus's revoke (`revoking` with an op id, then the tombstone), and a rerun joins the op a crash left. // The slot file goes from the organization's agent store. The remote step is the caller's -// `copyRemote(id, instance, record, service)` (the CLI or Console wires the managed adapter, as it -// wires busStop); without one it is recorded `not-attempted`, never claimed. A slot that cannot be -// removed keeps the record revoking and refuses the archive. Returns the writes and one line per copy. +// `copyRemote(id, instance, record, service)` (the CLI and Console wire the managed adapter, as they +// wire busStop). A remote step that failed, or that was not attempted because no adapter was given, +// keeps the record revoking (`failed: ['remote']`): the copy stays fenced, and the bus's resume at its +// next start, after a restore, runs the remote step again. The copy's line says which, never +// claiming a revoke. A slot that cannot be removed also keeps the record revoking, and refuses the +// archive. Returns the writes and one line per copy. const tombstoned = r => r?.state === 'unbound' && r.revoke?.done === true; async function discardCopySlot(ctx, id, instance, service) { - const file = join(ctx.configDir, 'secrets', 'agents', id, instance, service); + // Each parent must be the stack user's own private directory, or missing (no slot below it). + let parent = join(ctx.configDir, 'secrets'); + for (const part of ['', 'agents', id, instance]) { + if (part) parent = join(parent, part); + let st; + try { st = await fs.lstat(parent); } catch (e) { if (e.code === 'ENOENT') return 'absent'; return 'failed'; } + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) return 'failed'; + } + const file = join(parent, service); let removed = false; for (const path of [file + '.tmp', file]) { let st; @@ -348,7 +366,9 @@ async function discardCopies(ctx, id, cause, copyRemote) { try { remote[service] = await copyRemote(id, instance, record, service); } catch { remote[service] = 'failed'; } if (!['revoked', 'absent', 'not-managed', 'failed'].includes(remote[service])) remote[service] = 'failed'; } - const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote } }, { actor: 'bus', owner: true }); + // The bus is stopped (busStop answered before this), so the copy has no run left to stop. + const steps = Object.fromEntries(Object.entries(remote).map(([k, v]) => [k, v === 'not-attempted' ? 'failed' : v])); + const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote: steps } }, { actor: 'bus', owner: true }); if (done.revision !== undefined) writes++; copies.push({ agent: instance, state: done.state, slot, remote }); } @@ -436,10 +456,11 @@ async function deleteParents(base, relative) { // #1873 CB4: the deletion quarantine moves every copy slot with the agent store, and the receipt // lists each copy that held a service grant with its remote outcome. With no `copyRemote` from the // caller that outcome is `not-attempted`: the receipt never claims a token was revoked at the service. -const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked.'; +// An organization file that cannot be read is said so (`copiesUnreadable`), never an empty list. +const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked. When copiesUnreadable is true the organization file could not be read, so any copy grant still live at its service must be revoked there by hand.'; async function deleteCopies(ctx, id, copyRemote) { let doc; - try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return []; } + try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return { copies: [], unreadable: true }; } const out = []; for (const instance of Object.keys(doc?.copyBindings ?? {}).sort()) { const record = doc.copyBindings[instance]; @@ -454,7 +475,7 @@ async function deleteCopies(ctx, id, copyRemote) { out.push({ agent: instance, service, slot: 'quarantined', remote }); } } - return out; + return { copies: out, unreadable: false }; } export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRemote = null } = {}) { authorize(ctx); checkId(id); @@ -490,7 +511,8 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRem } if (!Array.isArray(result.copies)) { phase = 'copies'; - result.copies = await deleteCopies(ctx, id, copyRemote); + const found = await deleteCopies(ctx, id, copyRemote); + result.copies = found.copies; if (found.unreadable) result.copiesUnreadable = true; await atomic(receiptFile, encode(result)); } phase = 'inspect'; diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index ef238761f..5398aa3a5 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -464,16 +464,18 @@ async function route(req, res, { path, search, org, admin, principal, write, log if ((kind === 'archive' || kind === 'restore') && !sub && write) { const body = await readBody(req); strictKeys(body, ['revision']); if (!Number.isSafeInteger(body.revision) || body.revision < 1) return json(res, 422, { error: 'invalid', step: kind, message: 'A valid revision is required.' }); - let busStop; + let busStop, copyRemote = null; if (kind === 'archive') { const bus = await need(admin, 'bus', res, kind); if (!bus) return; if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: kind, message: 'bus control cannot stop a bus for archive on this Console yet' }); busStop = bus.busStopForDelete; + // #1873 CB4: archive revokes each copy's grant through the organization's managed adapter. + if (typeof bus.copyRemoteFor === 'function') copyRemote = await bus.copyRemoteFor(ctx); } try { const fn = kind === 'archive' ? service.archiveOrg : service.restoreOrg; if (typeof fn !== 'function') return json(res, 503, { error: 'not-configured', step: kind }); - const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}) }); + const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}), ...(copyRemote ? { copyRemote } : {}) }); const done = { outcome: 'done', step: kind, receipt: forReceipt(ctx, receipt), verb: verbFor(kind, id, { revision: body.revision }) }; if (kind === 'archive') return json(res, 200, done); return json(res, 200, { ...done, bus: await restoreBus({ id, ctx, admin, log, trail, by }) }); @@ -485,7 +487,8 @@ async function route(req, res, { path, search, org, admin, principal, write, log const bus = await need(admin, 'bus', res, 'delete'); if (!bus) return; if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: 'delete', message: 'bus control cannot stop a bus for deletion on this Console yet' }); try { - const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision }); + const copyRemote = typeof bus.copyRemoteFor === 'function' ? await bus.copyRemoteFor(ctx) : null; + const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision, ...(copyRemote ? { copyRemote } : {}) }); return json(res, 200, { outcome: 'done', step: 'delete', receipt: forReceipt(ctx, receipt), verb: verbFor('delete', id, { revision: body.revision }) }); } catch (e) { if (e.code === 'revision-conflict') return refusal(res, e, { step: 'delete', id }); return json(res, 409, { error: e.code || 'delete-refused', step: e.deleteStep || 'delete', message: plainMessage(ctx, e.message || 'Deletion stopped; inspect the receipt and resume with the CLI.'), receipt: forReceipt(ctx, e.deleteReceipt), verb: verbFor('delete', id, { revision: body.revision }) }); } } diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index d5d5e886c..dbea86bdf 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -13,6 +13,7 @@ import { actionTrail } from '../src/action-trail.mjs'; import { close } from './fixture.mjs'; class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } } +const COPY_REMOTE = async () => 'revoked'; const template = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' }, assistant: { definition: 'assistant' } }, projects: { work: { root: `/__mosaic_org__/${id}/projects/work` } } }); // In-memory C1/C2/C3 fakes with the PLAN.md signatures. `fail` injects errors. @@ -40,6 +41,8 @@ function fakes() { busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; }, busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; }, busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; }, + // #1873 CB4: the managed remote step archive and delete hand to the organization service. + copyRemoteFor: async ctx => { assert.equal(ctx.configDir, '/cfg'); return COPY_REMOTE; }, busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; }, busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; }, }; @@ -589,6 +592,14 @@ test('organization delete route enforces write fence and validates confirmation assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1, extra: true })).status, 400); }); +test('CB4 delete hands the managed copy remote to the organization service', async t => { + const c = await console_(t); + let seen = null; + c.service.deleteOrg = async (ctx, id, opts) => { seen = opts; return { org: id, bus: { stopped: false, reason: 'no host runs' }, moves: [], copies: [] }; }; + const r = await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1 }); + assert.equal(r.status, 200); assert.equal(seen.copyRemote, COPY_REMOTE); assert.equal(typeof seen.busStop, 'function'); +}); + test('delete refusal exposes progress and redacts receipt paths', async t => { const c = await console_(t); c.service.deleteOrg = async () => { const e = new OrgError('org-mismatch', 'unsafe path /data/org-runtime/acme'); e.deleteStep = 'inspect'; e.deleteReceipt = { bus: { stopped: false, reason: 'no host runs' }, moves: [], destination: '/data/deleted/acme-x' }; throw e; }; @@ -599,7 +610,7 @@ test('delete refusal exposes progress and redacts receipt paths', async t => { test('archive and restore use fenced owner routes with strict revisions and injected bus stop', async t => { const c = await console_(t); - c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; }; + c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); assert.equal(opts.copyRemote, COPY_REMOTE); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; }; c.service.restoreOrg = async (ctx, id, opts) => { c.calls.push(['restoreOrg', id, opts.ifRevision]); return { org: id, revision: 3 }; }; for (const step of ['archive', 'restore']) { const path = `/api/orgs/acme/${step}`;