From d7f2f883238dbd56c6def9e07984eec8d099f788 Mon Sep 17 00:00:00 2001 From: Harshit Khemani Date: Fri, 12 Jun 2026 06:28:46 +0530 Subject: [PATCH] fix(server): allow board members the null-mapped visibility actions agents already get (#7890) (#7935) 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 > - The authorization service (`server/src/services/authorization.ts`) decides every actor's actions; `permissionForAction()` intentionally maps read/visibility actions (`agent:read`, `issue:read`, `project:read`, `company_scope:read`, `runtime:manage`, `secrets:read`) to `null`, meaning "no explicit database grant required" > - The board-actor path's `if (!permissionKey) return deny(deny_unsupported_action)` guard caught those null-mapped actions *before* any membership-based evaluation, contradicting the intentional null mapping > - Result (#7890): board users with active company membership see "You have no agents" on the Dashboard — `filterAgentsForActor()` drops every agent because `access.decide({action: "agent:read"})` denies > - This pull request allows exactly those six actions for board users with an active company membership, mirroring the agent actor path's standard-trust policy so board and agent actors behave consistently > - The benefit is board members can actually see their company's agents, issues, and projects, while everything else (including `agent:wake` and `issue:mutate`, which have no board analog today) keeps its existing deny ## Linked Issues or Issue Description Fixes #7890 ## What Changed - `server/src/services/authorization.ts`: inside the board path's null-`permissionKey` branch, the six null-mapped visibility actions (`agent:read`, `company_scope:read`, `issue:read`, `project:read`, `runtime:manage`, `secrets:read`) now resolve via `getActiveMembership` — active membership → `allow` with the pre-existing `allow_simple_company_member` reason; no membership → `deny_missing_membership`. All other null-mapped actions (`agent:wake`, `issue:mutate`) keep `deny_unsupported_action`. - `server/src/__tests__/authorization-service.test.ts`: three regression tests in the existing embedded-postgres suite — member allowed the visibility actions, non-member denied with `deny_missing_membership`, and `agent:wake`/`issue:mutate` still denied. ## Verification - `npx vitest run server/src/__tests__/authorization-service.test.ts` → 20 passed (17 pre-existing + 3 new) against embedded postgres. - `pnpm --filter @paperclipai/server typecheck` → clean. - Policy rationale: the agent actor path's standard-trust branch already allows these same six actions company-wide (`allow_company_agent`); this PR gives board members the identical set, per the issue's note that the null mapping means "no explicit grant needed". `agent:wake` is self-only for agents and `issue:mutate` is assignee-gated — neither has a board semantic today (no route invokes them for board actors), so both intentionally keep the unsupported-action deny. ## Risks - This is authorization code, so reviewed conservatively: the change only affects the board (session user) path, only for actions that returned `null` from `permissionForAction()`, and only flips deny→allow when an **active** company membership exists. Instance admins and `local_implicit` boards were already allowed via earlier short-circuits. - Viewer members keep the four read-only visibility actions but are denied `runtime:manage` and `secrets:read` (`deny_missing_grant`), matching the `tasks:assign` viewer carve-out in the same board block (added in review follow-up 55f3b40). ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code, agentic mode with tool use (subagent implementation + independent adversarial review subagent), extended thinking enabled. ## 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 (none found for #7890) - [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 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 (N/A — server-only; the UI symptom is "no agents" with no styling change) - [x] I have updated relevant documentation to reflect my changes (N/A — no docs describe the board permission mapping) - [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: Claude Fable 5 --- .../__tests__/authorization-service.test.ts | 161 ++++++++++++++++++ server/src/services/authorization.ts | 33 ++++ 2 files changed, 194 insertions(+) diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 2bba3349..1d156105 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -495,6 +495,167 @@ describeEmbeddedPostgres("authorization service", () => { }); }); + it("allows null-mapped visibility actions for active same-company board members", async () => { + const company = await createCompany(db, "BoardVisibility"); + const userId = `user-${randomUUID()}`; + const project = await createProject(db, company.id, "Visible"); + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + const issue = await createIssue(db, company.id, { projectId: project.id }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "member", + }); + + const authorization = authorizationService(db); + const actor = { type: "board" as const, userId, source: "session" as const }; + + await expect(authorization.decide({ + actor, + action: "agent:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "company_scope:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "project:read", + resource: { type: "project", companyId: company.id, projectId: project.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "issue:read", + resource: { + type: "issue", + companyId: company.id, + issueId: issue.id, + projectId: issue.projectId, + parentIssueId: issue.parentId, + status: issue.status, + }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "runtime:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "secrets:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + }); + + 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"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, otherCompany.id, { role: "engineer" }); + const inactiveUserId = `user-${randomUUID()}`; + await db.insert(companyMemberships).values({ + companyId: memberCompany.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "member", + }); + await db.insert(companyMemberships).values({ + companyId: otherCompany.id, + principalType: "user", + principalId: inactiveUserId, + status: "removed", + membershipRole: "member", + }); + + const authorization = authorizationService(db); + + await expect(authorization.decide({ + actor: { type: "board", userId, source: "session" }, + action: "agent:read", + resource: { type: "agent", companyId: otherCompany.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_membership" }); + await expect(authorization.decide({ + actor: { type: "board", userId: inactiveUserId, source: "session" }, + action: "company_scope:read", + resource: { type: "company", companyId: otherCompany.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_membership" }); + }); + + it("keeps denying self-gated null-mapped actions for board members", async () => { + const company = await createCompany(db, "BoardWakeDenied"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "member", + }); + + const authorization = authorizationService(db); + + await expect(authorization.decide({ + actor: { type: "board", userId, source: "session" }, + action: "agent:wake", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_unsupported_action", + }); + const issue = await createIssue(db, company.id, { title: "Wake denied issue" }); + await expect(authorization.decide({ + actor: { type: "board", userId, source: "session" }, + action: "issue:mutate", + resource: { type: "issue", companyId: company.id, issueId: issue.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_unsupported_action", + }); + }); + + it("limits viewer members to read-only visibility actions", async () => { + const company = await createCompany(db, "BoardViewerVisibility"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "viewer", + }); + + const authorization = authorizationService(db); + const actor = { type: "board", userId, source: "session" } as const; + + await expect(authorization.decide({ + actor, + action: "agent:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "company_scope:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" }); + await expect(authorization.decide({ + actor, + action: "runtime:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + await expect(authorization.decide({ + actor, + action: "secrets:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + }); + it("denies legacy board assignment context for viewers", async () => { const company = await createCompany(db, "BoardViewerAssignment"); const userId = `user-${randomUUID()}`; diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 157c5d3c..df7af2a5 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -958,6 +958,39 @@ export function authorizationService(db: Db) { } } if (!permissionKey) { + if ( + input.action === "agent:read" || + input.action === "company_scope:read" || + input.action === "issue:read" || + input.action === "project:read" || + input.action === "runtime:manage" || + input.action === "secrets:read" + ) { + const membership = await getActiveMembership(companyId, "user", input.actor.userId); + // Mirroring the tasks:assign carve-out above, viewers keep the + // read-only visibility actions but not the privileged ones. + const requiresNonViewer = + input.action === "runtime:manage" || input.action === "secrets:read"; + if (membership && (!requiresNonViewer || membership.membershipRole !== "viewer")) { + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by standard same-company board membership visibility.", + }); + } + 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}.`, + }); + } return deny({ action: input.action, reason: "deny_unsupported_action",