fix(bus): CB3 review fixes, live registry state, read scope, revision to bind (#1873)
- The copy launch gate reads the organization registry entry on every check for a verified copy. A state other than active, or a new incarnation, refuses credential-revoked; an unreadable registry refuses credential-unavailable. - Repository reads need read or write repository scope, like writes need write scope. - The admitted organization revision travels from the launcher through bindLaunch. prepare checks it before and after the live proof, and admit checks it after the launch authority; a change refuses organization-changed. - Tests for the completion authority recheck, the broker session authority compare and an incarnation change; the copyRefusal comment now names where restart-required is answered.
This commit was merged in pull request #1947.
This commit is contained in:
1 parent
5f8079a972
commit
0b8fe202bc
11 files changed
+278
-38
No files matched your search
@@ -2141,7 +2141,7 @@ export class Broker {
|
|||||||
#launchProof(record) {
|
#launchProof(record) {
|
||||||
keys(
|
keys(
|
||||||
record,
|
record,
|
||||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript', 'copyAuthority'],
|
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript', 'copyAuthority', 'revision'],
|
||||||
['business', 'role', 'run', 'harness'],
|
['business', 'role', 'run', 'harness'],
|
||||||
);
|
);
|
||||||
const b = this.#business(identifier(record.business));
|
const b = this.#business(identifier(record.business));
|
||||||
@@ -2207,12 +2207,13 @@ export class Broker {
|
|||||||
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
|
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
|
||||||
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
||||||
// #1873 CB3: a bound copy's run carries the launch authority its prepare and final admission
|
// #1873 CB3: a bound copy's run carries the launch authority its prepare and final admission
|
||||||
// checked; it must still be the gate's now, and the session keeps it for every later use.
|
// checked, and the organization file revision (A3) the launch resolved; both must still hold
|
||||||
|
// now, and the session keeps the authority for every later use.
|
||||||
if (launchRole.copyBound?.length) {
|
if (launchRole.copyBound?.length) {
|
||||||
if (!this.#copyLaunch) fail('credential-unavailable');
|
if (!this.#copyLaunch) fail('credential-unavailable');
|
||||||
if (typeof record.copyAuthority !== 'string') fail('copy-authority-changed');
|
if (typeof record.copyAuthority !== 'string') fail('copy-authority-changed');
|
||||||
this.#copyLaunch.admit(record.business, record.role, record.copyAuthority);
|
this.#copyLaunch.admit(record.business, record.role, record.copyAuthority, record.revision ?? null);
|
||||||
} else if (record.copyAuthority !== undefined) fail('invalid-request');
|
} else if (record.copyAuthority !== undefined || record.revision !== undefined) fail('invalid-request');
|
||||||
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||||
this.#runs.set(key, session);
|
this.#runs.set(key, session);
|
||||||
return this.#cap(session);
|
return this.#cap(session);
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
// Every launch (prepare, final admission and bindLaunch) recomputes the launch authority: a digest
|
// Every launch (prepare, final admission and bindLaunch) recomputes the launch authority: a digest
|
||||||
// of the organization, copy, definition pin, copyId, incarnation, binding generation, each grant's
|
// of the organization, copy, definition pin, copyId, incarnation, binding generation, each grant's
|
||||||
// id and generation, the services and projects the copy reaches now, and their repositories at the
|
// id and generation, the services and projects the copy reaches now, and their repositories at the
|
||||||
// one origin the boot proved the token at. Prepare proves the slot
|
// one origin the boot proved the token at. Every check reads the registry entry again: an
|
||||||
// live again and reads the registry incarnation again; the digest before and after must agree. The
|
// organization no longer active, or with another incarnation, admits and sends nothing. Prepare
|
||||||
|
// proves the slot live again; the digest before and after must agree. The
|
||||||
// session keeps the digest bindLaunch admitted, and each call recomputes it before and after the
|
// session keeps the digest bindLaunch admitted, and each call recomputes it before and after the
|
||||||
// request: a revoke, a re-issue, a role change or a project change in between denies the call.
|
// request: a revoke, a re-issue, a role change or a project change in between denies the call.
|
||||||
//
|
//
|
||||||
@@ -24,8 +25,8 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|||||||
import { BusError } from './broker.mjs';
|
import { BusError } from './broker.mjs';
|
||||||
import { assertNotHeld } from './credentials.mjs';
|
import { assertNotHeld } from './credentials.mjs';
|
||||||
import { send } from './gitea-grant.mjs';
|
import { send } from './gitea-grant.mjs';
|
||||||
import { boundRepositories, verifyGiteaToken } from './copy-grant.mjs';
|
import { boundRepositories, tokenLevels, verifyGiteaToken } from './copy-grant.mjs';
|
||||||
import { orgIncarnation, readRegistry } from '../../organization/src/org-registry.mjs';
|
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||||
import { allGranted, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
import { allGranted, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
||||||
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
||||||
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
|
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
|
||||||
@@ -60,14 +61,30 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
|||||||
const verified = new Map();
|
const verified = new Map();
|
||||||
const failed = new Map();
|
const failed = new Map();
|
||||||
const fences = new Map();
|
const fences = new Map();
|
||||||
let incarnation = null;
|
|
||||||
const today = () => now().toISOString().slice(0, 10);
|
const today = () => now().toISOString().slice(0, 10);
|
||||||
const refreshIncarnation = async () => {
|
// 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;
|
||||||
try {
|
try {
|
||||||
incarnation = orgIncarnation(await readRegistry({ configDir }), org);
|
entry = readOrgEntrySync(configDir, org);
|
||||||
} catch {
|
} catch {
|
||||||
incarnation = null;
|
return deny('credential-unavailable');
|
||||||
}
|
}
|
||||||
|
return entry?.state === 'active' ? entry.incarnation : null;
|
||||||
|
};
|
||||||
|
// The organization file is still the revision the launch resolved its bundle from (#1873 A3).
|
||||||
|
const sameRevision = (business, revision) => {
|
||||||
|
if (revision === undefined) return;
|
||||||
|
let now;
|
||||||
|
try {
|
||||||
|
now = writer.read(business);
|
||||||
|
} catch {
|
||||||
|
deny('organization-unreadable');
|
||||||
|
}
|
||||||
|
if (typeof revision !== 'string' || now?.revision !== revision) deny('organization-changed');
|
||||||
};
|
};
|
||||||
const read = (business) => {
|
const read = (business) => {
|
||||||
try {
|
try {
|
||||||
@@ -134,7 +151,14 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
|||||||
async function boot(business) {
|
async function boot(business) {
|
||||||
if (business !== org) return;
|
if (business !== org) return;
|
||||||
const deadline = Date.now() + bootMs;
|
const deadline = Date.now() + bootMs;
|
||||||
await refreshIncarnation();
|
// An inactive or unreadable registry entry leaves null: the resolver refuses every copy with its
|
||||||
|
// own code.
|
||||||
|
let incarnation = null;
|
||||||
|
try {
|
||||||
|
incarnation = liveIncarnation();
|
||||||
|
} catch {
|
||||||
|
incarnation = null;
|
||||||
|
}
|
||||||
let doc;
|
let doc;
|
||||||
try {
|
try {
|
||||||
doc = writer.read(business).document;
|
doc = writer.read(business).document;
|
||||||
@@ -175,10 +199,15 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
|||||||
if (!v) {
|
if (!v) {
|
||||||
if (object(record) && record.state === 'bound' && allGranted(record.services))
|
if (object(record) && record.state === 'bound' && allGranted(record.services))
|
||||||
deny(failed.get(instance) ?? 'restart-required');
|
deny(failed.get(instance) ?? 'restart-required');
|
||||||
// Not bound: the resolver's own code (credential-unbound, a role change, a refused copy).
|
// Not bound: the resolver's own code (credential-unbound, a role change, a refused copy). This
|
||||||
resolve(business, instance, doc, record, incarnation);
|
// branch always refuses and holds no credential, so an unreadable registry is no incarnation.
|
||||||
|
let known = null;
|
||||||
|
try { known = liveIncarnation(); } catch { known = null; }
|
||||||
|
resolve(business, instance, doc, record, known);
|
||||||
deny('restart-required');
|
deny('restart-required');
|
||||||
}
|
}
|
||||||
|
// 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
|
if (!object(record) || record.state !== 'bound' || record.copyId !== v.copyId || record.orgIncarnation !== v.incarnation
|
||||||
|| incarnation !== v.incarnation) deny('credential-revoked');
|
|| incarnation !== v.incarnation) deny('credential-revoked');
|
||||||
if (record.generation !== v.generation || canonicalJson(grantsOf(record)) !== canonicalJson(v.grants)) deny('restart-required');
|
if (record.generation !== v.generation || canonicalJson(grantsOf(record)) !== canonicalJson(v.grants)) deny('restart-required');
|
||||||
@@ -216,18 +245,23 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
|||||||
},
|
},
|
||||||
// The launcher's prepare: the authority, a live proof of the slot at the service, a fresh
|
// The launcher's prepare: the authority, a live proof of the slot at the service, a fresh
|
||||||
// registry read, and the authority again; any change in between refuses.
|
// registry read, and the authority again; any change in between refuses.
|
||||||
async prepare(business, instance) {
|
// With `revision` (the launcher's, from the bundle it resolved) the organization file must still
|
||||||
|
// be that revision before and after the proof.
|
||||||
|
async prepare(business, instance, revision = undefined) {
|
||||||
|
sameRevision(business, revision);
|
||||||
const before = current(business, instance, { withToken: true });
|
const before = current(business, instance, { withToken: true });
|
||||||
await prove(before.doc, before.record, before.token, incarnation, Date.now() + prepareMs);
|
await prove(before.doc, before.record, before.token, before.record.orgIncarnation, Date.now() + prepareMs);
|
||||||
await refreshIncarnation();
|
|
||||||
const after = current(business, instance);
|
const after = current(business, instance);
|
||||||
if (after.authority !== before.authority) deny('copy-authority-changed');
|
if (after.authority !== before.authority) deny('copy-authority-changed');
|
||||||
|
sameRevision(business, revision);
|
||||||
return { authority: after.authority, services: [...after.services] };
|
return { authority: after.authority, services: [...after.services] };
|
||||||
},
|
},
|
||||||
// Final admission and bindLaunch: the digest the launcher carries must still be the authority.
|
// Final admission and bindLaunch: the digest the launcher carries must still be the authority,
|
||||||
admit(business, instance, digest) {
|
// and with `revision` the organization file still the one the launch resolved.
|
||||||
|
admit(business, instance, digest, revision = undefined) {
|
||||||
if (typeof digest !== 'string') deny('copy-authority-changed');
|
if (typeof digest !== 'string') deny('copy-authority-changed');
|
||||||
if (current(business, instance).authority !== digest) deny('copy-authority-changed');
|
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)),
|
||||||
// A write that can take a copy's grant away runs inside the fence. A definite answer (success,
|
// A write that can take a copy's grant away runs inside the fence. A definite answer (success,
|
||||||
@@ -264,8 +298,10 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
|||||||
if (!m || [m[1], m[2], ...m[3].split('/').slice(1)].some((x) => x === '.' || x === '..')) deny('copy-request-refused');
|
if (!m || [m[1], m[2], ...m[3].split('/').slice(1)].some((x) => x === '.' || x === '..')) deny('copy-request-refused');
|
||||||
const before = current(business, instance, { withToken: true });
|
const before = current(business, instance, { withToken: true });
|
||||||
if (before.authority !== copyAuthority) deny('copy-authority-changed');
|
if (before.authority !== copyAuthority) deny('copy-authority-changed');
|
||||||
const scopes = before.res.services.gitea.scopes;
|
// Gitea's own semantics, enforced here: a read needs read:repository or write:repository, a
|
||||||
if (!READ.includes(method) && !(WRITE.includes(method) && scopes.includes('write:repository'))) deny('copy-request-refused');
|
// write needs write:repository. A binding with neither sends no repository request.
|
||||||
|
const level = tokenLevels(before.res.services.gitea.scopes)?.repository;
|
||||||
|
if (READ.includes(method) ? !['read', 'write'].includes(level) : !(WRITE.includes(method) && level === 'write')) deny('copy-request-refused');
|
||||||
if (READ.includes(method) && body !== undefined) deny('copy-request-refused');
|
if (READ.includes(method) && body !== undefined) deny('copy-request-refused');
|
||||||
const { origin, repos } = before.reach;
|
const { origin, repos } = before.reach;
|
||||||
if (!repos.has(`${m[1]}/${m[2]}`.toLowerCase())) deny('copy-request-refused');
|
if (!repos.has(`${m[1]}/${m[2]}`.toLowerCase())) deny('copy-request-refused');
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function launchOp(m) {
|
|||||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session, authority: m.authority });
|
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session, authority: m.authority });
|
||||||
// #1873 CB3: a copy's launch prepare: the launch authority after a live proof at the service.
|
// #1873 CB3: a copy's launch prepare: the launch authority after a live proof at the service.
|
||||||
if (m.op === 'copyLaunch') return runtime.copyLaunch({ business: m.business, instance: m.instance });
|
if (m.op === 'copyLaunch') return runtime.copyLaunch({ business: m.business, instance: m.instance, revision: m.revision });
|
||||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||||
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
||||||
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ group("credential-revoking", "R1/R8: a revoke in flight blocks every new launch
|
|||||||
group("credential-unavailable", "R1/R8: a copy credential is used only while the service and the stored slot prove it", "Check that the copy's Gitea server answers, then ask the owner to issue the copy's credential again and restart the organization bus. A slot changed outside the bus is never trusted.");
|
group("credential-unavailable", "R1/R8: a copy credential is used only while the service and the stored slot prove it", "Check that the copy's Gitea server answers, then ask the owner to issue the copy's credential again and restart the organization bus. A slot changed outside the bus is never trusted.");
|
||||||
group("credential-expired", "R1/R8: a copy credential stops at its rotation date", "Ask the owner to issue a fresh credential for the copy with a new rotation date, then restart the organization bus.");
|
group("credential-expired", "R1/R8: a copy credential stops at its rotation date", "Ask the owner to issue a fresh credential for the copy with a new rotation date, then restart the organization bus.");
|
||||||
group("copy-authority-changed", "R1/R3/R8: the launch authority admitted at prepare must hold through bind and every use", "The copy's role, projects or binding changed during the launch. Read the copy's current binding and launch it again; a running session from the old authority must be stopped and relaunched.");
|
group("copy-authority-changed", "R1/R3/R8: the launch authority admitted at prepare must hold through bind and every use", "The copy's role, projects or binding changed during the launch. Read the copy's current binding and launch it again; a running session from the old authority must be stopped and relaunched.");
|
||||||
group("copy-request-refused", "R1/R8: a copy calls only its bound repositories with the methods its approved scopes allow", "Call a repository of a project the copy is bound to, with GET or HEAD, or a write method only when the binding approved write:repository. Ask the chief for a binding decision to widen it.");
|
group("copy-request-refused", "R1/R8: a copy calls only its bound repositories with the methods its approved scopes allow", "Call a repository of a project the copy is bound to, with GET or HEAD when the binding approved read:repository or write:repository, or a write method only when it approved write:repository. Ask the chief for a binding decision to widen it.");
|
||||||
|
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("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("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-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("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.");
|
||||||
|
|||||||
@@ -1348,10 +1348,13 @@ export async function startBroker({
|
|||||||
// #1873 CB3: the launcher's prepare for a copy the boot left needing a binding: the launch
|
// #1873 CB3: the launcher's prepare for a copy the boot left needing a binding: the launch
|
||||||
// authority { authority, services } after a live proof at the service, or the refusal. Any
|
// authority { authority, services } after a live proof at the service, or the refusal. Any
|
||||||
// other instance needs none and gets { authority: null }.
|
// other instance needs none and gets { authority: null }.
|
||||||
copyLaunch: async ({ business, instance }) => {
|
// `revision` is the organization file revision the launch resolved its bundle from (A3); a
|
||||||
|
// copy's prepare holds the file to it.
|
||||||
|
copyLaunch: async ({ business, instance, revision }) => {
|
||||||
if (typeof business !== 'string' || typeof instance !== 'string') throw new BusError('invalid-request');
|
if (typeof business !== 'string' || typeof instance !== 'string') throw new BusError('invalid-request');
|
||||||
if (!copyNeedsAuthority(business, instance)) return { authority: null, services: [] };
|
if (!copyNeedsAuthority(business, instance)) return { authority: null, services: [] };
|
||||||
return copyLaunch.prepare(business, instance);
|
if (typeof revision !== 'string') throw new BusError('invalid-request');
|
||||||
|
return copyLaunch.prepare(business, instance, revision);
|
||||||
},
|
},
|
||||||
// #1873 CB3: one checked Gitea request for a bound copy's run. Who it is for comes from the
|
// #1873 CB3: one checked Gitea request for a bound copy's run. Who it is for comes from the
|
||||||
// capability's session, never from the request. Returns { status, json }; nothing holds the token.
|
// capability's session, never from the request. Returns { status, json }; nothing holds the token.
|
||||||
|
|||||||
@@ -197,9 +197,10 @@ test('CB3-D: on scratch 1.27.1 a bound copy boots proved, launches with its auth
|
|||||||
const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
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 thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id;
|
||||||
const proofs = sent.length;
|
const proofs = sent.length;
|
||||||
const { authority } = await gate.prepare('demo', 'rev');
|
const revision = writer.read('demo').revision;
|
||||||
|
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||||
assert.ok(sent.slice(proofs).includes('GET /api/v1/token'));
|
assert.ok(sent.slice(proofs).includes('GET /api/v1/token'));
|
||||||
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority });
|
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision });
|
||||||
const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req));
|
const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req));
|
||||||
const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' });
|
const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' });
|
||||||
assert.equal(own.status, 200);
|
assert.equal(own.status, 200);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['st
|
|||||||
const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } };
|
const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } };
|
||||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||||
const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired',
|
const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired',
|
||||||
'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused'];
|
'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused', 'organization-changed', 'organization-unreadable'];
|
||||||
|
|
||||||
// demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea
|
// demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea
|
||||||
// alone; a binding may be narrower than the role). The
|
// alone; a binding may be narrower than the role). The
|
||||||
@@ -98,8 +98,9 @@ async function fixture(t, { services = READ, perm = 'read', issue = true, defini
|
|||||||
};
|
};
|
||||||
const launch = async (run = 'rev-run') => {
|
const launch = async (run = 'rev-run') => {
|
||||||
const thread = await threadFor(run);
|
const thread = await threadFor(run);
|
||||||
const { authority } = await gate.prepare('demo', 'rev');
|
const revision = writer.read('demo').revision;
|
||||||
return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority }) };
|
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||||
|
return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority, revision }) };
|
||||||
};
|
};
|
||||||
const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request));
|
const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request));
|
||||||
const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' };
|
const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' };
|
||||||
@@ -357,6 +358,159 @@ test('CB3 a change between prepare and bindLaunch, or between bindLaunch and use
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The organization's registry entry rewritten in place (mode kept), as a lifecycle change would.
|
||||||
|
const registryEdit = (f, fn) => {
|
||||||
|
const file = join(f.root, 'org-runtime.json');
|
||||||
|
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
fn(d.orgs.demo, d);
|
||||||
|
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
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']) {
|
||||||
|
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.equal(f.sent.length, before);
|
||||||
|
assert.equal(f.repoCalls(), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await t.test('archived while prepare\'s proof is at the server', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||||
|
const preparing = f.gate.prepare('demo', 'rev');
|
||||||
|
await h.arrived;
|
||||||
|
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||||
|
h.release();
|
||||||
|
await assert.rejects(preparing, refuses('credential-revoked'));
|
||||||
|
assert.equal(f.repoCalls(), 0);
|
||||||
|
});
|
||||||
|
await t.test('archived before boot', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||||
|
await f.boot();
|
||||||
|
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||||
|
});
|
||||||
|
await t.test('registry unsafe after launch', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const { cap } = await f.launch();
|
||||||
|
chmodSync(join(f.root, 'org-runtime.json'), 0o644);
|
||||||
|
const before = f.sent.length;
|
||||||
|
await assert.rejects(f.use(cap, f.GET), refuses('credential-unavailable'));
|
||||||
|
assert.equal(f.sent.length, before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CB3 an organization recreated under the same id (a new incarnation) admits and sends nothing for the old copy', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
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.equal(f.sent.length, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The project points at another repository on the same server: origin, binding and slot are as
|
||||||
|
// boot proved them, only the repositories the launch authority covers move.
|
||||||
|
const repointed = (f) => f.edit((d) => { d.projects.stack.remote = d.projects.stack.remote.replace('/owner/stack', '/owner/other'); });
|
||||||
|
|
||||||
|
test('CB3 a change while a call is in flight denies that call\'s answer, including one that moves only the launch authority', async (t) => {
|
||||||
|
for (const [name, change, code] of [['repository repointed', repointed, 'credential-revoked'],
|
||||||
|
...RACES.filter(([n]) => ['role definition changed', 'project excludes the copy'].includes(n)).map(([n, c]) => [n, c, null])]) {
|
||||||
|
await t.test(name, async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const { cap } = await f.launch();
|
||||||
|
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
|
||||||
|
const pending = f.use(cap, f.GET);
|
||||||
|
await h.arrived;
|
||||||
|
await change(f);
|
||||||
|
h.release();
|
||||||
|
await assert.rejects(pending, code ? refuses(code) : (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CB3 a running copy session whose launch authority moved answers no agent verb', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const { cap } = await f.launch();
|
||||||
|
// Past the copy check to the holder check: the session holds no claim yet.
|
||||||
|
await assert.rejects(f.call(cap, 'inbox'), refuses('not-holder'));
|
||||||
|
repointed(f);
|
||||||
|
assert.doesNotThrow(() => f.gate.authority('demo', 'rev'));
|
||||||
|
await assert.rejects(f.call(cap, 'inbox'), refuses('copy-authority-changed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CB3 a binding with no repository scope sends no repository request, read or write', async (t) => {
|
||||||
|
const f = await fixture(t, { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } });
|
||||||
|
await f.boot();
|
||||||
|
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
|
||||||
|
const { cap } = await f.launch();
|
||||||
|
for (const method of ['GET', 'HEAD', 'POST'])
|
||||||
|
await assert.rejects(f.use(cap, { method, path: '/api/v1/repos/owner/stack' }), refuses('copy-request-refused'), method);
|
||||||
|
assert.equal(f.repoCalls(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CB3 the organization file revision the launch resolved holds through prepare, final admission and bindLaunch', async (t) => {
|
||||||
|
// Same document, other bytes: the launch authority is unchanged, only the revision moves.
|
||||||
|
const rewrite = (f) => writeFileSync(f.file, JSON.stringify(JSON.parse(readFileSync(f.file, 'utf8'))) + '\n');
|
||||||
|
await t.test('changed before prepare', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const revision = f.writer.read('demo').revision;
|
||||||
|
rewrite(f);
|
||||||
|
const before = f.sent.length;
|
||||||
|
await assert.rejects(f.gate.prepare('demo', 'rev', revision), refuses('organization-changed'));
|
||||||
|
assert.equal(f.sent.length, before);
|
||||||
|
});
|
||||||
|
await t.test('changed during prepare\'s proof', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const revision = f.writer.read('demo').revision;
|
||||||
|
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||||
|
const preparing = f.gate.prepare('demo', 'rev', revision);
|
||||||
|
await h.arrived;
|
||||||
|
rewrite(f);
|
||||||
|
h.release();
|
||||||
|
await assert.rejects(preparing, refuses('organization-changed'));
|
||||||
|
});
|
||||||
|
await t.test('changed after final admission, before bindLaunch', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
const thread = await f.threadFor('r1');
|
||||||
|
const revision = f.writer.read('demo').revision;
|
||||||
|
const { authority } = await f.gate.prepare('demo', 'rev', revision);
|
||||||
|
f.gate.admit('demo', 'rev', authority, revision);
|
||||||
|
rewrite(f);
|
||||||
|
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
|
||||||
|
assert.throws(() => f.gate.admit('demo', 'rev', authority, revision), refuses('organization-changed'));
|
||||||
|
const bind = (extra) => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority, ...extra });
|
||||||
|
assert.throws(() => bind({ revision }), refuses('organization-changed'));
|
||||||
|
assert.throws(() => bind({}), refuses('organization-changed'));
|
||||||
|
assert.equal(f.repoCalls(), 0);
|
||||||
|
// The same authority on the file as it stands now binds: the refusal above was the revision's.
|
||||||
|
assert.ok(bind({ revision: f.writer.read('demo').revision }));
|
||||||
|
});
|
||||||
|
await t.test('a non-copy never carries a revision', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.boot();
|
||||||
|
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', revision: f.writer.read('demo').revision }), refuses('invalid-request'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => {
|
test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
await f.boot();
|
await f.boot();
|
||||||
|
|||||||
@@ -836,7 +836,7 @@ export function createLauncher({
|
|||||||
if (copyOf(b, instance)?.bound?.length) {
|
if (copyOf(b, instance)?.bound?.length) {
|
||||||
// The bus's refusal is the launch's: its code goes to the log and the thread as given.
|
// The bus's refusal is the launch's: its code goes to the log and the thread as given.
|
||||||
let prepared;
|
let prepared;
|
||||||
try { prepared = await host.op({ op: "copyLaunch", business: b.id, instance }); }
|
try { prepared = await host.op({ op: "copyLaunch", business: b.id, instance, revision }); }
|
||||||
catch (e) { throw new Refusal(typeof e?.code === "string" && /^[a-z][a-z0-9-]*$/.test(e.code) ? e.code : "credential-unavailable", `${instance}'s bound services can't be proved live`); }
|
catch (e) { throw new Refusal(typeof e?.code === "string" && /^[a-z][a-z0-9-]*$/.test(e.code) ? e.code : "credential-unavailable", `${instance}'s bound services can't be proved live`); }
|
||||||
if (typeof prepared?.authority !== "string") throw new Refusal("credential-unavailable", `${instance}'s bound services can't be proved live`);
|
if (typeof prepared?.authority !== "string") throw new Refusal("credential-unavailable", `${instance}'s bound services can't be proved live`);
|
||||||
copyAuthority = prepared.authority;
|
copyAuthority = prepared.authority;
|
||||||
@@ -871,7 +871,7 @@ export function createLauncher({
|
|||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
if (!outerStart) throw new Error("the session process exited at once");
|
if (!outerStart) throw new Error("the session process exited at once");
|
||||||
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
||||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}), ...(copyAuthority ? { copyAuthority } : {}) });
|
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}), ...(copyAuthority ? { copyAuthority, revision } : {}) });
|
||||||
bound = true;
|
bound = true;
|
||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
entry = {
|
entry = {
|
||||||
|
|||||||
@@ -647,7 +647,7 @@ async function threadLaunch(s, name) {
|
|||||||
return until(() => launchEvents(s).find((e) => e.thread === thread && ["launch", "refused"].includes(e.event)));
|
return until(() => launchEvents(s).find((e) => e.thread === thread && ["launch", "refused"].includes(e.event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("CB3: a binding approved after boot answers restart-required; after a restart the bus proves it live, the launcher carries one launch authority to bind, and a revoke at bind stops the run with no credential call", { skip, timeout: 120000 }, async (t) => {
|
test("CB3: a binding approved after boot answers restart-required; after a restart the bus proves it live, and the launcher carries one launch authority to bind", { skip, timeout: 120000 }, async (t) => {
|
||||||
const p = await boundCopy(t);
|
const p = await boundCopy(t);
|
||||||
const first = await start(t, p, { copyTransport: p.copyTransport });
|
const first = await start(t, p, { copyTransport: p.copyTransport });
|
||||||
assert.deepEqual(first.runtime.copyBound, {});
|
assert.deepEqual(first.runtime.copyBound, {});
|
||||||
@@ -658,8 +658,10 @@ test("CB3: a binding approved after boot answers restart-required; after a resta
|
|||||||
assert.ok(issued > 0);
|
assert.ok(issued > 0);
|
||||||
// Bound and granted after this boot: restart-required at the verb, at the bus's prepare, and at its admission.
|
// Bound and granted after this boot: restart-required at the verb, at the bus's prepare, and at its admission.
|
||||||
assert.equal(refusedAt(first, "researcher").code, "restart-required");
|
assert.equal(refusedAt(first, "researcher").code, "restart-required");
|
||||||
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher" }), (e) => e.code === "restart-required");
|
|
||||||
const revision = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
const revision = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||||
|
// A copy launch carries the revision its bundle came from; without one it is not a request.
|
||||||
|
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher" }), (e) => e.code === "invalid-request");
|
||||||
|
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher", revision }), (e) => e.code === "restart-required");
|
||||||
assert.equal(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision }).code, "copy-authority-changed");
|
assert.equal(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision }).code, "copy-authority-changed");
|
||||||
assert.deepEqual(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision, authority: "0".repeat(64) }), { ready: false, revision: null, code: "restart-required" });
|
assert.deepEqual(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision, authority: "0".repeat(64) }), { ready: false, revision: null, code: "restart-required" });
|
||||||
assert.equal(p.sent.length, issued);
|
assert.equal(p.sent.length, issued);
|
||||||
@@ -673,7 +675,7 @@ test("CB3: a binding approved after boot answers restart-required; after a resta
|
|||||||
// Final admission with an empty project list still needs the authority.
|
// Final admission with an empty project list still needs the authority.
|
||||||
const rev2 = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
const rev2 = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||||
assert.equal(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2 }).code, "copy-authority-changed");
|
assert.equal(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2 }).code, "copy-authority-changed");
|
||||||
const { authority } = await s.runtime.copyLaunch({ business: "acme", instance: "researcher" });
|
const { authority } = await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: rev2 });
|
||||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2, authority }), { ready: true, revision: null, code: null });
|
assert.deepEqual(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2, authority }), { ready: true, revision: null, code: null });
|
||||||
// Any other instance has no authority to carry.
|
// Any other instance has no authority to carry.
|
||||||
@@ -730,3 +732,27 @@ test("CB3 race: a reissue between the launcher's admission and bind refuses at b
|
|||||||
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: restart-required"), JSON.stringify(events));
|
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: restart-required"), JSON.stringify(events));
|
||||||
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("CB3 race: an organization file change between the launcher's admission and bind refuses at bind, even with the copy authority unchanged; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => {
|
||||||
|
const p = await boundCopy(t);
|
||||||
|
const file = join(p.root, "config", "businesses", "acme.json");
|
||||||
|
let s, raced = 0, authority = null;
|
||||||
|
// Same document, other bytes: the revision moves, the launch authority does not.
|
||||||
|
({ s } = await restarted(t, p, { beforeBind: async (r) => {
|
||||||
|
if (r.role !== "researcher" || raced++ !== 0) return;
|
||||||
|
authority = r.copyAuthority;
|
||||||
|
writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, "utf8"))) + "\n", { mode: 0o600 });
|
||||||
|
} }));
|
||||||
|
const event = await threadLaunch(s, "revrace");
|
||||||
|
assert.equal(raced, 1);
|
||||||
|
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||||
|
await until(() => s.running("researcher").length === 0);
|
||||||
|
assert.equal(repoCalls(p), 0);
|
||||||
|
// The refusal was the revision's: on the file as it stands the same authority comes back.
|
||||||
|
const fresh = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||||
|
assert.equal((await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: fresh })).authority, authority);
|
||||||
|
const events = launchEvents(s).filter((e) => e.instance === "researcher");
|
||||||
|
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||||
|
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: organization-changed"), JSON.stringify(events));
|
||||||
|
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||||
|
});
|
||||||
@@ -325,8 +325,9 @@ export function copyRefusal(business, instance) {
|
|||||||
if(business.roles?.[instance]?.unbound?.length)return {code:"credential-unbound",message:`${instance} has no binding for ${business.roles[instance].unbound.join(", ")}`};
|
if(business.roles?.[instance]?.unbound?.length)return {code:"credential-unbound",message:`${instance} has no binding for ${business.roles[instance].unbound.join(", ")}`};
|
||||||
const copy = copyOf(business, instance);
|
const copy = copyOf(business, instance);
|
||||||
if (!copy || copy.state === "instance") return null;
|
if (!copy || copy.state === "instance") return null;
|
||||||
// #1873 CB3: a copy whose binding was approved after the bus started waits for a restart; the
|
// #1873 CB3: a copy whose binding was approved after the bus started waits for a restart. The bus
|
||||||
// bus says so (copy-launch.mjs), and the launcher asks it before refusing credential-unbound.
|
// answers restart-required for it at instance.launch and thread.create (copy-launch.mjs), before a
|
||||||
|
// launch reaches the launcher, so this view's credential-unbound is only its last word.
|
||||||
if (copy.state === "unbound") {
|
if (copy.state === "unbound") {
|
||||||
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,23 @@ export function orgIncarnation(registry, id) {
|
|||||||
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
|
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
|
||||||
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
|
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
// #1873 CB3: one organization's registry entry read now, without the registry lock (the writer
|
||||||
|
// publishes by rename, so a read sees one whole file). For the copy launch gate, which checks the
|
||||||
|
// entry on every admission and use. null when no registry or no such entry; throws when unsafe.
|
||||||
|
export function readOrgEntrySync(configDir, id) {
|
||||||
|
const file = join(configDir, 'org-runtime.json');
|
||||||
|
let dir; try { dir = lstatSync(configDir); } catch (e) { throw quiet(e, CONFIG); }
|
||||||
|
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
|
||||||
|
let fd;
|
||||||
|
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||||
|
catch (e) { if (e.code === 'ENOENT') return null; throw quiet(e, 'the registry file'); }
|
||||||
|
try {
|
||||||
|
const st = fstatSync(fd);
|
||||||
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
|
||||||
|
const registry = validateRegistry(parseJsonFileText(readFileSync(fd, 'utf8'), file, 'registry file'), file);
|
||||||
|
return Object.hasOwn(registry.orgs, id) ? { state: registry.orgs[id].state, incarnation: orgIncarnation(registry, id) } : null;
|
||||||
|
} finally { closeSync(fd); }
|
||||||
|
}
|
||||||
export async function readRegistry({ configDir }) {
|
export async function readRegistry({ configDir }) {
|
||||||
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
||||||
|
|||||||
Reference in new issue
Block a user