Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c2ce1435 | ||
|
|
2196d37a47 | ||
|
|
ef1d3a4583 | ||
|
|
90245765f2 |
No files matched your search
@@ -223,6 +223,7 @@ export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance
|
||||
'instances.set add', 'instances.set change', 'instances.set delete']);
|
||||
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
|
||||
// action.allowed events, which event.emit refuses for any action but routine.
|
||||
const COPY_STOP_TIMEOUT_MS = 30000;
|
||||
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
|
||||
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
|
||||
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
|
||||
@@ -586,6 +587,8 @@ export class Broker {
|
||||
#sessions = new Map();
|
||||
#runs = new Map();
|
||||
#ended = new Set();
|
||||
// #1873 CB4: the copy revoke's stop waits on these, keyed business:run; #endSession resolves them.
|
||||
#endWaiters = new Map();
|
||||
// Row 156 round 2 (#1651, Filbert's F1 in 29033): role runs that have asked message.receive. The
|
||||
// runner asks it first after its landing turns, so a run not in here may be in one.
|
||||
#received = new Set();
|
||||
@@ -645,6 +648,7 @@ export class Broker {
|
||||
#copyGrant;
|
||||
// #1873 CB3: copy-launch.mjs's gate, or null. It answers for a copy the boot left needing a binding.
|
||||
#copyLaunch;
|
||||
#copyRevoker;
|
||||
#giteaKeys;
|
||||
#giteaUndo = [];
|
||||
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
|
||||
@@ -670,7 +674,7 @@ export class Broker {
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, copyLaunch = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, copyLaunch = null, copyRevoker = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -706,6 +710,7 @@ export class Broker {
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#copyGrant = copyGrant;
|
||||
this.#copyLaunch = copyLaunch;
|
||||
this.#copyRevoker = copyRevoker;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#accessStamp = accessStamp;
|
||||
@@ -2235,6 +2240,35 @@ export class Broker {
|
||||
const session = this.#runs.get(key) ?? fail('unknown-run');
|
||||
return this.#endSession(session, record);
|
||||
}
|
||||
// #1873 CB4 (design section 3, stop): every ordinary and thread run of a copy that carries the
|
||||
// copy's launch authority is ended: its claims end, a kill end goes to the launcher, and this waits
|
||||
// until each run's end is acknowledged (endLaunch, or recoverLaunch for a host that lost it). A run
|
||||
// of a named instance of the same name carries no copy authority and is not touched. Trusted host
|
||||
// API for the revoker only, never a socket verb. { confirmed } is true only when every run ended.
|
||||
async stopCopyRuns(business, instance, { op = null, timeoutMs = COPY_STOP_TIMEOUT_MS } = {}) {
|
||||
this.#business(identifier(business));
|
||||
string(instance, 64);
|
||||
const runs = [...this.#runs.entries()].filter(([k, r]) => r.business === business && r.role === instance
|
||||
&& typeof r.copyAuthority === 'string' && !this.#ended.has(k));
|
||||
if (!runs.length) return { confirmed: true, runs: [], alive: [] };
|
||||
const ids = runs.map(([, r]) => r.run);
|
||||
const host = { business, role: null, run: null };
|
||||
this.#store.transaction(() => {
|
||||
const held = this.#threaded(business, instance) ? this.#threadHolders(business, instance) : [this.#holder(business, instance)].filter(h => h?.op === 'claim');
|
||||
for (const h of held) if (ids.includes(h.holder_run)) this.#claimEnd(host, h, 'revoke', op, 'copy-revoked', 'bus');
|
||||
this.#event(host, 'action.allowed', { action: 'lifecycle.end', request: randomUUID(), via: 'bus', by: 'bus', runs: ids, reason: 'Copy credential revoked', kill: true }, instance);
|
||||
});
|
||||
const waits = runs.map(([k]) => new Promise((resolve) => {
|
||||
if (this.#ended.has(k)) return resolve();
|
||||
if (!this.#endWaiters.has(k)) this.#endWaiters.set(k, []);
|
||||
this.#endWaiters.get(k).push(resolve);
|
||||
}));
|
||||
let timer;
|
||||
await Promise.race([Promise.all(waits), new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); })]);
|
||||
clearTimeout(timer);
|
||||
const alive = runs.filter(([k]) => !this.#ended.has(k)).map(([, r]) => r.run);
|
||||
return { confirmed: alive.length === 0, runs: ids, alive };
|
||||
}
|
||||
#endSession(session, record) {
|
||||
const key = session.business + ':' + session.run;
|
||||
if (this.#ended.has(key)) fail('run-ended');
|
||||
@@ -2242,6 +2276,9 @@ export class Broker {
|
||||
const exitCode = record.exitCode ?? null;
|
||||
if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
|
||||
this.#ended.add(key);
|
||||
const waiting = this.#endWaiters.get(key);
|
||||
this.#endWaiters.delete(key);
|
||||
for (const resolve of waiting ?? []) resolve();
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
@@ -6418,6 +6455,13 @@ export class Broker {
|
||||
// credential joins the roles but is still a copy. Any other unknown name answers credential-unknown.
|
||||
const booted = this.#business(s.business);
|
||||
if (verb === 'credentials.issue' && this.#copyGrant && booted.copies && Object.hasOwn(booted.copies, a.agent)) return this.#issueCopyGrant(s, a);
|
||||
// #1873 CB4: an owner's revoke or delete of a copy's bound credential is the copy revoke: gate
|
||||
// closed, runs stopped, slot gone, grant revoked at the service, tombstone written.
|
||||
const binding = verb === 'credentials.issue' ? null : this.#copyRevoker?.binding(s.business, a.agent) ?? null;
|
||||
if (binding && (!Object.hasOwn(booted.roles, a.agent) || booted.roles[a.agent]?.copyBound)) return this.#revokeCopy(s, a);
|
||||
// A named instance's slot is the former copy's: nothing issues to it while that copy's revoke is
|
||||
// pending, or the revoke would take the new token.
|
||||
if (verb === 'credentials.issue' && this.#copyRevoker?.pending(s.business, a.agent)) fail('credential-revoking');
|
||||
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))
|
||||
@@ -6459,6 +6503,16 @@ export class Broker {
|
||||
#copyFence(business, instance, fn) {
|
||||
return this.#copyLaunch && typeof instance === 'string' ? this.#copyLaunch.fence(business, instance, fn) : Promise.resolve().then(fn);
|
||||
}
|
||||
// #1873 CB4: owner only, as every binding decision. A revoke that could not finish keeps the copy
|
||||
// fenced and answers credential-revoke-failed; the record holds the steps that did not.
|
||||
async #revokeCopy(s, a) {
|
||||
this.#copyOwner(s);
|
||||
const out = await this.#copyRevoker.run(s.business, a.agent, { cause: 'revoke', actor: s.human, owner: true });
|
||||
if (out.state === 'revoking') fail('credential-revoke-failed');
|
||||
const result = { agent: a.agent, state: out.state, op: out.op ?? null, remote: out.remote ?? null };
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
#issueCopyGrant(s, a) {
|
||||
this.#copyOwner(s);
|
||||
const passed = (e) => e instanceof BusError && (e.code.startsWith('credential-') || ['copy-unavailable', 'human-required',
|
||||
@@ -6665,6 +6719,7 @@ export class Broker {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
recheck();
|
||||
const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck});
|
||||
this.#copyAfterHire(s.business,p);
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice};
|
||||
} catch(error) {
|
||||
@@ -6704,6 +6759,7 @@ export class Broker {
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#copyFence(s.business,p.binding?.instance,()=>this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}));
|
||||
this.#copyAfterHire(s.business,p);
|
||||
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
|
||||
@@ -6726,9 +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); 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 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;
|
||||
|
||||
@@ -152,6 +152,8 @@ export function copyGrant({ credentials, writer, copies = null, organizations =
|
||||
boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }, (v) => assertNotHeld(v, [value]));
|
||||
const record = doc.copyBindings?.[instance];
|
||||
if (!object(record) || record.state === 'unbound' || !object(record.services?.[service])) deny('credential-unbound');
|
||||
// CB4: a pending revoke ends first; an issue never overwrites the slot it is discarding.
|
||||
if (record.state === 'revoking') deny(record.revoke?.failed ? 'credential-revoke-failed' : 'credential-revoking');
|
||||
const copy = copies?.org === business && Object.hasOwn(copies.copies ?? {}, instance) ? copies.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
if (copy.level !== record.source?.level || copy.name !== record.source?.name || (copy.project ?? null) !== (record.source?.project ?? null)
|
||||
|
||||
@@ -21,14 +21,22 @@
|
||||
// the next boot. The token is never held: each call reads the slot by the identity the boot proved
|
||||
// and checks it against a keyed MAC of the token the boot proved. Nothing here logs, and no result
|
||||
// or refusal holds the token, the slot's path or anything derived from the token.
|
||||
//
|
||||
// #1873 CB4 (design sections 3 and 6): close() shuts a copy before its revoke publishes anything:
|
||||
// it admits nothing and every call refuses, until open() after a definite finish. drain() aborts the
|
||||
// copy's requests in flight (prepare's proof and call()) and waits for each to settle. A check that
|
||||
// finds the binding's authority gone for good (the live copy shadowed, removed or colliding, its
|
||||
// role or pin changed, the slot gone or replaced, the rotation date passed, the service answering
|
||||
// 401) tells `onLoss` once per copy, so the revoke runs without waiting for a human; a transient
|
||||
// read failure never does. sweep() runs those checks for every copy holding a grant.
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { assertNotHeld } from './credentials.mjs';
|
||||
import { send } from './gitea-grant.mjs';
|
||||
import { boundRepositories, tokenLevels, verifyGiteaToken } from './copy-grant.mjs';
|
||||
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||
import { allGranted, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
||||
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
||||
import { allGranted, CAUSE_CODES, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
||||
import { COPY_BINDABLE, materializeCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveAgentLevels } from '../../organization/src/agent-levels.mjs';
|
||||
@@ -37,6 +45,13 @@ import { canonicalJson, sha256 } from '../../organization/src/util.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
// A refusal that also means the binding's authority is gone for good: `cause` is the revoke cause
|
||||
// (copy-bindings.mjs REVOKE_CAUSES) the gate reports through onLoss.
|
||||
const lose = (code, cause) => {
|
||||
const e = new BusError(code);
|
||||
e.loss = cause;
|
||||
throw e;
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// The repository API a copy session may call: /api/v1/repos/<owner>/<repo>[/<rest>], ASCII names,
|
||||
// no dot segment, no encoded separator, no query beyond plain pairs.
|
||||
@@ -54,27 +69,58 @@ export const PREPARE_BUDGET_MS = 8000;
|
||||
export const BOOT_BUDGET_MS = 20000;
|
||||
|
||||
export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snapshot, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(),
|
||||
prepareMs = PREPARE_BUDGET_MS, bootMs = BOOT_BUDGET_MS }) {
|
||||
prepareMs = PREPARE_BUDGET_MS, bootMs = BOOT_BUDGET_MS, onLoss = null }) {
|
||||
const org = snapshot?.org ?? null;
|
||||
const key = randomBytes(32);
|
||||
const mac = (token) => createHmac('sha256', key).update(token).digest();
|
||||
const verified = new Map();
|
||||
const failed = new Map();
|
||||
const fences = new Map();
|
||||
// CB4: instance -> the code a closed copy answers; instance -> Set of { abort, settled } in flight;
|
||||
// the copies already reported lost.
|
||||
const closed = new Map();
|
||||
const inflight = new Map();
|
||||
const lost = new Set();
|
||||
// CB4: the boot proof of a copy close() shut, kept until its revoke settles (open(…, true) restores it).
|
||||
const parked = new Map();
|
||||
const track = (instance) => {
|
||||
const ac = new AbortController();
|
||||
const entry = { ac, settled: null };
|
||||
if (!inflight.has(instance)) inflight.set(instance, new Set());
|
||||
inflight.get(instance).add(entry);
|
||||
return {
|
||||
signal: ac.signal,
|
||||
run: (p) => {
|
||||
entry.settled = Promise.resolve(p).then(() => {}, () => {});
|
||||
return p;
|
||||
},
|
||||
done: () => {
|
||||
const set = inflight.get(instance);
|
||||
set?.delete(entry);
|
||||
if (set && !set.size) inflight.delete(instance);
|
||||
},
|
||||
};
|
||||
};
|
||||
const today = () => now().toISOString().slice(0, 10);
|
||||
// The organization's registry incarnation as the registry stands now, or null when the entry is
|
||||
// gone, carries none, or is not active (reserved, deleting, archived). Read on every check, never
|
||||
// cached: an organization archived while the bus runs admits and sends nothing. An unreadable or
|
||||
// unsafe registry refuses credential-unavailable.
|
||||
const liveIncarnation = () => {
|
||||
let entry;
|
||||
const registry = () => {
|
||||
try {
|
||||
entry = readOrgEntrySync(configDir, org);
|
||||
return readOrgEntrySync(configDir, org);
|
||||
} catch {
|
||||
return deny('credential-unavailable');
|
||||
}
|
||||
};
|
||||
const liveIncarnation = () => {
|
||||
const entry = registry();
|
||||
return entry?.state === 'active' ? entry.incarnation : null;
|
||||
};
|
||||
// CB4 (design section 6): a verified copy of an organization no longer active answers the
|
||||
// organization's own state: archived, or gone (deleting, deleted, recreated under a new
|
||||
// incarnation).
|
||||
const inactive = () => (registry()?.state === 'archived' ? 'org-archived' : 'org-unavailable');
|
||||
// The organization file is still the revision the launch resolved its bundle from (#1873 A3).
|
||||
const sameRevision = (business, revision) => {
|
||||
if (revision === undefined) return;
|
||||
@@ -97,7 +143,27 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
// bindingFacts, without the refusals that function makes for a proposal).
|
||||
const facts = (business, instance, doc) => {
|
||||
const copy = snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
// CB4: the copies the file makes now, from the boot's root agents. An org instance of the name
|
||||
// shadows the copy, a removed source makes none, and a second claimant refuses both: each is
|
||||
// the end of this copy's binding. A file the materializer cannot read refuses and loses nothing.
|
||||
if (Object.hasOwn(doc.roles ?? {}, instance)) lose('copy-unavailable', 'shadowed');
|
||||
// A copy the boot did not make at all, from a root agents file it read, was fired or removed
|
||||
// before this start (a crash may have come before its revoke began): the binding ends. A boot
|
||||
// that could not read the root file makes no copy and loses nothing.
|
||||
if (!copy) return snapshot?.org === business && snapshot.root === 'ok' ? lose('copy-unavailable', 'retire') : deny('copy-unavailable');
|
||||
if (copy.state === 'refused') return copy.claims ? lose('copy-refused', 'collision') : deny('copy-unavailable');
|
||||
let live;
|
||||
try {
|
||||
live = materializeCopies({ orgId: business, doc, rolesDir,
|
||||
root: snapshot.root === 'refused' ? null : { state: snapshot.root, agents: snapshot.rootAgents ?? {}, definitions: snapshot.definitions ?? {} } });
|
||||
} catch {
|
||||
deny('copy-unavailable');
|
||||
}
|
||||
const now = Object.hasOwn(live.copies, instance) ? live.copies[instance] : null;
|
||||
if (!now) lose('copy-unavailable', 'retire');
|
||||
if (now.state === 'refused') return now.claims ? lose('copy-refused', 'collision') : deny('copy-unavailable');
|
||||
if (now.level !== copy.level || now.name !== copy.name || now.project !== copy.project) lose('copy-unavailable', 'retire');
|
||||
if (now.definition !== copy.definition) lose('credential-role-changed', 'role-changed');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
|
||||
let role = null;
|
||||
@@ -106,7 +172,8 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
} catch {
|
||||
role = null;
|
||||
}
|
||||
if (!pin || !role) deny('credential-role-changed');
|
||||
if (!pin) lose('credential-role-changed', 'role-changed');
|
||||
if (!role) deny('credential-role-changed');
|
||||
const code = Object.keys(doc.projects ?? {}).filter((p) => doc.projects[p]?.kind !== 'library');
|
||||
let projects = code;
|
||||
if (copy.level === 'root') {
|
||||
@@ -123,7 +190,10 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
const resolve = (business, instance, doc, record, inc) => {
|
||||
const f = facts(business, instance, doc);
|
||||
const res = resolveCopyBinding({ orgId: business, incarnation: inc, snapshot, instance, record, pin: f.pin, role: f.role, projects: f.projects });
|
||||
if (!res.ok) deny(res.code);
|
||||
if (!res.ok) {
|
||||
if (res.code === 'credential-role-changed' || res.code === 'credential-scope-exceeded') lose(res.code, 'role-changed');
|
||||
deny(res.code);
|
||||
}
|
||||
if (Object.keys(res.services).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
return { ...f, res };
|
||||
};
|
||||
@@ -133,24 +203,25 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
// Live proof of one slot token against the record: the service's own answer, the grant id the
|
||||
// verifier committed, and a rotation date not yet passed.
|
||||
// The transport held to a deadline: each call gets what is left, and none starts after it.
|
||||
const within = (deadline) => (req) => {
|
||||
const within = (deadline, signal = undefined) => (req) => {
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) return Promise.resolve({ status: 0, json: null, total: null });
|
||||
return transport({ ...req, timeoutMs: Math.min(req.timeoutMs ?? left, left) });
|
||||
if (left <= 0 || signal?.aborted) return Promise.resolve({ status: 0, json: null, total: null });
|
||||
return transport({ ...req, timeoutMs: Math.min(req.timeoutMs ?? left, left), ...(signal ? { signal } : {}) });
|
||||
};
|
||||
const prove = async (doc, record, token, inc, deadline) => {
|
||||
const prove = async (doc, record, token, inc, deadline, signal = undefined) => {
|
||||
const approved = record.services.gitea;
|
||||
if (approved.rotateBy < today()) deny('credential-expired');
|
||||
const { origin, repos } = boundRepositories(doc, approved.resources.projects);
|
||||
const login = copyBotLogin(inc, record.copyId, 'gitea');
|
||||
if (!login) deny('credential-revoked');
|
||||
const grant = await verifyGiteaToken({ transport: within(deadline), url: origin, ca, timeoutMs, token, approved: approved.scopes, repos, login, rotateBy: approved.rotateBy });
|
||||
const grant = await verifyGiteaToken({ transport: within(deadline, signal), url: origin, ca, timeoutMs, token, approved: approved.scopes, repos, login, rotateBy: approved.rotateBy });
|
||||
if (`gitea:${grant.userId}:${grant.tokenId}` !== approved.grantId) deny('credential-revoked');
|
||||
};
|
||||
|
||||
async function boot(business) {
|
||||
if (business !== org) return;
|
||||
const deadline = Date.now() + bootMs;
|
||||
const losses = [];
|
||||
// An inactive or unreadable registry entry leaves null: the resolver refuses every copy with its
|
||||
// own code.
|
||||
let incarnation = null;
|
||||
@@ -172,8 +243,9 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
try {
|
||||
if ((copy.missing ?? []).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
const { pin, res } = resolve(business, instance, doc, record, incarnation);
|
||||
if (record.services.gitea.rotateBy < today()) lose('credential-expired', 'expired');
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity) deny('credential-unavailable');
|
||||
if (!identity) lose('credential-unavailable', 'unavailable');
|
||||
const { token } = credentials.readCopy(slot(instance), 'gitea', identity);
|
||||
await prove(doc, record, token, incarnation, deadline);
|
||||
// The origin the token was proved at is the only one a call goes to.
|
||||
@@ -184,18 +256,51 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
}));
|
||||
} catch (e) {
|
||||
failed.set(instance, e instanceof BusError && KEPT.test(e.code) ? e.code : 'credential-unavailable');
|
||||
if (e?.loss) losses.push([instance, e.loss]);
|
||||
}
|
||||
}
|
||||
for (const [instance, cause] of losses) report(business, instance, cause);
|
||||
}
|
||||
// CB4: tell onLoss once that a copy's binding lost its authority for good, when its record still
|
||||
// holds a grant to revoke. onLoss is the revoker's start: it never throws into a check.
|
||||
function report(business, instance, cause) {
|
||||
if (lost.has(instance) || typeof onLoss !== 'function') return;
|
||||
let record;
|
||||
try {
|
||||
record = writer.read(business).document.copyBindings?.[instance];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!object(record) || record.state !== 'bound' || !Object.values(record.services ?? {}).some((x) => typeof x?.grantId === 'string')) return;
|
||||
lost.add(instance);
|
||||
try {
|
||||
onLoss(business, instance, cause);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// The current authority of a copy, or the refusal. `withToken` also returns the slot's token,
|
||||
// read now by the boot's identity and checked against the boot's MAC; only call() asks for it.
|
||||
function current(business, instance, { withToken = false } = {}) {
|
||||
function current(business, instance, opts = {}) {
|
||||
try {
|
||||
return check(business, instance, opts);
|
||||
} catch (e) {
|
||||
if (e?.loss && business === org && typeof instance === 'string') report(business, instance, e.loss);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
function check(business, instance, { withToken = false } = {}) {
|
||||
if (business !== org || typeof instance !== 'string') deny('copy-unavailable');
|
||||
if (fences.get(instance)) deny('credential-revoking');
|
||||
if (closed.has(instance)) deny(closed.get(instance));
|
||||
const v = verified.get(instance);
|
||||
// A verified copy of an organization archived or deleted since boot answers that first: a deleted
|
||||
// organization's file is gone, and its read would only say credential-unavailable.
|
||||
if (v) {
|
||||
const live = liveIncarnation();
|
||||
if (live !== v.incarnation) deny(live === null ? inactive() : 'org-unavailable');
|
||||
}
|
||||
const doc = read(business);
|
||||
const record = doc.copyBindings?.[instance];
|
||||
const v = verified.get(instance);
|
||||
if (!v) {
|
||||
if (object(record) && record.state === 'bound' && allGranted(record.services))
|
||||
deny(failed.get(instance) ?? 'restart-required');
|
||||
@@ -208,24 +313,27 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
}
|
||||
// A verified copy holds only while its organization is active under the incarnation boot proved.
|
||||
const incarnation = liveIncarnation();
|
||||
if (!object(record) || record.state !== 'bound' || record.copyId !== v.copyId || record.orgIncarnation !== v.incarnation
|
||||
|| incarnation !== v.incarnation) deny('credential-revoked');
|
||||
if (incarnation !== v.incarnation) deny(incarnation === null ? inactive() : 'org-unavailable');
|
||||
// CB4: a revoke in flight, an incomplete one, or a finished one's tombstone answers its own code.
|
||||
if (object(record) && record.state === 'revoking') deny(record.revoke?.failed ? 'credential-revoke-failed' : 'credential-revoking');
|
||||
if (object(record) && record.state === 'unbound' && record.revoke?.done === true && CAUSE_CODES[record.revoke.cause]) deny(CAUSE_CODES[record.revoke.cause]);
|
||||
if (!object(record) || record.state !== 'bound' || record.copyId !== v.copyId || record.orgIncarnation !== v.incarnation) deny('credential-revoked');
|
||||
if (record.generation !== v.generation || canonicalJson(grantsOf(record)) !== canonicalJson(v.grants)) deny('restart-required');
|
||||
const { res } = resolve(business, instance, doc, record, incarnation);
|
||||
if (canonicalJson(Object.keys(res.services)) !== canonicalJson(v.services)) deny('restart-required');
|
||||
// A project's remote moved to another server is a new binding: the token never goes there.
|
||||
const reach = boundRepositories(doc, res.services.gitea.projects);
|
||||
if (reach.origin !== v.origin) deny('restart-required');
|
||||
if (record.services.gitea.rotateBy < today()) deny('credential-expired');
|
||||
if (record.services.gitea.rotateBy < today()) lose('credential-expired', 'expired');
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity || identity.dev !== v.identity.dev || identity.ino !== v.identity.ino) deny('credential-unavailable');
|
||||
if (!identity || identity.dev !== v.identity.dev || identity.ino !== v.identity.ino) lose('credential-unavailable', 'unavailable');
|
||||
let token;
|
||||
try {
|
||||
token = credentials.readCopy(slot(instance), 'gitea', v.identity).token;
|
||||
} catch {
|
||||
deny('credential-unavailable');
|
||||
}
|
||||
if (!timingSafeEqual(mac(token), v.mac)) deny('credential-unavailable');
|
||||
if (!timingSafeEqual(mac(token), v.mac)) lose('credential-unavailable', 'unavailable');
|
||||
const authority = sha256(canonicalJson({
|
||||
org, instance, pin: v.pin, copyId: v.copyId, incarnation: v.incarnation, generation: v.generation, grants: v.grants, services: res.services,
|
||||
origin: v.origin, repos: [...reach.repos].sort(),
|
||||
@@ -250,7 +358,12 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
async prepare(business, instance, revision = undefined) {
|
||||
sameRevision(business, revision);
|
||||
const before = current(business, instance, { withToken: true });
|
||||
await prove(before.doc, before.record, before.token, before.record.orgIncarnation, Date.now() + prepareMs);
|
||||
const use = track(instance);
|
||||
try {
|
||||
await use.run(prove(before.doc, before.record, before.token, before.record.orgIncarnation, Date.now() + prepareMs, use.signal));
|
||||
} finally {
|
||||
use.done();
|
||||
}
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== before.authority) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
@@ -263,7 +376,72 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
if (current(business, instance).authority !== digest) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
},
|
||||
revoking: (business, instance) => business === org && Boolean(fences.get(instance)),
|
||||
revoking: (business, instance) => business === org && (Boolean(fences.get(instance)) || closed.has(instance)),
|
||||
// CB4: shut the copy before its revoke publishes anything. It admits nothing and every check
|
||||
// answers `code` until open(); the boot's proof of it is forgotten, so nothing reopens it but a
|
||||
// new boot. Idempotent.
|
||||
close(business, instance, code = 'credential-revoking') {
|
||||
if (business !== org || typeof instance !== 'string') return false;
|
||||
closed.set(instance, code);
|
||||
if (verified.has(instance) && !parked.has(instance)) parked.set(instance, verified.get(instance));
|
||||
verified.delete(instance);
|
||||
lost.add(instance);
|
||||
return true;
|
||||
},
|
||||
// After a definite finish: the record's own code (its tombstone) answers from then on. `restore`
|
||||
// is for an owner's revoke the writer refused before any change: the copy is as it was.
|
||||
open(business, instance, restore = false) {
|
||||
if (business !== org) return;
|
||||
closed.delete(instance);
|
||||
if (restore && parked.has(instance)) {
|
||||
verified.set(instance, parked.get(instance));
|
||||
lost.delete(instance);
|
||||
}
|
||||
parked.delete(instance);
|
||||
},
|
||||
closed: (business, instance) => business === org && closed.has(instance),
|
||||
// Abort every request in flight for the copy and wait for each to settle. Returns how many.
|
||||
async drain(business, instance) {
|
||||
if (business !== org) return 0;
|
||||
const entries = [...(inflight.get(instance) ?? [])];
|
||||
for (const e of entries) e.ac.abort();
|
||||
await Promise.allSettled(entries.map((e) => e.settled));
|
||||
return entries.length;
|
||||
},
|
||||
inflight: (business, instance) => (business === org ? inflight.get(instance)?.size ?? 0 : 0),
|
||||
// Every copy whose record holds a grant, checked as a use would check it (no service call): a
|
||||
// verified copy through current(), an unverified one by its live copy, role and slot. A loss
|
||||
// reaches onLoss. Returns the instances found lost.
|
||||
sweep(business) {
|
||||
if (business !== org) return [];
|
||||
let doc;
|
||||
try {
|
||||
doc = writer.read(business).document;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const found = [];
|
||||
for (const [instance, record] of Object.entries(doc.copyBindings ?? {})) {
|
||||
if (lost.has(instance) || !object(record) || record.state !== 'bound'
|
||||
|| !Object.values(record.services ?? {}).some((x) => typeof x?.grantId === 'string')) continue;
|
||||
try {
|
||||
if (verified.has(instance)) current(business, instance);
|
||||
else {
|
||||
const inc = liveIncarnation();
|
||||
if (inc === null || record.orgIncarnation !== inc) continue;
|
||||
resolve(business, instance, doc, record, inc);
|
||||
if (record.services.gitea?.rotateBy < today()) lose('credential-expired', 'expired');
|
||||
if (!credentials.copyIdentity(slot(instance), 'gitea')) lose('credential-unavailable', 'unavailable');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.loss) {
|
||||
report(business, instance, e.loss);
|
||||
found.push(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
},
|
||||
// A write that can take a copy's grant away runs inside the fence. A definite answer (success,
|
||||
// or a refusal the writer gave before any change) lifts it; an unknown outcome keeps it.
|
||||
async fence(business, instance, fn) {
|
||||
@@ -305,10 +483,21 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
if (READ.includes(method) && body !== undefined) deny('copy-request-refused');
|
||||
const { origin, repos } = before.reach;
|
||||
if (!repos.has(`${m[1]}/${m[2]}`.toLowerCase())) deny('copy-request-refused');
|
||||
const r = await transport({ url: origin, method, path, auth: { token: before.token }, ...(body === undefined ? {} : { body }), ca, timeoutMs });
|
||||
const use = track(instance);
|
||||
let r;
|
||||
try {
|
||||
r = await use.run(transport({ url: origin, method, path, auth: { token: before.token }, ...(body === undefined ? {} : { body }), ca, timeoutMs, signal: use.signal }));
|
||||
} finally {
|
||||
use.done();
|
||||
}
|
||||
// The answer is the session's only while the authority still holds.
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== copyAuthority) deny('credential-revoked');
|
||||
// CB4: the service refusing the token it accepted at boot is an upstream revoke.
|
||||
if (object(r) && r.status === 401) {
|
||||
report(business, instance, 'upstream');
|
||||
deny('credential-revoked');
|
||||
}
|
||||
if (!object(r) || !Number.isSafeInteger(r.status) || r.status === 0) deny('credential-unavailable');
|
||||
if (r.status >= 300 && r.status < 400) deny('copy-redirect-refused');
|
||||
const out = { status: r.status, json: r.json ?? null };
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// #1873 CB4: the revoke of an agent copy's credential binding, for every lifecycle event that ends it
|
||||
// (design docs/plans/2026-11-11_copy-credential-binding-design.md sections 3 and 6).
|
||||
//
|
||||
// One job per copy, in this order, and no lock held across any step:
|
||||
// 1. close the launch gate shuts the copy: no admission, every call refuses (sync, before any
|
||||
// await, so a launch that has not been admitted yet never escapes the stop inventory);
|
||||
// 2. begin the writer records the pending op (`revoking`, a fresh op id) or joins the one pending;
|
||||
// 3. drain the gate aborts the copy's requests in flight and waits for each to settle;
|
||||
// 4. stop every ordinary and thread run of the copy is ended, and the job waits for each run's
|
||||
// end to be acknowledged;
|
||||
// 5. slot the copy's slot file goes;
|
||||
// 6. remote the grant is revoked at the service through the organization's managed adapter, or
|
||||
// recorded `not-managed` when the organization has none;
|
||||
// 7. finish the writer records the unbound tombstone when every step was definite, or keeps the
|
||||
// record revoking with the steps that were not. Only a definite finish reopens the gate
|
||||
// (to the tombstone's own code); the stop is confirmed before that, always.
|
||||
// A throw at any step leaves the record revoking: resume() at the next start runs the same op id
|
||||
// again. Nothing in between resolves, launches or reloads the copy's token. `hooks` are for tests:
|
||||
// hooks[name]() runs at each named point and may throw to simulate a crash there.
|
||||
//
|
||||
// `gate` is the launch gate, or business => gate|null when only some organizations have one (the
|
||||
// gate holds one organization's copies). sweep() also finds a copy a named instance now shadows: the
|
||||
// name is in the file's roles, and its former copy's record is not a finished tombstone.
|
||||
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',
|
||||
'beforeRemote', 'afterRemote', 'beforeReceipt', 'afterReceipt',
|
||||
]);
|
||||
const OWNER_CAUSES = ['revoke', 'unbind'];
|
||||
const definite = (e) => typeof e?.code === 'string' && /^[a-z][a-z0-9-]{1,80}$/.test(e.code) && !e.code.startsWith('outcome-unknown');
|
||||
|
||||
export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote = null, definitions = () => ({}), hooks = {} }) {
|
||||
const jobs = new Map();
|
||||
const gateOf = (business) => (typeof gate === 'function' ? gate(business) : gate) ?? null;
|
||||
const tombstone = (r) => r?.state === 'unbound' && r.revoke?.done === true;
|
||||
const doc = (business) => {
|
||||
try {
|
||||
return writer.read(business).document;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const hook = async (name) => {
|
||||
if (typeof hooks[name] === 'function') await hooks[name]();
|
||||
};
|
||||
const slot = (business, instance) => business + '/' + instance;
|
||||
|
||||
async function job(business, instance, { cause, actor = 'bus', owner = false, expect = undefined }) {
|
||||
const gate = gateOf(business);
|
||||
const wasClosed = gate?.closed?.(business, instance) ?? false;
|
||||
gate?.close(business, instance);
|
||||
let begun;
|
||||
try {
|
||||
await hook('beforePublication');
|
||||
begun = writer.revokeCopyBinding(business, { phase: 'begin', instance, cause, ...(expect ? { expect } : {}) },
|
||||
{ actor, owner, definitions: definitions(business) });
|
||||
} catch (e) {
|
||||
// An owner's verb refused before any change leaves the copy as it was; a loss the bus found
|
||||
// keeps it closed whatever the writer said.
|
||||
if (definite(e) && !wasClosed && OWNER_CAUSES.includes(cause)) gate?.open(business, instance, true);
|
||||
throw e;
|
||||
}
|
||||
await hook('afterPublication');
|
||||
if (begun.state !== 'revoking') {
|
||||
gate?.open(business, instance);
|
||||
return begun;
|
||||
}
|
||||
const op = begun.op;
|
||||
const record = writer.read(business).document.copyBindings?.[instance];
|
||||
if (record?.state !== 'revoking' || record.revoke?.id !== op) throw new BusError('credential-binding-changed');
|
||||
await gate?.drain(business, instance);
|
||||
await hook('beforeStop');
|
||||
let stop = 'failed';
|
||||
try {
|
||||
const r = await stopRuns(business, instance, { op, cause: record.revoke.cause });
|
||||
if (r?.confirmed === true) stop = 'confirmed';
|
||||
} catch {
|
||||
stop = 'failed';
|
||||
}
|
||||
await hook('afterStop');
|
||||
// The gate is closed and drained: nothing reads the slot again, whether or not every run has
|
||||
// acknowledged its end yet. The record stays revoking until it has.
|
||||
await hook('beforeSecret');
|
||||
let secret = 'absent';
|
||||
for (const service of [...new Set([...Object.keys(record.services ?? {}), ...COPY_BINDABLE])].sort()) {
|
||||
let r;
|
||||
try {
|
||||
r = credentials.discardCopy(slot(business, instance), service);
|
||||
} catch {
|
||||
r = 'failed';
|
||||
}
|
||||
if (r === 'failed') secret = 'failed';
|
||||
else if (r === 'removed' && secret !== 'failed') secret = 'removed';
|
||||
}
|
||||
await hook('afterSecret');
|
||||
await hook('beforeRemote');
|
||||
const outcomes = {};
|
||||
for (const service of Object.keys(record.services ?? {}).sort()) {
|
||||
if (typeof record.services[service]?.grantId !== 'string') {
|
||||
outcomes[service] = 'absent';
|
||||
continue;
|
||||
}
|
||||
// An earlier pass of this op already revoked it at the service.
|
||||
if (record.revoke?.remote?.[service] === 'revoked') {
|
||||
outcomes[service] = 'revoked';
|
||||
continue;
|
||||
}
|
||||
if (typeof remote !== 'function') {
|
||||
outcomes[service] = 'not-managed';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
outcomes[service] = await remote(business, instance, record, service);
|
||||
} catch {
|
||||
outcomes[service] = 'failed';
|
||||
}
|
||||
if (!['revoked', 'absent', 'not-managed', 'failed'].includes(outcomes[service])) outcomes[service] = 'failed';
|
||||
}
|
||||
await hook('afterRemote');
|
||||
await hook('beforeReceipt');
|
||||
const done = writer.revokeCopyBinding(business, { phase: 'finish', instance, opId: op, steps: { stop, slot: secret, remote: outcomes } },
|
||||
{ actor: 'bus', owner: true, definitions: definitions(business) });
|
||||
await hook('afterReceipt');
|
||||
if (done.state === 'unbound') gate?.open(business, instance);
|
||||
else gate?.close(business, instance, 'credential-revoke-failed');
|
||||
return { ...done, steps: { stop, slot: secret, remote: outcomes } };
|
||||
}
|
||||
|
||||
const revoker = {
|
||||
// Run (or join) the copy's revoke and wait for it. Refusals propagate.
|
||||
run(business, instance, opts) {
|
||||
const key = business + '/' + instance;
|
||||
if (jobs.has(key)) return jobs.get(key);
|
||||
const p = job(business, instance, opts).finally(() => jobs.delete(key));
|
||||
jobs.set(key, p);
|
||||
return p;
|
||||
},
|
||||
// Fire and forget, for a loss the gate found: the job's outcome is in the record and its receipt.
|
||||
start(business, instance, opts) {
|
||||
revoker.run(business, instance, opts).catch(() => {});
|
||||
},
|
||||
has: (business, instance) => jobs.has(business + '/' + instance),
|
||||
// The copy's live binding record (pending, bound or revoking), or null. An unreadable file is null:
|
||||
// the verbs then take their ordinary path, which refuses on the same read.
|
||||
binding(business, instance) {
|
||||
const r = doc(business)?.copyBindings?.[instance];
|
||||
return r && ['pending', 'bound', 'revoking'].includes(r.state) ? r : null;
|
||||
},
|
||||
// True while the name's former copy still holds a record that is not a finished tombstone, or its
|
||||
// revoke runs: its slot is not the named instance's yet. An unreadable file answers true.
|
||||
pending(business, instance) {
|
||||
if (jobs.has(business + '/' + instance)) return true;
|
||||
const d = doc(business);
|
||||
if (d === null) return true;
|
||||
const r = d.copyBindings?.[instance];
|
||||
return r !== undefined && !tombstone(r);
|
||||
},
|
||||
// The gate's checks for every copy holding a grant, and each shadowed name's cleanup. Returns
|
||||
// nothing; each loss starts its revoke.
|
||||
sweep(business) {
|
||||
try {
|
||||
gateOf(business)?.sweep(business);
|
||||
} catch {}
|
||||
const d = doc(business);
|
||||
if (d === null) return;
|
||||
for (const [instance, r] of Object.entries(d.copyBindings ?? {}))
|
||||
if (Object.hasOwn(d.roles ?? {}, instance) && !tombstone(r) && r.state !== 'revoking')
|
||||
revoker.start(business, instance, { cause: 'shadowed', actor: 'bus' });
|
||||
},
|
||||
// Every revoke a previous run of the bus left pending, run again with its own op id. The owner
|
||||
// authorized an owner cause when it began; the host finishes it. Returns the settled outcomes.
|
||||
async resume(business) {
|
||||
const d = doc(business);
|
||||
if (d === null) return [];
|
||||
const pending = Object.entries(d.copyBindings ?? {}).filter(([, r]) => r?.state === 'revoking');
|
||||
// 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 })));
|
||||
},
|
||||
};
|
||||
return Object.freeze(revoker);
|
||||
}
|
||||
|
||||
// The remote step for a Gitea grant, through the organization's managed admin slot (row 236's): the
|
||||
// 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, 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, 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 ?? '');
|
||||
if (!m) return 'failed';
|
||||
let origin;
|
||||
try {
|
||||
origin = boundRepositories(read(business), record.services.gitea.resources?.projects).origin;
|
||||
} catch {
|
||||
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 {
|
||||
admin = grant.admin(business, (op, run) => run());
|
||||
} catch (e) {
|
||||
return e?.code === 'grant-admin-missing' ? 'not-managed' : 'failed';
|
||||
}
|
||||
let adminOrigin = null;
|
||||
try {
|
||||
adminOrigin = new URL(admin.meta.url).origin;
|
||||
} catch {}
|
||||
if (adminOrigin !== origin) return 'not-managed';
|
||||
if (admin.meta.rotateBy < today()) return 'failed';
|
||||
const login = copyBotLogin(record.orgIncarnation, record.copyId, 'gitea');
|
||||
if (!login) return 'failed';
|
||||
try {
|
||||
await grant.adminChecks(admin.api, admin.meta.account);
|
||||
const r = await admin.api.tokenDelete(login, m[2]);
|
||||
if (r?.status === 204 || r?.status === 200) return 'revoked';
|
||||
if (r?.status === 404) return 'absent';
|
||||
return 'failed';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
@@ -618,6 +618,45 @@ export class Credentials {
|
||||
unlinkSync(file);
|
||||
return true;
|
||||
}
|
||||
// #1873 CB4 (design section 3, revoke step "slot"): the slot of a copy whose binding is being
|
||||
// revoked goes, and its temp file with it, whatever file is there now; a named instance that
|
||||
// shadows a former copy's name shares the path, and its entry is held until this runs (runtime
|
||||
// boot), so the slot is never a token the bus loaded. An entry with a token in memory refuses:
|
||||
// that slot is the named instance's own. Returns 'removed' or 'absent'; a directory or an
|
||||
// unsafe parent refuses credential-file and the slot stays for the retry.
|
||||
discardCopy(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#tokens.has(k) || !SERVICES.includes(service)) deny('credential-reference');
|
||||
const file = this.#storePath(instance, service);
|
||||
let removed = false;
|
||||
try {
|
||||
privateDir(this.#storeRoot, { missing: true });
|
||||
privateDir(dirname(dirname(file)), { missing: true });
|
||||
privateDir(dirname(file), { missing: true });
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
}
|
||||
for (const path of [file + '.tmp', file]) {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') continue;
|
||||
deny('credential-file');
|
||||
}
|
||||
if (st.isDirectory()) deny('credential-file');
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
if (path === file) removed = true;
|
||||
}
|
||||
this.#inflight.get(k)?.abort();
|
||||
this.#held.delete(k);
|
||||
return removed ? 'removed' : 'absent';
|
||||
}
|
||||
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
|
||||
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
|
||||
reload(instance, service, date) {
|
||||
|
||||
@@ -92,21 +92,29 @@ export { definitionSha256 };
|
||||
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
|
||||
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
|
||||
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
|
||||
// X-Total-Count; it holds no header and no request detail.
|
||||
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
|
||||
// X-Total-Count; it holds no header and no request detail. An aborted `signal` (#1873 CB4: a revoke
|
||||
// draining a copy's in-flight call) destroys the request and answers status 0.
|
||||
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS, signal = undefined }) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false,
|
||||
req = null;
|
||||
const onAbort = () => {
|
||||
req?.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
};
|
||||
const end = (r) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
resolve(r);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
req?.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
}, timeoutMs);
|
||||
if (signal?.aborted) return onAbort();
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
try {
|
||||
const headers = {
|
||||
accept: 'application/json',
|
||||
|
||||
@@ -37,6 +37,9 @@ group("copy-request-refused", "R1/R8: a copy calls only its bound repositories w
|
||||
group("organization-changed organization-unreadable", "R3/R8: a launch holds to the organization file revision it resolved", "The organization file changed, or could not be read, while the launch was in progress. Launch the agent again so it resolves the file as it stands now.");
|
||||
group("copy-redirect-refused", "R1/R8: a copy credential goes only to the approved Gitea origin", "The Gitea server answered with a redirect. Fix the bound project's remote to name the server's final address, then ask the owner to issue the credential again; redirects are never followed.");
|
||||
group("project-repo-bound", "R1/R8: a binding grants the repository its projects name when the owner approved it", "Revoke the copy binding that names the project first: the chief raises an unbind decision and the owner approves it. Then set the project repository, then ask the chief to raise a new binding decision for the owner.");
|
||||
// #1873 CB4: a copy of an organization that is archived, being deleted or recreated never launches
|
||||
// or calls; its bindings were made for the organization as it was.
|
||||
group("org-archived org-unavailable", "R1/R8: a copy works only for the active organization it was bound in", "Read the organization's state in the owner Console. Ask the owner to restore it if it is archived; a deleted or recreated organization needs a fresh binding decision for each copy.");
|
||||
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
|
||||
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
|
||||
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
|
||||
|
||||
@@ -45,9 +45,11 @@ import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './defini
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { COPY_BINDABLE, loadWithCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
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';
|
||||
@@ -282,6 +284,8 @@ function modelRunInventory(dataRoot, bound, credentials) {
|
||||
};
|
||||
}
|
||||
|
||||
// #1873 CB4: how often the bus re-checks the copies holding a grant, and retries a pending revoke.
|
||||
const COPY_SWEEP_MS = 60000;
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
instanceRoot = null,
|
||||
@@ -343,7 +347,10 @@ export async function startBroker({
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
|
||||
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
|
||||
const modelRuns = new Map();
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null, copyLaunch = null;
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null, copyLaunch = null, copyRevoker = null, copyTimer = null;
|
||||
// #1873 CB4: a revoke's stop waits for the broker; a start that fails before it exists stops nothing.
|
||||
let brokerUp = null;
|
||||
const brokerReady = new Promise((resolve) => { brokerUp = resolve; });
|
||||
try {
|
||||
// Lifecycle Task 2 (#797): a product whose mapping is published but not yet activated is not part of
|
||||
// this process. A managed product is part of it only with both a committed activate receipt in the
|
||||
@@ -421,6 +428,13 @@ export async function startBroker({
|
||||
});
|
||||
for (const entry of sweepCopySlots(storeRoot, documents, references))
|
||||
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
|
||||
// #1873 CB4: a named instance shares its slot path with a former copy of the same name. While
|
||||
// that copy's record is not a finished tombstone, the slot is held (never loaded as the named
|
||||
// instance's) until the shadowed revoke discards it.
|
||||
for (const [id, doc] of documents)
|
||||
for (const [name, r] of Object.entries(doc?.copyBindings ?? {}))
|
||||
if (Object.hasOwn(doc.roles ?? {}, name) && !(r?.state === 'unbound' && r.revoke?.done === true))
|
||||
for (const service of new Set([...COPY_BINDABLE, ...Object.keys(r?.services ?? {})])) held.push(id + '/' + name + ':' + service);
|
||||
}
|
||||
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
|
||||
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
|
||||
@@ -446,9 +460,26 @@ export async function startBroker({
|
||||
// `businesses`. A binding approved later answers restart-required. copyGrant keeps the boot's own
|
||||
// snapshot (bootCopies), and so does the gate: what it promotes is decided at boot only.
|
||||
const bootCopies = copies;
|
||||
// #1873 CB4: the revoke of a copy's binding, for every organization this bus serves. The gate (one
|
||||
// organization's) closes and drains the copy; the broker stops its runs once it is up.
|
||||
if (organization && storeRoot) {
|
||||
const revokeWriter = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
|
||||
const grant = giteaGrant({ configDir: organization.dir, ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) });
|
||||
copyRevoker = makeCopyRevoker({
|
||||
writer: revokeWriter, credentials,
|
||||
gate: (id) => (copyLaunch && bootCopies?.org === id ? copyLaunch : null),
|
||||
stopRuns: async (id, instance, opts) => {
|
||||
const b = await brokerReady;
|
||||
return b ? b.stopCopyRuns(id, instance, opts) : { confirmed: false };
|
||||
},
|
||||
remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document, incarnation: (id) => readOrgEntrySync(organization.dir, id)?.incarnation ?? null }),
|
||||
definitions: (id) => (bootCopies?.org === id ? bootCopies.definitions ?? {} : {}),
|
||||
});
|
||||
}
|
||||
if (organization && storeRoot && copies) {
|
||||
copyLaunch = copyLaunchGate({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
configDir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies, ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) });
|
||||
configDir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies, ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}),
|
||||
onLoss: (id, instance, cause) => copyRevoker?.start(id, instance, { cause, actor: 'bus' }) });
|
||||
await copyLaunch.boot(copies.org);
|
||||
const verified = copyLaunch.bound();
|
||||
if (Object.keys(verified).length) {
|
||||
@@ -845,6 +876,7 @@ export async function startBroker({
|
||||
copies: bootCopies, organizations: () => Object.keys(businesses), ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) })
|
||||
: null,
|
||||
copyLaunch,
|
||||
copyRevoker,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -1328,6 +1360,20 @@ export async function startBroker({
|
||||
requestLifecycle.recover({ limit: 64 }).catch(e => process.stderr.write(`request: ${JSON.stringify({ event: 'reconcile-refused', code: e.code ?? 'request-failed' })}\n`));
|
||||
}, 30000);
|
||||
requestTimer.unref();
|
||||
// #1873 CB4: a revoke a previous run left pending finishes (or reports) with its own op id before
|
||||
// anything else can use the copy, then each copy holding a grant is checked now and every minute.
|
||||
brokerUp(broker);
|
||||
if (copyRevoker) {
|
||||
const pass = () => {
|
||||
for (const id of Object.keys(businesses)) {
|
||||
copyRevoker.resume(id).catch(() => {});
|
||||
copyRevoker.sweep(id);
|
||||
}
|
||||
};
|
||||
pass();
|
||||
copyTimer = setInterval(pass, COPY_SWEEP_MS);
|
||||
copyTimer.unref();
|
||||
}
|
||||
return {
|
||||
broker,
|
||||
requestLifecycle,
|
||||
@@ -1456,6 +1502,8 @@ export async function startBroker({
|
||||
launches: bound,
|
||||
readers: readCaps,
|
||||
async close() {
|
||||
clearInterval(copyTimer);
|
||||
brokerUp(null);
|
||||
clearInterval(requestTimer);
|
||||
clearInterval(usageTimer);
|
||||
await proxyUsage.close();
|
||||
@@ -1472,6 +1520,8 @@ export async function startBroker({
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
clearInterval(copyTimer);
|
||||
brokerUp(null);
|
||||
clearInterval(requestTimer);
|
||||
clearInterval(usageTimer);
|
||||
await proxyUsage?.close();
|
||||
|
||||
@@ -367,17 +367,19 @@ const registryEdit = (f, fn) => {
|
||||
};
|
||||
|
||||
test('CB3 an organization no longer active admits and sends nothing, with the binding and incarnation unchanged', async (t) => {
|
||||
for (const state of ['archived', 'deleting', 'reserved']) {
|
||||
// #1873 CB4 (design section 6): an archived organization answers org-archived, any other state that
|
||||
// is not active org-unavailable (CB3 answered credential-revoked for both).
|
||||
for (const [state, code] of [['archived', 'org-archived'], ['deleting', 'org-unavailable'], ['reserved', 'org-unavailable']]) {
|
||||
await t.test(`${state} after launch`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.state = state; });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoked'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses(code));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses(code));
|
||||
assert.equal(f.sent.length, before);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
@@ -390,7 +392,7 @@ test('CB3 an organization no longer active admits and sends nothing, with the bi
|
||||
await h.arrived;
|
||||
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||
h.release();
|
||||
await assert.rejects(preparing, refuses('credential-revoked'));
|
||||
await assert.rejects(preparing, refuses('org-archived'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test('archived before boot', async (t) => {
|
||||
@@ -416,9 +418,9 @@ test('CB3 an organization recreated under the same id (a new incarnation) admits
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.incarnation = 'f'.repeat(32); });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('org-unavailable'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('org-unavailable'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('org-unavailable'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// #1873 CB4 live acceptance: the owner's revoke of a bound copy against a scratch gitea/gitea:1.27.1, a
|
||||
// throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after. A bound copy boots, launches
|
||||
// and reads its repository; the owner revokes; the run is stopped and acknowledged, the slot goes, the
|
||||
// copy's token is deleted at the container through the organization's admin slot, and the next call on
|
||||
// the launch's authority refuses before any request. The test never pulls an image; without Docker or
|
||||
// the image it skips. Every token here is a scratch token this container made; none is printed, and
|
||||
// nothing here reaches git.mosaicstack.dev or any real service.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { copyRevoker, giteaCopyRemote } from '../src/copy-revoke.mjs';
|
||||
import { giteaGrant, send } from '../src/gitea-grant.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, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB4-D: on scratch 1.27.1 the owner revokes a bound copy: run stopped, slot gone, token deleted at the service, and the next call refuses unsent', { skip, timeout: 300000 }, async (t) => {
|
||||
// The child's complete TAP output is captured before any of it reaches this runner, so the parent
|
||||
// can search it for the minted values without putting one in an assertion.
|
||||
if (!process.env.CB4_REVOKE_CAPTURE) {
|
||||
const capture = mkdtempSync(join(tmpdir(), 'cb4cap-'));
|
||||
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
||||
const file = join(capture, 'minted');
|
||||
const childEnv = { ...process.env, CB4_REVOKE_CAPTURE: file };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB4-D:', fileURLToPath(import.meta.url)], {
|
||||
encoding: 'utf8', timeout: 280000, env: childEnv,
|
||||
});
|
||||
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
||||
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
||||
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
||||
t.diagnostic(output);
|
||||
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
||||
assert.equal(child.status, 0, 'Docker child passed');
|
||||
assert.equal(values.length, 2, 'both scratch tokens were minted: the acceptance actually ran');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cb4d-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`, name = 'cb4-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
||||
assert.equal(started.status, 0, 'scratch container starts');
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const minted = [];
|
||||
const createUser = (user, admin = false) => {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
||||
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, `create scratch ${user}`);
|
||||
};
|
||||
const mint = (user, scopes) => {
|
||||
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb4-' + randomBytes(4).toString('hex'),
|
||||
'--scopes', scopes.join(','), '--raw');
|
||||
assert.equal(r.status, 0, `mint scratch ${user}`);
|
||||
const value = r.stdout.trim();
|
||||
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
||||
minted.push(value);
|
||||
writeFileSync(process.env.CB4_REVOKE_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
||||
return value;
|
||||
};
|
||||
// The organization's dedicated grant administrator (owns no repository) and the repository owner.
|
||||
createUser('mosaic-grant', true);
|
||||
createUser('owner');
|
||||
const adminToken = mint('mosaic-grant', ['write:admin', 'write:repository', 'write:user']);
|
||||
const admin = 'token ' + adminToken, basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
|
||||
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: 'stack', auto_init: true, private: true } });
|
||||
assert.equal(made.status, 201, 'create owner/stack');
|
||||
|
||||
// The organization: one root agent "rev" (research, Gitea alone) bound read-only to project stack,
|
||||
// and the organization's Gitea admin slot on this container.
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb4o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${url}/owner/stack`;
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo');
|
||||
await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'research' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const grant = giteaGrant({ configDir: root, ca, timeoutMs: 10000 });
|
||||
grant.writeAdmin('demo', adminToken, { url, account: 'mosaic-grant', rotateBy: ahead(60), setAt: new Date().toISOString() });
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
t.after(() => credentials.close?.());
|
||||
// Every request the gate sends, by op only.
|
||||
const sent = [];
|
||||
const transport = (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
let revoker = null, b = null;
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000,
|
||||
onLoss: (business, instance, cause) => revoker?.start(business, instance, { cause, actor: 'bus' }) });
|
||||
// The launcher's part of a stop: every run the broker told it to kill exits and is acknowledged.
|
||||
const killed = () => store.all("SELECT body FROM events WHERE kind='action.allowed'").map((r) => JSON.parse(r.body))
|
||||
.filter((x) => x.action === 'lifecycle.end' && x.kill === true).flatMap((x) => x.runs);
|
||||
const stops = [];
|
||||
const stopRuns = async (business, instance, opts) => {
|
||||
stops.push(instance);
|
||||
const p = b.stopCopyRuns(business, instance, { ...opts, timeoutMs: 5000 });
|
||||
for (const run of killed()) try { b.endLaunch({ business, run, reason: 'killed' }); } catch {}
|
||||
return p;
|
||||
};
|
||||
revoker = copyRevoker({ writer, gate, credentials, stopRuns, remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document }),
|
||||
definitions: () => snapshot.definitions ?? {} });
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
||||
b = new Broker({
|
||||
store, businesses: { demo: business() }, copyLaunch: gate, copyRevoker: revoker,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
||||
});
|
||||
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services: READ } });
|
||||
const rec = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8')).copyBindings.rev;
|
||||
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
||||
createUser(login);
|
||||
assert.equal((await call(url, ca, 'PUT', `/api/v1/repos/owner/stack/collaborators/${login}`, { auth: admin, body: { permission: 'read' } })).status, 204,
|
||||
'bot is a read collaborator on owner/stack');
|
||||
const botToken = mint(login, ['read:repository', 'read:user']);
|
||||
assert.equal((await request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token: botToken })).state, 'bound');
|
||||
const tokens = async () => (await call(url, ca, 'GET', `/api/v1/users/${login}/tokens`, { auth: basic })).json;
|
||||
assert.equal((await tokens()).length, 1, 'the bot holds one token at the service');
|
||||
assert.match(rec().services.gitea.grantId, /^gitea:\d+:\d+$/, 'the record names the grant by ids');
|
||||
|
||||
// Boot, launch, one checked call on the launch's authority.
|
||||
await gate.boot('demo');
|
||||
assert.deepEqual(gate.bound(), { rev: ['gitea'] });
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness('demo', business());
|
||||
const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
const thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id;
|
||||
const revision = writer.read('demo').revision;
|
||||
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision });
|
||||
const session = { business: 'demo', role: 'rev', copyAuthority: authority };
|
||||
const own = await gate.call(b.copySession(cap), { method: 'GET', path: '/api/v1/repos/owner/stack' });
|
||||
assert.equal(own.status, 200);
|
||||
assert.equal(own.json.full_name, 'owner/stack');
|
||||
|
||||
// The owner's revoke.
|
||||
const out = await request_(human, 'credentials.revoke', { agent: 'rev', service: 'gitea', reason: 'Owner revoke' });
|
||||
assert.ok(out, 'the revoke answers');
|
||||
const r = rec();
|
||||
assert.equal(r.state, 'unbound');
|
||||
assert.equal(r.revoke.done, true);
|
||||
assert.equal(r.revoke.cause, 'revoke');
|
||||
assert.deepEqual(r.revoke.remote, { gitea: 'revoked' }, 'the record says the token was revoked at the service');
|
||||
assert.deepEqual(r.services, {});
|
||||
assert.ok(!existsSync(join(storeRoot, 'demo', 'rev', 'gitea')), 'slot removed');
|
||||
assert.deepEqual(stops, ['rev'], 'one stop, acknowledged');
|
||||
assert.ok(killed().includes('rev-run'), 'the run was told to stop');
|
||||
assert.throws(() => b.copySession(cap), (e) => e instanceof BusError, 'the launch session is gone');
|
||||
|
||||
// At the service: the bot has no token, and the old token itself is refused by Gitea.
|
||||
assert.deepEqual(await tokens(), [], 'token deleted at the service');
|
||||
const direct = await call(url, ca, 'GET', '/api/v1/user', { auth: 'token ' + botToken });
|
||||
assert.equal(direct.status, 401, 'Gitea refuses the revoked token');
|
||||
|
||||
// The next call on the launch's authority, and a new prepare: both refuse, nothing sent.
|
||||
const before = sent.length;
|
||||
await assert.rejects(gate.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' }), (e) => e instanceof BusError);
|
||||
await assert.rejects(gate.prepare('demo', 'rev', writer.read('demo').revision), { code: 'credential-revoked' });
|
||||
assert.equal(sent.length, before, 'no request after the revoke');
|
||||
t.diagnostic(`minted ${minted.length}`);
|
||||
});
|
||||
@@ -0,0 +1,748 @@
|
||||
// #1873 CB4 (design sections 3 and 6, section 8 row CB4): every lifecycle event that ends an agent
|
||||
// copy's credential binding closes its admissions, drains its calls in flight, stops each of its runs
|
||||
// and waits for the stop to be acknowledged, removes its slot, revokes the grant at the service
|
||||
// through the organization's managed admin slot (or records why not), and only then writes the
|
||||
// tombstone. A crash at any point leaves a pending op that the next start finishes under the same
|
||||
// id; nothing in between resolves, launches or reloads the copy's token. The fake Gitea is
|
||||
// fake-gitea-api.mjs on 127.0.0.1; the live check against a real Gitea is copy-revoke-docker.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
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, 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, 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';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const LOCAL = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const CODES = ['credential-revoke-failed', 'credential-revoking', 'credential-revoked', 'credential-unbound', 'credential-unavailable',
|
||||
'credential-expired', 'credential-role-changed', 'copy-unavailable', 'copy-refused', 'org-archived', 'org-unavailable'];
|
||||
|
||||
const refuses = (code) => (e) => { assert.ok(e instanceof BusError || e?.code, String(e?.stack ?? e)); assert.equal(e.code, code); return true; };
|
||||
async function until(pred, what, ms = 8000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await pred()) return;
|
||||
await sleep(10);
|
||||
}
|
||||
assert.fail(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
// One scratch root with one fake Gitea, shared by every organization a test makes, as one stack
|
||||
// instance's configuration directory is.
|
||||
async function world(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb4-')), rolesDir = rolesCopy(root);
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const api = await fakeGiteaApi(t, root);
|
||||
const data = join(root, 'data'); mkdirSync(data, { mode: 0o700 });
|
||||
return { root, rolesDir, api, data };
|
||||
}
|
||||
|
||||
// Organization `id` with project stack on the fake Gitea and the root copy `name` (research: it
|
||||
// needs gitea alone). bind() has the owner bind the copy and issue one live token for it. start()
|
||||
// builds the bus for the organization (store, credentials, gate, revoker, broker) on the files as
|
||||
// they stand, as a restart of the bus process would; boot() runs the gate's boot proof and promotes.
|
||||
// stopRuns acknowledges each run the broker told the launcher to kill, as the launcher's exit does,
|
||||
// unless `ack` is false. The revoke's remote step goes to the organization's admin slot.
|
||||
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false } = {}) {
|
||||
const { root, rolesDir, api } = w;
|
||||
const owner = id === 'demo' ? 'owner' : id;
|
||||
const doc = { ...businessDoc(root, id), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${api.url}/${owner}/stack`;
|
||||
// A project set up in git remote mode names its repository there, with no top-level remote.
|
||||
if (gitOnly) { doc.projects.stack.git = { mode: 'remote', remote: doc.projects.stack.remote }; delete doc.projects.stack.remote; }
|
||||
// The project root the organization service derives (archive and delete check it).
|
||||
doc.projects.stack.root = join(w.data, 'org-runtime', id, 'projects', 'stack');
|
||||
if (project) doc.projects.stack.agents = project;
|
||||
const file = writeJson(join(root, `businesses/${id}.json`), doc);
|
||||
chmodSync(join(root, 'businesses'), 0o700);
|
||||
const ctx = { configDir: root, dataRoot: w.data, instance: 'test', principal: LOCAL };
|
||||
const entry = await reserveOrg(ctx, id);
|
||||
// The organization service's owner proof, as createOrg writes it.
|
||||
writeFileSync(join(entry.root, 'owner.json'), JSON.stringify(LOCAL) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
mkdirSync(doc.projects.stack.root, { recursive: true, mode: 0o700 });
|
||||
await activateOrg(ctx, id);
|
||||
const writer = organizationWriter({ dir: root, rolesDir });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
||||
const grant = giteaGrant({ configDir: root, ca: api.ca, timeoutMs: 3000 });
|
||||
if (admin) grant.writeAdmin(id, api.adminToken, { url: api.url, account: 'mosaic-grant', rotateBy: adminRotateBy, setAt: new Date().toISOString() });
|
||||
const repo = api.addRepo(owner, 'stack');
|
||||
const storeDir = join(root, `store-${id}`); mkdirSync(storeDir, { mode: 0o700 });
|
||||
const f = {
|
||||
id, name, root, rolesDir, api, file, writer, ctx, storeRoot, grant, repo, stopMs, ack: true, sent: [], stops: [],
|
||||
clock: Date.now(), hooks: {}, rootAgents: { [name]: { definition } }, project: null, runs: [],
|
||||
GET: { method: 'GET', path: `/api/v1/repos/${owner}/stack` },
|
||||
};
|
||||
f.slot = join(storeRoot, id, name, 'gitea');
|
||||
f.rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
|
||||
f.advance = (days) => { f.clock += days * 86400000; };
|
||||
// The owner's edit of the organization file, through the writer (a hand edit breaks the registry
|
||||
// digest fence, and the writer then refuses every write, a revoke's included).
|
||||
f.edit = (fn) => { const { document, revision } = writer.read(id); const d = structuredClone(document); fn(d); writer.write(id, d, { revision, actor: 'local-dev-owner' }); };
|
||||
f.call = (cap, verb, args = {}) => Promise.resolve().then(() => f.b.request(cap, { verb, args }));
|
||||
f.repoCalls = () => f.sent.filter((x) => x.includes('/api/v1/repos/')).length;
|
||||
f.killed = () => f.store.all("SELECT body FROM events WHERE kind='action.allowed'").map((r) => JSON.parse(r.body))
|
||||
.filter((x) => x.action === 'lifecycle.end' && x.kill === true).flatMap((x) => x.runs);
|
||||
f.stopRuns = async (business, instance, opts) => {
|
||||
f.stops.push({ instance, ...opts });
|
||||
const p = f.b.stopCopyRuns(business, instance, { ...opts, timeoutMs: f.stopMs });
|
||||
if (f.ack) for (const run of f.killed()) try { f.b.endLaunch({ business, run, reason: 'killed' }); } catch {}
|
||||
return p;
|
||||
};
|
||||
// A start of the bus process on the files as they stand: the boot snapshot from `rootAgents`
|
||||
// (the root agents file at this start), and a fresh store handle, credentials, gate and revoker.
|
||||
f.start = (rootAgents = f.rootAgents) => {
|
||||
try { f.store?.close(); } catch {}
|
||||
try { f.credentials?.close(); } catch {}
|
||||
const now = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const snapshot = materializeCopies({ orgId: id, doc: now, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: rootAgents }, { rolesDir }) }, rolesDir });
|
||||
const store = new Store(storeDir);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot: storeDir, configDir: root, assertClean: () => {}, copies: (x) => snapshot.org === x ? snapshot : null });
|
||||
const transport = async (req) => { f.sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
let revoker = null;
|
||||
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, incarnation: (x) => readOrgEntrySync(root, x)?.incarnation ?? null }), definitions: () => snapshot.definitions ?? {} });
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies(id, { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issueGrant = (...a) => copyGrant({ credentials, writer, copies: snapshot, organizations: () => [id], ca: api.ca, timeoutMs: 3000 })(...a);
|
||||
const b = new Broker({ store, businesses: { [id]: business() }, copyLaunch: gate, copyRevoker: revoker,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness(id, business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) });
|
||||
Object.assign(f, { store, credentials, gate, revoker, b, snapshot, business, reload: () => b.reloadBusiness(id, business()) });
|
||||
f.owner = b.bindConsole({ business: id, human: 'jason', subject: SUBJECT });
|
||||
f.human = b.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
|
||||
f.boot = async () => {
|
||||
await gate.boot(id);
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness(id, business());
|
||||
};
|
||||
return f;
|
||||
};
|
||||
t.after(() => { try { f.store?.close(); } catch {} try { f.credentials?.close(); } catch {} });
|
||||
f.bind = async (hire = { level: 'root', name }) => {
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', ...hire, reason: 'Bind the copy', binding: { services } });
|
||||
const r = f.rec();
|
||||
f.login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
||||
f.bot = api.addUser(f.login).user;
|
||||
repo.collaborators.push(f.login); repo.permissions[f.login] = perm;
|
||||
f.token = api.addToken(f.login, `t${api.issued.length}`, services.gitea.scopes);
|
||||
await f.call(f.human, 'credentials.issue', { agent: name, service: 'gitea', date: ahead(30), token: f.token.sha1 });
|
||||
};
|
||||
f.launch = async (run) => {
|
||||
f.project ??= (await f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
const thread = (await f.call(f.human, 'thread.create', { project: f.project, name: run, instance: name })).id;
|
||||
const revision = writer.read(id).revision;
|
||||
const { authority } = await f.gate.prepare(id, name, revision);
|
||||
const cap = f.b.bindLaunch({ business: id, role: name, run, harness: 'pi', thread, copyAuthority: authority, revision });
|
||||
f.runs.push(run);
|
||||
return { run, thread, authority, cap };
|
||||
};
|
||||
f.use = (cap, request = f.GET) => Promise.resolve().then(() => f.gate.call(f.b.copySession(cap), request));
|
||||
// The launcher's prepare at the file's revision (none when the file is gone: a deleted organization).
|
||||
f.prepare = () => Promise.resolve().then(() => {
|
||||
let revision;
|
||||
try { revision = writer.read(id).revision; } catch { revision = undefined; }
|
||||
return f.gate.prepare(id, name, revision);
|
||||
});
|
||||
// The next request matching `match` waits at the fake server until release() lets it through.
|
||||
f.hold = (match) => {
|
||||
let release, arrived;
|
||||
const go = new Promise((r) => { release = r; });
|
||||
const seen = new Promise((r) => { arrived = r; });
|
||||
const unrule = api.rule((e, body) => match(e, body), async () => { unrule(); arrived(); await go; return { pass: true }; });
|
||||
return { release: () => release(), arrived: seen };
|
||||
};
|
||||
f.start();
|
||||
return f;
|
||||
}
|
||||
|
||||
// A bound, booted copy with one live run: what every lifecycle test starts from.
|
||||
async function live(t, w, opts) {
|
||||
const f = await org(t, w, opts);
|
||||
await f.bind();
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
f.start();
|
||||
await f.boot();
|
||||
assert.deepEqual(Object.keys(f.gate.bound()), [f.name]);
|
||||
const r = await f.launch('run-1');
|
||||
assert.equal((await f.use(r.cap)).status, 200);
|
||||
return { f, r };
|
||||
}
|
||||
const tombstoned = (f) => f.rec()?.state === 'unbound' && f.rec()?.revoke?.done === true;
|
||||
// The end state of every completed revoke: the run is gone, the slot is gone, the token is gone at
|
||||
// the service (or the record says why it was not attempted there), and the copy refuses with
|
||||
// nothing sent.
|
||||
async function assertRevoked(f, r, { cause, code, remote = 'revoked' }) {
|
||||
await until(() => tombstoned(f), `${cause} tombstone`);
|
||||
const rec = f.rec();
|
||||
assert.equal(rec.revoke.cause, cause);
|
||||
assert.deepEqual(rec.revoke.remote, { gitea: remote });
|
||||
assert.deepEqual(rec.services, {});
|
||||
assert.ok(!existsSync(f.slot), 'slot removed');
|
||||
if (remote === 'revoked') assert.deepEqual(f.bot.tokens, [], 'token deleted at the service');
|
||||
if (r) {
|
||||
assert.ok(f.killed().includes(r.run), 'the run was told to stop');
|
||||
assert.throws(() => f.b.copySession(r.cap), (e) => e instanceof BusError);
|
||||
}
|
||||
const sent = f.sent.length;
|
||||
await assert.rejects(f.prepare(), refuses(code));
|
||||
assert.equal(f.sent.length, sent, 'a refused prepare sends nothing');
|
||||
assert.ok(!JSON.stringify(rec).includes(f.token.sha1));
|
||||
}
|
||||
|
||||
test('CB4 every revoke refusal code is in the refusal table with a rail and a next step naming no path', () => {
|
||||
for (const code of CODES) {
|
||||
const entry = REFUSALS[code];
|
||||
assert.ok(entry, code);
|
||||
assert.match(entry.rail, /^R\d+[/:]/, code);
|
||||
assert.ok(entry.nextStep.length > 20, code);
|
||||
assert.ok(!/(^|\s)(\/|~\/)\S/.test(entry.nextStep), `${code} names a path`);
|
||||
}
|
||||
assert.deepEqual(REVOKE_POINTS.length, 10);
|
||||
});
|
||||
|
||||
for (const verb of ['credentials.revoke', 'credentials.delete']) {
|
||||
test(`CB4 the owner's ${verb} of a bound copy: closed, drained, every run stopped and acknowledged, slot gone, token revoked at Gitea, then tombstone`, async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
const r2 = await f.launch('run-2');
|
||||
// A call in flight when the revoke starts is aborted and settles before the stop.
|
||||
const held = f.hold((e) => e.path === f.GET.path);
|
||||
let settledBeforeRelease = false, released = false;
|
||||
const inflight = f.use(r2.cap).then((x) => ({ ok: x }), (e) => ({ err: e })).finally(() => { settledBeforeRelease = !released; });
|
||||
await held.arrived;
|
||||
assert.equal(f.gate.inflight(f.id, f.name), 1);
|
||||
const args = verb === 'credentials.revoke' ? { agent: f.name, service: 'gitea', reason: 'Owner revoke' } : { agent: f.name, reason: 'Owner delete' };
|
||||
const out = await f.call(f.human, verb, args);
|
||||
// The drain aborted the call and waited for it: it settled before the server would ever answer.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(f.gate.inflight(f.id, f.name), 0, 'drained before the revoke answered');
|
||||
assert.equal(settledBeforeRelease, true, 'the in-flight call settled before the server released it');
|
||||
released = true;
|
||||
held.release();
|
||||
assert.equal(out.state, 'unbound');
|
||||
assert.deepEqual(out.remote, { gitea: 'revoked' });
|
||||
const settled = await inflight;
|
||||
assert.ok(settled.err || settled.ok?.status === 0, 'the in-flight call was aborted');
|
||||
assert.equal(f.gate.inflight(f.id, f.name), 0);
|
||||
// Both runs got one kill end, and each acknowledged before the tombstone was written.
|
||||
assert.deepEqual(f.killed().sort(), ['run-1', 'run-2']);
|
||||
assert.equal(f.stops.length, 1);
|
||||
assert.equal(f.stops[0].op, f.rec().revoke.id);
|
||||
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' });
|
||||
assert.throws(() => f.b.copySession(r2.cap), (e) => e instanceof BusError);
|
||||
// A second revoke is a no-op answer, never a second remote call.
|
||||
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
|
||||
await assert.rejects(f.call(f.human, verb, args), (e) => e instanceof BusError);
|
||||
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB4 only the owner revokes: the tester, a reader, a member with org.hire and the copy itself refuse, and nothing changes', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
const member = f.b.bindHireConsole({ business: f.id, actor: 'pat', subject: { provider: 'test', iss: 'https://issuer.test', sub: 'pat' }, rights: ['org.hire', 'decide'] });
|
||||
const callers = { tester: f.b.bindTester({ business: f.id }), reader: f.b.bindReader({ business: f.id }), member, copy: r.cap };
|
||||
for (const [who, cap] of Object.entries(callers))
|
||||
for (const [verb, args] of [['credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Not mine' }], ['credentials.delete', { agent: f.name, reason: 'Not mine' }]])
|
||||
await assert.rejects(f.call(cap, verb, args), (e) => e instanceof BusError, `${who} ${verb}`);
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
assert.equal(f.rec().revoke, undefined);
|
||||
assert.equal(f.gate.closed(f.id, f.name), false);
|
||||
assert.equal((await f.use(r.cap)).status, 200);
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
assert.deepEqual(f.killed(), []);
|
||||
});
|
||||
|
||||
test('CB4 unbind through the owner\'s decision revokes the copy: credential-unbound, token gone', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'root', name: f.name, reason: 'Unbind the copy', binding: { services: null } });
|
||||
await assertRevoked(f, r, { cause: 'unbind', code: 'credential-unbound' });
|
||||
});
|
||||
|
||||
test('CB4 slot removal: the next call refuses credential-unavailable and the binding is revoked', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
unlinkSync(f.slot);
|
||||
await assert.rejects(f.use(r.cap), refuses('credential-unavailable'));
|
||||
await assertRevoked(f, r, { cause: 'unavailable', code: 'credential-unavailable' });
|
||||
});
|
||||
|
||||
test('CB4 expiry: past rotateBy the call refuses credential-expired and the binding is revoked', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
f.advance(31);
|
||||
const before = f.repoCalls();
|
||||
await assert.rejects(f.use(r.cap), refuses('credential-expired'));
|
||||
assert.equal(f.repoCalls(), before);
|
||||
await assertRevoked(f, r, { cause: 'expired', code: 'credential-expired' });
|
||||
});
|
||||
|
||||
test('CB4 expiry found by the sweep with no call at all revokes too', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
f.advance(31);
|
||||
f.revoker.sweep(f.id);
|
||||
await assertRevoked(f, r, { cause: 'expired', code: 'credential-expired' });
|
||||
});
|
||||
|
||||
test('CB4 role change: the definition the binding pinned changed, the call refuses and the binding is revoked', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
const role = join(f.rolesDir, 'research.json');
|
||||
const d = JSON.parse(readFileSync(role, 'utf8'));
|
||||
writeFileSync(role, JSON.stringify({ ...d, title: `${d.title} changed` }, null, 2) + '\n');
|
||||
await assert.rejects(f.use(r.cap), refuses('credential-role-changed'));
|
||||
await assertRevoked(f, r, { cause: 'role-changed', code: 'credential-role-changed' });
|
||||
});
|
||||
|
||||
test('CB4 fire: a copy whose root agent is gone at the next start (the fire landed, its revoke never began) is revoked at boot', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
// The root agent was fired and the bus died before the revoke published anything.
|
||||
f.start({});
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
f.revoker.sweep(f.id);
|
||||
await until(() => tombstoned(f), 'retire tombstone');
|
||||
assert.equal(f.rec().revoke.cause, 'retire');
|
||||
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
|
||||
assert.ok(!existsSync(f.slot));
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
await assert.rejects(f.prepare(), refuses('copy-unavailable'));
|
||||
});
|
||||
|
||||
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 = 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) => {
|
||||
const w = await world(t);
|
||||
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' });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
f.start();
|
||||
await f.boot();
|
||||
assert.deepEqual(Object.keys(f.gate.bound()), ['rev.stack']);
|
||||
const r = await f.launch('run-1');
|
||||
assert.equal((await f.use(r.cap)).status, 200);
|
||||
// The agent leaves the project through the writer alone: the broker never reloads, so only the
|
||||
// gate's live check sees the copy gone.
|
||||
f.writer.setProjectAgent(f.id, { revision: f.writer.read(f.id).revision, project: 'stack', name: 'rev', entry: null }, 'jason');
|
||||
await assert.rejects(f.use(r.cap), refuses('copy-unavailable'));
|
||||
await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' });
|
||||
});
|
||||
|
||||
test('CB4 the finish keeps a remote revoke an earlier pass recorded: a later absent never replaces it', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
f.ack = false; f.stopMs = 200;
|
||||
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
|
||||
const op = f.rec().revoke.id;
|
||||
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
|
||||
// A later pass that saw the token already gone reports absent; the writer keeps revoked.
|
||||
const opts = { actor: 'bus', owner: true, definitions: f.snapshot.definitions ?? {} };
|
||||
f.writer.revokeCopyBinding(f.id, { phase: 'finish', instance: f.name, opId: op, steps: { stop: 'failed', slot: 'absent', remote: { gitea: 'absent' } } }, opts);
|
||||
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
|
||||
f.writer.revokeCopyBinding(f.id, { phase: 'finish', instance: f.name, opId: op, steps: { stop: 'confirmed', slot: 'absent', remote: { gitea: 'absent' } } }, opts);
|
||||
assert.ok(tombstoned(f));
|
||||
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
|
||||
});
|
||||
|
||||
test('CB4 name collision: a project agent now claims the copy\'s instance name; the call refuses copy-refused and the binding is revoked', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w, { name: 'rev.stack' });
|
||||
f.writer.setProjectAgent(f.id, { revision: f.writer.read(f.id).revision, project: 'stack', name: 'rev', entry: { definition: 'research' } }, 'jason');
|
||||
await assert.rejects(f.use(r.cap), refuses('copy-refused'));
|
||||
await assertRevoked(f, r, { cause: 'collision', code: 'copy-refused' });
|
||||
});
|
||||
|
||||
test('CB4 shadowing: a named instance over a former copy\'s slot revokes the copy; nothing issues to the name until the tombstone', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
// Hold the stop so the revoke stays pending while the named instance asks for its slot.
|
||||
f.ack = false; f.stopMs = 60000;
|
||||
f.edit((d) => { d.roles[f.name] = { definition: 'research', credentials: { gitea: d.roles.coder.credentials.gitea } }; });
|
||||
f.revoker.sweep(f.id);
|
||||
await until(() => f.rec().state === 'revoking', 'revoking record');
|
||||
assert.equal(f.rec().revoke.cause, 'shadowed');
|
||||
assert.ok(f.revoker.pending(f.id, f.name));
|
||||
f.reload();
|
||||
await assert.rejects(f.call(f.human, 'credentials.issue', { agent: f.name, service: 'gitea', date: ahead(30), token: 'a'.repeat(40) }), refuses('credential-revoking'));
|
||||
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
|
||||
// The launcher acknowledges the kill: the revoke finishes and the name is free.
|
||||
for (const run of f.killed()) f.b.endLaunch({ business: f.id, run, reason: 'killed' });
|
||||
await until(() => tombstoned(f), 'shadowed tombstone');
|
||||
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
|
||||
assert.ok(!existsSync(f.slot));
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
assert.equal(f.revoker.pending(f.id, f.name), false);
|
||||
// The issue now takes the named instance's ordinary path (this fixture's verbs answer unknown).
|
||||
await assert.rejects(f.call(f.human, 'credentials.issue', { agent: f.name, service: 'gitea', date: ahead(30), token: 'a'.repeat(40) }), refuses('credential-unknown'));
|
||||
});
|
||||
|
||||
test('CB4 an unacknowledged stop keeps the copy fenced and the record revoking; resume after the ack finishes the same op', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
f.ack = false; f.stopMs = 200;
|
||||
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
|
||||
const rec = f.rec();
|
||||
assert.equal(rec.state, 'revoking');
|
||||
assert.deepEqual(rec.revoke.failed, ['stop']);
|
||||
// The remote step ran (the gate was closed and drained) and its definite outcome is kept.
|
||||
assert.deepEqual(rec.revoke.remote, { gitea: 'revoked' });
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
const op = rec.revoke.id;
|
||||
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
|
||||
// Fenced: no call, no prepare, no launch.
|
||||
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
|
||||
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
|
||||
// The launcher's exit arrives; the next pass finishes the op.
|
||||
f.b.endLaunch({ business: f.id, run: r.run, reason: 'killed' });
|
||||
f.ack = true;
|
||||
await f.revoker.resume(f.id);
|
||||
assert.ok(tombstoned(f));
|
||||
assert.equal(f.rec().revoke.id, op);
|
||||
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes, 'a revoked grant is never called again');
|
||||
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
|
||||
});
|
||||
|
||||
test('CB4 a remote revoke that fails keeps the copy fenced; the slot is gone; a later pass revokes it', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
const unrule = w.api.rule((e) => e.method === 'DELETE' && e.path.includes('/tokens/'), { status: 500, json: { message: 'down' } });
|
||||
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
|
||||
assert.equal(f.rec().state, 'revoking');
|
||||
assert.deepEqual(f.rec().revoke.failed, ['remote']);
|
||||
assert.ok(!existsSync(f.slot));
|
||||
assert.equal(f.bot.tokens.length, 1, 'never claimed revoked');
|
||||
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
|
||||
await assert.rejects(f.use(r.cap), (e) => e instanceof BusError);
|
||||
unrule();
|
||||
await f.revoker.resume(f.id);
|
||||
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
|
||||
});
|
||||
|
||||
test('CB4 an organization without a managed admin slot records not-managed and never claims the token revoked', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w, { admin: false });
|
||||
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
|
||||
assert.deepEqual(out.remote, { gitea: 'not-managed' });
|
||||
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked', remote: 'not-managed' });
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
});
|
||||
|
||||
test('CB4 an expired admin slot is a failed remote step, not not-managed: the copy stays fenced', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w, { adminRotateBy: '2020-01-01' });
|
||||
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }), refuses('credential-revoke-failed'));
|
||||
assert.deepEqual(f.rec().revoke.failed, ['remote']);
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
});
|
||||
|
||||
// A crash at each point of the revoke, then a restart of the bus on the same files: the copy never
|
||||
// gets its authority back, the pending op is never lost, and resume finishes it under the same id.
|
||||
for (const point of REVOKE_POINTS) {
|
||||
test(`CB4 crash at ${point}, then restart: no resurrected authority, no lost op`, async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
let fired = false;
|
||||
f.hooks[point] = () => { if (!fired) { fired = true; throw new Error(`crash at ${point}`); } };
|
||||
await assert.rejects(f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }));
|
||||
assert.ok(fired);
|
||||
const crashed = f.rec();
|
||||
// Nothing the crashed process holds counts: a new process starts on the files.
|
||||
f.start();
|
||||
await f.boot();
|
||||
if (point === 'beforePublication') {
|
||||
// Nothing was published: the binding is as it was, and the owner's verb answered an error.
|
||||
assert.equal(crashed.state, 'bound');
|
||||
assert.deepEqual(Object.keys(f.gate.bound()), [f.name]);
|
||||
const again = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
|
||||
assert.equal(again.state, 'unbound');
|
||||
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
|
||||
return;
|
||||
}
|
||||
assert.ok(['revoking', 'unbound'].includes(crashed.state), crashed.state);
|
||||
const op = crashed.revoke.id;
|
||||
assert.match(op, /\S/);
|
||||
// No resurrected authority: the boot proves nothing, prepare and launch refuse, nothing is sent.
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
const sent = f.sent.length;
|
||||
await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoked', 'credential-revoke-failed'].includes(e.code));
|
||||
assert.equal(f.sent.length, sent);
|
||||
assert.throws(() => f.b.copySession(r.cap), (e) => e instanceof BusError);
|
||||
// No lost op: resume finishes it under the same id.
|
||||
await f.revoker.resume(f.id);
|
||||
assert.ok(tombstoned(f), JSON.stringify(f.rec()));
|
||||
assert.equal(f.rec().revoke.id, op);
|
||||
// The token went at the service before the crash or at resume; either way it is gone now.
|
||||
assert.ok(['revoked', 'absent'].includes(f.rec().revoke.remote.gitea), f.rec().revoke.remote.gitea);
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
assert.ok(!existsSync(f.slot));
|
||||
await assert.rejects(f.prepare(), refuses('credential-revoked'));
|
||||
// A second restart finds nothing pending and changes nothing.
|
||||
const done = readFileSync(f.file, 'utf8');
|
||||
f.start();
|
||||
await f.boot();
|
||||
assert.deepEqual(await f.revoker.resume(f.id), []);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), done);
|
||||
});
|
||||
}
|
||||
|
||||
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: '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 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);
|
||||
// A bus still holding the old gate answers the organization's state.
|
||||
await assert.rejects(f.use(r.cap), (e) => ['org-archived', 'run-ended'].includes(e.code));
|
||||
await assert.rejects(f.prepare(), refuses('org-archived'));
|
||||
await orgService.restoreOrg(f.ctx, f.id, { ifRevision: out.revision });
|
||||
f.start();
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
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;
|
||||
// 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, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'revoked' } }]);
|
||||
assert.ok(tombstoned(f));
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
});
|
||||
|
||||
test('CB4 archive refuses when a slot parent is not the stack user\'s private directory, and the slot stays', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
chmodSync(dirname(f.slot), 0o755);
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
await assert.rejects(orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) }), refuses('credential-revoke-failed'));
|
||||
assert.ok(existsSync(f.slot));
|
||||
assert.equal(f.rec().state, 'revoking');
|
||||
});
|
||||
|
||||
test('CB4 archive rerun after a crash joins the pending op', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
// The archive crashed after its first write.
|
||||
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 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));
|
||||
});
|
||||
|
||||
test('CB4 delete quarantines the copy slot and its receipt never claims a remote revoke; a recreated organization never resolves the old copy', 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.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
|
||||
assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'not-attempted' }]);
|
||||
assert.match(out.note, /only where copies lists a copy with remote revoked/);
|
||||
assert.ok(!existsSync(f.slot));
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
await assert.rejects(f.use(r.cap), (e) => ['org-unavailable', 'run-ended'].includes(e.code));
|
||||
await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'organization-unreadable', 'credential-unavailable'].includes(e.code));
|
||||
// Recreated under the same id: a new incarnation. The old gate still refuses.
|
||||
const doc = { ...businessDoc(w.root, f.id), plugins: { harness: ['pi'] } };
|
||||
writeJson(f.file, doc);
|
||||
await reserveOrg(f.ctx, f.id); await activateOrg(f.ctx, f.id);
|
||||
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 the owner\'s project.remote.set cannot change the repository of a project a copy binding still grants; the revoke then finishes at Gitea', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w, { gitOnly: true });
|
||||
const { revision } = f.writer.read(f.id);
|
||||
assert.throws(() => f.writer.setProjectRemote(f.id, { revision, project: 'stack', remote: `${w.api.url}/owner/elsewhere` }, 'jason'), refuses('project-repo-bound'));
|
||||
assert.equal(f.writer.read(f.id).revision, revision);
|
||||
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
|
||||
assert.equal(out.state, 'unbound');
|
||||
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' });
|
||||
});
|
||||
|
||||
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 resume keeps a failed pass closed with its failure code while it runs again', async () => {
|
||||
const calls = [];
|
||||
const gate = { closed: () => true, close: (b, i, c) => calls.push([i, c ?? null]), open: () => calls.push(['open']), drain: async () => {} };
|
||||
const copyBindings = { rev: { state: 'revoking', revoke: { id: 'op-1', cause: 'expired', at: 'x', failed: ['remote'] } }, two: { state: 'revoking', revoke: { id: 'op-2', cause: 'expired', at: 'x' } } };
|
||||
const writer = { read: () => ({ document: { copyBindings } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } };
|
||||
const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => 'absent' }, stopRuns: async () => ({ confirmed: true }) });
|
||||
await revoker.resume('acme');
|
||||
assert.deepEqual(calls.slice(0, 2), [['rev', 'credential-revoke-failed'], ['two', null]]);
|
||||
assert.ok(!calls.some((c) => c[0] === 'open'));
|
||||
});
|
||||
|
||||
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);
|
||||
const { f: acme, r: ar } = await live(t, w, { id: 'acme' });
|
||||
const ok = async (label) => {
|
||||
assert.equal((await acme.use(ar.cap)).status, 200, label);
|
||||
const fresh = await acme.launch(`acme-${label}`);
|
||||
assert.equal((await acme.use(fresh.cap)).status, 200, label);
|
||||
};
|
||||
// During the revoke: hold demo's stop and call acme meanwhile.
|
||||
demo.ack = false; demo.stopMs = 60000;
|
||||
const revoke = demo.call(demo.human, 'credentials.revoke', { agent: demo.name, service: 'gitea', reason: 'Owner revoke' });
|
||||
await until(() => demo.rec().state === 'revoking', 'demo revoking');
|
||||
await ok('during-revoke');
|
||||
demo.b.endLaunch({ business: demo.id, run: dr.run, reason: 'killed' });
|
||||
assert.equal((await revoke).state, 'unbound');
|
||||
await ok('after-revoke');
|
||||
// Archive and delete demo.
|
||||
let revision = (await listOrgs(demo.ctx)).find((o) => o.id === 'demo').revision;
|
||||
const archived = await orgService.archiveOrg(demo.ctx, 'demo', { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
|
||||
await ok('after-archive');
|
||||
await orgService.deleteOrg(demo.ctx, 'demo', { confirm: 'demo', ifRevision: archived.revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
|
||||
await ok('after-delete');
|
||||
assert.equal(acme.rec().state, 'bound');
|
||||
assert.equal(acme.bot.tokens.length, 1);
|
||||
assert.ok(existsSync(acme.slot));
|
||||
assert.deepEqual(demo.bot.tokens, []);
|
||||
});
|
||||
+19
-4
@@ -1295,9 +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.
|
||||
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
|
||||
@@ -1309,7 +1316,15 @@ require the source and deleted folder to be on the same filesystem.
|
||||
|
||||
`mosaic org archive` and `mosaic org delete` stop the organization bus, so like
|
||||
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
|
||||
their usage checks and before any config is read.
|
||||
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 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? {}); }
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -92,3 +92,24 @@ test('E1: an agent run may restore only with --no-bus', async t => {
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
|
||||
test('CB4 archive and delete from the CLI hand the managed copy remote to the organization service', async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'cb4-cli-')), saved = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
t.after(() => { process.env.HOME = saved; rmSync(home, { recursive: true, force: true }); });
|
||||
const configDir = join(home, '.config', 'mosaic-e1test'), dataRoot = join(home, 'data');
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir, dataRoot, instance: 'e1test', principal: DEV_OWNER };
|
||||
await service.createOrg(ctx, 'alpha', await service.templateRender('alpha'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json') });
|
||||
const asked = [];
|
||||
const copyRemoteFor = async c => { asked.push([c.configDir, c.dataRoot]); return async () => 'revoked'; };
|
||||
const run = words => orgCommand(words, io(env), { system: { dataRoot }, copyRemoteFor });
|
||||
const revision = (await listOrgs(ctx)).find(e => e.id === 'alpha').revision;
|
||||
await run(['archive', 'alpha', '--if-revision', String(revision), '--json']);
|
||||
assert.deepEqual(asked, [[configDir, dataRoot]]);
|
||||
const archived = (await listOrgs(ctx)).find(e => e.id === 'alpha').revision;
|
||||
await run(['delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(archived), '--json']);
|
||||
assert.deepEqual(asked, [[configDir, dataRoot], [configDir, dataRoot]]);
|
||||
assert.equal((await listOrgs(ctx)).find(e => e.id === 'alpha')?.state ?? 'deleted', 'deleted');
|
||||
});
|
||||
@@ -29,6 +29,17 @@
|
||||
// service holds a grant, and no grant id appears twice across the organization's copies. Launch
|
||||
// admission, which re-proves each grant, is CB3.
|
||||
//
|
||||
// CB4 (revocation and lifecycle, design section 6): `revoking` is the durable pending operation a
|
||||
// revoke, an unbind of a granted record, a slot loss, an expiry, an upstream revoke, a role change,
|
||||
// a fire, a shadow, a collision, an archive or a delete leaves before any stop or remote call. It
|
||||
// keeps its services (and grant ids), so a restart knows what to revoke and the boot sweep keeps the
|
||||
// slot until the job ends, and it never resolves. `revoke` is { id, cause, at, failed?, remote? } while
|
||||
// revoking: `failed` lists the steps (stop, slot, remote) whose outcome was not definite, `remote` the
|
||||
// services whose outcome already was, and the record stays fenced (credential-revoke-failed) until a
|
||||
// retry finishes them. The unbound tombstone
|
||||
// it ends in carries { id, cause, at, done: true, remote: { service: outcome } }, where an outcome
|
||||
// is revoked, absent, not-managed (no admin slot to revoke with) or not-attempted. Never a token.
|
||||
//
|
||||
// Everything here is pure: no file is read, no secret, no slot.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
@@ -43,14 +54,28 @@ const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker"];
|
||||
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker", "revoke"];
|
||||
const SERVICE_KEYS = ["slot", "scopes", "resources"];
|
||||
const GRANT_KEYS = ["rotateBy", "grantId", "grantGeneration"];
|
||||
const GRANT_ID = /^(gitea|vikunja):[1-9][0-9]{0,15}:[1-9][0-9]{0,15}$/;
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const realDay = (value) => typeof value === "string" && DAY.test(value) && !Number.isNaN(Date.parse(value))
|
||||
&& new Date(value).toISOString().slice(0, 10) === value;
|
||||
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound"]);
|
||||
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound", "revoking"]);
|
||||
// CB4: why a record is revoking or tombstoned, and what the next launch or call answers for it.
|
||||
export const REVOKE_CAUSES = Object.freeze(["revoke", "unbind", "unavailable", "expired", "upstream", "role-changed", "retire", "shadowed", "collision", "archive", "delete"]);
|
||||
export const REVOKE_STEPS = Object.freeze(["stop", "slot", "remote"]);
|
||||
export const REMOTE_OUTCOMES = Object.freeze(["revoked", "absent", "not-managed", "not-attempted"]);
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
||||
// The tombstone codes the resolver answers for a copy that is still there; the copy codes come from
|
||||
// the copy itself, and an archive or delete tombstone answers credential-unbound.
|
||||
const TOMBSTONE_CODES = ["credential-revoked", "credential-expired", "credential-unavailable", "credential-role-changed"];
|
||||
// The refusal a tombstone's cause answers at the next launch or call (design section 6).
|
||||
export const CAUSE_CODES = Object.freeze({
|
||||
revoke: "credential-revoked", upstream: "credential-revoked", unbind: "credential-unbound", unavailable: "credential-unavailable",
|
||||
expired: "credential-expired", "role-changed": "credential-role-changed", retire: "copy-unavailable", shadowed: "copy-unavailable",
|
||||
collision: "copy-refused", archive: "credential-unbound", delete: "credential-unbound",
|
||||
});
|
||||
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
|
||||
export const BINDABLE_SERVICES = SERVICES;
|
||||
export const MAX_BINDINGS = 256;
|
||||
@@ -82,6 +107,11 @@ const TEXT = {
|
||||
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
|
||||
"copy-unavailable": () => "copy is no longer active in this organization.",
|
||||
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
|
||||
"credential-revoked": (s) => `credentials.${s} was revoked for this copy.`,
|
||||
"credential-revoking": (s) => `credentials.${s} is being revoked for this copy.`,
|
||||
"credential-unavailable": (s) => `credentials.${s} is not available for this copy.`,
|
||||
"credential-expired": (s) => `credentials.${s} passed its rotation date.`,
|
||||
"credential-revoke-failed": (s) => `credentials.${s} revocation is incomplete; the owner must retry cleanup.`,
|
||||
};
|
||||
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
|
||||
export function bindingText(code, service = null) {
|
||||
@@ -247,7 +277,10 @@ export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
|
||||
if (!isPlainObject(r.services)) refuse(at(i, "services"));
|
||||
const services = Object.keys(r.services);
|
||||
if ((r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
||||
// CB4: a revoking record keeps what it is revoking, which may be nothing (an older tombstone
|
||||
// whose slot a shadow, an archive or a delete cleans).
|
||||
if (r.state !== "revoking" && (r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
||||
checkRevoke(r, i);
|
||||
for (const service of services) {
|
||||
const v = r.services[service];
|
||||
const extra = isPlainObject(v) ? Object.keys(v).filter((k) => !SERVICE_KEYS.includes(k)) : [];
|
||||
@@ -278,6 +311,31 @@ export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// CB4: the `revoke` field. Required while revoking, optional on the unbound tombstone (its done
|
||||
// form), absent otherwise.
|
||||
function checkRevoke(r, i) {
|
||||
const v = r.revoke;
|
||||
if (v === undefined) {
|
||||
if (r.state === "revoking") refuse(at(i, "revoke"));
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(v) || !["revoking", "unbound"].includes(r.state) || typeof v.id !== "string" || !UUID.test(v.id)
|
||||
|| !REVOKE_CAUSES.includes(v.cause) || typeof v.at !== "string" || !ISO.test(v.at) || Number.isNaN(Date.parse(v.at))) refuse(at(i, "revoke"));
|
||||
const keys = Object.keys(v);
|
||||
if (r.state === "revoking") {
|
||||
if (keys.some((k) => !["id", "cause", "at", "failed", "remote"].includes(k))) refuse(at(i, "revoke"));
|
||||
if (v.failed !== undefined && (!Array.isArray(v.failed) || v.failed.length === 0 || new Set(v.failed).size !== v.failed.length
|
||||
|| v.failed.some((x) => !REVOKE_STEPS.includes(x)))) refuse(at(i, "revoke"));
|
||||
// The definite remote outcomes an incomplete pass already had, so a retry never calls a revoked
|
||||
// grant again or records its 404 as `absent`.
|
||||
if (v.remote !== undefined && (!isPlainObject(v.remote) || Object.keys(v.remote).length === 0
|
||||
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o)))) refuse(at(i, "revoke"));
|
||||
return;
|
||||
}
|
||||
if (keys.sort().join(",") !== "at,cause,done,id,remote" || v.done !== true || !isPlainObject(v.remote)
|
||||
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o))) refuse(at(i, "revoke"));
|
||||
}
|
||||
|
||||
// ---- Resolver (CB0) ----
|
||||
|
||||
// What a copy's binding grants in this organization, from plain inputs only:
|
||||
@@ -306,6 +364,11 @@ export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, rec
|
||||
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
|
||||
const source = { level: copy.level, name: copy.name, project: copy.project };
|
||||
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
|
||||
// CB4: a revoke in progress or one whose outcome is not definite never resolves; a finished one
|
||||
// answers its cause's code (design section 6) until a new binding.
|
||||
if (record.state === "revoking") return no(record.revoke?.failed ? "credential-revoke-failed" : "credential-revoking", first);
|
||||
if (record.state === "unbound" && record.revoke?.done === true && TOMBSTONE_CODES.includes(CAUSE_CODES[record.revoke.cause]))
|
||||
return no(CAUSE_CODES[record.revoke.cause], first);
|
||||
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
|
||||
if (record.state !== "bound") return no("credential-unbound", first);
|
||||
const approved = record.services ?? {};
|
||||
|
||||
@@ -147,6 +147,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
const current = doc.copyBindings?.[facts.instance] ?? null;
|
||||
if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed');
|
||||
if (after === null && (!current || current.state === 'unbound')) fail('credential-unbound');
|
||||
if (current?.state === 'revoking') fail('credential-revoking');
|
||||
// An org reserved before CB0 gets its incarnation here, under the registry lock.
|
||||
const incarnation = ensureOrgIncarnation(configDir, business);
|
||||
if (incarnation === null) fail('copy-unavailable');
|
||||
|
||||
@@ -11,6 +11,8 @@ import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withReg
|
||||
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||
import { canonicalJson, parseJsonFileText } from './util.mjs';
|
||||
import { organizationWriter } from './organization.mjs';
|
||||
import { COPY_BINDABLE } from './agent-copies.mjs';
|
||||
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
||||
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
|
||||
@@ -193,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 });
|
||||
});
|
||||
@@ -300,9 +309,75 @@ export async function projectRepoSet(ctx, id, project, opts = {}) {
|
||||
export async function queueShow(ctx, id) { return showSetup(ctx, id, null); }
|
||||
export async function projectRepoShow(ctx, id, project) { return showSetup(ctx, id, project); }
|
||||
|
||||
// #1873 CB4 (design section 6, Q3): archive discards every copy credential the organization holds.
|
||||
// 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 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) {
|
||||
// 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;
|
||||
try { st = await fs.lstat(path); } catch (e) { if (e.code === 'ENOENT' || e.code === 'ENOTDIR') continue; return 'failed'; }
|
||||
if (!st.isFile() || st.uid !== process.getuid()) return 'failed';
|
||||
try { await fs.unlink(path); } catch (e) { if (e.code !== 'ENOENT') return 'failed'; }
|
||||
if (path === file) removed = true;
|
||||
}
|
||||
return removed ? 'removed' : 'absent';
|
||||
}
|
||||
async function discardCopies(ctx, id, cause, copyRemote) {
|
||||
const writer = organizationWriter({ dir: ctx.configDir, rolesDir, dataRoot: ctx.dataRoot });
|
||||
let writes = 0; const copies = [];
|
||||
// An organization with no live copy record archives as before, without reading through the writer.
|
||||
const raw = writer.readRaw(id);
|
||||
if (raw.readable && Object.values(raw.document.copyBindings ?? {}).every(tombstoned)) return { writes, copies };
|
||||
const doc = writer.read(id).document;
|
||||
for (const instance of Object.keys(doc.copyBindings ?? {}).sort()) {
|
||||
if (tombstoned(doc.copyBindings[instance])) continue;
|
||||
const begun = writer.revokeCopyBinding(id, { phase: 'begin', instance, cause }, { actor: 'bus', owner: true });
|
||||
if (begun.revision !== undefined) writes++;
|
||||
if (begun.state !== 'revoking') continue;
|
||||
const record = writer.read(id).document.copyBindings[instance];
|
||||
let slot = 'absent';
|
||||
for (const service of [...new Set([...Object.keys(record.services ?? {}), ...COPY_BINDABLE])].sort()) {
|
||||
const r = await discardCopySlot(ctx, id, instance, service);
|
||||
if (r === 'failed') slot = 'failed'; else if (r === 'removed' && slot !== 'failed') slot = 'removed';
|
||||
}
|
||||
const remote = {};
|
||||
for (const service of Object.keys(record.services ?? {}).sort()) {
|
||||
if (typeof record.services[service]?.grantId !== 'string') { remote[service] = 'absent'; continue; }
|
||||
if (record.revoke?.remote?.[service] === 'revoked') { remote[service] = 'revoked'; continue; }
|
||||
if (typeof copyRemote !== 'function') { remote[service] = 'not-attempted'; continue; }
|
||||
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';
|
||||
}
|
||||
// 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 });
|
||||
}
|
||||
return { writes, copies };
|
||||
}
|
||||
|
||||
// Archive and restore share the bus-start/delete fence, but do not hold the
|
||||
// registry lock while waiting for the bus host to stop.
|
||||
async function lifecycleChange(ctx, id, { ifRevision, busStop }, operation) {
|
||||
async function lifecycleChange(ctx, id, { ifRevision, busStop, copyRemote = null }, operation) {
|
||||
authorize(ctx); checkId(id);
|
||||
if (operation === 'archive' && typeof busStop !== 'function') fail('org-invalid', 'archive needs a bus stop function');
|
||||
return withOrgLifecycle(ctx, id, async () => {
|
||||
@@ -310,17 +385,25 @@ async function lifecycleChange(ctx, id, { ifRevision, busStop }, operation) {
|
||||
const inspect = async () => {
|
||||
const current = await readCurrentUnlocked(ctx, id, { includeArchived: true });
|
||||
if (current.state !== expected) fail(current.state === 'archived' ? 'org-archived' : 'org-invalid', `organization is not ${expected}`);
|
||||
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
|
||||
if (!Number.isSafeInteger(ifRevision) || current.revision !== expectRevision) fail('revision-conflict', 'organization revision changed');
|
||||
return current;
|
||||
};
|
||||
let expectRevision = ifRevision;
|
||||
await withRegistry(ctx, inspect, { write: false });
|
||||
const bus = operation === 'archive' ? await busStop(ctx, id) : null;
|
||||
if (operation === 'archive' && (!bus || typeof bus !== 'object' || typeof bus.stopped !== 'boolean')) fail('org-unavailable', 'bus stop outcome is uncertain');
|
||||
let copies = null;
|
||||
if (operation === 'archive') {
|
||||
// Each binding write counts the registry revision up by one; the archive expects exactly those.
|
||||
const discarded = await discardCopies(ctx, id, 'archive', copyRemote);
|
||||
expectRevision = ifRevision + discarded.writes; copies = discarded.copies;
|
||||
if (copies.some(c => c.state !== 'unbound' && c.slot === 'failed')) fail('credential-revoke-failed', 'a copy credential could not be discarded; rerun the archive');
|
||||
}
|
||||
return withRegistry(ctx, async registry => {
|
||||
const current = await inspect();
|
||||
if (!Number.isSafeInteger(current.revision + 1)) fail('org-mismatch', 'organization revision cannot be incremented');
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
const record = { op: `org.${operation}`, org: id, actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision + 1, bus };
|
||||
const record = { op: `org.${operation}`, org: id, actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision + 1, bus, ...(copies ? { copies } : {}) };
|
||||
let fd;
|
||||
try { fd = await fs.open(join(ctx.dataRoot, 'org-lifecycle.log'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600); }
|
||||
catch (e) { if (e.code === 'ENXIO') fail('org-mismatch', 'unsafe organization lifecycle log'); throw e; }
|
||||
@@ -370,7 +453,31 @@ async function deleteParents(base, relative) {
|
||||
return true;
|
||||
}
|
||||
// busStop comes from the caller (the CLI's busStopForDelete), so business code does not import the bus host.
|
||||
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {}) {
|
||||
// #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.
|
||||
// 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 { copies: [], unreadable: true }; }
|
||||
const out = [];
|
||||
for (const instance of Object.keys(doc?.copyBindings ?? {}).sort()) {
|
||||
const record = doc.copyBindings[instance];
|
||||
if (tombstoned(record) || !record?.services || typeof record.services !== 'object') continue;
|
||||
for (const service of Object.keys(record.services).sort()) {
|
||||
if (typeof record.services[service]?.grantId !== 'string') continue;
|
||||
let remote = 'not-attempted';
|
||||
if (typeof copyRemote === 'function') {
|
||||
try { remote = await copyRemote(id, instance, record, service); } catch { remote = 'failed'; }
|
||||
if (!['revoked', 'absent', 'not-managed', 'failed'].includes(remote)) remote = 'failed';
|
||||
}
|
||||
out.push({ agent: instance, service, slot: 'quarantined', remote });
|
||||
}
|
||||
}
|
||||
return { copies: out, unreadable: false };
|
||||
}
|
||||
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRemote = null } = {}) {
|
||||
authorize(ctx); checkId(id);
|
||||
if (typeof busStop !== 'function') fail('org-invalid', 'deletion needs a bus stop function');
|
||||
if (confirm !== id) fail('org-confirm', 'confirmation must match the organization ID');
|
||||
@@ -395,13 +502,19 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: DELETE_NOTE }; await atomic(receiptFile, encode(result), true); }
|
||||
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
|
||||
if (!result.bus) {
|
||||
phase = 'bus';
|
||||
result.bus = await busStop(ctx, id);
|
||||
await atomic(receiptFile, encode(result));
|
||||
}
|
||||
if (!Array.isArray(result.copies)) {
|
||||
phase = 'copies';
|
||||
const found = await deleteCopies(ctx, id, copyRemote);
|
||||
result.copies = found.copies; if (found.unreadable) result.copiesUnreadable = true;
|
||||
await atomic(receiptFile, encode(result));
|
||||
}
|
||||
phase = 'inspect';
|
||||
await withRegistry(ctx, async registry => {
|
||||
const entry = registry.orgs[id];
|
||||
|
||||
@@ -12,8 +12,9 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { REPO_BOUND, projectBindings, repoBoundMessage } from "./repo-remote.mjs";
|
||||
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
|
||||
import { allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { REMOTE_OUTCOMES, REVOKE_CAUSES, allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
|
||||
|
||||
@@ -31,6 +32,8 @@ const fail = (code, message) => {
|
||||
// #1873 CB1: the capability only applyCopyBindingDecision holds. Every other commit refuses a change
|
||||
// to `copyBindings`, whatever options its caller spreads in (write() passes its caller's options).
|
||||
const BINDING_WRITE = Symbol("copy-binding-write");
|
||||
// CB4: a system revoke rechecks the record under the locks instead of pinning a revision.
|
||||
const ANY_REVISION = Symbol("any-revision");
|
||||
const bindingsFence = (old, document, capability) => {
|
||||
if (capability !== BINDING_WRITE && canonicalJson(document?.copyBindings ?? null) !== canonicalJson(old.copyBindings ?? null)) {
|
||||
fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
@@ -306,7 +309,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if (entry && (entry.state !== 'active' || entry.digest !== before || !Number.isSafeInteger(entry.revision + 1))) {
|
||||
fail('org-mismatch', 'organization document and registry disagree');
|
||||
}
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
if (revision !== ANY_REVISION && revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
// #1873 CB1: a binding names the registry incarnation it was approved in. A removed and
|
||||
// recreated organization with the same id, or a legacy entry with none, never matches.
|
||||
if (incarnation !== undefined && (!entry || orgIncarnation(registry, id) === null || orgIncarnation(registry, id) !== incarnation)) fail("decision-stale", "organization incarnation changed; raise the decision again");
|
||||
@@ -652,13 +655,21 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// The only exception to the projects fence for an existing software mapping.
|
||||
// Build from the locked document and check both the candidate and serialized bytes.
|
||||
function setProjectRemote(id, { revision, project, remote }, actor) {
|
||||
// #1873 CB4 (F7): never under a copy binding that still grants the project, as in project repo set
|
||||
// and the owner's update: a changed repository would strand that binding's remote revoke.
|
||||
const refuseBound = (old) => {
|
||||
const bound = projectBindings(old, project);
|
||||
if (bound.length) fail(REPO_BOUND, repoBoundMessage(project, bound));
|
||||
};
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
refuseBound(old);
|
||||
const problem = remoteProblem(old, document, project, remote);
|
||||
if (problem) fail(problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
refuseBound(old);
|
||||
remoteTarget(old, project, remote);
|
||||
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
@@ -755,6 +766,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
const humanOnly = () => fail("human-required", bindingText("human-required"));
|
||||
// Before the lock: no owner, no write. The agent identities are checked again under it.
|
||||
if (owner !== true || actor === "tester") humanOnly();
|
||||
const revokeId = randomUUID(), revokeAt = new Date().toISOString();
|
||||
const build = old => {
|
||||
const agents = [
|
||||
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
|
||||
@@ -766,12 +778,20 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : b.fresh !== true)
|
||||
|| (current && canonicalJson(current.source) !== canonicalJson(b.source))) fail("credential-binding-changed", bindingText("credential-binding-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
if (b.after === null && (!current || current.state === "unbound")) fail("credential-binding-changed", bindingText("credential-binding-changed"));
|
||||
// CB4: nothing changes a binding while its revoke is pending; the revoke ends it first.
|
||||
if (current?.state === "revoking") fail("credential-revoking", bindingText("credential-revoking", Object.keys(current.services)[0] ?? null));
|
||||
const pin = definitionPin(b.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
|
||||
if (!samePin(pin, b.definition)) fail("credential-role-changed", bindingText("credential-role-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
// CB4: an unbind of a record that holds a grant leaves the pending revoke (the bus stops the
|
||||
// copy's runs, discards its slot and revokes at the service, then writes the tombstone); one
|
||||
// with no grant has nothing at a service, and its staged slot goes at the next start.
|
||||
const granted = b.after === null && Object.values(current?.services ?? {}).some((v) => typeof v?.grantId === "string");
|
||||
const record = {
|
||||
copyId: b.copyId, orgIncarnation: b.orgIncarnation, source: structuredClone(b.source), definition: structuredClone(b.definition),
|
||||
generation: b.generation + 1, state: b.after === null ? "unbound" : "pending",
|
||||
services: b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
|
||||
generation: b.generation + 1, state: granted ? "revoking" : b.after === null ? "unbound" : "pending",
|
||||
services: granted ? structuredClone(current.services) : b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
|
||||
...(granted ? { revoke: { id: revokeId, cause: "unbind", at: revokeAt } } : {}),
|
||||
...(granted && current.tracker ? { tracker: structuredClone(current.tracker) } : {}),
|
||||
};
|
||||
return { ...structuredClone(old), copyBindings: { ...(old.copyBindings ?? {}), [b.instance]: record } };
|
||||
};
|
||||
@@ -787,6 +807,105 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
// #1873 CB4 (design sections 3 and 6): the durable pending operation of a copy credential revoke,
|
||||
// in two writes around the work no lock is held across (the stop, the slot discard, the remote
|
||||
// revoke). `begin` writes `revoking` with a fresh op id (or joins the one already pending) before
|
||||
// anything else; `finish` writes the unbound tombstone with the done marker when every step was
|
||||
// definite, or keeps the record revoking with the steps that were not. A restart finds the record
|
||||
// revoking and runs the same op id again; nothing in between resolves or launches the copy.
|
||||
// begin: { phase: "begin", instance, cause, opId?, expect?: { copyId } }
|
||||
// finish: { phase: "finish", instance, opId, steps: { stop: "confirmed"|"failed",
|
||||
// slot: "removed"|"absent"|"failed", remote: { service: outcome|"failed" } } }
|
||||
// The owner's own revoke and unbind need `owner`; a system cause (a loss the bus detected, a fire, a
|
||||
// shadow, a collision, an archive or a delete) runs as the host. No agent identity ever writes.
|
||||
// Returns { state, op, joined?, failed? }. An older tombstone without the done marker begins only
|
||||
// for a cause that cleans a slot it may have left (shadowed, archive, delete).
|
||||
const OWNER_CAUSES = ["revoke", "unbind"];
|
||||
const CLEANING_CAUSES = ["shadowed", "archive", "delete"];
|
||||
const STEP_OUTCOMES = { stop: ["confirmed", "failed"], slot: ["removed", "absent", "failed"] };
|
||||
function revokeCopyBinding(id, op, { actor, owner = false, definitions = {} } = {}) {
|
||||
const { phase, instance } = op ?? {};
|
||||
if (!["begin", "finish"].includes(phase) || typeof instance !== "string") fail("invalid-request");
|
||||
if (phase === "begin" && !REVOKE_CAUSES.includes(op.cause)) fail("invalid-request");
|
||||
if (phase === "finish") {
|
||||
const st = op.steps;
|
||||
if (typeof op.opId !== "string" || !st || !STEP_OUTCOMES.stop.includes(st.stop) || !STEP_OUTCOMES.slot.includes(st.slot)
|
||||
|| !st.remote || typeof st.remote !== "object" || Object.values(st.remote).some((o) => o !== "failed" && !REMOTE_OUTCOMES.includes(o))) fail("invalid-request");
|
||||
}
|
||||
const humanOnly = () => fail("human-required", bindingText("human-required"));
|
||||
if (actor === "tester" || (phase === "begin" && OWNER_CAUSES.includes(op.cause) && owner !== true)) humanOnly();
|
||||
const opId = op.opId ?? randomUUID(), at = new Date().toISOString();
|
||||
let out = null;
|
||||
// The next document, or null when there is nothing to write (out says why).
|
||||
const plan = (old) => {
|
||||
const agents = [
|
||||
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [`${e?.definition}.${n}`]),
|
||||
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
|
||||
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), ...(old.retired ?? []),
|
||||
];
|
||||
// A named instance is an agent too, except the host actor name the bus uses for itself.
|
||||
if (agents.includes(actor) || (actor !== "bus" && Object.hasOwn(old.roles ?? {}, actor))) humanOnly();
|
||||
const current = old.copyBindings?.[instance];
|
||||
let record;
|
||||
if (phase === "begin") {
|
||||
if (!current) { out = { state: "absent" }; return null; }
|
||||
if (op.expect && current.copyId !== op.expect.copyId) fail("credential-binding-changed", bindingText("credential-binding-changed"));
|
||||
if (current.state === "revoking") { out = { state: "revoking", op: current.revoke.id, joined: true, cause: current.revoke.cause }; return null; }
|
||||
if (current.state === "unbound" && (current.revoke?.done === true || !CLEANING_CAUSES.includes(op.cause))) { out = { state: "unbound", op: current.revoke?.id ?? null }; return null; }
|
||||
const { revoke: _r, ...rest } = structuredClone(current);
|
||||
record = { ...rest, generation: current.generation + 1, state: "revoking", revoke: { id: opId, cause: op.cause, at } };
|
||||
out = { state: "revoking", op: opId, joined: false, cause: op.cause };
|
||||
} else {
|
||||
if (current?.state === "unbound" && current.revoke?.id === opId && current.revoke.done === true) { out = { state: "unbound", op: opId }; return null; }
|
||||
if (!current || current.state !== "revoking" || current.revoke?.id !== opId) fail("credential-binding-changed", bindingText("credential-binding-changed"));
|
||||
const st = op.steps;
|
||||
const failed = [...(st.stop !== "confirmed" ? ["stop"] : []), ...(st.slot === "failed" ? ["slot"] : []),
|
||||
...(Object.values(st.remote).includes("failed") ? ["remote"] : [])];
|
||||
// A definite remote outcome from an earlier pass stands: a grant revoked then is never `absent` now.
|
||||
const prior = current.revoke.remote ?? {};
|
||||
if (failed.length) {
|
||||
const definite = { ...prior, ...Object.fromEntries(Object.entries(st.remote).filter(([s, o]) => o !== "failed" && prior[s] !== "revoked")) };
|
||||
const revoke = { ...structuredClone(current.revoke), failed, ...(Object.keys(definite).length ? { remote: definite } : {}) };
|
||||
if (canonicalJson(current.revoke) === canonicalJson(revoke)) { out = { state: "revoking", op: opId, failed }; return null; }
|
||||
record = { ...structuredClone(current), generation: current.generation + 1, revoke };
|
||||
out = { state: "revoking", op: opId, failed };
|
||||
} else {
|
||||
const remote = Object.fromEntries(Object.keys(current.services).sort().map((s) => [s, prior[s] === "revoked" ? "revoked" : st.remote[s] ?? prior[s] ?? "not-attempted"]));
|
||||
const { tracker: _t, revoke: _r, ...rest } = structuredClone(current);
|
||||
record = { ...rest, generation: current.generation + 1, state: "unbound", services: {},
|
||||
revoke: { id: opId, cause: current.revoke.cause, at: current.revoke.at, done: true, remote } };
|
||||
out = { state: "unbound", op: opId, remote };
|
||||
}
|
||||
}
|
||||
return { ...structuredClone(old), copyBindings: { ...old.copyBindings, [instance]: record } };
|
||||
};
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const first = plan(read(id).document);
|
||||
if (first === null) return out;
|
||||
try {
|
||||
const verb = phase === "begin" ? "binding.revoke" : out.state === "unbound" ? "binding.revoked" : "binding.revoke-failed";
|
||||
const result = commit(id, (old) => {
|
||||
const next = plan(old);
|
||||
if (next === null) fail("organization-retry", "binding changed");
|
||||
return next;
|
||||
}, {
|
||||
revision: ANY_REVISION, actor, pathsOnly: true, capability: BINDING_WRITE,
|
||||
guard: (old, next) => {
|
||||
const again = plan(old);
|
||||
if (again === null || canonicalJson(next) !== canonicalJson(again)) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
projectsProtected(old, next);
|
||||
definitionsProtected(old, next);
|
||||
},
|
||||
detail: { verb, agent: instance, op: opId, cause: phase === "begin" ? op.cause : undefined, failed: out.failed },
|
||||
});
|
||||
return { ...out, revision: result.revision };
|
||||
} catch (e) {
|
||||
if (e?.code !== "organization-retry") throw e;
|
||||
}
|
||||
}
|
||||
fail("organization-locked", "binding changed while the revoke was recorded; retry");
|
||||
}
|
||||
|
||||
// #1873 CB2 (design section 5 steps 4 to 6): a copy's service credential is issued in two writes
|
||||
// around the remote work; no lock is held across it. stageCopyGrant deactivates first: the record
|
||||
// goes pending, the service loses its grantId and rotateBy (grantGeneration stays) and the binding
|
||||
@@ -807,7 +926,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if (owner !== true || actor === "tester" || agents.includes(actor)) fail("human-required", bindingText("human-required"));
|
||||
const current = old.copyBindings?.[instance];
|
||||
if (!current || current.copyId !== op.copyId || current.generation !== op.generation || current.orgIncarnation !== op.incarnation
|
||||
|| current.decision !== op.decision || current.state === "unbound" || !Object.hasOwn(current.services ?? {}, service)
|
||||
|| current.decision !== op.decision || current.state === "unbound" || current.state === "revoking" || !Object.hasOwn(current.services ?? {}, service)
|
||||
|| Object.hasOwn(old.roles ?? {}, instance)) fail("credential-binding-changed", bindingText("credential-binding-changed", service));
|
||||
const pin = definitionPin(current.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
|
||||
if (!samePin(pin, current.definition)) fail("credential-role-changed", bindingText("credential-role-changed", service));
|
||||
@@ -972,5 +1091,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyPack, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyPack, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, revokeCopyBinding, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export async function checkRemote(url) {
|
||||
// issued (bus copy-grant boundRepositories), and the decision records only project names. So a
|
||||
// project's repository does not change under a binding that still grants. These are the copies
|
||||
// whose binding names `project` and still grants: `pending` (approved, not yet verified) and
|
||||
// `bound` (active). The `unbound` tombstone an unbind leaves grants nothing and holds no services.
|
||||
// `bound` (active), and `revoking` (CB4: services kept until the revoke finishes). The `unbound` tombstone an unbind leaves grants nothing and holds no services.
|
||||
// `doc` is the organization document as the loader validated it (copy-bindings.mjs schema).
|
||||
export const REPO_BOUND = 'project-repo-bound';
|
||||
const GRANTING = BINDING_STATES.filter((s) => s !== 'unbound');
|
||||
@@ -119,7 +119,9 @@ export function projectBindings(doc, project) {
|
||||
return out.sort((a, b) => (a.instance < b.instance ? -1 : a.instance > b.instance ? 1 : 0));
|
||||
}
|
||||
export function repoBoundMessage(project, bindings) {
|
||||
const list = bindings.map((b) => `${b.instance} (${b.state === 'bound' ? 'active' : 'approved, not yet verified'})`).join(', ');
|
||||
// #1873 CB4: a revoking binding still holds its services until the revoke finishes, so it counts.
|
||||
const label = { bound: 'active', pending: 'approved, not yet verified', revoking: 'being revoked' };
|
||||
const list = bindings.map((b) => `${b.instance} (${label[b.state]})`).join(', ');
|
||||
return `project ${project} is in a copy binding that still grants it: ${list}. Revoke that copy binding first (the chief raises an unbind decision and the owner approves it), then set the repository, then ask the chief to raise a new binding decision`;
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,13 @@ test('repo set is refused while an active (bound) copy binding names the project
|
||||
['qa.work (approved, not yet verified), rev (active)']);
|
||||
});
|
||||
|
||||
test('repo set is refused while a copy binding naming the project is still revoking (CB4)', async () => {
|
||||
// The revoking record keeps its services until the revoke finishes, so the project stays granted.
|
||||
const revoking = { ...binding('rev.work', 'bound', { project: 'work' }), state: 'revoking',
|
||||
revoke: { id: '22222222-2222-4222-8222-222222222222', cause: 'revoke', at: '2026-11-12T00:00:00.000Z', failed: ['remote'] } };
|
||||
await refusedUnderBinding({ 'rev.work': revoking }, ['rev.work (being revoked)']);
|
||||
});
|
||||
|
||||
test('repo set is allowed after the binding is revoked (unbound)', async () => {
|
||||
const c = context(); const r = await orgWithRepo(c);
|
||||
seam.useCheck(async () => ({ result: 'reachable', message: 'the repository answered' }));
|
||||
|
||||
@@ -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 }) }); }
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user