diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index e17f0e35b5a..0b3ee35c5a1 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -218,6 +218,93 @@ describeEmbeddedPostgres("authorization service", () => { expect(decision.explanation).toContain("simple mode"); }); + it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => { + const company = await createCompany(db, "ProtectedAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Production deployment authority", + }, + managedBy: "paperclip-ee-permissions", + }, + }, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(decision.explanation).toContain("requires approval"); + }); + + it("requires an explicit grant before assigning to a private target agent", async () => { + const company = await createCompany(db, "PrivateAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + agentVisibility: { + mode: "private", + hiddenFromDefaultDirectory: true, + }, + assignmentPolicy: { + mode: "company_default", + protectedAgentRequiresApproval: false, + }, + protectedAgent: { + requiresApproval: false, + }, + managedBy: "paperclip-ee-permissions", + }, + }, + }); + + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentId: targetAgent.id, + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(denied.explanation).toContain("private"); + expect(allowed).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { permissionKey: "tasks:assign_scope" }, + }); + }); + 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()}`; diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 6413ba21625..f8f3777781f 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -274,6 +274,7 @@ describe("agent issue mutation checkout ownership", () => { vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.clearAllMocks(); + delete (mockAccessService as any).decide; mockAccessService.canUser.mockReset(); mockAccessService.hasPermission.mockReset(); mockAgentService.getById.mockReset(); @@ -828,4 +829,37 @@ describe("agent issue mutation checkout ownership", () => { }), ); }); + + it("uses the authorization decision path for assignment changes", async () => { + const decide = vi.fn(async () => ({ + allowed: false, + action: "tasks:assign", + reason: "deny_policy_restricted", + explanation: "Target agent requires approval before task assignment.", + })); + (mockAccessService as any).decide = decide; + mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: makeAgent(peerAgentId), + }); + + const app = await createApp(ownerActor()); + const res = await request(app) + .patch(`/api/issues/${issueId}`) + .send({ assigneeAgentId: peerAgentId }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("requires approval"); + expect(decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "tasks:assign", + resource: expect.objectContaining({ + type: "issue", + companyId, + issueId, + assigneeAgentId: peerAgentId, + }), + })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts index aa6ea950a7f..ab20f4f8b8a 100644 --- a/server/src/__tests__/plugin-access-authorization-host-services.test.ts +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -17,6 +17,7 @@ import { const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const pluginId = "plugin-record-id"; function createEventBusStub() { return { @@ -78,7 +79,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => }) .returning() .then((rows) => rows[0]!); - const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); await expect( services.authorization.setGrants({ @@ -96,7 +97,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => { const company = await createCompany(db, "PAZ"); - const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); const created = await services.access.createInvite({ companyId: company.id, @@ -123,7 +124,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => it("filters authorization audit entries by allow or deny decision details", async () => { const company = await createCompany(db, "PAU"); - const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); await db.insert(activityLog).values([ { companyId: company.id, @@ -145,16 +146,18 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => }, ]); - const allowed = await services.authorization.searchAudit({ - companyId: company.id, - action: "authorization.assignment_preview", - decision: "allow", - }); - const denied = await services.authorization.searchAudit({ - companyId: company.id, - action: "authorization.assignment_preview", - decision: "deny", - }); + const [allowed, denied] = await Promise.all([ + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "allow", + }), + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "deny", + }), + ]); expect(allowed).toHaveLength(1); expect(allowed[0]!.entityId).toBe("issue-1"); @@ -163,4 +166,79 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => expect(denied[0]!.entityId).toBe("issue-2"); services.dispose(); }); + + it("uses persisted agent policy for plugin assignment preview and explanation", async () => { + const company = await createCompany(db, "PAP"); + const [actorAgent, targetAgent] = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: "Actor agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + { + companyId: company.id, + name: "Protected target", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + ]) + .returning(); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent!.id, + status: "active", + membershipRole: "member", + }); + + const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); + const updatedPolicy = await services.authorization.updatePolicy({ + companyId: company.id, + resourceType: "agent", + resourceId: targetAgent!.id, + policy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Needs board approval", + }, + managedBy: "paperclip-ee-permissions", + }, + }); + const input = { + companyId: company.id, + actor: { + type: "agent" as const, + agentId: actorAgent!.id, + companyId: company.id, + source: "agent_key" as const, + }, + target: { assigneeAgentId: targetAgent!.id }, + }; + const [policy, preview, explanation] = await Promise.all([ + Promise.resolve(updatedPolicy), + services.authorization.previewAssignment(input), + services.authorization.explainAssignment(input), + ]); + + expect(policy.policy).toMatchObject({ + protectedAgent: { requiresApproval: true }, + }); + expect(preview).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(explanation).toMatchObject(preview); + services.dispose(); + }); }); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 0c19848822f..52fe1c84750 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).not.toHaveBeenCalled(); }); + it("forwards authorized bridge company scope to the plugin worker", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/health`) + .send({ companyId: companyA, params: { view: "compact" } }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "getData", { + key: "health", + companyId: companyA, + params: { view: "compact" }, + renderEnvironment: null, + }); + }); + it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => { readyPlugin(); const call = vi.fn().mockResolvedValue({ ok: true }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 43511895698..ebcb20c9e76 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1253,6 +1253,7 @@ export function issueRoutes( } type TaskAssignmentAuthorizationScope = { + issueId?: string | null; projectId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; @@ -1285,6 +1286,7 @@ export function issueRoutes( resource: { type: "issue", companyId, + issueId: assignmentScope?.issueId ?? null, projectId: assignmentScope?.projectId ?? null, parentIssueId: assignmentScope?.parentIssueId ?? null, assigneeAgentId: assignmentScope?.assigneeAgentId ?? null, @@ -3698,6 +3700,7 @@ export function issueRoutes( if (assigneeWillChange && !transition.workflowControlledAssignment) { if (!isAgentReturningIssueToCreator) { await assertCanAssignTasks(req, existing.companyId, { + issueId: existing.id, projectId: await resolveAssignmentProjectId({ companyId: existing.companyId, projectId: updateFields.projectId === undefined @@ -4484,6 +4487,7 @@ export function issueRoutes( if (issue.assigneeAgentId !== req.body.agentId) { await assertCanAssignTasks(req, issue.companyId, { + issueId: issue.id, projectId: issue.projectId ?? null, parentIssueId: issue.parentId ?? null, assigneeAgentId: req.body.agentId, diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index a0379c92e07..97cd13bf584 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -18,7 +18,9 @@ export function normalizeAgentPermissions( } const record = permissions as Record; + const preserved = { ...record }; return { + ...preserved, canCreateAgents: typeof record.canCreateAgents === "boolean" ? record.canCreateAgents diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 0d4762cc9d4..faf63723673 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -554,7 +554,7 @@ export function agentService(db: Db) { const updated = await db .update(agents) .set({ - permissions: normalizeAgentPermissions(permissions, existing.role), + permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role), updatedAt: new Date(), }) .where(eq(agents.id, id)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 78bc98362c3..e3760146ea5 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -4,7 +4,9 @@ import { agents, companyMemberships, instanceUserRoles, + issues, principalPermissionGrants, + projects, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; @@ -64,6 +66,7 @@ export type AuthorizationDecision = { | "deny_company_boundary" | "deny_missing_membership" | "deny_missing_grant" + | "deny_policy_restricted" | "deny_scope" | "deny_unsupported_action"; grant?: { @@ -121,6 +124,104 @@ function isSimpleAssignableAgentStatus(status: string | null | undefined) { return status !== "pending_approval" && status !== "terminated"; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function objectIsEmpty(value: Record) { + return Object.keys(value).length === 0; +} + +function readPolicyObject(container: unknown, key: string): Record | null { + if (!isPlainRecord(container)) return null; + const value = container[key]; + return isPlainRecord(value) ? value : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +type AssignmentPolicyEffect = + | { kind: "none" } + | { kind: "restricted"; explanation: string } + | { kind: "requires_approval"; explanation: string } + | { kind: "unknown"; explanation: string }; + +function evaluateAuthorizationPolicyForAssignment( + policy: Record | null | undefined, + label: string, +): AssignmentPolicyEffect { + if (!policy || objectIsEmpty(policy)) return { kind: "none" }; + + const agentVisibility = readPolicyObject(policy, "agentVisibility"); + const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy"); + const protectedAgent = readPolicyObject(policy, "protectedAgent"); + const knownTopLevelKeys = new Set([ + "agentVisibility", + "assignmentPolicy", + "protectedAgent", + "managedBy", + ]); + const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key)); + const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent); + if (hasUnknownTopLevelKey || !hasKnownPolicySection) { + return { + kind: "unknown", + explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`, + }; + } + + const visibilityMode = readString(agentVisibility?.mode); + if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") { + return { + kind: "unknown", + explanation: `${label} has an unsupported agent visibility policy mode.`, + }; + } + + const assignmentMode = readString(assignmentPolicy?.mode); + if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") { + return { + kind: "unknown", + explanation: `${label} has an unsupported assignment policy mode.`, + }; + } + + const requiresApproval = + readBoolean(protectedAgent?.requiresApproval) === true || + readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true; + if (requiresApproval) { + return { + kind: "requires_approval", + explanation: `${label} requires approval before task assignment.`, + }; + } + + if ( + visibilityMode === "private" || + readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true + ) { + return { + kind: "restricted", + explanation: `${label} is private and cannot use simple company-wide task assignment.`, + }; + } + + if (assignmentMode === "protected") { + return { + kind: "restricted", + explanation: `${label} is protected and requires an explicit assignment grant.`, + }; + } + + return { kind: "none" }; +} + async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) { if (rootAgentId === targetAgentId) return true; @@ -348,6 +449,24 @@ export function authorizationService(db: Db) { .then((rows) => rows[0] ?? null); } + async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) { + const row = await db + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy"); + } + + async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) { + const row = await db + .select({ executionPolicy: issues.executionPolicy }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionPolicy, "authorizationPolicy"); + } + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { if (resource.type !== "issue") return true; if (resource.assigneeAgentId) { @@ -364,6 +483,52 @@ export function authorizationService(db: Db) { return true; } + async function assignmentPolicyEffect(resource: AuthorizationResource): Promise { + if (resource.type !== "issue") return { kind: "none" }; + + const checks: Array> = []; + if (resource.assigneeAgentId) { + checks.push( + loadAgent(resource.assigneeAgentId).then((agent) => + evaluateAuthorizationPolicyForAssignment( + readPolicyObject(agent?.permissions, "authorizationPolicy"), + "Target agent", + ), + ), + ); + } + if (resource.projectId) { + checks.push( + loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target project"), + ), + ); + } + if (resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target issue"), + ), + ); + } + if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"), + ), + ); + } + if (checks.length === 0) return { kind: "none" }; + + const effects = await Promise.all(checks); + return ( + effects.find((effect) => effect.kind === "unknown") ?? + effects.find((effect) => effect.kind === "requires_approval") ?? + effects.find((effect) => effect.kind === "restricted") ?? + { kind: "none" } + ); + } + async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { const rows = await db .select({ id: agents.id, reportsTo: agents.reportsTo }) @@ -415,6 +580,28 @@ export function authorizationService(db: Db) { return broadDecision; } + async function denyForAssignmentPolicyIfNeeded( + policyEffect: AssignmentPolicyEffect, + ): Promise { + if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null; + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: policyEffect.explanation, + }); + } + + function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision { + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: + policyEffect.kind === "restricted" + ? policyEffect.explanation + : "Restrictive authorization policy blocks simple company-wide task assignment.", + }); + } + if (input.actor.type === "none") { return deny({ action: input.action, @@ -453,15 +640,18 @@ export function authorizationService(db: Db) { explanation: "Task assignment target agent is not active in the target company.", }); } + const policyEffect = await assignmentPolicyEffect(input.resource); + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; const membership = await getActiveMembership(companyId, "user", input.actor.userId); - if (membership && membership.membershipRole !== "viewer") { + if (policyEffect.kind === "none" && 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)) { + if (policyEffect.kind === "none" && !input.actor.memberships && input.actor.companyIds?.includes(companyId)) { return allow({ action: input.action, reason: "allow_simple_company_member", @@ -477,7 +667,11 @@ export function authorizationService(db: Db) { }); } if (input.action === "tasks:assign") { - return decideWithTaskAssignmentGrants("user", input.actor.userId); + const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId); + if (grantDecision.allowed) return grantDecision; + const policyEffect = await assignmentPolicyEffect(input.resource); + if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect); + return grantDecision; } return decidePrincipalGrant({ companyId, @@ -529,6 +723,14 @@ export function authorizationService(db: Db) { explanation: "Task assignment target agent is not active in the target company.", }); } + const policyEffect = await assignmentPolicyEffect(input.resource); + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; + if (policyEffect.kind === "restricted") { + const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); + if (grantDecision.allowed) return grantDecision; + return denyRestrictedAssignmentPolicy(policyEffect); + } return allow({ action: input.action, reason: "allow_simple_company_member", diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 9f3142d9cfd..22ccac212e6 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -2443,7 +2443,9 @@ export function buildHostServices( action: "tasks:assign", resource: { type: "issue", companyId, ...params.target }, scope: { + issueId: params.target.issueId ?? null, projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, assigneeAgentId: params.target.assigneeAgentId ?? null, assigneeUserId: params.target.assigneeUserId ?? null, }, @@ -2457,7 +2459,9 @@ export function buildHostServices( action: "tasks:assign", resource: { type: "issue", companyId, ...params.target }, scope: { + issueId: params.target.issueId ?? null, projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, assigneeAgentId: params.target.assigneeAgentId ?? null, assigneeUserId: params.target.assigneeUserId ?? null, },