From 7787106e5cd55819e538ace2d7a70cab5120e4be Mon Sep 17 00:00:00 2001 From: Apolinario Ratio Date: Fri, 14 Aug 2026 01:14:05 +0800 Subject: [PATCH] fix board key issue writes across assignees (#9025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Board users and board API keys coordinate agents by commenting on and updating issues. > - `issue:comment` and `issue:mutate` are intentionally null-mapped authorization actions, so they need explicit same-company fallback handling. > - Same-company board-key writes worked for unassigned or same-actor issues but failed for issues assigned to another agent. > - That blocked cross-agent coordination because a board key could not comment on or patch another agent's issue even inside the same company. > - This pull request adds the missing board-member issue-write fallback while keeping viewers denied and sparse service calls fail-closed. > - The benefit is that non-viewer board members can coordinate agent work across assignees without restoring broad instance-admin elevation. ## Linked Issues or Issue Description No public GitHub issue exists. Duplicate search performed: - `gh search prs --repo paperclipai/paperclip "board key issue mutate"` returned only this PR. - `gh search issues --repo paperclipai/paperclip "board key authorization boundary"` returned no issues. Bug description: ### What happened Same-company board-key actors received `403 "Issue is outside this actor's authorization boundary"` when posting comments or patching issues assigned to another agent. ### Expected behavior Active same-company non-viewer board members can comment on and mutate issues in their company, regardless of agent assignee; viewer members remain denied. ### Steps to reproduce Authenticate as a board API key for an active non-viewer company member, then `POST /api/issues/{id}/comments` or `PATCH /api/issues/{id}` against an issue assigned to a different agent in the same company. ### Paperclip version or commit Observed against the current published 2026.626.0 package line and fixed against current `master`. ### Deployment mode Authenticated/tailnet board-key access. ## What Changed - Added a board-actor fallback for `issue:comment` and `issue:mutate` in `server/src/services/authorization.ts`. - Restricted that fallback to fully contextualized issue resources with issue id, status, and explicit assignee fields so sparse service calls still fail closed. - Allowed active same-company non-viewer board memberships and denied viewer memberships for these issue-write actions. - Added regression coverage for non-viewer board-key comment/mutate on an issue assigned to another agent. - Added regression coverage for viewer denial on both `issue:comment` and `issue:mutate`. ## Verification - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts` passed: 35/35. - `pnpm --filter @paperclipai/server typecheck` passed. - `git diff --check` passed. ## Risks Low-to-moderate authorization risk because this changes issue-write access. The scope is constrained to active same-company board memberships, excludes viewers, and requires route-shaped issue context before granting access. Cross-company access and sparse/null-mapped calls continue to fail closed. > 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 coding agent using GPT-5-class reasoning with local shell, GitHub CLI, and test execution tools in an OpenClaw/Codex 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 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 - [ ] 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 Co-authored-by: ApolinarioRatio --- .../__tests__/authorization-service.test.ts | 76 +++++++++++++++++++ server/src/services/authorization.ts | 35 +++++++++ 2 files changed, 111 insertions(+) diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 407d1bdf61..871aabb27e 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1351,6 +1351,82 @@ describeEmbeddedPostgres("authorization service", () => { })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); }); + it("allows same-company non-viewer board members to comment and mutate issues assigned to another agent", async () => { + const company = await createCompany(db, "BoardIssueMutation"); + const userId = `user-${randomUUID()}`; + const assignee = await createAgent(db, company.id, { role: "engineer" }); + const issue = await createIssue(db, company.id, { assigneeAgentId: assignee.id }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + + const authorization = authorizationService(db); + const actor = { type: "board" as const, userId, source: "board_key" as const }; + const resource = { + type: "issue" as const, + companyId: company.id, + issueId: issue.id, + projectId: issue.projectId, + parentIssueId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }; + + await expect(authorization.decide({ + actor, + action: "issue:comment", + resource, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "issue:mutate", + resource, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + }); + + it("denies same-company viewer board members issue comment and mutation", async () => { + const company = await createCompany(db, "BoardViewerIssueMutation"); + const userId = `user-${randomUUID()}`; + const assignee = await createAgent(db, company.id, { role: "engineer" }); + const issue = await createIssue(db, company.id, { assigneeAgentId: assignee.id }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "viewer", + }); + + const authorization = authorizationService(db); + const actor = { type: "board" as const, userId, source: "board_key" as const }; + const resource = { + type: "issue" as const, + companyId: company.id, + issueId: issue.id, + projectId: issue.projectId, + parentIssueId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }; + + await expect(authorization.decide({ + actor, + action: "issue:comment", + resource, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + await expect(authorization.decide({ + actor, + action: "issue:mutate", + resource, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + }); + it("denies null-mapped visibility actions for board users without an active membership", async () => { const memberCompany = await createCompany(db, "BoardVisibilityMember"); const otherCompany = await createCompany(db, "BoardVisibilityOther"); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index fe461488df..063de1e271 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1716,6 +1716,41 @@ export function authorizationService(db: Db) { }); } if (!permissionKey) { + if (input.action === "issue:comment" || input.action === "issue:mutate") { + if ( + input.resource.type !== "issue" || + !input.resource.issueId || + typeof input.resource.status !== "string" || + input.resource.assigneeAgentId === undefined || + input.resource.assigneeUserId === undefined + ) { + return deny({ + action: input.action, + reason: "deny_unsupported_action", + explanation: `No board permission mapping exists for ${input.action}.`, + }); + } + const membership = await getActiveMembership(companyId, "user", input.actor.userId); + if (membership && membership.membershipRole !== "viewer") { + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by standard same-company board membership issue mutation.", + }); + } + if (membership) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: `Viewer membership does not grant ${input.action}.`, + }); + } + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`, + }); + } if ( input.action === "agent:read" || input.action === "company_scope:read" ||