mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(api): publish agent-grant access operations as board_or_agent in the API catalog (#20349)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents read the API catalog (`server/src/routes/openapi.ts`) to decide which operations they may call. The `x-paperclip-authorization` actor tells them who performs each operation. > - The invite, join-request and member-list routes admit agents that hold the matching company grant (`users:invite`, `joins:approve`, `users:manage_permissions`). > - The catalog still listed these operations as board-only. > - In a chat eval, a CEO agent with `users:invite` read `actor: "board"` on `POST /invites`, treated it as a final boundary, and told the person it could not invite anyone. > - This pull request publishes those operations as `board_or_agent` and names the grant each needs. It also names the Members page on member edits, which stay board-only. > - The benefit is that a granted agent uses the permission the board gave it, and agents without the grant still get a 403. ## Linked Issues or Issue Description **What happened?** A board granted the CEO agent `users:invite` with `PATCH /api/agents/:id/permission-grants`. The person asked in chat: "Invite alex@example.test to join this company." The agent searched the API catalog. It found `POST /api/companies/{companyId}/invites` with `x-paperclip-authorization: { actor: "board" }`. It concluded that invites are board-only and did not try the call. The route itself accepts agents with `users:invite`. **Expected behavior** The catalog shows `board_or_agent` for operations whose route accepts an agent with a company grant. The description names the grant. The agent creates the invite and returns the join link. **Steps to reproduce** 1. Grant an agent `users:invite`. 2. Read `GET /api/openapi.json` and find `POST /api/companies/{companyId}/invites`. 3. See `x-paperclip-authorization: { actor: "board" }`, although `assertCompanyPermission` admits the agent. **Paperclip version or commit** Master at the merge base of this branch. **Deployment mode** Any. Found in the runner full-stack chat eval `access.invite-teammate`. ## What Changed - Remove six operations from `BOARD_ONLY_OPERATIONS`: `POST`/`GET /companies/{companyId}/invites`, `GET /join-requests`, `POST /join-requests/{requestId}/approve` and `/reject`, and `GET /members`. Their routes use `assertCompanyPermission`, which admits agents with the grant. - Add descriptions that name the grant for each operation. The invite description also says it returns a shareable `inviteUrl` (no email field), and that membership starts only after acceptance. - Member edits (`PATCH /members/{memberId}`, `/role-and-grants`, `/permissions` and `POST /archive`) stay board-only. The route guard requires board access, and a test keeps the catalog in line with that guard. - Describe those member edits as board-only even for agents holding `users:manage_permissions`, and name the Members page (`/<prefix>/company/settings/members`) where a person makes the change. The member list description says the same. An agent then links the page instead of searching for an agent route. - Add a regression test in `openapi-routes.test.ts`. ## Verification - `cd server && node ../node_modules/vitest/vitest.mjs run --config vitest.config.ts src/__tests__/openapi-routes.test.ts` — 43 passed. The new test also checks that the Members page is named. With master's `openapi.ts`, the new test fails (`expected { actor: 'board' } to deeply equal { actor: 'board_or_agent' }`). - `src/services/native-runtime/runner-api.test.ts` — 2075 passed. ## Risks - Low risk. This changes catalog metadata only. Route guards do not change. An agent without the grant still gets 403. - Agents with these grants will now attempt these operations instead of stopping. That is the intended behavior of the grant. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution. ## 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>
This commit is contained in:
1 parent
c67909cb0f
commit
16e9157577
2 files changed
+35
-6
No files matched your search
@@ -421,6 +421,31 @@ describe("openapi routes", () => {
|
||||
expect(create).toContain("/{prefix}/issues/{identifier}");
|
||||
});
|
||||
|
||||
it("publishes grant-gated access operations as board_or_agent and keeps member edits board-only", () => {
|
||||
const spec = buildOpenApiSpec() as any;
|
||||
for (const [path, method, grant] of [
|
||||
["/api/companies/{companyId}/invites", "post", "users:invite"],
|
||||
["/api/companies/{companyId}/invites", "get", "users:invite"],
|
||||
["/api/companies/{companyId}/join-requests", "get", "joins:approve"],
|
||||
["/api/companies/{companyId}/join-requests/{requestId}/approve", "post", "joins:approve"],
|
||||
["/api/companies/{companyId}/join-requests/{requestId}/reject", "post", "joins:approve"],
|
||||
["/api/companies/{companyId}/members", "get", "users:manage_permissions"],
|
||||
] as const) {
|
||||
const operation = spec.paths[path][method];
|
||||
expect(operation["x-paperclip-authorization"], `${method} ${path}`).toEqual({ actor: "board_or_agent" });
|
||||
expect(operation.description).toContain(grant);
|
||||
}
|
||||
expect(spec.paths["/api/companies/{companyId}/invites"].post.description).toContain("inviteUrl");
|
||||
for (const path of [
|
||||
"/api/companies/{companyId}/members/{memberId}/permissions",
|
||||
"/api/companies/{companyId}/members/{memberId}/role-and-grants",
|
||||
]) {
|
||||
expect(spec.paths[path].patch["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
||||
expect(spec.paths[path].patch.description).toContain("/<prefix>/company/settings/members");
|
||||
}
|
||||
expect(spec.paths["/api/companies/{companyId}/members"].get.description).toContain("/<prefix>/company/settings/members");
|
||||
});
|
||||
|
||||
it("documents owned conversation reads and exact revision access separately", () => {
|
||||
const spec = buildOpenApiSpec() as any;
|
||||
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
|
||||
|
||||
@@ -1486,13 +1486,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
||||
"GET /api/companies/import/jobs/{jobId}",
|
||||
"POST /api/board-claim/{token}/claim",
|
||||
"GET /api/cli-auth/me",
|
||||
"POST /api/companies/{companyId}/invites",
|
||||
"GET /api/companies/{companyId}/invites",
|
||||
"POST /api/companies/{companyId}/openclaw/invite-prompt",
|
||||
"GET /api/companies/{companyId}/join-requests",
|
||||
"POST /api/companies/{companyId}/join-requests/{requestId}/approve",
|
||||
"POST /api/companies/{companyId}/join-requests/{requestId}/reject",
|
||||
"GET /api/companies/{companyId}/members",
|
||||
"PATCH /api/companies/{companyId}/members/{memberId}",
|
||||
"PATCH /api/companies/{companyId}/members/{memberId}/role-and-grants",
|
||||
"POST /api/companies/{companyId}/members/{memberId}/archive",
|
||||
@@ -7276,6 +7270,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/invites",
|
||||
tags: ["access"],
|
||||
summary: "List company invites",
|
||||
description: "Board members or agents holding users:invite in this company. Lists invite records; it never returns invite tokens.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
@@ -7285,6 +7280,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/invites",
|
||||
tags: ["access"],
|
||||
summary: "Create a company invite",
|
||||
description: "Board members or agents holding users:invite in this company. Creates a shareable join link (inviteUrl) that the person who should join opens; it does not send email and has no email field. Write the returned inviteUrl as a Markdown link in your reply. Membership starts only after the person accepts the link (and a join request is approved when required). humanRole defaults to operator.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createCompanyInviteSchema),
|
||||
@@ -7297,6 +7293,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/join-requests",
|
||||
tags: ["access"],
|
||||
summary: "List company join requests",
|
||||
description: "Board members or agents holding joins:approve in this company.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
@@ -7306,6 +7303,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/join-requests/{requestId}/approve",
|
||||
tags: ["access"],
|
||||
summary: "Approve a company join request",
|
||||
description: "Board members or agents holding joins:approve in this company.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), requestId: z.string() }),
|
||||
},
|
||||
@@ -7317,6 +7315,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/join-requests/{requestId}/reject",
|
||||
tags: ["access"],
|
||||
summary: "Reject a company join request",
|
||||
description: "Board members or agents holding joins:approve in this company.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), requestId: z.string() }),
|
||||
},
|
||||
@@ -7363,6 +7362,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/members",
|
||||
tags: ["access"],
|
||||
summary: "List company members",
|
||||
description: "Board members or agents holding users:manage_permissions in this company. Returns each member's role, status and explicit grants. Changing a member's role, grants or status stays board-only: A person makes this change on the Members page at /<prefix>/company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
@@ -7372,6 +7372,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/members/{memberId}",
|
||||
tags: ["access"],
|
||||
summary: "Update a company member status or role",
|
||||
description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at /<prefix>/company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), memberId: z.string() }),
|
||||
body: jsonBody(updateCompanyMemberSchema),
|
||||
@@ -7389,6 +7390,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/members/{memberId}/role-and-grants",
|
||||
tags: ["access"],
|
||||
summary: "Update a company member role and explicit grants",
|
||||
description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at /<prefix>/company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), memberId: z.string() }),
|
||||
body: jsonBody(updateCompanyMemberWithPermissionsSchema),
|
||||
@@ -7406,6 +7408,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/members/{memberId}/archive",
|
||||
tags: ["access"],
|
||||
summary: "Archive a company member",
|
||||
description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at /<prefix>/company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), memberId: z.string() }),
|
||||
body: jsonBody(archiveCompanyMemberSchema),
|
||||
@@ -7423,6 +7426,7 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/members/{memberId}/permissions",
|
||||
tags: ["access"],
|
||||
summary: "Update explicit company member permissions",
|
||||
description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at /<prefix>/company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), memberId: z.string() }),
|
||||
body: jsonBody(updateMemberPermissionsSchema),
|
||||
|
||||
Reference in new issue
Block a user