fix(server): allow board members the null-mapped visibility actions agents already get
permissionForAction() intentionally returns null for agent:read, company_scope:read, issue:read, project:read, runtime:manage and secrets:read (no explicit grant required), but the board-actor path's !permissionKey guard denied them all with deny_unsupported_action before any membership evaluation — board users with active company membership saw no agents on the Dashboard (filterAgentsForActor dropped every row). Mirror the agent actor path: board users with an active company membership are allowed exactly those six actions (allow_simple_company_member); without a membership they get deny_missing_membership. agent:wake and issue:mutate keep falling through to deny_unsupported_action — neither has a board analog today. Fixes #7890 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f3db7b88ea
commit
717cc0f076
|
|
@ -495,6 +495,121 @@ 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 decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "agent:wake",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_unsupported_action",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies legacy board assignment context for viewers", async () => {
|
||||
const company = await createCompany(db, "BoardViewerAssignment");
|
||||
const userId = `user-${randomUUID()}`;
|
||||
|
|
|
|||
|
|
@ -958,6 +958,28 @@ 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);
|
||||
if (membership) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_simple_company_member",
|
||||
explanation: "Allowed by standard same-company board membership visibility.",
|
||||
});
|
||||
}
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue