fix(org): repo set refuses under a copy binding that still grants the project (#1873 F7)

A copy binding grants the repositories its projects' remotes name when a
credential is issued, and its decision records only project names. repo set
(CLI, route, Settings tab) is now refused with project-repo-bound while a
pending or bound copy binding names the project, before any check, origin
change or write, and again under the lock. The message names the copies and
the next step: revoke the binding, set the repository, raise a new binding
decision. An unbound binding, or none, behaves as before. Bindings are read
from the loader-validated document; binding, launcher and broker code are
unchanged. New refusal-table entry for project-repo-bound.
This commit was merged in pull request #1948.
This commit is contained in:
jason.woltje committed 2026-11-12 13:39:26 -06:00
1 parent 0e3ffd2133
commit 2ad4b30ebd
12 files changed
+237 -5

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ group("copy-authority-changed", "R1/R3/R8: the launch authority admitted at prep
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("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("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("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.");
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.");
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."); 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.");
+2 -1
View File
@@ -1281,7 +1281,8 @@ existing repository. Local-only acceptance records the risk of losing the only
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
repo show ID PROJECT [--json]`. `mosaic org project repo set ID PROJECT repo show ID PROJECT [--json]`. `mosaic org project repo set ID PROJECT
(--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json]` (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json]`
changes a project's repository later. A remote is https or ssh; its checkout's changes a project's repository later; it is refused with `project-repo-bound`
(exit 2) while an approved or active copy binding names the project. A remote is https or ssh; its checkout's
`origin` is set and checked once with `git ls-remote`, without credentials, and `origin` is set and checked once with `git ls-remote`, without credentials, and
the receipt says `reachable`, `needs-credential` or `unreachable`. An ssh check the receipt says `reachable`, `needs-credential` or `unreachable`. An ssh check
does not verify the host key and says so. A copy does not verify the host key and says so. A copy
+35 -1
View File
@@ -3,7 +3,8 @@
// once, so the check runs for real and answers "unreachable" without leaving the host. // once, so the check runs for real and answers "unreachable" without leaving the host.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { execFileSync, spawnSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
@@ -55,3 +56,36 @@ test('org project repo init --remote and repo set: origin follows, the check is
assert.match(named.check.message, /resolve|timed out/i); assert.match(named.check.message, /resolve|timed out/i);
assert.equal(git(init.root, 'remote', '-v'), `origin\t${invalid} (fetch)\norigin\t${invalid} (push)`); assert.equal(git(init.root, 'remote', '-v'), `origin\t${invalid} (fetch)\norigin\t${invalid} (push)`);
}); });
// #1873 F7: while a copy binding that names the project is approved or active, repo set is refused
// with exit 2 and the next step; once the binding is unbound (revoked) the same set goes through.
// The bindings are written into the document as the CB1 writer would leave them.
test('org project repo set is refused under a copy binding that names the project, and allowed after revoke', () => {
const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') };
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr);
const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); };
const file = join(home, 'alpha.json');
run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file);
const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--local-only', '--accept-local-loss-risk');
const configDir = join(home, '.config/mosaic-orgtest');
const seed = (state) => {
const docFile = join(configDir, 'businesses/alpha.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
doc.copyBindings = { 'rev.work': { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'project', name: 'rev', project: 'work' },
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] } } },
decision: '11111111-1111-4111-8111-111111111111' } };
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(docFile, raw);
const regFile = join(configDir, 'org-runtime.json'); const reg = JSON.parse(readFileSync(regFile, 'utf8'));
reg.orgs.alpha.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
};
const invalid = 'https://git.example.invalid/acme/other';
seed('pending');
const rev = ok('org', 'show', 'alpha', '--json').revision;
const refused = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev));
assert.equal(refused.status, 2, refused.stderr);
assert.match(refused.stderr, /project-repo-bound: project work is in a copy binding that still grants it: rev\.work \(approved, not yet verified\)\. Revoke that copy binding first/);
assert.equal(git(init.root, 'remote', '-v'), '');
seed('unbound');
const set = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev), '--json');
assert.equal(set.revision, rev + 1); assert.equal(git(init.root, 'remote', 'get-url', 'origin'), invalid);
});
+9
View File
@@ -599,6 +599,15 @@ project's repository: a new URL, or remote to local only and back. It runs the
same URL rules and check, sets or removes `origin`, records the binding in the same URL rules and check, sets or removes `origin`, records the binding in the
organization file's `projects.PROJECT.git`, and writes an organization file's `projects.PROJECT.git`, and writes an
`org.project.repo.set` receipt. A stale `--if-revision` is `revision-conflict`. `org.project.repo.set` receipt. A stale `--if-revision` is `revision-conflict`.
While a copy binding that names the project is approved (`pending`) or active
(`bound`), repo set is refused with `project-repo-bound`, before any check or
change to `origin`, even for the URL already recorded: the binding grants the
repositories its projects name at credential issue, and its decision records
only project names (#1873 F7). The message names the copies. Revoke the 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. An
`unbound` binding, or none, does not refuse. A binding decision raised but not
yet approved is not read here.
Queue review transports and transitions are not provided by this setup. Queue review transports and transitions are not provided by this setup.
The local development owner is accepted only through the existing named-instance The local development owner is accepted only through the existing named-instance
+5 -1
View File
@@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { validateBusinessDocument } from './organization-config.mjs'; import { validateBusinessDocument } from './organization-config.mjs';
import { checkRemote, parseRepoRemote } from './repo-remote.mjs'; import { checkRemote, parseRepoRemote, projectBindings, REPO_BOUND, repoBoundMessage } from './repo-remote.mjs';
import { ID_PATTERN } from './vocabulary.mjs'; import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs'; import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs'; import { loadInstalled } from '../../plugins/src/loader.mjs';
@@ -271,10 +271,14 @@ export async function projectRepoSet(ctx, id, project, opts = {}) {
if (!Number.isSafeInteger(ifRevision)) fail('org-invalid', 'repo set needs --if-revision'); if (!Number.isSafeInteger(ifRevision)) fail('org-invalid', 'repo set needs --if-revision');
// Fail fast on the cheap checks before the network check, then again under the lock. // Fail fast on the cheap checks before the network check, then again under the lock.
await projectRepoShow(ctx, id, project).then(r => { if (r.current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); }); await projectRepoShow(ctx, id, project).then(r => { if (r.current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); });
// #1873 F7: never under a copy binding that still grants this project; checked again under the lock.
const refuseBound = doc => { const bound = projectBindings(doc, project); if (bound.length) fail(REPO_BOUND, repoBoundMessage(project, bound)); };
refuseBound((await readCurrent(ctx, id)).doc);
const check = selection.mode === 'remote' ? await checkRemote(selection.remote) : null; const check = selection.mode === 'remote' ? await checkRemote(selection.remote) : null;
return withOrgLifecycle(ctx, id, () => withRegistry(ctx, async registry => { return withOrgLifecycle(ctx, id, () => withRegistry(ctx, async registry => {
const latest = await readCurrentUnlocked(ctx, id); if (!Object.hasOwn(latest.doc.projects, project)) fail('org-unknown', 'unknown project'); const latest = await readCurrentUnlocked(ctx, id); if (!Object.hasOwn(latest.doc.projects, project)) fail('org-unknown', 'unknown project');
if (latest.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); if (latest.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
refuseBound(latest.doc);
const root = projectPath(latest.root, project); const root = projectPath(latest.root, project);
const init = (await receipts(latest.root)).findLast(r => r.op === 'org.project.repo.init' && r.project === project); const init = (await receipts(latest.root)).findLast(r => r.op === 'org.project.repo.init' && r.project === project);
if (!init || init.root !== root || !same(init.subject, latest.principal)) fail('org-unavailable', 'setup receipt unavailable'); if (!init || init.root !== root || !same(init.subject, latest.principal)) fail('org-unavailable', 'setup receipt unavailable');
+24
View File
@@ -6,6 +6,7 @@
// wants a credential, or could not be reached. Nothing here pushes, fetches objects or writes. // wants a credential, or could not be reached. Nothing here pushes, fetches objects or writes.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { parseRepoRemote } from './vocabulary.mjs'; import { parseRepoRemote } from './vocabulary.mjs';
import { BINDING_STATES } from './copy-bindings.mjs';
export { parseRepoRemote }; export { parseRepoRemote };
@@ -99,6 +100,29 @@ export async function checkRemote(url) {
return { result: outcome.result, message, url: parsed.url }; return { result: outcome.result, message, url: parsed.url };
} }
// #1873 F7: a copy binding grants the repositories its projects' remotes name when a credential is
// 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.
// `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');
export function projectBindings(doc, project) {
const out = [];
for (const [instance, record] of Object.entries(doc?.copyBindings ?? {})) {
if (!GRANTING.includes(record?.state)) continue;
const names = record.source?.project === project
|| Object.values(record.services ?? {}).some((v) => v?.resources?.projects?.includes(project));
if (names) out.push({ instance, state: record.state });
}
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(', ');
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`;
}
// TEST SEAM. Tests reach a local bare repository through this and nothing else: lsRemote with a // TEST SEAM. Tests reach a local bare repository through this and nothing else: lsRemote with a
// widened protocol list, and a stand-in for checkRemote's git call. No module under packages/*/src // widened protocol list, and a stand-in for checkRemote's git call. No module under packages/*/src
// may import or name it; tests/repo-remote.test.mjs enforces that by scanning the sources. // may import or name it; tests/repo-remote.test.mjs enforces that by scanning the sources.
@@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process';
import { chmodSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { chmodSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { parseRepoRemote, checkRemote, classifyCheck, summarizeGitError, CHECK_SSH_COMMAND, NEEDS_CREDENTIAL_MESSAGE, SSH_HOST_UNVERIFIED, repoRemoteTestSeam } from '../src/repo-remote.mjs'; import { parseRepoRemote, checkRemote, classifyCheck, summarizeGitError, CHECK_SSH_COMMAND, NEEDS_CREDENTIAL_MESSAGE, SSH_HOST_UNVERIFIED, repoRemoteTestSeam } from '../src/repo-remote.mjs';
import { createOrg, templateRender, projectRepoInit, projectRepoShow, projectRepoSet, queueInit, showOrg, updateOrg } from '../src/org-service.mjs'; import { createOrg, templateRender, projectRepoInit, projectRepoShow, projectRepoSet, queueInit, showOrg, updateOrg } from '../src/org-service.mjs';
@@ -232,6 +233,88 @@ test('a set whose document write fails leaves origin ahead of the record, and sh
} finally { seam.useCheck(null); chmodSync(businesses, 0o700); } } finally { seam.useCheck(null); chmodSync(businesses, 0o700); }
}); });
// #1873 F7: a copy binding grants the repositories its projects' remotes name when a credential is
// issued, and its decision records only project names. While a binding that names the project is
// approved (pending) or active (bound), repo set refuses before any check or origin change, and
// names the next step. An unbound (revoked) binding, or none, leaves repo set as it was.
// Bindings are seeded into the document directly, as the CB1 writer would leave them; the registry
// digest is updated so the read fences accept the document.
function seedBindings(c, bindings) {
const file = join(c.configDir, 'businesses', 'alpha.json');
const doc = JSON.parse(readFileSync(file, 'utf8')); doc.copyBindings = bindings;
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(file, raw, { mode: 0o600 });
const registryFile = join(c.configDir, 'org-runtime.json'); const registry = JSON.parse(readFileSync(registryFile, 'utf8'));
registry.orgs.alpha.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(registryFile, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600 });
}
function binding(instance, state, { project = null, projects = ['work'], n = 1 } = {}) {
const name = project ? instance.slice(0, -(project.length + 1)) : instance;
const gitea = { slot: 'self', scopes: ['read:repository'], resources: { projects } };
if (state === 'bound') Object.assign(gitea, { rotateBy: '2099-01-01', grantId: `gitea:5:${n}`, grantGeneration: 1 });
return { copyId: String(n).repeat(32), orgIncarnation: 'a'.repeat(32),
source: { level: project ? 'project' : 'root', name, project }, definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) },
generation: 1, state, services: state === 'unbound' ? {} : { gitea }, decision: `1111111${n}-1111-4111-8111-111111111111` };
}
async function refusedUnderBinding(bindings, expect) {
const c = context(); const r = await orgWithRepo(c);
const before = readFileSync(join(r.root, '../../receipts.jsonl'), 'utf8');
seedBindings(c, bindings);
let checks = 0; seam.useCheck(async () => { checks++; return { result: 'reachable', message: 'the repository answered' }; });
try {
const rev = (await showOrg(c, 'alpha')).revision;
for (const selection of [{ mode: 'remote', remote: 'https://git.example.com/team/other' }, { mode: 'local-only', acceptLocalLossRisk: true }]) {
await assert.rejects(projectRepoSet(c, 'alpha', 'work', { ...selection, ifRevision: rev }), (e) => {
assert.equal(e.code, 'project-repo-bound');
for (const text of expect) assert.ok(e.message.includes(text), `${text} in ${e.message}`);
assert.match(e.message, /Revoke that copy binding first .*then set the repository, then ask the chief to raise a new binding decision/);
return true;
});
}
assert.equal(checks, 0, 'no check ran'); assert.equal(git(r.root, 'remote', '-v'), '', 'origin unchanged');
assert.equal((await showOrg(c, 'alpha')).revision, rev); assert.equal(readFileSync(join(r.root, '../../receipts.jsonl'), 'utf8'), before, 'no receipt');
assert.deepEqual(loadBusiness('alpha', { dir: c.configDir, rolesDir: REPO_ROLES }).projects.work.git, { mode: 'local-only' });
} finally { seam.useCheck(null); }
}
test('repo set is refused while an approved (pending) copy binding names the project', async () => {
await refusedUnderBinding({ 'rev.work': binding('rev.work', 'pending', { project: 'work' }) }, ['rev.work (approved, not yet verified)']);
});
test('repo set is refused while an active (bound) copy binding names the project', async () => {
// A root copy whose gitea resources name the project, beside a binding for another project that does not count.
await refusedUnderBinding({ rev: binding('rev', 'bound', { projects: ['other', 'work'] }), dev: binding('dev', 'bound', { projects: ['other'], n: 2 }) },
['rev (active)']);
await refusedUnderBinding({ rev: binding('rev', 'bound', { projects: ['work'] }), 'qa.work': binding('qa.work', 'pending', { project: 'work', n: 2 }) },
['qa.work (approved, not yet verified), rev (active)']);
});
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' }));
try {
const url = 'https://git.example.com/team/other';
seedBindings(c, { 'rev.work': binding('rev.work', 'bound', { project: 'work' }) });
const rev = (await showOrg(c, 'alpha')).revision;
await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: url, ifRevision: rev }), { code: 'project-repo-bound' });
// The unbind leaves the tombstone; a binding for another project does not count.
const revoked = { 'rev.work': binding('rev.work', 'unbound', { project: 'work' }), dev: binding('dev', 'bound', { projects: ['other'], n: 2 }) };
seedBindings(c, revoked);
const set = await projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: url, ifRevision: rev });
assert.equal(set.op, 'org.project.repo.set'); assert.equal(set.revision, rev + 1); assert.equal(git(r.root, 'remote', 'get-url', 'origin'), url);
assert.deepEqual(loadBusiness('alpha', { dir: c.configDir, rolesDir: REPO_ROLES }).copyBindings, revoked, 'bindings untouched');
} finally { seam.useCheck(null); }
});
// Unchanged behaviour: it passes on a source that never refuses, and guards against refusing too much.
test('repo set is allowed with no copy binding', async () => {
const c = context(); const r = await orgWithRepo(c);
seam.useCheck(async () => ({ result: 'reachable', message: 'the repository answered' }));
try {
const rev = (await showOrg(c, 'alpha')).revision; const url = 'https://git.example.com/team/other';
const set = await projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: url, ifRevision: rev });
assert.equal(set.revision, rev + 1); assert.equal(git(r.root, 'remote', 'get-url', 'origin'), url);
} finally { seam.useCheck(null); }
});
test('repo set refuses bad URLs before any check, and needs an initialized repository', async () => { test('repo set refuses bad URLs before any check, and needs an initialized repository', async () => {
const c = context(); await createOrg(c, 'alpha', await templateRender('alpha')); const c = context(); await createOrg(c, 'alpha', await templateRender('alpha'));
let checks = 0; seam.useCheck(async () => { checks++; return { result: 'reachable', message: 'x' }; }); let checks = 0; seam.useCheck(async () => { checks++; return { result: 'reachable', message: 'x' }; });
+1 -1
View File
@@ -17,7 +17,7 @@ baseline CLI.
| Save organization or theme | `mosaic org update <id> --from <file> --if-revision <revision>` | S6 integration hook | | Save organization or theme | `mosaic org update <id> --from <file> --if-revision <revision>` | S6 integration hook |
| Initialize organization checkout | `mosaic org queue init <id> --local-only --accept-local-loss-risk` or `--remote <url>` | S6 integration hook | | Initialize organization checkout | `mosaic org queue init <id> --local-only --accept-local-loss-risk` or `--remote <url>` | S6 integration hook |
| Initialize project Git | `mosaic org project repo init <id> <project> --local-only --accept-local-loss-risk` or `--remote <url>` | S6 integration hook; remote mode sets and checks `origin` (#1873 P1-6) | | Initialize project Git | `mosaic org project repo init <id> <project> --local-only --accept-local-loss-risk` or `--remote <url>` | S6 integration hook; remote mode sets and checks `origin` (#1873 P1-6) |
| Change a project's repository | `mosaic org project repo set <id> <project> (--local-only --accept-local-loss-risk \| --remote <url>) --if-revision <revision>` | Enabled (#1873 P1-7): the owner's Repository section on an organization project's Settings tab, `/api/orgs/<id>/repo/project/<project>`; needs send mode | | Change a project's repository | `mosaic org project repo set <id> <project> (--local-only --accept-local-loss-risk \| --remote <url>) --if-revision <revision>` | Enabled (#1873 P1-7): the owner's Repository section on an organization project's Settings tab, `/api/orgs/<id>/repo/project/<project>`; needs send mode; refused (409 `project-repo-bound`) while an approved or active copy binding names the project |
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only | | Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only | | Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook | | Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
+2
View File
@@ -274,6 +274,8 @@ function refusal(res, error, { step, id, unknownOk = false }) {
if (code === 'org-forbidden') return json(res, 403, { error: 'org-forbidden', step, message: String(error.message ?? code) }); if (code === 'org-forbidden') return json(res, 403, { error: 'org-forbidden', step, message: String(error.message ?? code) });
if (code === 'org-exists' || code === 'org-overlap' || code === 'exists') return json(res, 409, { error: code, step, message: 'it already exists; read it back instead of creating it again', readBack: id ? `/api/orgs/${id}/setup` : null }); if (code === 'org-exists' || code === 'org-overlap' || code === 'exists') return json(res, 409, { error: code, step, message: 'it already exists; read it back instead of creating it again', readBack: id ? `/api/orgs/${id}/setup` : null });
if (code === 'org-invalid' && (step === 'queue' || step === 'project')) return json(res, 422, { error: code, step, message: String(error.message), errors: [{ path: '$.remote', field: 'remote', message: String(error.message) }] }); if (code === 'org-invalid' && (step === 'queue' || step === 'project')) return json(res, 422, { error: code, step, message: String(error.message), errors: [{ path: '$.remote', field: 'remote', message: String(error.message) }] });
// #1873 F7: a copy binding still grants this project; the message names the copies and the next step.
if (code === 'project-repo-bound') return json(res, 409, { error: code, step, message: String(error.message ?? code) });
if (code === 'org-risk') return json(res, 422, { error: code, step, message: String(error.message), errors: [{ path: '$.acceptLocalLossRisk', field: 'acceptLocalLossRisk', message: String(error.message) }] }); if (code === 'org-risk') return json(res, 422, { error: code, step, message: String(error.message), errors: [{ path: '$.acceptLocalLossRisk', field: 'acceptLocalLossRisk', message: String(error.message) }] });
if (typeof code === 'string' && /^org-|invalid|refused|required/.test(code)) return json(res, 422, { error: code, step, message: String(error.message ?? code), errors: error.errors ? withField(error.errors) : [] }); if (typeof code === 'string' && /^org-|invalid|refused|required/.test(code)) return json(res, 422, { error: code, step, message: String(error.message ?? code), errors: error.errors ? withField(error.errors) : [] });
if (unknownOk) return json(res, 502, { error: 'outcome-unknown', step, message: 'the outcome is unknown. Read the step back before retrying.', readBack: id ? `/api/orgs/${id}/setup` : null }); if (unknownOk) return json(res, 502, { error: 'outcome-unknown', step, message: 'the outcome is unknown. Read the step back before retrying.', readBack: id ? `/api/orgs/${id}/setup` : null });
+2 -1
View File
@@ -125,6 +125,7 @@
'project-unknown': 'This project was not found in the organization.', 'project-unknown': 'This project was not found in the organization.',
'not-configured': 'Changing the repository is not available on this Console.', 'not-configured': 'Changing the repository is not available on this Console.',
'readback-failed': 'The repository could not be read back, so nothing was changed. Inspect it with the show command.', 'readback-failed': 'The repository could not be read back, so nothing was changed. Inspect it with the show command.',
'project-repo-bound': 'A copy binding still grants this project. Revoke that copy binding first, then set the repository, then ask the chief to raise a new binding decision.',
'outcome-unknown': 'The outcome is unknown. The setting was read again; check it before you retry.', 'outcome-unknown': 'The outcome is unknown. The setting was read again; check it before you retry.',
}; };
const CHECK = { reachable: 'Reachable', 'needs-credential': 'Needs a credential', unreachable: 'Unreachable' }; const CHECK = { reachable: 'Reachable', 'needs-credential': 'Needs a credential', unreachable: 'Unreachable' };
@@ -141,7 +142,7 @@
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' }; return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
} }
// A refusal the server explained (the URL rules, a missing setup) keeps its own words. // A refusal the server explained (the URL rules, a missing setup) keeps its own words.
const refusalLine = r => (['invalid', 'org-invalid'].includes(r.code) && r.message) ? r.message : LINES[r.code] || LINES['outcome-unknown']; const refusalLine = r => (['invalid', 'org-invalid', 'project-repo-bound'].includes(r.code) && r.message) ? r.message : LINES[r.code] || LINES['outcome-unknown'];
let active = null; let active = null;
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; }); document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
+41
View File
@@ -523,6 +523,47 @@ test('against the real C2 service: create, set up, resume and refuse an overwrit
assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser'); assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser');
}); });
// #1873 F7: Settings cannot change the repository of a project an approved or active copy binding
// names. The refusal is 409 with the copies and the next step; after revoke the same save goes through.
test('against the real C2 service: repo set is refused under a copy binding and allowed after revoke', async t => {
const service = await import('../../organization/src/org-service.mjs');
const registry = await import('../../organization/src/org-registry.mjs');
const { repoRemoteTestSeam } = await import('../../organization/src/repo-remote.mjs');
let checks = 0; repoRemoteTestSeam.useCheck(async () => { checks++; return { result: 'reachable', message: 'the repository answered' }; });
t.after(() => repoRemoteTestSeam.useCheck(null));
const { createHash } = await import('node:crypto');
const root = mkdtempSync(join(tmpdir(), 'oa')); const configDir = join(root, 'config'), dataRoot = join(root, 'data');
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
const admin = createOrgAdmin({ service, registry, bus: null, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const c = await console_(t, { orgAdmin: admin });
const tpl = await c.call('GET', '/api/org-template?id=acme');
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: tpl.body.doc })).status, 201);
assert.equal((await c.call('POST', '/api/orgs/acme/setup/project/work', { mode: 'local-only', acceptLocalLossRisk: true })).status, 201);
const seed = state => {
const docFile = join(configDir, 'businesses/acme.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
doc.copyBindings = { rev: { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] }, ...(state === 'bound' ? { rotateBy: '2099-01-01', grantId: 'gitea:5:7', grantGeneration: 1 } : {}) } },
decision: '11111111-1111-4111-8111-111111111111' } };
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(docFile, raw);
const regFile = join(configDir, 'org-runtime.json'); const reg = JSON.parse(readFileSync(regFile, 'utf8'));
reg.orgs.acme.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
};
const remote = 'https://git.example.invalid/acme/other';
for (const [state, label] of [['pending', 'approved, not yet verified'], ['bound', 'active']]) {
seed(state);
const rev = (await c.call('GET', '/api/orgs/acme/repo/project/work')).body.current.revision;
const r = await c.call('POST', '/api/orgs/acme/repo/project/work', { mode: 'remote', remote, ifRevision: rev });
assert.equal(r.status, 409, r.raw); assert.equal(r.body.error, 'project-repo-bound'); assert.equal(r.body.step, 'repo');
assert.equal(r.body.message, `project work is in a copy binding that still grants it: rev (${label}). 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`);
}
assert.equal(checks, 0);
seed('unbound');
const rev = (await c.call('GET', '/api/orgs/acme/repo/project/work')).body.current.revision;
const r = await c.call('POST', '/api/orgs/acme/repo/project/work', { mode: 'remote', remote, ifRevision: rev });
assert.equal(r.status, 200, r.raw); assert.equal(r.body.receipt.origin, remote); assert.equal(checks, 1);
});
test('preset routes expose installed choices and refuse invalid queries', async t => { test('preset routes expose installed choices and refuse invalid queries', async t => {
const service = await import('../../organization/src/org-service.mjs'); const service = await import('../../organization/src/org-service.mjs');
const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
@@ -10,6 +10,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs'; import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs';
@@ -167,6 +168,37 @@ test('the owner edits the repository on Settings: remote, refusals, local only a
assert.deepEqual(p.errors, []); assert.deepEqual(p.errors, []);
}); });
// #1873 F7: an active copy binding names the project, so Settings is refused with the copy and the
// next step, the entry is kept and git is untouched; after the binding is revoked the same save lands.
test('Settings refuses the repository change under an active copy binding and saves after revoke', { timeout: 60000 }, async t => {
repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null));
const p = await console_(t); const { b } = p;
await seed(p);
const configDir = join(p.root, 'config');
const bind = state => {
const docFile = join(configDir, 'businesses/acme.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
doc.copyBindings = { rev: { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:7', grantGeneration: 1 } },
decision: '11111111-1111-4111-8111-111111111111' } };
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(docFile, raw);
const regFile = join(configDir, 'org-runtime.json'); const reg = JSON.parse(readFileSync(regFile, 'utf8'));
reg.orgs.acme.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
};
bind('bound');
await settings(p);
const url = 'https://git.example.invalid/acme/work';
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
await fill(b, '#project-repo-url', url);
assert.equal(await save(p, '/copy binding/'), 'project work is in a copy binding that still grants it: rev (active). 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');
assert.equal(await b.evaluate(`document.querySelector('#project-repo-url').value`), url, 'the entry is kept');
assert.equal(p.remotes(), '');
bind('unbound');
assert.match(await save(p, '/Saved/'), new RegExp(`Origin is ${url.replace(/[.]/g, '\\.')}\\.`));
assert.equal(p.remotes(), `origin\t${url} (fetch)\norigin\t${url} (push)`);
assert.deepEqual(p.errors, []);
});
test('a read-only Console shows the setting, refuses the change in plain words and git is untouched', { timeout: 60000 }, async t => { test('a read-only Console shows the setting, refuses the change in plain words and git is untouched', { timeout: 60000 }, async t => {
repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null)); repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null));
// Seed through a send-mode Console, then open the same organization in one without send mode. // Seed through a send-mode Console, then open the same organization in one without send mode.