From e27eb756e690233cf3ae0b2bd6e0a0da26b9354d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 11:52:47 -0500 Subject: [PATCH] Preserve scoped policy audiences and Google consent choices Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 36 ++++++++++++++++++- .../src/routes/connection-management-authz.ts | 8 +++-- .../tool-access/oauth/authorization-start.ts | 17 +++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 963940c179d..06a357ab8e3 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -1,4 +1,4 @@ -import { defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared"; +import { googleWorkspaceSelectedScopes, defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared"; import { randomUUID, generateKeyPairSync } from "node:crypto"; import { join } from "node:path"; import { createServer } from "node:http"; @@ -321,6 +321,21 @@ describe("connection management with production default agents", () => { expect(await f.request("GET", `${path}/${hidden.id}`)).toMatchObject({ status: 404 }); }); + it("keeps narrow policy create, update, reorder, and delete within their actual audiences", async () => { + const f = await fixture(); + const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!; + await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + const path = `/companies/${f.companyId}/tools/policies`; + const created = await f.request("POST", path, { name: "Own target rule", policyType: "allow", selectors: { agentId: f.agentId } }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { description: "Retain the target" })).toMatchObject({ status: 200 }); + expect(await f.request("POST", `${path}/reorder`, { policyIds: [created.body.id] })).toMatchObject({ status: 200 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: {} })).toMatchObject({ status: 403 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: { agentId: other.id } })).toMatchObject({ status: 403 }); + expect(await f.request("POST", path, { name: "Company rule", policyType: "allow", selectors: {} })).toMatchObject({ status: 403 }); + expect(await f.request("DELETE", `${path}/${created.body.id}`)).toMatchObject({ status: 200 }); + }); + it("bounds and deduplicates resource selectors before per-resource database work", async () => { const f = await fixture(); const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); @@ -437,6 +452,25 @@ describe("connection management with production default agents", () => { expect(activity).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })])); }); + it("saves the addressed user's Google service choices before handing off browser consent", async () => { + const f = await fixture(); + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Selected Google services", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft", + config: { url: "https://1.1.1.1/mcp", sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace" } }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + const services = { gmail: "read" as const }; + const started = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true, workspaceServices: services }); + expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } }); + const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)); + expect((saved.config.oauth as { pendingWorkspaceServiceRequests: Record }).pendingWorkspaceServiceRequests).toEqual({ + [f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services), requestedAt: expect.any(String) }, + }); + const restored = await f.request("GET", `/tool-connections/${saved.id}`); + expect(restored).toMatchObject({ status: 200, body: { config: { oauth: { pendingWorkspaceServiceRequests: { + [f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services) }, + } } } } }); + expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, saved.id))).toHaveLength(0); + }); + it("discovers and invokes connection APIs through default production native bindings", async () => { const f = await fixture(); const definitions = await f.authority.definitions(); diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 251d7bb8261..8b0ac2718f8 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -119,6 +119,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai const extraResources: ConnectionManagementResource[] = []; const bindingTargets: { targetType: string; targetId: string }[] = []; const selectors: Record[] = []; + const policyAudiences: Record[] = []; + if (Object.hasOwn(body, "selectors")) policyAudiences.push(body.selectors ?? {}); const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds", "routineId", "routineIds"]; const seenSelectors = new Set(); const addSelector = (selected: Record) => { @@ -149,11 +151,11 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai for (const id of new Set(Array.isArray(body.policyIds) ? body.policyIds : [])) { const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(id))); if (policy && companyId && policy.companyId !== companyId && req.actor.type === "board") throw new HttpError(422, "Policies must belong to this company"); - bindCompany(policy); addSelector(policy.selectors); + bindCompany(policy); addSelector(policy.selectors); policyAudiences.push(policy.selectors); } if (req.params.policyId) { const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(req.params.policyId))); - bindCompany(policy); addSelector(policy.selectors); + bindCompany(policy); addSelector(policy.selectors); policyAudiences.push(policy.selectors); } if (action === "connections:runtime" && (req.params.slotId || req.params.id)) { const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, uuid(req.params.slotId ?? req.params.id))); @@ -309,7 +311,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai for (const member of Array.isArray(body.config?.members) ? body.config.members : []) addSelector(member.binding ?? {}); for (const binding of Array.isArray(body.bindings) ? body.bindings : []) addSelector(binding); if (req.method !== "GET" && (operation.includes("/tools/policies") || operation.includes("/trust-rules/")) && - selectors.some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) { + (policyAudiences.length ? policyAudiences : [{}]).some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) { const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId)); companyAgents.forEach(agent => targets.add(agent.id)); } diff --git a/server/src/services/tool-access/oauth/authorization-start.ts b/server/src/services/tool-access/oauth/authorization-start.ts index 58f511514b4..0972611c16b 100644 --- a/server/src/services/tool-access/oauth/authorization-start.ts +++ b/server/src/services/tool-access/oauth/authorization-start.ts @@ -181,6 +181,23 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies // An agent has no browser session. Reuse the saved setup; the addressed // human starts provider authorization in their own authenticated session. // No provider state or credentials are issued by this handoff. + if (sourceTemplateKey === "google-workspace" && (input.workspaceServices || input.scopes)) { + await input.actor.managementContext.reauthorize(); + const userId = input.actor.managementContext.responsibleUserId!; + await db.transaction(async tx => { + const [saved] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).for("no key update"); + if (!saved) throw notFound("Connection not found"); + const rawOauth = saved.config.oauth; + const oauth = rawOauth && typeof rawOauth === "object" && !Array.isArray(rawOauth) ? rawOauth as Record : {}; + const prior = oauth.pendingWorkspaceServiceRequests; + const pending = prior && typeof prior === "object" && !Array.isArray(prior) ? prior as Record : {}; + const requested = { ...pending, [userId]: { requestedScopes, requestedAt: now().toISOString() } }; + const config = { ...saved.config, oauth: { ...oauth, pendingWorkspaceServiceRequests: requested } }; + const transportOauth = saved.transportConfig.oauth; + const transportConfig = { ...saved.transportConfig, oauth: { ...(transportOauth && typeof transportOauth === "object" && !Array.isArray(transportOauth) ? transportOauth : {}), pendingWorkspaceServiceRequests: requested } }; + await tx.update(toolConnections).set({ config, transportConfig, updatedAt: now() }).where(eq(toolConnections.id, saved.id)); + }); + } const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)); if (!company) throw notFound("Company not found"); const destination = new URL(input.redirectUri);