fix(server): allow board members the null-mapped visibility actions agents already get (#7890) (#7935)
## 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user