From ff2d463eedda94bb7a2563cc9c4d9deab2364f22 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:35:52 -0600 Subject: [PATCH] feat(cli): manage agent grants and keep remote login on the public origin (#20323) ## Thinking Path > - Paperclip lets people control their AI agents and permissions. > - Remote operators need a browser-approved CLI login. > - Proxies can send that login's approval link to an internal host. > - Human member commands cannot grant an agent an explicit permission such as commentary:view. > - This change keeps approval on the canonical public origin and adds Board-only single-grant commands. > - The API checks company access and permission-management authority and records each change. > - An operator can sign in, grant one agent permission, and preserve the agent's other grants. ## Linked Issues or Issue Description **What happened?** A proxied CLI login returned an approval URL on the upstream host. The signed-in browser was on the public host. The CLI also had no command to add an arbitrary explicit agent grant: member commands manage human members, while agent permissions:update exposes fixed configuration fields. **Expected behavior** Approval uses the configured canonical public origin. A Board operator with users:manage_permissions can grant or revoke one explicit agent permission without replacing other grants or activating an inactive membership. **Steps to reproduce** Configure a public auth origin. Create a CLI challenge through a proxy that replaces forwarded-host headers. Inspect the approval URL. Then try to grant commentary:view to an agent through the CLI. ## What Changed - Use the existing public-origin resolver for CLI approval URLs. - Add agent permission:grant and permission:revoke commands. - Add PATCH /api/agents/{id}/permission-grants with strict shared validation and Board-only OpenAPI metadata. - Serialize each mutation on the agent row. Preserve unrelated grants and membership state. Commit the grant and its activity record together and publish activity after commit. - Document remote login and single-grant management. ## Verification - The new approval-link regression failed before the fix and passes after it. - All 25 CLI auth route tests pass. - All 72 affected CLI, login and OpenAPI tests pass. - All 5 PostgreSQL grant tests pass, including actual commentary reads (403 before grant, 200 after grant, 403 after revoke), preservation, audit attribution, concurrent additions, invalid inputs, inactive or missing membership, agent self-grants, cross-company denial, and rollback on audit failure. - Shared and CLI typechecks and server declaration-layer checks pass. - pnpm verify requests broad CI coverage because doc/CLI.md is a global verification input. Greptile reviewed final head 338c58fb248e19d0b65a050074d82f2a83d28f21 at 5/5 with no findings. All 58 non-skipped checks pass on that head. CI run: https://github.com/paperclipai/paperclip/actions/runs/42663640985. Live target acceptance is in progress. ## Risks This adds an explicit permission-management API. It requires Board authentication, company access, and users:manage_permissions. Foreign-company agents return 404. Granting a key intentionally makes that key unscoped in its company; revocation removes that key. Other keys and agent configuration remain unchanged. Inactive memberships and unapproved or terminated agents cannot gain grants. No schema change is needed. Revert this change to remove the new management surface; existing grants retain their normal authorization rules. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code editing, shell tools, and browser testing. The runtime does not expose a more specific model ID or context window. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- cli/src/__tests__/agent-lifecycle.test.ts | 11 ++ cli/src/commands/client/agent.ts | 19 +++ doc/CLI.md | 13 +- packages/shared/src/index.ts | 2 + packages/shared/src/validators/access.ts | 7 + packages/shared/src/validators/index.ts | 2 + .../__tests__/agent-permission-grants.test.ts | 127 ++++++++++++++++++ server/src/__tests__/cli-auth-routes.test.ts | 27 +++- server/src/__tests__/openapi-routes.test.ts | 11 ++ server/src/app.ts | 2 + server/src/routes/access.ts | 2 +- server/src/routes/agent-permission-grants.ts | 72 ++++++++++ server/src/routes/openapi.ts | 12 ++ 13 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 server/src/__tests__/agent-permission-grants.test.ts create mode 100644 server/src/routes/agent-permission-grants.ts diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index 0410be70c7c..204e84ec7cc 100644 --- a/cli/src/__tests__/agent-lifecycle.test.ts +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -131,6 +131,17 @@ describe("agent lifecycle commands", () => { ]); }); + it("grants and revokes one permission without replacing the grant list", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + await run(["agent", "permission:grant", AGENT_ID, "commentary:view"]); + await run(["agent", "permission:revoke", AGENT_ID, "commentary:view"]); + expect(fetchMock.mock.calls.map(call => [call[0], call[1]?.method, JSON.parse(call[1]?.body)])).toEqual([ + [`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: true }], + [`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: false }], + ]); + }); + it("refuses to delete an instructions file without confirmation", async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); vi.stubGlobal("fetch", fetchMock); diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index c8e6a122f59..189e313d466 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -6,6 +6,7 @@ import { updateAgentInstructionsBundleSchema, updateAgentInstructionsPathSchema, updateAgentPermissionsSchema, + updateAgentPermissionGrantSchema, updateAgentSchema, upsertAgentInstructionsFileSchema, wakeAgentSchema, @@ -462,6 +463,24 @@ export function registerAgentCommands(program: Command): void { }), ); + for (const [command, enabled] of [["permission:grant", true], ["permission:revoke", false]] as const) { + addCommonClientOptions( + agent.command(command) + .description(`${enabled ? "Grant" : "Revoke"} one agent permission (Board permission manager only)`) + .argument("", "Agent UUID") + .argument("", "Permission key, such as commentary:view") + .action(async (agentId: string, permissionKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentPermissionGrantSchema.parse({ permissionKey, enabled }); + printOutput(await ctx.api.patch(apiPath`/api/agents/${agentId}/permission-grants`, payload), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + addCommonClientOptions( agent .command("configuration") diff --git a/doc/CLI.md b/doc/CLI.md index 646a815d1d4..c767a4a793b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -9,7 +9,9 @@ Paperclip CLI now supports both: ## Browser-approved Board authentication `npx paperclipai auth login --api-base ` creates a ten-minute -challenge and prints a browser approval link. Sign in to the instance, review +challenge and prints a browser approval link. The link uses the instance's +canonical public origin when configured, so reverse proxies do not send the +browser to an internal upstream hostname. Sign in to the instance, review the requesting command and full Board access, then approve. The pending token cannot authenticate before approval. The CLI verifies its resulting identity before storing its credential in its private auth store. The default Board key @@ -586,6 +588,8 @@ Agent configuration and runtime endpoints: ```sh npx paperclipai agent permissions:update --payload-json '{"canCreateAgents":true,"canCreateSkills":true,"canAssignTasks":true}' +npx paperclipai agent permission:grant commentary:view +npx paperclipai agent permission:revoke commentary:view npx paperclipai agent configuration npx paperclipai agent config-revisions npx paperclipai agent config-revision:get @@ -603,6 +607,13 @@ npx paperclipai agent instructions-file:put --path AGENTS.md --conten npx paperclipai agent instructions-file:delete --path AGENTS.md --yes ``` +`permission:grant` and `permission:revoke` require a Board login with +`users:manage_permissions` in the agent's company. They change one permission +key and preserve all other grants. A grant applies without starting a new run. +Granting a key replaces that key's scope with an unscoped company grant; +revoking removes the key. Use `agent get --json` to inspect +`access.grants`. These commands do not reactivate an inactive membership. + Agent config, instructions, skills, project env, environment, secret, and workspace edits affect the next run. Active runs finish with the config they started with. When a saved session, reused workspace, or sandbox lease no longer matches the effective next-run config, Paperclip may start fresh execution and records non-sensitive freshness categories in run result JSON and workspace operation logs. `agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9ab71a2cf39..e84cedb5f46 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2374,6 +2374,8 @@ export { updateCompanyMemberWithPermissionsSchema, archiveCompanyMemberSchema, updateMemberPermissionsSchema, + updateAgentPermissionGrantSchema, + type UpdateAgentPermissionGrant, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index c046b84d56d..03d78d52b9a 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -104,6 +104,13 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +export const updateAgentPermissionGrantSchema = z.object({ + permissionKey: z.enum(PERMISSION_KEYS), + enabled: z.boolean(), +}).strict(); + +export type UpdateAgentPermissionGrant = z.infer; + const editableMembershipStatuses = ["pending", "active", "suspended"] as const; export const updateCompanyMemberSchema = z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d62b0678d5f..248b0b48865 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -768,6 +768,8 @@ export { updateCompanyMemberWithPermissionsSchema, archiveCompanyMemberSchema, updateMemberPermissionsSchema, + updateAgentPermissionGrantSchema, + type UpdateAgentPermissionGrant, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, diff --git a/server/src/__tests__/agent-permission-grants.test.ts b/server/src/__tests__/agent-permission-grants.test.ts new file mode 100644 index 00000000000..5dbbf69c0cc --- /dev/null +++ b/server/src/__tests__/agent-permission-grants.test.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, + principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { agentPermissionGrantRoutes } from "../routes/agent-permission-grants.js"; +import { agentCommentaryRoutes } from "../routes/agent-commentary.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import * as activityLogging from "../services/activity-log.js"; + +describe("Board agent permission grants", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("agent-permission-grants-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Permission grants", issuePrefix: `G${companyId.slice(0, 6)}` }); + await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.com`, + createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "process", status: "idle" }); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }, + { companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, + ]); + await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", + principalId: userId, permissionKey: "users:manage_permissions" }); + return { companyId, agentId, userId }; + } + type Fixture = Awaited>; + function app(actor: express.Request["actor"]) { + const api = express(); + api.use(express.json()); + api.use((req, _res, next) => { req.actor = actor; next(); }); + api.use(agentPermissionGrantRoutes(db)); + api.use(agentCommentaryRoutes(db)); + api.use(errorHandler); + return api; + } + function owner(f: Fixture) { + return app({ type: "board", source: "session", userId: f.userId, companyIds: [f.companyId], + memberships: [{ companyId: f.companyId, membershipRole: "owner", status: "active" }] }); + } + const path = (f: Fixture) => `/agents/${f.agentId}/permission-grants`; + const grant = { permissionKey: "commentary:view", enabled: true }; + const grants = (f: Fixture) => db.select().from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalType, "agent"), + eq(principalPermissionGrants.principalId, f.agentId), + )); + + it("grants and revokes commentary reads while preserving other grants and recording the operator", async () => { + const f = await seed(); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", + principalId: f.agentId, permissionKey: "tasks:assign", scope: { keep: true } }); + const reader = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId }); + const commentary = `/companies/${f.companyId}/agent-commentary`; + expect((await request(reader).get(commentary)).status).toBe(403); + const response = await request(owner(f)).patch(path(f)).send(grant); + expect(response.status, response.text).toBe(200); + expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, ...grant }); + expect((await request(reader).get(commentary)).status).toBe(200); + expect(await grants(f)).toEqual(expect.arrayContaining([ + expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } }), + expect.objectContaining({ permissionKey: "commentary:view", scope: null, grantedByUserId: f.userId }), + ])); + expect((await request(owner(f)).patch(path(f)).send({ ...grant, enabled: false })).status).toBe(200); + expect((await request(reader).get(commentary)).status).toBe(403); + expect(await grants(f)).toEqual([expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } })]); + const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), + eq(activityLog.action, "agent.permission_grant_updated"))); + expect(audit).toHaveLength(2); + expect(audit.map(row => row.details)).toEqual(expect.arrayContaining([grant, { ...grant, enabled: false }])); + expect(audit.every(row => row.actorId === f.userId && row.companyId === f.companyId)).toBe(true); + }); + + it("denies agent self-grants, foreign-company owners, and users without permission-management authority", async () => { + const f = await seed(), foreign = await seed(); + const self = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId }); + expect((await request(self).patch(path(f)).send(grant)).status).toBe(403); + expect((await request(app({ type: "none", source: "none" })).patch(path(f)).send(grant)).status).toBe(401); + expect((await request(owner(foreign)).patch(path(f)).send(grant)).status).toBe(404); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId))); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.userId)); + expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(403); + expect(await grants(f)).toEqual([]); + }); + + it("rejects invalid grants and inactive agents or memberships without reactivating them", async () => { + const f = await seed(), api = owner(f); + expect((await request(api).patch(path(f)).send({ ...grant, permissionKey: "invalid" })).status).toBe(400); + expect((await request(api).patch(path(f)).send({ ...grant, scope: { companyId: "foreign" } })).status).toBe(400); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId)); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + await db.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agentId)); + await db.update(companyMemberships).set({ status: "suspended" }).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + expect(await grants(f)).toEqual([]); + await db.delete(companyMemberships).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + }); + + it("serializes concurrent additions of the same grant", async () => { + const f = await seed(), api = owner(f); + const responses = await Promise.all([request(api).patch(path(f)).send(grant), request(api).patch(path(f)).send(grant)]); + expect(responses.map(response => response.status)).toEqual([200, 200]); + expect(await grants(f)).toHaveLength(1); + }); + + it("rolls back the grant if its audit record cannot be saved", async () => { + const f = await seed(); + const failure = vi.spyOn(activityLogging, "logActivity").mockRejectedValueOnce(new Error("Audit storage unavailable")); + try { + expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(500); + expect(await grants(f)).toEqual([]); + } finally { failure.mockRestore(); } + }); +}); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index c60cf770d06..24b6857ecb9 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -53,7 +53,7 @@ function registerModuleMocks() { let appImportCounter = 0; -async function createApp(actor: any, db: any = {} as any, options: { cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { +async function createApp(actor: any, db: any = {} as any, options: { authPublicBaseUrl?: string; cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { appImportCounter += 1; const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`; const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`; @@ -137,6 +137,31 @@ describe("cli auth routes", () => { expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"); }); + it("uses the configured public origin for CLI approval behind an upstream proxy", async () => { + mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ + challenge: { + id: "12345678-1234-4123-8123-123456789abc", + expiresAt: new Date(Date.now() + 60_000), + }, + challengeSecret: "pcp_cli_auth_secret", + pendingBoardToken: "pcp_board_token", + }); + const app = await createApp({ type: "none", source: "none" }, {}, { + authPublicBaseUrl: "https://workspace.example.com/", + }); + const res = await request(app) + .post("/api/cli-auth/challenges") + .set("Host", "upstream.example.net") + .set("X-Forwarded-Host", "upstream.example.net") + .set("X-Forwarded-Proto", "https") + .send({ command: "paperclipai auth login", requestedAccess: "board" }); + + expect(res.status).toBe(201); + expect(res.body.approvalUrl).toBe( + `https://workspace.example.com${res.body.approvalPath}`, + ); + }); + it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => { let now = 1_000; mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0b28e22572a..98d8ad9d8e3 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -23,6 +23,7 @@ const apiPrefixes: Record = { "adapters.ts": "/api", "agents.ts": "/api", "agent-project-access.ts": "/api", + "agent-permission-grants.ts": "/api", "agent-commentary.ts": "/api", "agent-chat-messages.ts": "/api", "agent-avatars.ts": "/api", @@ -1180,6 +1181,16 @@ describe("openapi routes", () => { }); }); + it("documents agent permission grants as Board-only with a strict request", () => { + const operation = loadSpecRoutes().spec.paths["/api/agents/{id}/permission-grants"].patch; + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, required: ["permissionKey", "enabled"], + properties: { enabled: { type: "boolean" }, permissionKey: { enum: expect.arrayContaining(["commentary:view"]) } }, + }); + }); + it("documents additive project access as board-only with a strict request", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/agents/{id}/project-access"]; diff --git a/server/src/app.ts b/server/src/app.ts index 2a3560b5014..df846ded332 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -106,6 +106,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import { agentPermissionGrantRoutes } from "./routes/agent-permission-grants.js"; import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js"; import type { SetupTokenSessionService } from "./services/setup-token-session.js"; import { @@ -795,6 +796,7 @@ export async function createApp( }, }), ); + api.use(agentPermissionGrantRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(agentProfileAvatarRoutes(db, opts.storageService)); api.use(projectToolRoutes(db)); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3279a1ec780..96e549afdfa 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2795,7 +2795,7 @@ export function accessRoutes( created.challenge.id, created.challengeSecret, ); - const baseUrl = requestBaseUrl(req); + const baseUrl = resolveBaseUrl(req, opts.authPublicBaseUrl); res.status(201).json({ id: created.challenge.id, token: created.challengeSecret, diff --git a/server/src/routes/agent-permission-grants.ts b/server/src/routes/agent-permission-grants.ts new file mode 100644 index 00000000000..ae2e529d142 --- /dev/null +++ b/server/src/routes/agent-permission-grants.ts @@ -0,0 +1,72 @@ +import { Router } from "express"; +import { agents, companyMemberships, principalPermissionGrants, type Db } from "@paperclipai/db"; +import { updateAgentPermissionGrantSchema, isUuidLike } from "@paperclipai/shared"; +import { and, eq } from "drizzle-orm"; +import { badRequest, conflict, forbidden, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService } from "../services/access.js"; +import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; + +export function agentPermissionGrantRoutes(db: Db) { + const router = Router(); + + router.patch("/agents/:id/permission-grants", validate(updateAgentPermissionGrantSchema), async (req, res) => { + assertAuthenticated(req); + assertBoard(req); + const id = req.params.id as string; + if (!isUuidLike(id)) throw badRequest("Agent ID must be a UUID"); + const { permissionKey, enabled } = req.body; + const publications: ActivityPublication[] = []; + const result = await db.transaction(async (tx) => { + const agent = await tx.select({ id: agents.id, companyId: agents.companyId, status: agents.status }) + .from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0]); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + const txDb = tx as unknown as Db; + const access = accessService(txDb); + if (req.actor.source !== "local_implicit" + && !await access.canUser(agent.companyId, req.actor.userId, "users:manage_permissions")) { + throw forbidden("Missing permission: users:manage_permissions"); + } + if (enabled) { + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Cannot grant permissions to an unapproved or terminated agent"); + } + const membership = await tx.select().from(companyMemberships).where(and( + eq(companyMemberships.companyId, agent.companyId), + eq(companyMemberships.principalType, "agent"), + eq(companyMemberships.principalId, agent.id), + )).for("update").then(rows => rows[0]); + if (!membership || membership.status !== "active") { + throw conflict("Agent company membership is not active"); + } + await tx.insert(principalPermissionGrants).values({ + companyId: agent.companyId, principalType: "agent", principalId: agent.id, + permissionKey, scope: null, grantedByUserId: req.actor.userId ?? null, + }).onConflictDoUpdate({ + target: [principalPermissionGrants.companyId, principalPermissionGrants.principalType, + principalPermissionGrants.principalId, principalPermissionGrants.permissionKey], + set: { scope: null, grantedByUserId: req.actor.userId ?? null, updatedAt: new Date() }, + }); + } else { + await tx.delete(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, agent.companyId), + eq(principalPermissionGrants.principalType, "agent"), + eq(principalPermissionGrants.principalId, agent.id), + eq(principalPermissionGrants.permissionKey, permissionKey), + )); + } + await logActivity(txDb, { + companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", + action: "agent.permission_grant_updated", entityType: "agent", entityId: agent.id, + details: { permissionKey, enabled }, + }, publications); + return { agentId: agent.id, companyId: agent.companyId, permissionKey, enabled }; + }); + for (const publication of publications) publishActivity(publication); + res.json(result); + }); + + return router; +} diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 02dea5ce76a..816c533ad41 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -51,6 +51,7 @@ import { updateAgentSchema, updatePrimaryAgentSchema, updateAgentPermissionsSchema, + updateAgentPermissionGrantSchema, addAgentProjectAccessSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, @@ -1429,6 +1430,7 @@ const browserUseOperations = [ ] as const; const BOARD_ONLY_OPERATIONS = new Set([ + "PATCH /api/agents/{id}/permission-grants", // Budget changes are board-only (assertBoard in routes/costs.ts). "POST /api/companies/{companyId}/budgets/policies", "POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve", @@ -3834,6 +3836,16 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/permission-grants", + tags: ["agents"], + summary: "Grant or revoke one explicit agent permission", + description: "Board-only. Requires users:manage_permissions in the agent's company. Enables an unscoped grant or removes that permission key without replacing other grants. Does not reactivate inactive memberships or start agent work. Writes an activity record in the same transaction.", + request: { params: z.object({ id: z.string().uuid() }), body: jsonBody(updateAgentPermissionGrantSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() }); registry.registerPath({ method: "get",