Harden API route authorization boundaries (#4122)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The REST API is the control-plane boundary for companies, agents,
plugins, adapters, costs, invites, and issue mutations.
> - Several routes still relied on broad board or company access checks
without consistently enforcing the narrower actor, company, and
active-checkout boundaries those operations require.
> - That can allow agents or non-admin users to mutate sensitive
resources outside the intended governance path.
> - This pull request hardens the route authorization layer and adds
regression coverage for the audited API surfaces.
> - The benefit is tighter multi-company isolation, safer plugin and
adapter administration, and stronger enforcement of active issue
ownership.

## What Changed

- Added route-level authorization checks for budgets, plugin
administration/scoped routes, adapter management, company import/export,
direct agent creation, invite test resolution, and issue mutation/write
surfaces.
- Enforced active checkout ownership for agent-authenticated issue
mutations, while preserving explicit management overrides for permitted
managers.
- Restricted sensitive adapter and plugin management operations to
instance-admin or properly scoped actors.
- Tightened company portability and invite probing routes so agents
cannot cross company boundaries.
- Updated access constants and the Company Access UI copy for the new
active-checkout management grant.
- Added focused regression tests covering cross-company denial, agent
self-mutation denial, admin-only operations, and active checkout
ownership.
- Rebased the branch onto `public-gh/master` and fixed validation
fallout from the rebase: heartbeat-context route ordering and a company
import/export e2e fixture that now opts out of direct-hire approval
before using direct agent creation.
- Updated onboarding and signoff e2e setup to create seed agents through
`/agent-hires` plus board approval, so they remain compatible with the
approval-gated new-agent default.
- Addressed Greptile feedback by removing a duplicate company export API
alias, avoiding N+1 reporting-chain lookups in active-checkout override
checks, allowing agent mutations on unassigned `in_progress` issues, and
blocking NAT64 invite-probe targets.

## Verification

- `pnpm exec vitest run
server/src/__tests__/issues-goal-context-routes.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/adapter-routes-authz.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/company-portability-routes.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/invite-test-resolution-route.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/agent-adapter-validation-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/invite-test-resolution-route.test.ts`
- `pnpm -r typecheck`
- `pnpm --filter server typecheck`
- `pnpm --filter ui typecheck`
- `pnpm build`
- `pnpm test:e2e -- tests/e2e/onboarding.spec.ts
tests/e2e/signoff-policy.spec.ts`
- `pnpm test:e2e -- tests/e2e/signoff-policy.spec.ts`
- `pnpm test:run` was also run. It failed under default full-suite
parallelism with two order-dependent failures in
`plugin-routes-authz.test.ts` and `routines-e2e.test.ts`; both files
passed when rerun directly together with `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/routines-e2e.test.ts`.

## Risks

- Medium risk: this changes authorization behavior across multiple
sensitive API surfaces, so callers that depended on broad board/company
access may now receive `403` or `409` until they use the correct
governance path.
- Direct agent creation now respects the company-level board-approval
requirement; integrations that need pending hires should use
`/api/companies/:companyId/agent-hires`.
- Active in-progress issue mutations now require checkout ownership or
an explicit management override, which may reveal workflow assumptions
in older automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent, tool-using workflow with local shell,
Git, GitHub CLI, and repository tests.

## 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 checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-20 10:56:48 -05:00 committed by GitHub
parent 549ef11c14
commit 7a329fb8bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1903 additions and 138 deletions

View file

@ -77,6 +77,32 @@ async function createApp(actor: Record<string, unknown>) {
return app;
}
const companyId = "11111111-1111-4111-8111-111111111111";
const exportRequest = {
include: { company: true, agents: true, projects: true },
};
function createExportResult() {
return {
rootPath: "paperclip",
manifest: {
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
includes: { company: true, agents: true, projects: true, issues: false, skills: false },
company: null,
schemaVersion: 1,
generatedAt: "2026-01-01T00:00:00.000Z",
source: null,
},
files: {},
warnings: [],
};
}
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
@ -90,30 +116,53 @@ describe("company portability routes", () => {
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
companyId,
role: "engineer",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
.post(`/api/companies/${companyId}/exports/preview`)
.send(exportRequest);
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId,
role: "engineer",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId,
source: "agent_key",
runId: "run-1",
});
for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) {
const res = await request(app).post(path).send(exportRequest);
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
}
expect(mockCompanyPortabilityService.exportBundle).not.toHaveBeenCalled();
});
it("allows CEO agents to use company-scoped export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
companyId,
role: "ceo",
});
mockCompanyPortabilityService.previewExport.mockResolvedValue({
@ -128,19 +177,64 @@ describe("company portability routes", () => {
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
.post(`/api/companies/${companyId}/exports/preview`)
.send(exportRequest);
expect(res.status).toBe(200);
expect(res.body.rootPath).toBe("paperclip");
});
it("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId,
role: "ceo",
});
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId,
source: "agent_key",
runId: "run-1",
});
for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) {
const res = await request(app).post(path).send(exportRequest);
expect(res.status).toBe(200);
expect(res.body.rootPath).toBe("paperclip");
}
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2);
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(1, companyId, exportRequest);
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(2, companyId, exportRequest);
});
it("allows board users to export through legacy and CEO-safe bundle routes", async () => {
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) {
const res = await request(app).post(path).send(exportRequest);
expect(res.status).toBe(200);
expect(res.body.rootPath).toBe("paperclip");
}
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2);
});
it("rejects replace collision strategy on CEO-safe import routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",