mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Merge connection authority and saved consent fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: Preserve scoped policy audiences and Google consent choices
This commit is contained in:
commit
7eca024e1e
3 files changed
+57
-4
No files matched your search
@@ -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<string, unknown> }).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();
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
const policyAudiences: Record<string, unknown>[] = [];
|
||||
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<string>();
|
||||
const addSelector = (selected: Record<string, unknown>) => {
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> : {};
|
||||
const prior = oauth.pendingWorkspaceServiceRequests;
|
||||
const pending = prior && typeof prior === "object" && !Array.isArray(prior) ? prior as Record<string, unknown> : {};
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user