diff --git a/__tests__/integration/app.test.js b/__tests__/integration/app.test.js index da1cb5a..5b0de65 100644 --- a/__tests__/integration/app.test.js +++ b/__tests__/integration/app.test.js @@ -351,11 +351,18 @@ describe('app', () => { owner: { login: 'pulseengine' }, ...(overrides.repository || {}) }, + sender: { login: 'avrabe', ...(overrides.sender || {}) }, ...(overrides.payload || {}) } }; } + beforeEach(() => { + // Bug #4 added an org-membership gate before provisioning. + // Tests below assume the sender IS a member (true). + checkOrganizationMembership.mockResolvedValue(true); + }); + it('ignores issues when controller_repo is disabled', async () => { _setConfigForTesting({}); const { handlers } = setupApp(); @@ -409,6 +416,35 @@ describe('app', () => { const body = context.octokit.issues.createComment.mock.calls[0][0].body; expect(body).toMatch(/task store offline/i); }); + + // Bug #4 — auth gate before provisioning + it('rejects sender not in allowed_command_users with a comment', async () => { + _setConfigForTesting({ + allowed_command_users: ['avrabe'], + controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' } + }); + const { handlers } = setupApp(); + const context = createIssueOpenedContext({ + sender: { login: 'random-stranger' } + }); + await handlers['issues.opened'](context); + const body = context.octokit.issues.createComment.mock.calls[0][0].body; + expect(body).toMatch(/not authorised/i); + // Critically: did NOT proceed to enqueue / post "Request accepted" + expect(body).not.toMatch(/Request accepted/i); + }); + + it('rejects sender who fails the org-membership check', async () => { + _setConfigForTesting({ + controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' } + }); + checkOrganizationMembership.mockResolvedValue(false); + const { handlers } = setupApp(); + const context = createIssueOpenedContext(); + await handlers['issues.opened'](context); + const body = context.octokit.issues.createComment.mock.calls[0][0].body; + expect(body).toMatch(/must be a member/i); + }); }); // ========================================================================= diff --git a/src/app.js b/src/app.js index db1446d..ee13376 100644 --- a/src/app.js +++ b/src/app.js @@ -365,11 +365,39 @@ function registerApp(app, options = {}) { return; } - const fields = parseIssueFormBody(issue.body || ''); - const validation = validateProvisionRequest(fields); - const owner = repository.owner.login; const repo = repository.name; + const senderLogin = sender?.login; + + // Auth gate: provisioning a real org repo is a high-trust action. + // Comment-driven ChatOps already requires `allowed_command_users` AND + // org membership; the issue-form path (Cut A provisioning) had no such + // gate before this commit (Bug #4 in `docs/agent-fleet/bugs.md`). + // Anyone able to file a labelled issue could trigger repo creation. + const config = getConfig(); + const allowedUsers = config?.allowed_command_users || []; + if (allowedUsers.length > 0 && !allowedUsers.includes(senderLogin)) { + await issueOps.createComment(context.octokit, { + owner, repo, issue_number: issue.number, + body: `❌ You are not authorised to provision repos. Allowed users: ${allowedUsers.join(', ')}.` + }); + if (deliveryId) markProcessed(deliveryId); + return; + } + // Defence in depth: also require org membership in the controller repo's + // owner org. checkOrganizationMembership returns false on 404 / network + // errors — if it can't confirm membership, refuse. + if (!(await checkOrganizationMembership(context.octokit, owner, senderLogin))) { + await issueOps.createComment(context.octokit, { + owner, repo, issue_number: issue.number, + body: '❌ You must be a member of the organisation to provision repos.' + }); + if (deliveryId) markProcessed(deliveryId); + return; + } + + const fields = parseIssueFormBody(issue.body || ''); + const validation = validateProvisionRequest(fields); if (!validation.valid) { await issueOps.createComment(context.octokit,{