From 2ad4b30ebd624956f922f809dcb631ff2544ee5d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 21:06:15 -0600 Subject: [PATCH] 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. --- packages/bus/src/refusals.mjs | 1 + packages/cli/README.md | 3 +- packages/cli/tests/org-repo-set.test.mjs | 36 +++++++- packages/organization/README.md | 9 ++ packages/organization/src/org-service.mjs | 6 +- packages/organization/src/repo-remote.mjs | 24 ++++++ .../organization/tests/repo-remote.test.mjs | 83 +++++++++++++++++++ packages/webui/README.md | 2 +- packages/webui/src/org-admin.mjs | 2 + packages/webui/src/public/project-remote.js | 3 +- packages/webui/tests/org-admin.test.mjs | 41 +++++++++ .../webui/tests/project-repo-browser.test.mjs | 32 +++++++ 12 files changed, 237 insertions(+), 5 deletions(-) diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index badcde6b1..4b9ab07c9 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -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("organization-changed organization-unreadable", "R3/R8: a launch holds to the organization file revision it resolved", "The organization file changed, or could not be read, while the launch was in progress. Launch the agent again so it resolves the file as it stands now."); group("copy-redirect-refused", "R1/R8: a copy credential goes only to the approved Gitea origin", "The Gitea server answered with a redirect. Fix the bound project's remote to name the server's final address, then ask the owner to issue the credential again; redirects are never followed."); +group("project-repo-bound", "R1/R8: a binding grants the repository its projects name when the owner approved it", "Revoke the copy binding that names the project first: the chief raises an unbind decision and the owner approves it. Then set the project repository, then ask the chief to raise a new binding decision for the owner."); group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision."); group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy."); group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure."); diff --git a/packages/cli/README.md b/packages/cli/README.md index e7bf30b39..ab10049a7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 repo show ID PROJECT [--json]`. `mosaic org project repo set ID PROJECT (--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 the receipt says `reachable`, `needs-credential` or `unreachable`. An ssh check does not verify the host key and says so. A copy diff --git a/packages/cli/tests/org-repo-set.test.mjs b/packages/cli/tests/org-repo-set.test.mjs index ce862b4f2..668f6a410 100644 --- a/packages/cli/tests/org-repo-set.test.mjs +++ b/packages/cli/tests/org-repo-set.test.mjs @@ -3,7 +3,8 @@ // once, so the check runs for real and answers "unreachable" without leaving the host. import { test } from 'node:test'; 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 { tmpdir } from 'node:os'; 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.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); +}); diff --git a/packages/organization/README.md b/packages/organization/README.md index 0c5febd11..5873d60e4 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -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 organization file's `projects.PROJECT.git`, and writes an `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. The local development owner is accepted only through the existing named-instance diff --git a/packages/organization/src/org-service.mjs b/packages/organization/src/org-service.mjs index a9a07cff1..01a5049be 100644 --- a/packages/organization/src/org-service.mjs +++ b/packages/organization/src/org-service.mjs @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; 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 { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.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'); // 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'); }); + // #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; 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'); if (latest.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); + refuseBound(latest.doc); const root = projectPath(latest.root, 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'); diff --git a/packages/organization/src/repo-remote.mjs b/packages/organization/src/repo-remote.mjs index c3ef7cea1..055edeb9f 100644 --- a/packages/organization/src/repo-remote.mjs +++ b/packages/organization/src/repo-remote.mjs @@ -6,6 +6,7 @@ // wants a credential, or could not be reached. Nothing here pushes, fetches objects or writes. import { spawn } from 'node:child_process'; import { parseRepoRemote } from './vocabulary.mjs'; +import { BINDING_STATES } from './copy-bindings.mjs'; export { parseRepoRemote }; @@ -99,6 +100,29 @@ export async function checkRemote(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 // 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. diff --git a/packages/organization/tests/repo-remote.test.mjs b/packages/organization/tests/repo-remote.test.mjs index c0f40c2fc..bcf40b0a9 100644 --- a/packages/organization/tests/repo-remote.test.mjs +++ b/packages/organization/tests/repo-remote.test.mjs @@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process'; import { chmodSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; 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 { 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); } }); +// #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 () => { const c = context(); await createOrg(c, 'alpha', await templateRender('alpha')); let checks = 0; seam.useCheck(async () => { checks++; return { result: 'reachable', message: 'x' }; }); diff --git a/packages/webui/README.md b/packages/webui/README.md index d5c226449..47056d68d 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -17,7 +17,7 @@ baseline CLI. | Save organization or theme | `mosaic org update --from --if-revision ` | S6 integration hook | | Initialize organization checkout | `mosaic org queue init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook | | Initialize project Git | `mosaic org project repo init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook; remote mode sets and checks `origin` (#1873 P1-6) | -| Change a project's repository | `mosaic org project repo set (--local-only --accept-local-loss-risk \| --remote ) --if-revision ` | Enabled (#1873 P1-7): the owner's Repository section on an organization project's Settings tab, `/api/orgs//repo/project/`; needs send mode | +| Change a project's repository | `mosaic org project repo set (--local-only --accept-local-loss-risk \| --remote ) --if-revision ` | Enabled (#1873 P1-7): the owner's Repository section on an organization project's Settings tab, `/api/orgs//repo/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 ` | CLI only | | Inspect or clear a dead lifecycle lock | `mosaic org lock show ` / `mosaic org lock clear ` | CLI only | | Start, stop or restart a bus | `mosaic bus start ` / `mosaic bus stop ` / `mosaic bus restart ` | S6 integration hook | diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index f84597ab2..ef238761f 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -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-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) }] }); + // #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 (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 }); diff --git a/packages/webui/src/public/project-remote.js b/packages/webui/src/public/project-remote.js index be56f2f2f..c028f575c 100644 --- a/packages/webui/src/public/project-remote.js +++ b/packages/webui/src/public/project-remote.js @@ -125,6 +125,7 @@ 'project-unknown': 'This project was not found in the organization.', '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.', + '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.', }; 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' }; } // 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; document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; }); diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index eec8c6a28..d5d5e886c 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -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'); }); +// #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 => { const service = await import('../../organization/src/org-service.mjs'); const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); diff --git a/packages/webui/tests/project-repo-browser.test.mjs b/packages/webui/tests/project-repo-browser.test.mjs index ce981fd7e..e1fd26d6d 100644 --- a/packages/webui/tests/project-repo-browser.test.mjs +++ b/packages/webui/tests/project-repo-browser.test.mjs @@ -10,6 +10,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.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, []); }); +// #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 => { 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.