import test from 'node:test'; import assert from 'node:assert/strict'; import * as policy from '../src/org-rights.mjs'; import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs'; // Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are // written out: adding a real bus verb or widening a bundle must change this pin. const RIGHTS = { 'org.view': { grantable: true }, 'thread.read': { grantable: true }, 'thread.write': { grantable: true }, 'board.read': { grantable: true }, 'task.write': { grantable: true }, decide: { grantable: true }, 'org.hire': { grantable: true }, 'request.write': { grantable: false }, 'members.manage': { grantable: false }, 'org.setup': { grantable: false }, 'org.lifecycle': { grantable: false }, }; const BUNDLES = { owner: ['board.read', 'decide', 'members.manage', 'org.hire', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'], member: ['board.read', 'org.view', 'thread.read'], collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'], approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'], }; const VERBS = { 'autonomy.get.console': null, 'autonomy.set.console': null, 'autonomy.review.console': null, 'decision.confirm.console': null, 'decision.reverse.console': null, 'project.list': 'org.view', names: 'org.view', 'thread.list': 'thread.read', 'thread.messages': 'thread.read', plugins: 'thread.read', 'thread.open': 'thread.write', 'thread.create': 'thread.write', 'thread.send': 'thread.write', 'thread.share': 'thread.write', 'thread.unshare': 'thread.write', 'thread.shares': 'thread.write', inbox: 'board.read', agents: 'board.read', 'thread.claims': 'board.read', tasks: 'board.read', decisions: 'board.read', usage: 'board.read', limits: 'board.read', 'request.get.console': 'board.read', 'request.projects.console': 'board.read', 'request.artifact.console': 'board.read', 'request.workspace.console': 'board.read', trail: null, credentials: null, replies: null, messages: null, state: null, 'tasks.create': 'task.write', 'tasks.edit': 'task.write', 'tasks.archive': 'task.write', 'tasks.unarchive': 'task.write', 'tasks.delete': null, 'decision.preview.console': 'decide', 'decision.resolve.console': 'decide', 'hire.resolve.console': 'org.hire', 'hire.preview.console': 'org.hire', 'hire.start.console': 'org.hire', 'hire.templates.console': 'org.hire', 'definition.import.console': 'org.hire', 'definition.edit.console': 'org.hire', 'definition.invent.console': 'org.hire', 'definition.remove.console': 'org.hire', 'request.answer.console': 'request.write', 'request.attach.console': 'request.write', 'request.cancel.console': 'request.write', 'request.mode.console': 'request.write', 'request.draft.console': 'request.write', 'prd.revoke.console': 'request.write', 'plugins.set': 'org.setup', 'system-types.console': 'org.setup', 'project.remote.get.console': 'org.setup', 'project.remote.set.console': 'org.setup', }; test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => { assert.deepEqual({ ...policy.RIGHTS }, RIGHTS); }); test('R1 org.hire belongs to owner and no non-owner template', () => { assert.ok(policy.ROLE_RIGHTS.owner.includes('org.hire')); for (const role of ['member', 'collaborator', 'approver']) { assert.ok(!policy.ROLE_RIGHTS[role].includes('org.hire'), role); } }); test('R1 an owner can grant org.hire to a member without widening the template', () => { const rights = policy.normalizeRights(policy.ROLE_RIGHTS.member, { add: ['org.hire'] }); assert.deepEqual(rights, ['board.read', 'org.hire', 'org.view', 'thread.read']); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, rights), true); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, ['org.hire']), false); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, ['org.hire', 'future.right']), false); assert.throws(() => policy.normalizeRights(rights, { add: ['future.right'] }), { code: 'unknown-right' }); assert.deepEqual(policy.ROLE_RIGHTS.member, ['board.read', 'org.view', 'thread.read']); }); test('R1 hire resolution requires org.hire while generic decision resolution keeps decide', () => { assert.equal(policy.VERB_RIGHTS['hire.resolve.console'], 'org.hire'); assert.equal(policy.VERB_RIGHTS['decision.resolve.console'], 'decide'); }); for (const [role, rights] of Object.entries(BUNDLES)) { test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => { assert.deepEqual(policy.ROLE_RIGHTS[role], rights); }); } test('M1 owner holds every right; only owner holds any reserved right', () => { assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort()); for (const role of ['member', 'collaborator', 'approver']) { for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`); assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write')); } assert.ok(!policy.ROLE_RIGHTS.member.includes('decide')); assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write')); }); test('M1 canGrant allows only known grantable rights held by the granter', () => { assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true); assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true); assert.equal(policy.canGrant(['org.view'], ['thread.read']), false); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true); for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) { assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right); } assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false); }); test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => { for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) { assert.equal(policy.canGrant(value, ['org.view']), false); assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false); } }); test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => { const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']); assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']); assert.deepEqual(policy.normalizeRights([]), ['org.view']); const add = Object.freeze(['task.write', 'decide', 'task.write']); const remove = Object.freeze(['thread.read']); assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']); assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']); assert.notEqual(policy.normalizeRights(rights), rights); }); test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => { for (const rights of [[], ['org.view']]) { assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' }); } }); test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => { for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) { const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right'; for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) { assert.throws(() => policy.normalizeRights(rights, options), { code }, right); } } }); test('M1 normalization refuses malformed lists, options and non-string rights', () => { for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) { assert.throws(() => policy.normalizeRights(value)); assert.throws(() => policy.normalizeRights([], { add: value })); assert.throws(() => policy.normalizeRights([], { remove: value })); } for (const options of [null, [], 'member', 1, { typo: [] }]) { assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' }); } }); test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => { assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS); for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right)); }); test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => { for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) { for (const verb of verbs) { assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`); assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`); assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb); } } assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([ ...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits', 'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb. // T1: the definition verbs. The CLI calls them today; the Console route comes later. 'definition.import.console', 'definition.edit.console', 'definition.invent.console', 'definition.remove.console', ])].sort()); }); test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => { for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) { assert.equal(policy.VERB_RIGHTS[verb], null, verb); } for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) { assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb); assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false); } }); test('M1 unknown verbs, rights and role property names never inherit an assignment', () => { for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) { assert.equal(policy.VERB_RIGHTS[name], undefined); assert.equal(policy.RIGHTS[name], undefined); if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined); assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' }); } }); test('M1 only thread.write needs collaborator; every other known right needs viewer', () => { for (const right of Object.keys(RIGHTS)) { assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right); } for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' }); }); test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => { for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) { assert.equal(Object.getPrototypeOf(map), null); assert.ok(Object.isFrozen(map)); assert.throws(() => { map.extra = 'decide'; }, TypeError); } for (const descriptor of Object.values(policy.RIGHTS)) { assert.ok(Object.isFrozen(descriptor)); assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError); } for (const bundle of Object.values(policy.ROLE_RIGHTS)) { assert.ok(Object.isFrozen(bundle)); assert.throws(() => bundle.push('decide'), TypeError); } assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read']))); });