fix(organization): CB4 re-check, project.remote.set refuses under a copy binding that still grants the project (#1873)
This commit was merged in pull request #1954.
This commit is contained in:
1 parent
2196d37a47
commit
95c2ce1435
2 files changed
+23
-1
No files matched your search
@@ -62,11 +62,13 @@ async function world(t) {
|
||||
// they stand, as a restart of the bus process would; boot() runs the gate's boot proof and promotes.
|
||||
// stopRuns acknowledges each run the broker told the launcher to kill, as the launcher's exit does,
|
||||
// unless `ack` is false. The revoke's remote step goes to the organization's admin slot.
|
||||
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null } = {}) {
|
||||
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false } = {}) {
|
||||
const { root, rolesDir, api } = w;
|
||||
const owner = id === 'demo' ? 'owner' : id;
|
||||
const doc = { ...businessDoc(root, id), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${api.url}/${owner}/stack`;
|
||||
// A project set up in git remote mode names its repository there, with no top-level remote.
|
||||
if (gitOnly) { doc.projects.stack.git = { mode: 'remote', remote: doc.projects.stack.remote }; delete doc.projects.stack.remote; }
|
||||
// The project root the organization service derives (archive and delete check it).
|
||||
doc.projects.stack.root = join(w.data, 'org-runtime', id, 'projects', 'stack');
|
||||
if (project) doc.projects.stack.agents = project;
|
||||
@@ -651,6 +653,17 @@ test('CB4 an owner update cannot change the repository of a project a copy bindi
|
||||
await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision });
|
||||
});
|
||||
|
||||
test('CB4 the owner\'s project.remote.set cannot change the repository of a project a copy binding still grants; the revoke then finishes at Gitea', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w, { gitOnly: true });
|
||||
const { revision } = f.writer.read(f.id);
|
||||
assert.throws(() => f.writer.setProjectRemote(f.id, { revision, project: 'stack', remote: `${w.api.url}/owner/elsewhere` }, 'jason'), refuses('project-repo-bound'));
|
||||
assert.equal(f.writer.read(f.id).revision, revision);
|
||||
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
|
||||
assert.equal(out.state, 'unbound');
|
||||
await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' });
|
||||
});
|
||||
|
||||
test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { REPO_BOUND, projectBindings, repoBoundMessage } from "./repo-remote.mjs";
|
||||
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
|
||||
import { REMOTE_OUTCOMES, REVOKE_CAUSES, allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
@@ -654,13 +655,21 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// The only exception to the projects fence for an existing software mapping.
|
||||
// Build from the locked document and check both the candidate and serialized bytes.
|
||||
function setProjectRemote(id, { revision, project, remote }, actor) {
|
||||
// #1873 CB4 (F7): never under a copy binding that still grants the project, as in project repo set
|
||||
// and the owner's update: a changed repository would strand that binding's remote revoke.
|
||||
const refuseBound = (old) => {
|
||||
const bound = projectBindings(old, project);
|
||||
if (bound.length) fail(REPO_BOUND, repoBoundMessage(project, bound));
|
||||
};
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
refuseBound(old);
|
||||
const problem = remoteProblem(old, document, project, remote);
|
||||
if (problem) fail(problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
refuseBound(old);
|
||||
remoteTarget(old, project, remote);
|
||||
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
|
||||
Reference in new issue
Block a user