fix(org): copy binding review round, CB1 (#1873)
Security review P2s: - A binding proposal keeps only its structured request fields. The free-text reason is never written to the private proposal or covered by its digest. - A root copy's receipts are checked at prepare, at check, and again under the organization lock before the write and before publication. A copy removed, replaced or unreadable since boot refuses with instance-fired, copy-stale or root-policy-unreadable. Build review P2: - A root copy's admitted projects come from the project unions, recomputed from the current document and the boot's root agents. A project that excludes the copy, or whose union refuses, is never in reach; a request naming it refuses with credential-scope-exceeded. Build review P3s: tests for proposal-time shadowing, the loader's copyBindings validation, a pending record that covers every service, a stricter path check on the refusal table, and refusal codes on every bare rejects in the binding tests.
This commit was merged in pull request #1940.
This commit is contained in:
1 parent
d23673cd3e
commit
5dbd7e72d3
4 files changed
+136
-17
No files matched your search
@@ -76,8 +76,8 @@ test('CB1 bus: no autonomy level routes a binding away from the owner', async t
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to, 'human', authority);
|
||||
// The authority resolver cannot take it, nor can the chief through the agent verb.
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), authority);
|
||||
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), authority);
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
assert.equal(f.writes(), 0, authority);
|
||||
assert.deepEqual(f.bindings(), {}, authority);
|
||||
// A project-level unbind is not root; it lists the lines it removes and still goes to the owner.
|
||||
@@ -86,7 +86,7 @@ test('CB1 bus: no autonomy level routes a binding away from the owner', async t
|
||||
const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } });
|
||||
assert.equal(u.hire.level, 'project');
|
||||
assert.equal(u.route_to, 'human');
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }));
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' });
|
||||
assert.equal(f.bindings()['checker.stack'].state, 'pending');
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,9 @@ test('CB1 bus: members with org.hire or decide, the tester and a reader cannot r
|
||||
await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' });
|
||||
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }));
|
||||
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }));
|
||||
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }));
|
||||
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' });
|
||||
assert.equal(f.writes(), 0);
|
||||
assert.deepEqual(f.bindings(), {});
|
||||
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
@@ -130,6 +130,6 @@ test('CB1 bus: every new refusal has a table entry with a next step', () => {
|
||||
for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
assert.ok(REFUSALS[code].nextStep?.length > 20, code);
|
||||
assert.ok(!REFUSALS[code].nextStep.includes('/home'), code);
|
||||
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
|
||||
}
|
||||
});
|
||||
@@ -252,7 +252,9 @@ export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
// record copyBindings[instance] from the organization document, or undefined
|
||||
// pin definitionPin of the copy's current definition (null: cannot pin)
|
||||
// role the copy's current resolved role ({credentials: [...]})
|
||||
// projects the organization's currently admitted project ids
|
||||
// projects the project ids that admit this copy now: for a root copy, the projects
|
||||
// whose union holds it (an excluded or refusing project is left out), not
|
||||
// the organization's whole project list
|
||||
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
|
||||
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
|
||||
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
|
||||
@@ -293,7 +295,8 @@ export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, rec
|
||||
|
||||
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
|
||||
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
|
||||
// projects admitted for the copy. Never trims a broader request. Returns the normalized services.
|
||||
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
|
||||
// Never trims a broader request. Returns the normalized services.
|
||||
export function checkBindingRequest(services, { copy, role, projects }) {
|
||||
if (services === null) return null;
|
||||
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ensureOrgIncarnation, orgIncarnation, readRegistry, withRegistryWriter
|
||||
import { checkDefinitions } from './definitions.mjs';
|
||||
import { loadRole } from './role.mjs';
|
||||
import { copyInstanceName } from './agent-copies.mjs';
|
||||
import { resolveAgentLevels } from './agent-levels.mjs';
|
||||
import { bindingFail, checkBindingRequest, definitionPin, newCopyId, samePin } from './copy-bindings.mjs';
|
||||
const fail = code => { throw new OrganizationError(code); };
|
||||
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
|
||||
@@ -111,6 +112,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
const copy = instance !== null && snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.name !== request.name || copy.project !== project || copy.level !== request.level) fail('copy-unavailable');
|
||||
if (copy.state === 'refused') fail(copy.claims ? 'copy-refused' : 'copy-unavailable');
|
||||
// A root copy is only as fresh as its root receipts: removed, replaced or unreadable since boot refuses.
|
||||
if (copy.level === 'root') { const stale = rootRefusal(copy.name); if (stale) fail(stale); }
|
||||
// A named instance added since boot shadows the copy (design section 6).
|
||||
if (Object.hasOwn(doc.roles ?? {}, instance)) fail('copy-unavailable');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
@@ -118,7 +121,18 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
if (!pin) fail('credential-definition-unpinned');
|
||||
const role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
|
||||
if (!role) fail('credential-definition-unpinned');
|
||||
return { instance, copy, pin, role, definitions, source: { level: copy.level, name: copy.name, project } };
|
||||
// The projects that admit this copy now. A root copy reaches only the projects whose union,
|
||||
// recomputed from this document and the boot's root agents, still holds it: an exclude entry or a
|
||||
// refusing union bars it, and a request naming that project refuses (credential-scope-exceeded).
|
||||
const code = Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library');
|
||||
let projects = code;
|
||||
if (copy.level === 'root') {
|
||||
let levels;
|
||||
try { levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir }); }
|
||||
catch (error) { if (error?.name === 'BusinessError') fail('copy-unavailable'); throw error; }
|
||||
projects = code.filter(p => levels.unions[p]?.ok && levels.unions[p].agents.some(a => a.level === 'root' && a.name === copy.name));
|
||||
}
|
||||
return { instance, copy, pin, role, definitions, projects, source: { level: copy.level, name: copy.name, project } };
|
||||
};
|
||||
const bindingLines = services => credentialLines(Object.entries(services ?? {}).map(([service, v]) => ({ service, scopes: v.scopes })));
|
||||
async function prepareBinding(business, input, { id: decision, expiresAt }) {
|
||||
@@ -128,7 +142,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
const facts = bindingFacts(business, input, doc);
|
||||
let after;
|
||||
try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library') }); }
|
||||
try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
const current = doc.copyBindings?.[facts.instance] ?? null;
|
||||
if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed');
|
||||
@@ -141,7 +155,10 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
instance: facts.instance, source: facts.source, copyId: current?.copyId ?? newCopyId(), fresh: !current, orgIncarnation: incarnation,
|
||||
definition: facts.pin, before: current && current.state !== 'unbound' ? structuredClone(current.services) : null, after, generation: current?.generation ?? 0,
|
||||
};
|
||||
const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request: structuredClone(input), entry: null, definition: null,
|
||||
// Only the structured fields are kept. The free-text reason is never written to the proposal or
|
||||
// covered by its digest: a chief may paste a path or a token fragment there by mistake.
|
||||
const request = { op: input.op, level: input.level, name: input.name, ...(input.level === 'project' ? { project: input.project } : {}), binding: { services: structuredClone(input.binding.services) } };
|
||||
const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request, entry: null, definition: null,
|
||||
binding, flagged: [], credentialLines: bindingLines(after ?? binding.before), expiresAt };
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
const st = lstatSync(home); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & 0o077) fail('organization-refused');
|
||||
@@ -163,7 +180,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : !b.fresh)) fail('credential-binding-changed');
|
||||
if (b.after !== null) {
|
||||
let after;
|
||||
try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: Object.keys(doc.projects ?? {}).filter(x => doc.projects[x]?.kind !== 'library') }); }
|
||||
try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
if (canonicalJson(after) !== canonicalJson(b.after)) fail('decision-stale');
|
||||
}
|
||||
@@ -321,7 +338,11 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
const p=await check(business,decision,caller);
|
||||
if(p.kind==='binding') {
|
||||
const facts=await checkBinding(business,p);
|
||||
return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish});
|
||||
// A root copy's receipts are read again under the organization lock, before the write and
|
||||
// before publication, so a removal or replacement after checkBinding still refuses.
|
||||
const fresh=()=>{if(facts.copy.level==='root'){const stale=rootRefusal(facts.copy.name);if(stale)fail(stale);}};
|
||||
return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,
|
||||
beforeWrite:()=>{fresh();caller.beforeWrite?.();},beforePublish:()=>{fresh();caller.beforePublish?.();}});
|
||||
}
|
||||
const r=p.request;
|
||||
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
|
||||
|
||||
@@ -158,12 +158,13 @@ test('CB0 copyBindings schema: ids and scope names only, every refusal positiona
|
||||
|
||||
// ---- CB1: decision subtype, guarded writer ----
|
||||
|
||||
function setup(t, { incarnation = true } = {}) {
|
||||
function setup(t, { incarnation = true, edit = d => d } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
edit(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
@@ -301,7 +302,7 @@ test('CB1 stale revision, digest or generation, expiry, replay and a wrong incar
|
||||
const s = await f.prepare(f.bind(), ID3);
|
||||
await f.service.apply('demo', s.id, { ...OWNER, digest: s.digest });
|
||||
const once = f.bytes();
|
||||
await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }));
|
||||
await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }), { code: 'decision-stale' });
|
||||
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${s.id}.json`), 'utf8')), digest: s.digest };
|
||||
// Generation: the writer refuses a proposal at an old generation even with a fresh revision.
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...full, revision: f.writer.read('demo').revision }, { actor: 'jason', owner: true }), { code: 'credential-binding-changed' });
|
||||
@@ -317,7 +318,7 @@ test('CB1 stale revision, digest or generation, expiry, replay and a wrong incar
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...uf, binding: { ...uf.binding, orgIncarnation: 'f'.repeat(32), definition: { ...uf.binding.definition, sha256: '0'.repeat(64) } } }, { actor: 'jason', owner: true }), { code: 'credential-role-changed' });
|
||||
// A binding is never reversed through the AU inverse, and never adjusted.
|
||||
await assert.rejects(f.service.reverse('demo', s.id, OWNER), { code: 'decision-stale' });
|
||||
await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }));
|
||||
await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('CB1 a legacy organization gets its incarnation at the first proposal, under the registry lock', async t => {
|
||||
@@ -347,6 +348,10 @@ test('CB1 a pending or partial binding never launches the copy', async t => {
|
||||
assert.equal(resolveCopyBinding({ ...args, record: rec }).code, 'credential-unbound');
|
||||
// Even forced to bound, gitea alone is partial for a role that also needs vikunja.
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: { ...rec, state: 'bound' } }), { ok: false, code: 'credential-unbound', service: 'vikunja' });
|
||||
// A pending record that covers every service the role needs is still not a binding; bound, it is.
|
||||
const whole = { ...rec, services: { ...rec.services, vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } } };
|
||||
assert.equal(resolveCopyBinding({ ...args, record: { ...whole, state: 'bound' } }).ok, true);
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: whole }), { ok: false, code: 'credential-unbound', service: 'gitea' });
|
||||
});
|
||||
|
||||
test('CB1 no proposal, record, receipt or refusal names a token or a path', async t => {
|
||||
@@ -370,6 +375,65 @@ test('CB1 no proposal, record, receipt or refusal names a token or a path', asyn
|
||||
void sha;
|
||||
});
|
||||
|
||||
test('CB1 fix: the free-text reason is never written to a binding proposal', async t => {
|
||||
const f = setup(t);
|
||||
const leak = '/home/someone/.config/mosaic-dev/secrets/gitea tok-fragment-9f8e';
|
||||
const p = await f.prepare(f.bind(GITEA, { reason: `Bind; see ${leak}` }));
|
||||
const stored = readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8');
|
||||
assert.ok(!stored.includes(leak) && !stored.includes('tok-fragment') && !stored.includes('"reason"'), 'no reason text on disk');
|
||||
assert.deepEqual(Object.keys(JSON.parse(stored).request).sort(), ['binding', 'level', 'name', 'op']);
|
||||
assert.ok(!JSON.stringify(p).includes('tok-fragment'));
|
||||
// The proposal still applies on its digest.
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings.rev.state, 'pending');
|
||||
assert.ok(!readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('tok-fragment'));
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy removed or replaced since boot refuses at prepare, at apply and before publication', async t => {
|
||||
const receipts = root => join(root, 'agents-receipts.jsonl');
|
||||
const add = (root, row) => writeFileSync(receipts(root), JSON.stringify(row) + '\n', { flag: 'a', mode: 0o600 });
|
||||
// Removed since boot: prepare refuses instance-fired; replaced: copy-stale.
|
||||
for (const [verb, code] of [['root.agent.remove', 'instance-fired'], ['root.agent.set', 'copy-stale']]) {
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'rev', verb, at: FUTURE });
|
||||
await assert.rejects(f.prepare(), { code }, verb);
|
||||
let saved = []; try { saved = readdirSync(join(f.dataRoot, 'authority-proposals')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
assert.deepEqual(saved, [], verb);
|
||||
}
|
||||
// Prepared while fresh, then the root copy is removed: apply refuses and writes nothing.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
add(f.root, { agent: 'rev', verb: 'root.agent.remove', at: FUTURE });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'instance-fired' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// The change lands after checkBinding, under the lock: the publication fence still refuses.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
let wrote = false;
|
||||
const late = () => { if (!wrote) { wrote = true; add(f.root, { agent: 'rev', verb: 'root.agent.set', at: FUTURE }); } };
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, beforeWrite: late }), { code: 'copy-stale' });
|
||||
assert.equal(wrote, true);
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// A receipt for another root agent leaves this copy fresh; an unreadable receipts file refuses.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'other', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare();
|
||||
writeFileSync(receipts(f.root), 'not json\n', { mode: 0o600 });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'root-policy-unreadable' });
|
||||
}
|
||||
// A project copy does not read root receipts.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'checker', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings['checker.stack'].state, 'pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 the org service refuses copyBindings on create and on a whole-document update', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-svc-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -383,3 +447,34 @@ test('CB1 the org service refuses copyBindings on create and on a whole-document
|
||||
assert.equal((await showOrg(c, 'alpha')).doc.copyBindings, undefined);
|
||||
await updateOrg(c, 'alpha', current.doc, { ifRevision: current.revision });
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy binds only projects whose union still admits it', async t => {
|
||||
const f = setup(t, { edit: doc => { doc.projects.web = { root: doc.projects.stack.root }; doc.projects.stack.agents.rev = { exclude: true }; } });
|
||||
// stack excludes rev: a request naming it refuses whole, never trimmed to web.
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack', 'web'] } })), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'] } }, {}), ID2), { code: 'credential-scope-exceeded' });
|
||||
// web still admits it.
|
||||
const p = await f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['web'] } }), ID3);
|
||||
assert.deepEqual(p.binding.after.gitea.resources.projects, ['web']);
|
||||
});
|
||||
|
||||
test('CB1 fix: a named instance added after boot shadows the copy at proposal time', async t => {
|
||||
const f = setup(t); await f.ready;
|
||||
const r = f.writer.read('demo'), edited = structuredClone(r.document);
|
||||
const like = Object.values(edited.roles).find(e => e.definition === 'reviewer');
|
||||
edited.roles.rev = { ...structuredClone(like), tracker: { bot: 'bot-demo-rev', botId: 977 } };
|
||||
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
|
||||
await assert.rejects(f.prepare(), { code: 'copy-unavailable' });
|
||||
});
|
||||
|
||||
test('CB1 fix: the loader refuses a copyBindings record holding a token, naming neither the value nor a path', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-load-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), copyBindings: { rev: { ...record(), state: 'pending', token: SECRET } } };
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: roles }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes(root));
|
||||
// The same document without the token loads.
|
||||
const { token: _, ...clean } = doc.copyBindings.rev;
|
||||
validateBusinessDocument({ ...doc, copyBindings: { rev: clean } }, file, { rolesDir: roles });
|
||||
});
|
||||
Reference in new issue
Block a user