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:
Harshit Khemani
2026-06-12 06:28:46 +05:30
committed by GitHub
parent 7058d7b6c3
commit d7f2f88323
2 changed files with 194 additions and 0 deletions
@@ -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()}`;
+33
View File
@@ -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",