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