diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 18aea077210..14c227dd6ce 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -58,7 +58,7 @@ export interface AgentInstructionsBundle { export interface AgentAccessState { canAssignTasks: boolean; - taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; + taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none"; membership: CompanyMembership | null; grants: PrincipalPermissionGrant[]; } diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 218d653f8bc..2609322d50d 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1342,6 +1342,24 @@ describe.sequential("agent permission routes", () => { expect(res.body.access.taskAssignSource).toBe("explicit_grant"); }, 15_000); + it("reports simple-mode task assignment as enabled for active company agent members", async () => { + mockAccessService.listPrincipalGrants.mockResolvedValue([]); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); + + expect(res.status).toBe(200); + expect(res.body.access.canAssignTasks).toBe(true); + expect(res.body.access.taskAssignSource).toBe("simple_default"); + }, 15_000); + it("keeps task assignment enabled when agent creation privilege is enabled", async () => { mockAgentService.updatePermissions.mockResolvedValue({ ...baseAgent, diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index eb598eb1114..e17f0e35b5a 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -125,10 +125,12 @@ describeEmbeddedPostgres("authorization service", () => { grantedByUserId: "owner", }); - const decision = await authorizationService(db).decide({ - actor: { type: "board", userId, source: "session" }, + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "user", + principalId: userId, action: "tasks:assign", - resource: { type: "company", companyId: company.id }, + permissionKey: "tasks:assign", }); expect(decision).toMatchObject({ @@ -190,9 +192,10 @@ describeEmbeddedPostgres("authorization service", () => { expect(decision.explanation).toContain("Agent key cannot access another company"); }); - it("denies task assignment without a grant or legacy creator authority", async () => { - const company = await createCompany(db, "AssignmentDeny"); + it("allows simple-mode task assignment between same-company agents without explicit grants", async () => { + const company = await createCompany(db, "AssignmentDefault"); const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); await db.insert(companyMemberships).values({ companyId: company.id, principalType: "agent", @@ -204,14 +207,66 @@ describeEmbeddedPostgres("authorization service", () => { const decision = await authorizationService(db).decide({ actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, action: "tasks:assign", - resource: { type: "company", companyId: company.id }, + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + expect(decision.explanation).toContain("simple mode"); + }); + + it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => { + const company = await createCompany(db, "BoardAssignmentDefault"); + 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: "operator", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, source: "session" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + }); + + it("denies simple-mode assignment to a target agent from another company", async () => { + const sourceCompany = await createCompany(db, "AssignmentSource"); + const targetCompany = await createCompany(db, "AssignmentTarget"); + const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" }); + const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: sourceCompany.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, }); expect(decision).toMatchObject({ allowed: false, - reason: "deny_missing_grant", + reason: "deny_company_boundary", }); - expect(decision.explanation).toContain("Missing permission: tasks:assign"); }); it("preserves legacy CEO agent creator authority", async () => { @@ -240,16 +295,20 @@ describeEmbeddedPostgres("authorization service", () => { projectIds: [project.id], }); - const allowed = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, projectId: project.id, assigneeAgentId: targetAgent.id }, + permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: targetAgent.id }, }); - const denied = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, projectId: otherProject.id, assigneeAgentId: targetAgent.id }, + permissionKey: "tasks:assign_scope", scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id }, }); @@ -275,16 +334,20 @@ describeEmbeddedPostgres("authorization service", () => { managedSubtreeAgentIds: [managerAgent.id], }); - const allowed = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, assigneeAgentId: grandchildAgent.id }, + permissionKey: "tasks:assign_scope", scope: { assigneeAgentId: grandchildAgent.id }, }); - const denied = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, assigneeAgentId: outsideAgent.id }, + permissionKey: "tasks:assign_scope", scope: { assigneeAgentId: outsideAgent.id }, }); @@ -305,16 +368,20 @@ describeEmbeddedPostgres("authorization service", () => { assigneeAgentIds: [allowedTarget.id], }); - const allowed = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, assigneeAgentId: allowedTarget.id }, + permissionKey: "tasks:assign_scope", scope: { assigneeAgentId: allowedTarget.id }, }); - const denied = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, assigneeAgentId: deniedTarget.id }, + permissionKey: "tasks:assign_scope", scope: { assigneeAgentId: deniedTarget.id }, }); @@ -328,10 +395,12 @@ describeEmbeddedPostgres("authorization service", () => { const targetAgent = await createAgent(db, company.id); await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign"); - const decision = await authorizationService(db).decide({ - actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, action: "tasks:assign", - resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + permissionKey: "tasks:assign", scope: { assigneeAgentId: targetAgent.id }, }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 290627f262b..ce683cb9bde 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -501,6 +501,15 @@ export function agentRoutes( }; } + if (membership?.status === "active") { + return { + canAssignTasks: true, + taskAssignSource: "simple_default" as const, + membership, + grants, + }; + } + return { canAssignTasks: false, taskAssignSource: "none" as const, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 6ccd1862692..78bc98362c3 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -58,6 +58,7 @@ export type AuthorizationDecision = { | "allow_legacy_agent_creator" | "allow_self" | "allow_company_agent" + | "allow_simple_company_member" | "allow_manager_chain" | "deny_unauthenticated" | "deny_company_boundary" @@ -116,6 +117,10 @@ function scopeIncludesId(ids: string[], id: string | null | undefined) { return Boolean(id && ids.includes(id)); } +function isSimpleAssignableAgentStatus(status: string | null | undefined) { + return status !== "pending_approval" && status !== "terminated"; +} + async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) { if (rootAgentId === targetAgentId) return true; @@ -334,6 +339,7 @@ export function authorizationService(db: Db) { id: agents.id, companyId: agents.companyId, role: agents.role, + status: agents.status, reportsTo: agents.reportsTo, permissions: agents.permissions, }) @@ -342,6 +348,22 @@ export function authorizationService(db: Db) { .then((rows) => rows[0] ?? null); } + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { + if (resource.type !== "issue") return true; + if (resource.assigneeAgentId) { + const target = await loadAgent(resource.assigneeAgentId); + return Boolean( + target && + target.companyId === resource.companyId && + isSimpleAssignableAgentStatus(target.status), + ); + } + if (resource.assigneeUserId) { + return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId)); + } + return true; + } + async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { const rows = await db .select({ id: agents.id, reportsTo: agents.reportsTo }) @@ -423,6 +445,30 @@ export function authorizationService(db: Db) { explanation: "Board user id is required.", }); } + if (input.action === "tasks:assign") { + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + 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 simple mode company-wide task assignment default.", + }); + } + if (!input.actor.memberships && input.actor.companyIds?.includes(companyId)) { + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by legacy company membership context.", + }); + } + } if (!permissionKey) { return deny({ action: input.action, @@ -459,6 +505,37 @@ export function authorizationService(db: Db) { }); } + const actorAgent = await loadAgent(actorAgentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Actor agent was not found in the target company.", + }); + } + + if (input.action === "tasks:assign") { + if (!isSimpleAssignableAgentStatus(actorAgent.status)) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: "Actor agent is not active for simple mode task assignment.", + }); + } + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by simple mode company-wide task assignment default.", + }); + } + if (input.action === "issue:mutate") { const resource = input.resource.type === "issue" ? input.resource : null; if (resource?.assigneeAgentId === actorAgentId) { @@ -488,12 +565,7 @@ export function authorizationService(db: Db) { }); } - let deniedGrantDecision: AuthorizationDecision | null = null; - if (input.action === "tasks:assign") { - const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); - if (grantDecision.allowed) return grantDecision; - deniedGrantDecision = grantDecision; - } else if (permissionKey) { + if (permissionKey) { const grantDecision = await decidePrincipalGrant({ companyId, principalType: "agent", @@ -505,20 +577,10 @@ export function authorizationService(db: Db) { if (grantDecision.allowed) return grantDecision; } - const actorAgent = await loadAgent(actorAgentId); - if (!actorAgent || actorAgent.companyId !== companyId) { - return deny({ - action: input.action, - reason: "deny_company_boundary", - explanation: "Actor agent was not found in the target company.", - }); - } - if ( (input.action === "agents:create" || input.action === "agent_config:read" || input.action === "agent_config:update" || - input.action === "tasks:assign" || input.action === "tasks:manage_active_checkouts") && canCreateAgentsLegacy(actorAgent) ) { @@ -529,8 +591,6 @@ export function authorizationService(db: Db) { }); } - if (deniedGrantDecision) return deniedGrantDecision; - if ( input.action === "tasks:manage_active_checkouts" && input.resource.type === "issue" && diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index a803b8f730d..699e5631062 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1713,7 +1713,9 @@ function ConfigurationTab({ ? "Enabled automatically while this agent can create new agents." : taskAssignSource === "explicit_grant" ? "Enabled via explicit company permission grant." - : "Disabled unless explicitly granted."; + : taskAssignSource === "simple_default" + ? "Enabled by simple company-wide task assignment defaults." + : "Disabled unless explicitly granted."; return (