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

@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
getRun: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
@ -22,6 +26,7 @@ function registerRouteMocks() {
vi.doMock("../services/index.js", () => ({
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
}
@ -75,4 +80,32 @@ describe("activity routes", () => {
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]);
});
it("requires company access before creating activity events", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-2/activity")
.send({
actorId: "user-1",
action: "test.event",
entityType: "issue",
entityId: "issue-1",
});
expect(res.status).toBe(403);
expect(mockActivityService.create).not.toHaveBeenCalled();
});
it("requires company access before listing issues for another company's run", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
companyId: "company-2",
});
const app = await createApp();
const res = await request(app).get("/api/heartbeat-runs/run-2/issues");
expect(res.status).toBe(403);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
});

View file

@ -59,6 +59,8 @@ const mockBudgetService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
listTaskSessions: vi.fn(),
resetRuntimeSession: vi.fn(),
getRun: vi.fn(),
cancelRun: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
@ -397,4 +399,26 @@ describe("agent permission routes", () => {
},
]);
});
it("rejects heartbeat cancellation outside the caller company scope", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "33333333-3333-4333-8333-333333333333",
agentId,
status: "running",
});
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({});
expect(res.status).toBe(403);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
});

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",

View file

@ -175,4 +175,50 @@ describe("company portability routes", () => {
expect(res.status).toBe(403);
expect(res.body.error).toContain("Board access required");
});
it("requires instance admin for new-company import preview", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["11111111-1111-4111-8111-111111111111"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post("/api/companies/import/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "new_company", newCompanyName: "Imported Test" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Instance admin");
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("requires instance admin for new-company import apply", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["11111111-1111-4111-8111-111111111111"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post("/api/companies/import")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "new_company", newCompanyName: "Imported Test" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Instance admin");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
});

View file

@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { activityService } from "../services/activity.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { issueService } from "../services/index.js";
import { heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
const createActivitySchema = z.object({
@ -20,6 +20,7 @@ const createActivitySchema = z.object({
export function activityRoutes(db: Db) {
const router = Router();
const svc = activityService(db);
const heartbeat = heartbeatService(db);
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
@ -46,6 +47,7 @@ export function activityRoutes(db: Db) {
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const event = await svc.create({
companyId,
...req.body,
@ -80,6 +82,12 @@ export function activityRoutes(db: Db) {
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.json([]);
return;
}
assertCompanyAccess(req, run.companyId);
const result = await svc.issuesForRun(runId);
res.json(result);
});

View file

@ -2296,6 +2296,10 @@ export function agentRoutes(db: Db) {
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
const existing = await heartbeat.getRun(runId);
if (existing) {
assertCompanyAccess(req, existing.companyId);
}
const run = await heartbeat.cancelRun(runId);
if (run) {

View file

@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
addApprovalCommentSchema,
@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) {
const secretsSvc = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function requireApprovalAccess(req: Request, id: string) {
const approval = await svc.getById(id);
if (!approval) {
return null;
}
assertCompanyAccess(req, approval.companyId);
return approval;
}
router.get("/companies/:companyId/approvals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) {
async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
const approval = await svc.requestRevision(
id,
req.body.decidedByUserId ?? "board",

View file

@ -24,7 +24,7 @@ import {
logActivity,
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db, storage?: StorageService) {
const router = Router();
@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) {
return parsed;
}
function assertImportTargetAccess(
req: Request,
target: { mode: "new_company" } | { mode: "existing_company"; companyId: string },
) {
if (target.mode === "new_company") {
assertInstanceAdmin(req);
return;
}
assertCompanyAccess(req, target.companyId);
}
async function assertCanUpdateBranding(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) {
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
}
assertImportTargetAccess(req, req.body.target);
const preview = await portability.previewImport(req.body);
res.json(preview);
});
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
}
assertImportTargetAccess(req, req.body.target);
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
await logActivity(db, {