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" ||