2026-03-18 21:03:41 -05:00
|
|
|
import { Router, type Request } from "express";
|
2026-03-03 08:45:26 -06:00
|
|
|
import type { Db } from "@paperclipai/db";
|
2026-03-02 09:06:58 -06:00
|
|
|
import {
|
2026-04-02 09:11:49 -05:00
|
|
|
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
2026-03-02 09:06:58 -06:00
|
|
|
companyPortabilityExportSchema,
|
|
|
|
|
companyPortabilityImportSchema,
|
|
|
|
|
companyPortabilityPreviewSchema,
|
|
|
|
|
createCompanySchema,
|
2026-04-02 09:11:49 -05:00
|
|
|
feedbackTargetTypeSchema,
|
|
|
|
|
feedbackTraceStatusSchema,
|
|
|
|
|
feedbackVoteValueSchema,
|
2026-03-18 21:03:41 -05:00
|
|
|
updateCompanyBrandingSchema,
|
2026-03-02 09:06:58 -06:00
|
|
|
updateCompanySchema,
|
2026-03-03 08:45:26 -06:00
|
|
|
} from "@paperclipai/shared";
|
2026-04-02 09:11:49 -05:00
|
|
|
import { badRequest, forbidden } from "../errors.js";
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
import { validate } from "../middleware/validate.js";
|
2026-03-14 22:00:12 -05:00
|
|
|
import {
|
|
|
|
|
accessService,
|
2026-03-18 21:03:41 -05:00
|
|
|
agentService,
|
2026-03-14 22:00:12 -05:00
|
|
|
budgetService,
|
|
|
|
|
companyPortabilityService,
|
|
|
|
|
companyService,
|
2026-04-02 09:11:49 -05:00
|
|
|
feedbackService,
|
2026-03-14 22:00:12 -05:00
|
|
|
logActivity,
|
|
|
|
|
} from "../services/index.js";
|
2026-03-19 07:24:04 -05:00
|
|
|
import type { StorageService } from "../storage/types.js";
|
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>
2026-04-10 11:55:27 -05:00
|
|
|
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
|
2026-03-19 07:24:04 -05:00
|
|
|
export function companyRoutes(db: Db, storage?: StorageService) {
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const router = Router();
|
|
|
|
|
const svc = companyService(db);
|
2026-03-18 21:03:41 -05:00
|
|
|
const agents = agentService(db);
|
2026-03-19 07:24:04 -05:00
|
|
|
const portability = companyPortabilityService(db, storage);
|
2026-02-23 14:40:32 -06:00
|
|
|
const access = accessService(db);
|
2026-03-14 22:00:12 -05:00
|
|
|
const budgets = budgetService(db);
|
2026-04-02 09:11:49 -05:00
|
|
|
const feedback = feedbackService(db);
|
|
|
|
|
|
|
|
|
|
function parseBooleanQuery(value: unknown) {
|
|
|
|
|
return value === true || value === "true" || value === "1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseDateQuery(value: unknown, field: string) {
|
|
|
|
|
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
|
|
|
|
const parsed = new Date(value);
|
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
|
|
|
throw badRequest(`Invalid ${field} query value`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
|
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>
2026-04-10 11:55:27 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 21:03:41 -05:00
|
|
|
async function assertCanUpdateBranding(req: Request, companyId: string) {
|
|
|
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
|
if (req.actor.type === "board") return;
|
|
|
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
|
|
|
|
|
|
|
|
const actorAgent = await agents.getById(req.actor.agentId);
|
|
|
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
|
|
|
throw forbidden("Agent key cannot access another company");
|
|
|
|
|
}
|
|
|
|
|
if (actorAgent.role !== "ceo") {
|
|
|
|
|
throw forbidden("Only CEO agents can update company branding");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 21:54:10 -05:00
|
|
|
async function assertCanManagePortability(req: Request, companyId: string, capability: "imports" | "exports") {
|
|
|
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
|
if (req.actor.type === "board") return;
|
|
|
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
|
|
|
|
|
|
|
|
const actorAgent = await agents.getById(req.actor.agentId);
|
|
|
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
|
|
|
throw forbidden("Agent key cannot access another company");
|
|
|
|
|
}
|
|
|
|
|
if (actorAgent.role !== "ceo") {
|
|
|
|
|
throw forbidden(`Only CEO agents can manage company ${capability}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 14:40:32 -06:00
|
|
|
router.get("/", async (req, res) => {
|
|
|
|
|
assertBoard(req);
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const result = await svc.list();
|
2026-02-23 14:40:32 -06:00
|
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
|
|
|
|
res.json(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const allowed = new Set(req.actor.companyIds ?? []);
|
|
|
|
|
res.json(result.filter((company) => allowed.has(company.id)));
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
});
|
|
|
|
|
|
2026-02-23 14:40:32 -06:00
|
|
|
router.get("/stats", async (req, res) => {
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin
|
|
|
|
|
? null
|
|
|
|
|
: new Set(req.actor.companyIds ?? []);
|
2026-02-17 20:14:05 -06:00
|
|
|
const stats = await svc.stats();
|
2026-02-23 14:40:32 -06:00
|
|
|
if (!allowed) {
|
|
|
|
|
res.json(stats);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const filtered = Object.fromEntries(Object.entries(stats).filter(([companyId]) => allowed.has(companyId)));
|
|
|
|
|
res.json(filtered);
|
2026-02-17 20:14:05 -06:00
|
|
|
});
|
|
|
|
|
|
2026-03-06 14:25:34 -06:00
|
|
|
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
|
|
|
|
|
router.get("/issues", (_req, res) => {
|
|
|
|
|
res.status(400).json({
|
|
|
|
|
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
router.get("/:companyId", async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
2026-02-23 14:40:32 -06:00
|
|
|
assertCompanyAccess(req, companyId);
|
2026-03-18 16:28:40 -05:00
|
|
|
// Allow agents (CEO) to read their own company; board always allowed
|
|
|
|
|
if (req.actor.type !== "agent") {
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
}
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const company = await svc.getById(companyId);
|
|
|
|
|
if (!company) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
res.json(company);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 09:11:49 -05:00
|
|
|
router.get("/:companyId/feedback-traces", async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
|
|
|
|
|
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
|
|
|
|
|
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
|
|
|
|
|
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
|
|
|
|
|
const issueId = typeof req.query.issueId === "string" && req.query.issueId.trim().length > 0 ? req.query.issueId : undefined;
|
|
|
|
|
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
|
|
|
|
|
? req.query.projectId
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const traces = await feedback.listFeedbackTraces({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId,
|
|
|
|
|
projectId,
|
|
|
|
|
targetType: targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined,
|
|
|
|
|
vote: voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined,
|
|
|
|
|
status: statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined,
|
|
|
|
|
from: parseDateQuery(req.query.from, "from"),
|
|
|
|
|
to: parseDateQuery(req.query.to, "to"),
|
|
|
|
|
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
|
|
|
|
|
includePayload: parseBooleanQuery(req.query.includePayload),
|
|
|
|
|
});
|
|
|
|
|
res.json(traces);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-02 09:06:58 -06:00
|
|
|
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
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>
2026-04-20 10:56:48 -05:00
|
|
|
await assertCanManagePortability(req, companyId, "exports");
|
2026-03-02 09:06:58 -06:00
|
|
|
const result = await portability.exportBundle(companyId, req.body);
|
|
|
|
|
res.json(result);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
2026-03-18 21:54:10 -05:00
|
|
|
assertBoard(req);
|
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>
2026-04-10 11:55:27 -05:00
|
|
|
assertImportTargetAccess(req, req.body.target);
|
2026-03-02 09:06:58 -06:00
|
|
|
const preview = await portability.previewImport(req.body);
|
|
|
|
|
res.json(preview);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
|
2026-03-18 21:54:10 -05:00
|
|
|
assertBoard(req);
|
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>
2026-04-10 11:55:27 -05:00
|
|
|
assertImportTargetAccess(req, req.body.target);
|
2026-03-02 09:06:58 -06:00
|
|
|
const actor = getActorInfo(req);
|
|
|
|
|
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
|
|
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId: result.company.id,
|
|
|
|
|
actorType: actor.actorType,
|
|
|
|
|
actorId: actor.actorId,
|
|
|
|
|
action: "company.imported",
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: result.company.id,
|
|
|
|
|
agentId: actor.agentId,
|
|
|
|
|
runId: actor.runId,
|
|
|
|
|
details: {
|
|
|
|
|
include: req.body.include ?? null,
|
|
|
|
|
agentCount: result.agents.length,
|
|
|
|
|
warningCount: result.warnings.length,
|
|
|
|
|
companyAction: result.company.action,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
res.json(result);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 21:54:10 -05:00
|
|
|
router.post("/:companyId/exports/preview", validate(companyPortabilityExportSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
await assertCanManagePortability(req, companyId, "exports");
|
|
|
|
|
const preview = await portability.previewExport(companyId, req.body);
|
|
|
|
|
res.json(preview);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/:companyId/exports", validate(companyPortabilityExportSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
await assertCanManagePortability(req, companyId, "exports");
|
|
|
|
|
const result = await portability.exportBundle(companyId, req.body);
|
|
|
|
|
res.json(result);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/:companyId/imports/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
await assertCanManagePortability(req, companyId, "imports");
|
|
|
|
|
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
|
|
|
|
|
throw forbidden("Safe import route can only target the route company");
|
|
|
|
|
}
|
|
|
|
|
if (req.body.collisionStrategy === "replace") {
|
|
|
|
|
throw forbidden("Safe import route does not allow replace collision strategy");
|
|
|
|
|
}
|
|
|
|
|
const preview = await portability.previewImport(req.body, {
|
|
|
|
|
mode: "agent_safe",
|
|
|
|
|
sourceCompanyId: companyId,
|
|
|
|
|
});
|
|
|
|
|
res.json(preview);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/:companyId/imports/apply", validate(companyPortabilityImportSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
await assertCanManagePortability(req, companyId, "imports");
|
|
|
|
|
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
|
|
|
|
|
throw forbidden("Safe import route can only target the route company");
|
|
|
|
|
}
|
|
|
|
|
if (req.body.collisionStrategy === "replace") {
|
|
|
|
|
throw forbidden("Safe import route does not allow replace collision strategy");
|
|
|
|
|
}
|
|
|
|
|
const actor = getActorInfo(req);
|
|
|
|
|
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null, {
|
|
|
|
|
mode: "agent_safe",
|
|
|
|
|
sourceCompanyId: companyId,
|
|
|
|
|
});
|
|
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId: result.company.id,
|
|
|
|
|
actorType: actor.actorType,
|
|
|
|
|
actorId: actor.actorId,
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: result.company.id,
|
|
|
|
|
agentId: actor.agentId,
|
|
|
|
|
runId: actor.runId,
|
|
|
|
|
action: "company.imported",
|
|
|
|
|
details: {
|
|
|
|
|
include: req.body.include ?? null,
|
|
|
|
|
agentCount: result.agents.length,
|
|
|
|
|
warningCount: result.warnings.length,
|
|
|
|
|
companyAction: result.company.action,
|
|
|
|
|
importMode: "agent_safe",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
res.json(result);
|
|
|
|
|
});
|
|
|
|
|
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
router.post("/", validate(createCompanySchema), async (req, res) => {
|
|
|
|
|
assertBoard(req);
|
2026-02-23 14:40:32 -06:00
|
|
|
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
|
|
|
|
throw forbidden("Instance admin required");
|
|
|
|
|
}
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const company = await svc.create(req.body);
|
2026-02-23 14:40:32 -06:00
|
|
|
await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active");
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId: company.id,
|
|
|
|
|
actorType: "user",
|
|
|
|
|
actorId: req.actor.userId ?? "board",
|
|
|
|
|
action: "company.created",
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: company.id,
|
|
|
|
|
details: { name: company.name },
|
|
|
|
|
});
|
2026-03-14 22:00:12 -05:00
|
|
|
if (company.budgetMonthlyCents > 0) {
|
|
|
|
|
await budgets.upsertPolicy(
|
|
|
|
|
company.id,
|
|
|
|
|
{
|
|
|
|
|
scopeType: "company",
|
|
|
|
|
scopeId: company.id,
|
|
|
|
|
amount: company.budgetMonthlyCents,
|
|
|
|
|
windowKind: "calendar_month_utc",
|
|
|
|
|
},
|
|
|
|
|
req.actor.userId ?? "board",
|
|
|
|
|
);
|
|
|
|
|
}
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
res.status(201).json(company);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 16:28:40 -05:00
|
|
|
router.patch("/:companyId", async (req, res) => {
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const companyId = req.params.companyId as string;
|
2026-02-23 14:40:32 -06:00
|
|
|
assertCompanyAccess(req, companyId);
|
2026-03-18 16:28:40 -05:00
|
|
|
|
|
|
|
|
const actor = getActorInfo(req);
|
2026-04-02 09:11:49 -05:00
|
|
|
const existingCompany = await svc.getById(companyId);
|
|
|
|
|
if (!existingCompany) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-18 16:28:40 -05:00
|
|
|
let body: Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
if (req.actor.type === "agent") {
|
|
|
|
|
// Only CEO agents may update company branding fields
|
|
|
|
|
const agentSvc = agentService(db);
|
|
|
|
|
const actorAgent = req.actor.agentId ? await agentSvc.getById(req.actor.agentId) : null;
|
|
|
|
|
if (!actorAgent || actorAgent.role !== "ceo") {
|
|
|
|
|
throw forbidden("Only CEO agents or board users may update company settings");
|
|
|
|
|
}
|
|
|
|
|
if (actorAgent.companyId !== companyId) {
|
|
|
|
|
throw forbidden("Agent key cannot access another company");
|
|
|
|
|
}
|
|
|
|
|
body = updateCompanyBrandingSchema.parse(req.body);
|
|
|
|
|
} else {
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
body = updateCompanySchema.parse(req.body);
|
2026-04-02 09:11:49 -05:00
|
|
|
|
|
|
|
|
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
|
|
|
|
|
body = {
|
|
|
|
|
...body,
|
|
|
|
|
feedbackDataSharingConsentAt: new Date(),
|
|
|
|
|
feedbackDataSharingConsentByUserId: req.actor.userId ?? "local-board",
|
|
|
|
|
feedbackDataSharingTermsVersion:
|
|
|
|
|
typeof body.feedbackDataSharingTermsVersion === "string" && body.feedbackDataSharingTermsVersion.length > 0
|
|
|
|
|
? body.feedbackDataSharingTermsVersion
|
|
|
|
|
: DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-03-18 16:28:40 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const company = await svc.update(companyId, body);
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
if (!company) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId,
|
2026-03-18 16:28:40 -05:00
|
|
|
actorType: actor.actorType,
|
|
|
|
|
actorId: actor.actorId,
|
|
|
|
|
agentId: actor.agentId,
|
|
|
|
|
runId: actor.runId,
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
action: "company.updated",
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: companyId,
|
2026-03-18 16:28:40 -05:00
|
|
|
details: body,
|
2026-03-18 21:03:41 -05:00
|
|
|
});
|
|
|
|
|
res.json(company);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.patch("/:companyId/branding", validate(updateCompanyBrandingSchema), async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
await assertCanUpdateBranding(req, companyId);
|
|
|
|
|
const company = await svc.update(companyId, req.body);
|
|
|
|
|
if (!company) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const actor = getActorInfo(req);
|
|
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId,
|
|
|
|
|
actorType: actor.actorType,
|
|
|
|
|
actorId: actor.actorId,
|
|
|
|
|
agentId: actor.agentId,
|
|
|
|
|
runId: actor.runId,
|
|
|
|
|
action: "company.branding_updated",
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: companyId,
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
details: req.body,
|
|
|
|
|
});
|
|
|
|
|
res.json(company);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.post("/:companyId/archive", async (req, res) => {
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
const companyId = req.params.companyId as string;
|
2026-02-23 14:40:32 -06:00
|
|
|
assertCompanyAccess(req, companyId);
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
const company = await svc.archive(companyId);
|
|
|
|
|
if (!company) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await logActivity(db, {
|
|
|
|
|
companyId,
|
|
|
|
|
actorType: "user",
|
|
|
|
|
actorId: req.actor.userId ?? "board",
|
|
|
|
|
action: "company.archived",
|
|
|
|
|
entityType: "company",
|
|
|
|
|
entityId: companyId,
|
|
|
|
|
});
|
|
|
|
|
res.json(company);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-17 20:14:05 -06:00
|
|
|
router.delete("/:companyId", async (req, res) => {
|
|
|
|
|
assertBoard(req);
|
|
|
|
|
const companyId = req.params.companyId as string;
|
2026-02-23 14:40:32 -06:00
|
|
|
assertCompanyAccess(req, companyId);
|
2026-02-17 20:14:05 -06:00
|
|
|
const company = await svc.remove(companyId);
|
|
|
|
|
if (!company) {
|
|
|
|
|
res.status(404).json({ error: "Company not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:27 -06:00
|
|
|
return router;
|
|
|
|
|
}
|