Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions __tests__/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});

// =========================================================================
Expand Down
34 changes: 31 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,{
Expand Down
Loading