Files
PaperClipAI/server/src/routes/authz.ts
T
DottaandClaude Opus 5.5 6383eb8033 fix(authz): require board authority for agent budget changes on every route (#20213)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Budgets stop agent spending. The budget hard stop is a control-plane
invariant
> - The budget routes (`PATCH /api/agents/:id/budgets`, `PATCH
/api/companies/:id/budgets`) are board-only
> - `PATCH /api/agents/:id` also accepts `budgetMonthlyCents`, and the
configuration service writes the agent budget policy from it. That route
had no board check for the field
> - So an agent, such as a CEO, a manager or the agent itself, can
change a monthly budget without a board user. This is a second route
around a board-only control, like the pause bypass that #20164 fixed
> - This pull request applies the budget route authority to every route
that writes an agent budget, and gives agents the final board-only
denial with a link to the budget page (#20151)
> - The benefit is that only the board changes budgets, and an agent
that asks gets a final answer that it can pass to the person

## Linked Issues or Issue Description

Refs #17857 (earlier fix, now in conflict with master; this PR rebases
it and adds the budget page link). Refs #20164 and #20151 (same pattern
for PATCH status and board-only denials).

**What happened**

An agent can send `PATCH /api/agents/{id}` with `budgetMonthlyCents`.
The route accepts it, and the configuration service writes the agent
budget policy. This works for the agent's own budget and, with update
permission, for other agents. The same field also reaches budget policy
through a configuration rollback, direct agent creation, an immediate
hire and a built-in agent provision. A chat eval
(`budgets-costs.resolve-budget`) found the route.

**Expected behavior**

Only a board user can change an active budget, as on the dedicated
budget routes. An agent gets `403` with code `board_access_required`.
The denial says it is final and links the agent's budget tab
(`/<prefix>/agents/<id>/budgets`) or the company budgets page
(`/<prefix>/activity/budgets`). An agent can still propose a budget in a
hire that waits for board approval. The approving board user activates
it.

**Steps to reproduce**

1. On master, authenticate as an agent of a company (for example the
CEO).
2. Send `PATCH /api/agents/{agentId}` with body `{"budgetMonthlyCents":
5000}`, for its own id or for another agent.
3. See `200`. The agent budget policy changes with no board action.
4. The new route tests reproduce this: on master source they fail with
`expected 200 to be 403`.

## What Changed

- `PATCH /api/agents/:id`: a changed `budgetMonthlyCents` requires a
board actor. An unchanged amount with other edits still works.
- Configuration rollback: a rollback that changes the budget requires a
board actor.
- `POST /api/companies/:id/agents` and an immediate `agent-hires`: a
positive budget requires a board actor. A hire that needs board approval
keeps the proposed budget and does not activate it before approval.
- Built-in agent provision: a budget override requires a board actor.
- `prepareAgentConfiguration` rejects an agent-originated budget change
again (defense in depth). An agent rollback with an unchanged amount
does not rewrite the policy.
- Board writes keep the actual operator identity. The initial hire
budget is saved in the hire transaction.
- `assertBoard` and `boardAccessRequired` take an optional board page.
Budget denials link the agent's budget tab or the company budgets page,
not the general agent profile.
- Agent access map updated.
- This includes the four commits of #17857 (f970f6b01f, 8c06aba41c,
e5682d22fa, b6cd49d4b6), rebased onto master with the conflicts
resolved, then squashed.

## Verification

- Red on master source, same tests: 12 budget tests fail. The PATCH
tests fail with `expected 200 to be 403` for the agent's own budget and
for a CEO on another agent. Built-in provision fails with `expected 200
to be 403`. Creation and immediate hire fail with `expected 201 to be
403`.
- Green with the fix: `cd server && npx vitest run
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/built-in-agent-routes.test.ts
src/__tests__/built-in-agents.test.ts
src/__tests__/agent-hire-idempotency-routes.test.ts
src/__tests__/board-access-denial.test.ts
src/__tests__/agent-budget-configuration.test.ts`: 222 passed.
- `src/modules/agent-lifecycle/__tests__/lifecycle.test.ts` (embedded
Postgres): 76 passed.
- `agent-skills-routes`, `agent-adapter-validation-routes`,
`openapi-routes`, `agent-cross-tenant-authz-routes`: 275 passed.
- `node scripts/check-maps.mjs`: passed. `git diff --check`: passed.
- Server `tsc --noEmit`: no errors in changed files. One error in
`native-session-executor.ts` comes from a linked runner build in the
local worktree, not from this change. CI runs the full typecheck.

## Risks

- Agent clients that set an active budget directly now get `403`. They
must propose the budget in an approval-gated hire, or ask a board user.
- Agent creation with no budget or a zero budget is not changed. Pending
hire budgets do not activate before approval.
- Trusted internal plugin provisioning keeps its prior behavior and gets
no board identity.
- No schema or migration change.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution, in Claude Code. The rebased commits from #17857 were first
written with OpenAI `gpt-6.1-sol` through Codex.

## 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
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:50:16 -06:00

393 lines
16 KiB
TypeScript

import type { Request, Response } from "express";
import type { SecretBindingTargetType } from "@paperclipai/shared";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { responsibleUserAuthzShadowMode } from "../services/authorization.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
function throwOrShadowResponsibleUserCompanyAccessDeny(
req: Request,
companyId: string,
code: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE",
message: string,
) {
logger.warn({
authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
code,
action: "company_access",
companyId,
actorAgentId: req.actor.agentId ?? null,
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
method: req.method,
}, "responsible-user company access intersection denied");
if (responsibleUserAuthzShadowMode()) return;
throw new HttpError(403, message, { code });
}
export function assertAuthenticated(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
}
export const BOARD_ACCESS_REQUIRED_CODE = "board_access_required";
// Agent API subresources whose controls live on their own profile tab.
const AGENT_PROFILE_TABS: Record<string, [string, string]> = {
keys: ["API keys", "api-keys"],
permissions: ["permissions", "permissions"],
budgets: ["budget", "budgets"],
identity: ["identity and instructions", "identity"],
"instructions-bundle": ["identity and instructions", "identity"],
"instructions-path": ["identity and instructions", "identity"],
configuration: ["harness settings", "runtime"],
"runtime-state": ["harness settings", "runtime"],
};
/** A board page as [label, path], where the path keeps <prefix> for the agent to fill. */
export type BoardControlPage = [label: string, path: string];
/** Budgets are board-only. These pages hold the budget controls behind any route that writes one. */
export const COMPANY_BUDGETS_PAGE: BoardControlPage = ["budgets", "/<prefix>/activity/budgets"];
export function agentBudgetPage(agentId: string): BoardControlPage {
return [`the agent's ${AGENT_PROFILE_TABS.budgets![0]}`, `/<prefix>/agents/${agentId}/${AGENT_PROFILE_TABS.budgets![1]}`];
}
// The board page that holds the control behind a board-only API route, when one
// is known. Paths keep <prefix> for the agent to fill from its own task identifier.
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) => [string, string] | null]> = [
[/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "/<prefix>/company/settings/secrets"]],
[/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE],
[/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "/<prefix>/company/settings/connections"]],
[/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "/<prefix>/company/settings/members"]],
[/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "/<prefix>/apps/gateways"]],
[/^\/approvals\/([^/]+)/, match => ["the approval", `/<prefix>/approvals/${match[1]}`]],
[/^\/projects\/([^/]+)\/access-members(?:\/|$)/, (match, req) => {
const projectId = [req.params?.id, match[1]].find((id): id is string => typeof id === "string" && UUID_PATTERN.test(id));
return projectId ? ["the project's access settings", `/<prefix>/projects/${projectId}/configuration`] : null;
}],
[/^\/agents\/([0-9a-f-]{36})(?:\/([^/]+))?(?:\/|$)/i, match => {
const tab = match[2] && Object.hasOwn(AGENT_PROFILE_TABS, match[2]) ? AGENT_PROFILE_TABS[match[2]] : undefined;
return tab ? [`the agent's ${tab[0]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/agents/${match[1]}`];
}],
[/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "/<prefix>/company/settings"]],
];
// Plugin management pages. A plugin id or key segment opens that plugin's settings
// (the page accepts either); a plugin-manager endpoint segment opens the manager.
// An operator can hide plugin management, so link it only while it is visible.
const PLUGIN_REFERENCE = /^[a-z0-9][a-z0-9._-]*$/i;
const PLUGIN_MANAGER_SEGMENTS = new Set(["examples", "install", "tools", "ui-contributions"]);
function pluginControlPage(path: string): [string, string] | null {
const match = /^\/plugins(?:\/([^/]+))?(?:\/|$)/.exec(path);
if (!match || getHiddenSettings().has("instance.plugins")) return null;
const segment = match[1];
const plugin = segment && PLUGIN_REFERENCE.test(segment) && !PLUGIN_MANAGER_SEGMENTS.has(segment) ? segment : null;
return plugin
? ["the plugin's settings", `/<prefix>/company/settings/instance/plugins/${plugin}`]
: ["plugin settings", "/<prefix>/company/settings/instance/plugins"];
}
// Every plugin management route is board-only, so a denial on any one of them
// (often the list an agent reads first) also answers the plugin's other management steps.
const PLUGIN_ROUTE = /^\/plugins(?:\/|$)/;
const PLUGIN_ADMINISTRATION_SCOPE = "Plugin management is board-only throughout: listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route either.";
function deniedApiPath(req: Request) {
return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, "");
}
function boardControlPage(req: Request) {
const path = deniedApiPath(req);
const pluginPage = pluginControlPage(path);
if (pluginPage) return pluginPage;
for (const [pattern, page] of BOARD_CONTROL_PAGES) {
const match = pattern.exec(path);
if (match) return page(match, req);
}
return null;
}
/**
* A board-only denial. Agents get a final, actionable remediation so they hand the
* action to the person instead of searching other routes for one that does not exist.
*/
export function boardAccessRequired(req: Request, controlPage?: BoardControlPage) {
if (req.actor.type !== "agent") return forbidden("Board access required");
const page = controlPage ?? boardControlPage(req);
const scope = PLUGIN_ROUTE.test(deniedApiPath(req)) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` : "";
const where = page
? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as <prefix>.`
: "They can do it in the Paperclip web app or with their own board credentials. Link a page only if you already know its route; do not search for one.";
return forbidden("Board access required", {
code: BOARD_ACCESS_REQUIRED_CODE,
remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${scope}${where}`,
});
}
/**
* Board-only check. Pass `controlPage` when the board control lives on a different
* page from the one the route path implies, e.g. a budget field on a general route.
*/
export function assertBoard(req: Request, controlPage?: BoardControlPage) {
if (req.actor.type !== "board") {
throw boardAccessRequired(req, controlPage);
}
}
export function hasBoardOrgAccess(req: Request) {
if (req.actor.type !== "board") {
return false;
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return true;
}
return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0;
}
export function assertBoardOrgAccess(req: Request) {
assertBoard(req);
if (hasBoardOrgAccess(req)) {
return;
}
throw forbidden("Company membership or instance admin access required");
}
export function assertBoardOrAgent(req: Request) {
if (req.actor.type === "agent") {
return;
}
if (req.actor.type === "board") {
assertBoardOrgAccess(req);
return;
}
throw forbidden("Board or agent access required");
}
export function assertInstanceAdmin(req: Request) {
assertBoard(req);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
export function assertCompanyAccess(req: Request, companyId: string) {
assertAuthenticated(req);
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (req.actor.type === "agent" && req.actor.onBehalfOfUserId?.trim()) {
const membership = req.actor.onBehalfOfMemberships?.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) {
throwOrShadowResponsibleUserCompanyAccessDeny(
req,
companyId,
"RESPONSIBLE_USER_UNAVAILABLE",
"Responsible user is unavailable for this company",
);
return;
}
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
if (!isSafeMethod && membership.membershipRole === "viewer") {
throwOrShadowResponsibleUserCompanyAccessDeny(
req,
companyId,
"RESPONSIBLE_USER_UNAUTHORIZED",
"Responsible user is not authorized for write access",
);
}
}
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
const allowedCompanies = req.actor.companyIds ?? [];
if (!allowedCompanies.includes(companyId)) {
throw forbidden("User does not have access to this company");
}
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) {
const membership = req.actor.memberships.find((item) => item.companyId === companyId);
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
}
if (membership.membershipRole === "viewer") {
throw forbidden("Viewer access is read-only");
}
}
}
}
/**
* Non-throwing access check for routes that look up a resource by id
* before responding. Prefer this over `assertCompanyAccess` whenever the
* route can reach the access check only after a successful `getById`
* (i.e. after confirming the resource exists).
*
* Using `assertCompanyAccess` in that position leaks resource existence
* across tenants: a 404 means "no such resource" while a 403 means "exists
* in another tenant". Any authenticated user can enumerate IDs and
* distinguish the two responses.
*
* Most routes should use `getAccessibleResource` below, which wraps the
* whole pattern. When composing manually (bespoke not-found responses),
* the shape is:
*
* const issue = await svc.getById(id);
* if (!issue || !hasCompanyAccess(req, issue.companyId)) {
* res.status(404).json({ error: "Issue not found" });
* return;
* }
*
* so both "does not exist" and "exists but cross-tenant" return the same
* 404, removing the oracle.
*
* Note: this intentionally does not replicate the write-path membership
* checks in `assertCompanyAccess` (active membership, viewer read-only).
* Routes that need those checks for authorized tenants should still call
* `assertCompanyAccess` after the 404 gate — the oracle concern is only
* about the existence check.
*
* The company-scope semantics must stay in lockstep with
* `assertCompanyAccess`: in particular, signed-in instance admins do NOT
* get blanket access to companies they are not a member of.
*/
export function hasCompanyAccess(req: Request, companyId: string): boolean {
if (req.actor.type === "none") return false;
if (req.actor.type === "agent") return req.actor.companyId === companyId;
if (req.actor.source === "local_implicit") return true;
return (req.actor.companyIds ?? []).includes(companyId);
}
/**
* Preferred way to fetch a company-scoped resource by id inside a route
* handler. Wraps the two-step pattern described on `hasCompanyAccess` so
* new routes cannot accidentally reintroduce the existence oracle:
*
* - missing resource → 404 `{ error: notFoundMessage }`, returns null
* - exists but cross-tenant → identical 404, returns null
* - accessible → runs `assertCompanyAccess` (write-path
* membership checks on non-safe methods) and returns the resource
*
* Usage:
*
* const goal = await getAccessibleResource(req, res, svc.getById(id), "Goal not found");
* if (!goal) return;
*
* Routes with bespoke not-found behavior (legacy `200 []` contracts,
* audit-logged denials) should still compose `hasCompanyAccess` directly.
*/
export async function getAccessibleResource<T extends { companyId: string }>(
req: Request,
res: Response,
resource: T | null | undefined | Promise<T | null | undefined>,
notFoundMessage: string,
): Promise<T | null> {
const resolved = await resource;
if (!resolved || !hasCompanyAccess(req, resolved.companyId)) {
res.status(404).json({ error: notFoundMessage });
return null;
}
assertCompanyAccess(req, resolved.companyId);
return resolved;
}
export function getActorInfo(req: Request): (
{
actorType: "agent";
actorId: string;
agentId: string | null;
runId: string | null;
agentApiKeyId: string | null;
actorSource: "agent_key" | "agent_jwt";
}
| {
actorType: "user";
actorId: string;
sessionId: string | null;
agentId: null;
runId: string | null;
agentApiKeyId: null;
actorSource: "local_implicit" | "session" | "board_key" | "mcp_oauth" | "cloud_tenant";
}
) {
assertAuthenticated(req);
if (req.actor.type === "agent") {
const actorSource = req.actor.source === "agent_jwt" ? "agent_jwt" : "agent_key";
return {
actorType: "agent" as const,
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
runId: req.actor.runId ?? null,
agentApiKeyId: req.actor.keyId ?? null,
actorSource,
};
}
const actorSource =
req.actor.source === "local_implicit" ||
req.actor.source === "board_key" ||
req.actor.source === "cloud_tenant" ||
req.actor.source === "mcp_oauth"
? req.actor.source
: "session";
return {
actorType: "user" as const,
actorId: req.actor.userId ?? "board",
sessionId: req.actor.sessionId ?? null,
agentId: null,
runId: req.actor.runId ?? null,
agentApiKeyId: null,
actorSource,
};
}
/**
* The actor-scoped fields of a secret-binding context, keyed to a caller-supplied
* consumer identity. Structurally matches `SecretConsumerContext` in
* `services/secrets.ts` (whose types are not exported), so the return value slots
* into `resolveAdapterConfigForRuntime`'s 3rd argument
* (`Omit<SecretBindingContext, "configPath">`) unchanged.
*/
export type ActorSecretContext = {
consumerType: SecretBindingTargetType;
consumerId: string;
actorType: "agent" | "user";
actorId: string | null;
actorSource: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "mcp_oauth" | "cloud_tenant";
responsibleUserId: string | null;
};
/**
* Build the actor-scoped portion of a secret-binding context from `req.actor`,
* taking the consumer identity as parameters. The responsible user is derived
* server-side (`req.actor.userId ?? req.actor.onBehalfOfUserId ?? null`) and is
* never request-body-controllable; a `null` result surfaces downstream as the
* intended `responsible_user_missing` loud failure for a required user secret.
*
* `consumerType` is a parameter (not hardcoded `"agent"`) so callers can record an
* honest consumer — `agent` for a persisted agent, `environment`/`system` for a
* prospective config with no persisted consumer.
*
* Never sets `configPath` (the resolver injects it) or `allowedBindingIds`.
*/
export function buildActorSecretContext(
req: Request,
params: { consumerType: SecretBindingTargetType; consumerId: string },
): ActorSecretContext {
const info = getActorInfo(req);
return {
consumerType: params.consumerType,
consumerId: params.consumerId,
actorType: info.actorType,
actorId: info.actorId,
actorSource: info.actorSource,
responsibleUserId: req.actor.userId ?? req.actor.onBehalfOfUserId ?? null,
};
}