fix(authz): scope import, approvals, activity, and heartbeat routes (#3315)

## Thinking Path

> - Paperclip orchestrates AI agents and company-scoped control-plane
actions for zero-human companies.
> - This change touches the server authz boundary around company
portability, approvals, activity, and heartbeat-run operations.
> - The vulnerability was that board-authenticated callers could cross
company boundaries or create new companies through import paths without
the same authorization checks enforced elsewhere.
> - Once that gap existed, an attacker could chain it into higher-impact
behavior through agent execution paths.
> - The fix needed to harden every confirmed authorization gap in the
reported chain, not just the first route that exposed it.
> - This pull request adds the missing instance-admin and company-access
checks and adds regression tests for each affected route.
> - The benefit is that cross-company actions and new-company import
flows now follow the same control-plane authorization rules as the rest
of the product.

## What Changed

- Required instance-admin access for `new_company` import preview/apply
flows in `server/src/routes/companies.ts`.
- Required company access before approval decision routes in
`server/src/routes/approvals.ts`.
- Required company access for activity creation and heartbeat-run issue
listing in `server/src/routes/activity.ts`.
- Required company access before heartbeat cancellation in
`server/src/routes/agents.ts`.
- Added regression coverage in the corresponding server route tests.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/company-portability-routes.test.ts
src/__tests__/approval-routes-idempotency.test.ts
src/__tests__/activity-routes.test.ts
src/__tests__/agent-permissions-routes.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- Prior verification on the original security patch branch also included
`pnpm build`.

## Risks

- Low code risk: the change is narrow and only adds missing
authorization gates to existing routes.
- Operational risk: the advisory is already public, so this PR should be
merged quickly to minimize the public unpatched window.
- Residual product risk remains around open signup / bootstrap defaults,
which was intentionally left out of this patch because the current
first-user onboarding flow depends on it.

## Model Used

- OpenAI GPT-5 Codex coding agent with tool use and local code execution
in the Codex CLI environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Forgotten <forgottenrunes@protonmail.com>
This commit is contained in:
Dotta 2026-04-10 11:55:27 -05:00 committed by GitHub
parent 4477ca2a7e
commit ac664df8e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 203 additions and 10 deletions

View file

@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
}));
function createApp() {
function createApp(actorOverrides: Record<string, unknown> = {}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -49,6 +49,7 @@ function createApp() {
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
...actorOverrides,
};
next();
});
@ -84,6 +85,14 @@ describe("approval routes idempotent retries", () => {
});
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: "agent-1",
});
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-1",
@ -107,6 +116,13 @@ describe("approval routes idempotent retries", () => {
});
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
});
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-1",
@ -126,6 +142,40 @@ describe("approval routes idempotent retries", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects approval decisions for companies outside the caller scope", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-2",
companyId: "company-2",
type: "hire_agent",
status: "pending",
payload: {},
});
const res = await request(createApp())
.post("/api/approvals/approval-2/approve")
.send({});
expect(res.status).toBe(403);
expect(mockApprovalService.approve).not.toHaveBeenCalled();
});
it("rejects approval revision requests for companies outside the caller scope", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-3",
companyId: "company-2",
type: "hire_agent",
status: "pending",
payload: {},
});
const res = await request(createApp())
.post("/api/approvals/approval-3/request-revision")
.send({ decisionNote: "Need changes" });
expect(res.status).toBe(403);
expect(mockApprovalService.requestRevision).not.toHaveBeenCalled();
});
it("lets agents create generic issue-linked board approval requests", async () => {
mockApprovalService.create.mockResolvedValue({
id: "approval-1",