From dea8e86bf4f4f70de165c1f06f9767fc52b7c279 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 05:06:05 +0000 Subject: [PATCH] fix: close phase 4 connection QA gaps Co-Authored-By: Paperclip --- ...connection-grants-phase4-migration.test.ts | 68 +++++++--- .../migrations/0229_sticky_nightcrawler.sql | 126 ++++++++++++------ server/src/__tests__/access-service.test.ts | 43 +++++- .../src/__tests__/tool-access-service.test.ts | 64 ++++++++- server/src/middleware/error-handler.ts | 1 + server/src/services/access.ts | 11 +- server/src/services/tool-access.ts | 2 +- ui/src/api/tools.ts | 21 +++ ui/src/lib/queryKeys.ts | 2 + ui/src/pages/apps/AppDetail.test.tsx | 77 +++++++++++ ui/src/pages/apps/AppDetail.tsx | 70 +++++++++- ui/src/pages/apps/app-detail/SetupPanel.tsx | 80 ++++++++++- 12 files changed, 499 insertions(+), 66 deletions(-) diff --git a/packages/db/src/connection-grants-phase4-migration.test.ts b/packages/db/src/connection-grants-phase4-migration.test.ts index 139de8581b0..db0e3fe14fe 100644 --- a/packages/db/src/connection-grants-phase4-migration.test.ts +++ b/packages/db/src/connection-grants-phase4-migration.test.ts @@ -36,12 +36,12 @@ describe("connection grants phase 4 migration", () => { expect(migrationSql).toContain(`ON CONFLICT DO NOTHING`); }); - it("fails ambiguous personal-secret ownership closed with reauthorization guidance", () => { - expect(migrationSql).toContain(`Cannot migrate personal connection credential`); - expect(migrationSql).toContain(`Reauthorize the affected personal connection grants`); - expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") <> 1`); - expect(migrationSql).toContain(`other_g."kind" <> 'user'`); - expect(migrationSql).toContain(`jsonb_array_elements(c."credential_secret_refs")`); + it("remediates ambiguous personal-secret ownership without assigning an arbitrary owner", () => { + expect(migrationSql).toContain(`phase4_ambiguous_personal_secrets`); + expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") AS "owner_count"`); + expect(migrationSql).toContain(`organization_grant."kind" <> 'user'`); + expect(migrationSql).toContain(`"status" = 'needs_reauthorization'`); + expect(migrationSql).toContain(`"health_status" = 'missing_secret'`); }); }); @@ -109,7 +109,7 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () => ) `; } - return { companyId, secretId, ownerUserId: input.ownerUserIds[0]! }; + return { companyId, connectionId, secretId, ownerUserId: input.ownerUserIds[0]! }; } try { @@ -133,20 +133,56 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () => const ambiguous = await seedLegacyCredential({ ownerUserIds: ["alice", "bob"] }); await rewindMigration(); - await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow( - /legacy ownership is ambiguous/, - ); + await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined(); + expect(await sql<{ + status: string; + is_default: boolean; + credential_secret_refs: unknown[]; + }[]>` + SELECT "status", "is_default", "credential_secret_refs" + FROM "connection_grants" + WHERE "company_id" = ${ambiguous.companyId} + `).toEqual([ + { status: "needs_reauthorization", is_default: false, credential_secret_refs: [] }, + { status: "needs_reauthorization", is_default: false, credential_secret_refs: [] }, + ]); + expect(await sql<{ scope: string; owner_user_id: string | null }[]>` + SELECT "scope", "owner_user_id" + FROM "company_secrets" + WHERE "id" = ${ambiguous.secretId} + `).toEqual([{ scope: "company", owner_user_id: null }]); - await sql`DELETE FROM "company_secrets" WHERE "company_id" = ${ambiguous.companyId}`; - await sql`DELETE FROM "companies" WHERE "id" = ${ambiguous.companyId}`; - await seedLegacyCredential({ + const mixed = await seedLegacyCredential({ ownerUserIds: ["carol"], includeOrganizationGrant: true, includeConnectionReference: true, }); - await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow( - /legacy ownership is ambiguous/, - ); + await rewindMigration(); + await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined(); + expect(await sql<{ + status: string; + enabled: boolean; + health_status: string; + credential_secret_refs: unknown[]; + }[]>` + SELECT "status", "enabled", "health_status", "credential_secret_refs" + FROM "tool_connections" + WHERE "id" = ${mixed.connectionId} + `).toEqual([{ + status: "draft", + enabled: false, + health_status: "missing_secret", + credential_secret_refs: [], + }]); + expect(await sql<{ status: string; credential_secret_refs: unknown[] }[]>` + SELECT "status", "credential_secret_refs" + FROM "connection_grants" + WHERE "company_id" = ${mixed.companyId} + ORDER BY "kind" + `).toEqual([ + { status: "needs_reauthorization", credential_secret_refs: [] }, + { status: "needs_reauthorization", credential_secret_refs: [] }, + ]); } finally { await sql.end(); } diff --git a/packages/db/src/migrations/0229_sticky_nightcrawler.sql b/packages/db/src/migrations/0229_sticky_nightcrawler.sql index cebad244231..f2d522a316a 100644 --- a/packages/db/src/migrations/0229_sticky_nightcrawler.sql +++ b/packages/db/src/migrations/0229_sticky_nightcrawler.sql @@ -19,56 +19,104 @@ ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_dele CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx" ON "connection_grant_delegations" USING btree ("company_id","agent_id");--> statement-breakpoint CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq" ON "connection_grant_delegations" USING btree ("grant_id","agent_id"); --> statement-breakpoint -DO $$ -DECLARE - ambiguous_secret record; -BEGIN +-- A legacy company-scoped credential can only become user-scoped when exactly +-- one personal owner references it and no organization grant or connection +-- also references it. Ambiguous rows fail closed in-place: remove every live +-- reference, require reauthorization, and disable affected connections. This +-- keeps the instance bootable while ensuring the credential is never assigned +-- to an arbitrary user. +DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets"; +--> statement-breakpoint +CREATE TEMP TABLE "phase4_ambiguous_personal_secrets" ON COMMIT DROP AS +WITH personal_secret_owners AS ( SELECT s."id" AS "secret_id", - count(DISTINCT g."subject_user_id") AS "personal_owner_count", - count(*) FILTER (WHERE other_g."id" IS NOT NULL) AS "organization_reference_count", - count(*) FILTER (WHERE c."id" IS NOT NULL) AS "connection_reference_count" - INTO ambiguous_secret + s."company_id", + count(DISTINCT g."subject_user_id") AS "owner_count" FROM "company_secrets" s JOIN "connection_grants" g ON g."company_id" = s."company_id" AND g."kind" = 'user' CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") personal_ref - LEFT JOIN "connection_grants" other_g - ON other_g."company_id" = s."company_id" - AND other_g."kind" <> 'user' - AND EXISTS ( + WHERE s."scope" = 'company' + AND s."id"::text = personal_ref ->> 'secretId' + GROUP BY s."id", s."company_id" +) + SELECT owners."secret_id", owners."company_id" + FROM personal_secret_owners owners + WHERE owners."owner_count" <> 1 + OR EXISTS ( SELECT 1 - FROM jsonb_array_elements(other_g."credential_secret_refs") other_ref - WHERE other_ref ->> 'secretId' = s."id"::text + FROM "connection_grants" organization_grant + CROSS JOIN LATERAL jsonb_array_elements(organization_grant."credential_secret_refs") organization_ref + WHERE organization_grant."company_id" = owners."company_id" + AND organization_grant."kind" <> 'user' + AND organization_ref ->> 'secretId' = owners."secret_id"::text ) - LEFT JOIN "tool_connections" c - ON c."company_id" = s."company_id" - AND EXISTS ( + OR EXISTS ( SELECT 1 - FROM jsonb_array_elements(c."credential_secret_refs") connection_ref - WHERE connection_ref ->> 'secretId' = s."id"::text + FROM "tool_connections" connection + CROSS JOIN LATERAL jsonb_array_elements(connection."credential_secret_refs") connection_ref + WHERE connection."company_id" = owners."company_id" + AND connection_ref ->> 'secretId' = owners."secret_id"::text + ); +--> statement-breakpoint +WITH ambiguous_secrets AS ( + SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets" +) +UPDATE "connection_grants" grant_row +SET + "credential_secret_refs" = COALESCE(( + SELECT jsonb_agg(ref) + FROM jsonb_array_elements(grant_row."credential_secret_refs") ref + WHERE NOT EXISTS ( + SELECT 1 + FROM ambiguous_secrets ambiguous + WHERE ambiguous."company_id" = grant_row."company_id" + AND ref ->> 'secretId' = ambiguous."secret_id"::text ) - WHERE s."id"::text = personal_ref ->> 'secretId' - AND s."scope" = 'company' - GROUP BY s."id" - HAVING count(DISTINCT g."subject_user_id") <> 1 - OR count(*) FILTER (WHERE other_g."id" IS NOT NULL) > 0 - OR count(*) FILTER (WHERE c."id" IS NOT NULL) > 0 - LIMIT 1; - - IF FOUND THEN - RAISE EXCEPTION 'Cannot migrate personal connection credential %: legacy ownership is ambiguous', ambiguous_secret."secret_id" - USING ERRCODE = '23514', - DETAIL = format( - 'personal owners=%s, organization grant references=%s, connection references=%s', - ambiguous_secret."personal_owner_count", - ambiguous_secret."organization_reference_count", - ambiguous_secret."connection_reference_count" - ), - HINT = 'Reauthorize the affected personal connection grants with one credential per user before retrying this migration.'; - END IF; -END $$; + ), '[]'::jsonb), + "status" = 'needs_reauthorization', + "is_default" = false, + "updated_at" = now() +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(grant_row."credential_secret_refs") ref + JOIN ambiguous_secrets ambiguous + ON ambiguous."company_id" = grant_row."company_id" + AND ref ->> 'secretId' = ambiguous."secret_id"::text +); +--> statement-breakpoint +WITH ambiguous_secrets AS ( + SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets" +) +UPDATE "tool_connections" connection_row +SET + "credential_secret_refs" = COALESCE(( + SELECT jsonb_agg(ref) + FROM jsonb_array_elements(connection_row."credential_secret_refs") ref + WHERE NOT EXISTS ( + SELECT 1 + FROM ambiguous_secrets ambiguous + WHERE ambiguous."company_id" = connection_row."company_id" + AND ref ->> 'secretId' = ambiguous."secret_id"::text + ) + ), '[]'::jsonb), + "status" = 'draft', + "enabled" = false, + "health_status" = 'missing_secret', + "health_message" = 'Legacy personal credential ownership was ambiguous. Reauthorize this connection.', + "last_error" = 'oauth_reauthorization_required', + "updated_at" = now() +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(connection_row."credential_secret_refs") ref + JOIN ambiguous_secrets ambiguous + ON ambiguous."company_id" = connection_row."company_id" + AND ref ->> 'secretId' = ambiguous."secret_id"::text +); +--> statement-breakpoint +DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets"; --> statement-breakpoint INSERT INTO "user_secret_definitions" ( "company_id", "key", "name", "description", "provider", "managed_mode", diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index 73074ab485f..be8929434db 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -478,6 +478,47 @@ describeEmbeddedPostgres("access service", () => { .toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]); }); + it("destroys every owned personal secret on suspension, including unreferenced secrets", async () => { + const { company } = await createCompanyWithOwner(db); + const member = await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: `member-${randomUUID()}`, + status: "active", + membershipRole: "member", + }).returning().then((rows) => rows[0]!); + const definition = await db.insert(userSecretDefinitions).values({ + companyId: company.id, + key: `oauth-${randomUUID()}`, + name: "Personal OAuth token", + }).returning().then((rows) => rows[0]!); + const ownedSecrets = await db.insert(companySecrets).values({ + companyId: company.id, + scope: "user", + ownerUserId: member.principalId, + userSecretDefinitionId: definition.id, + key: `oauth-${randomUUID()}`, + name: "Orphaned personal credential", + }).returning(); + const otherUserSecret = await db.insert(companySecrets).values({ + companyId: company.id, + scope: "user", + ownerUserId: "another-user", + userSecretDefinitionId: definition.id, + key: `oauth-${randomUUID()}`, + name: "Another user's credential", + }).returning().then((rows) => rows[0]!); + + await accessService(db).updateMember(company.id, member.id, { status: "suspended" }); + + expect(await db.select().from(companySecrets).where(inArray( + companySecrets.id, + ownedSecrets.map((secret) => secret.id), + ))).toHaveLength(0); + expect(await db.select().from(companySecrets).where(eq(companySecrets.id, otherUserSecret.id))) + .toHaveLength(1); + }); + it("allows owner and admin role-default grants to manage environments", async () => { const { company, owner } = await createCompanyWithOwner(db); const access = accessService(db); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 1c0f2ae1549..e3dec4e51b7 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -912,6 +912,67 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("enforces delegation owner and manager permissions through the HTTP routes", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + await grantBoardUser(db, company.id, "alice", [], "member"); + await grantBoardUser(db, company.id, "mallory", [], "member"); + await grantBoardUser(db, company.id, "manager", ["tools:manage_connections"], "operator"); + const { connection } = await createBrokerConnection(db, company.id); + const grant = await db.insert(connectionGrants).values({ + companyId: company.id, + connectionId: connection.id, + kind: "user", + subjectUserId: "alice", + status: "active", + isDefault: false, + }).returning().then((rows) => rows[0]!); + + const nonOwner = await request(createRouteApp( + db, + boardSessionActor(company.id, "member", "mallory"), + )) + .post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`) + .send({ agentId: agent.id }); + expect(nonOwner.status).toBe(403); + + const created = await request(createRouteApp( + db, + boardSessionActor(company.id, "member", "alice"), + )) + .post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`) + .send({ agentId: agent.id }); + expect(created.status).toBe(201); + expect(created.body).toMatchObject({ grantId: grant.id, agentId: agent.id }); + + const unrelatedRevoke = await request(createRouteApp( + db, + boardSessionActor(company.id, "member", "mallory"), + )).delete( + `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`, + ); + expect(unrelatedRevoke.status).toBe(403); + + const managerRevoke = await request(createRouteApp( + db, + boardSessionActor(company.id, "operator", "manager"), + )).delete( + `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`, + ); + expect(managerRevoke.status).toBe(200); + expect(await db.select().from(connectionGrantDelegations).where(eq( + connectionGrantDelegations.id, + created.body.id, + ))).toHaveLength(0); + expect(await db.select().from(toolAccessAuditEvents).where(eq( + toolAccessAuditEvents.connectionId, + connection.id, + ))).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice" }), + expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager" }), + ])); + }); + it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); @@ -1038,6 +1099,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(inactiveOwner.status).toBe(403); expect(inactiveOwner.body).toMatchObject({ code: "grant_owner_membership_inactive", + remediation: { action: "restore_membership_or_reconnect" }, }); expect(inactiveOwner.body.error).toContain("not an active company member"); }); @@ -3714,7 +3776,7 @@ describeEmbeddedPostgres("tool access service", () => { issueId: issue.id, kind: "request_confirmation", status: "pending", - title: "Connect your account", + title: "Connect your Slack to continue", }); expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } }); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 922106a498c..18b4a44d83a 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -101,6 +101,7 @@ export function errorHandler( "connection_not_installed", "subject_not_permitted", "standing_delegation_required", + "grant_owner_membership_inactive", ]).has(typeof details?.code === "string" ? details.code : ""); recordResponsibleUserDenialFromHttpError(req, details); if (err.status >= 500) { diff --git a/server/src/services/access.ts b/server/src/services/access.ts index e5adcd3028e..46d47ade7b6 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -50,16 +50,17 @@ export function accessService(db: Db) { eq(connectionGrants.subjectUserId, userId), )); const grantIds = ownedGrants.map((grant) => grant.id); - const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) => - grant.credentialSecretRefs.map((ref) => ref.secretId), - ))]; - const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({ + // Membership removal is an ownership boundary, not merely a grant cleanup. + // Destroy every personal secret owned by the departing user, including + // orphaned rows that are not referenced by a current grant. Restricting the + // lookup to grant refs leaves recoverable credentials on disk and lets them + // reappear if the same identity is later reactivated. + const ownedSecrets = await tx.select({ id: companySecrets.id, }).from(companySecrets).where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, userId), - inArray(companySecrets.id, referencedSecretIds), )); const secretIds = ownedSecrets.map((secret) => secret.id); const removedDelegations = grantIds.length === 0 ? [] : await tx diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 9cd9b2f7812..92a7d690215 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2353,7 +2353,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} key: `connection:${input.connection.uid}:delegation:${input.ownerUserId}:${input.agentId}`, revisionId: input.connection.updatedAt.toISOString(), label: `Delegate ${input.connection.name}`, - href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup`, + href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup#personal-identity`, }, }; const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and( diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index a6bc260e4c5..0861344bb84 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -53,6 +53,8 @@ import type { UpdateToolMcpGateway, CreateToolTrustRuleFromActionRequest, ToolRedactedValueSummary, + ConnectionGrant, + ConnectionGrantDelegation, } from "@paperclipai/shared"; import { api } from "./client"; @@ -75,6 +77,10 @@ export type ToolPoliciesResponse = { policies: ToolPolicy[] }; export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] }; export type ToolGalleryResponse = { apps: AppDefinition[] }; export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] }; +export type ConnectionGrantsResponse = { + connection: { id: string; uid: string }; + grants: ConnectionGrant[]; +}; export type CreateGatewayTokenInput = Omit & { expiresAt?: string | Date | null; }; @@ -306,6 +312,21 @@ export const toolsApi = { `/tool-connections/${connectionId}/installs`, { installs }, ), + // --- Identity grants --- + listConnectionGrants: (connectionId: string) => + api.get(`/tool-connections/${connectionId}/grants`), + createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) => + api.post( + `/tool-connections/${connectionId}/grants/${grantId}/delegations`, + { agentId }, + ), + revokeConnectionGrantDelegation: ( + connectionId: string, + grantId: string, + delegationId: string, + ) => api.delete( + `/tool-connections/${connectionId}/grants/${grantId}/delegations/${delegationId}`, + ), createConnection: (companyId: string, input: CreateToolConnectionInput) => api.post(`/companies/${companyId}/tools/connections`, input), updateConnection: (connectionId: string, input: UpdateToolConnectionInput) => diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 21895c2e68b..baf8698cdd9 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -29,6 +29,8 @@ export const queryKeys = { connection: (connectionId: string) => ["tools", "connection", connectionId] as const, connectionInstalls: (connectionId: string) => ["tools", "connection", connectionId, "installs"] as const, + connectionGrants: (connectionId: string) => + ["tools", "connection", connectionId, "grants"] as const, catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const, connectionActivity: (connectionId: string) => ["tools", "connection", connectionId, "activity"] as const, diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 25d617f192f..e7b94bbec84 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -21,6 +21,9 @@ const finishAppMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const refreshCatalogMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); +const listConnectionGrantsMock = vi.hoisted(() => vi.fn()); +const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn()); +const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn()); const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const getSessionMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); @@ -50,6 +53,14 @@ vi.mock("@/api/tools", () => ({ archiveConnection: vi.fn(), refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId), startOAuth: (connectionId: string) => startOAuthMock(connectionId), + listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId), + createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) => + createConnectionGrantDelegationMock(connectionId, grantId, agentId), + revokeConnectionGrantDelegation: ( + connectionId: string, + grantId: string, + delegationId: string, + ) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId), reconnectConnection: vi.fn(), }, })); @@ -265,6 +276,16 @@ describe("AppDetail", () => { authorizationUrl: "https://example.test/oauth", expiresAt: "2026-07-10T00:00:00.000Z", }); + listConnectionGrantsMock.mockResolvedValue({ + connection: { id: "conn-1", uid: "conn-1" }, + grants: [], + }); + createConnectionGrantDelegationMock.mockResolvedValue({ + id: "delegation-1", + grantId: "grant-user", + agentId: "agent-1", + }); + revokeConnectionGrantDelegationMock.mockResolvedValue({}); listUserDirectoryMock.mockResolvedValue({ users: [] }); getSessionMock.mockResolvedValue({ user: { id: "user-1", name: "Dotta", image: null }, @@ -993,4 +1014,60 @@ describe("AppDetail", () => { expect(body.length).toBeGreaterThan(0); expect(body).not.toContain(authorizationUrl); }); + + it("lets the personal grant owner delegate autonomous access to a named agent", async () => { + getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "per_user" })); + listConnectionGrantsMock.mockResolvedValue({ + connection: { id: "conn-1", uid: "conn-1" }, + grants: [{ + id: "grant-user", + companyId: "company-1", + connectionId: "conn-1", + kind: "user", + subjectUserId: "user-1", + providerTenant: null, + credentialSecretRefs: [], + status: "active", + isDefault: false, + createdByAgentId: null, + createdByUserId: "user-1", + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + lastUsedAt: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + delegations: [], + }], + }); + + await renderAppDetail(); + + const trigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Allow autonomous access"); + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const agentCheckbox = document.body.querySelector('[aria-label="Allow Coder"]'); + expect(agentCheckbox).toBeTruthy(); + await act(async () => { + agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await act(async () => { + Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(createConnectionGrantDelegationMock).toHaveBeenCalledWith( + "conn-1", + "grant-user", + "agent-1", + ); + }); }); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 4f9742ce6c5..434e4e297a9 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -76,6 +76,11 @@ export function AppDetail() { queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId && !!activeTab, }); + const grantsQuery = useQuery({ + queryKey: queryKeys.tools.connectionGrants(connectionId), + queryFn: () => toolsApi.listConnectionGrants(connectionId), + enabled: !!connectionId && activeTab === "setup", + }); const installsQuery = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connectionId), queryFn: () => toolsApi.getConnectionInstalls(connectionId), @@ -104,7 +109,9 @@ export function AppDetail() { const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"), queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"), + enabled: !!selectedCompanyId && ( + activeTab === "setup" || activeTab === "permissions" || activeTab === "activity" + ), }); const activityQuery = useQuery({ queryKey: queryKeys.tools.connectionActivity(connectionId), @@ -120,7 +127,7 @@ export function AppDetail() { const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - enabled: activeTab === "activity", + enabled: activeTab === "setup" || activeTab === "activity", }); const connection = connectionQuery.data; @@ -299,6 +306,53 @@ export function AppDetail() { }), }); + const invalidateGrants = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); + }; + + const replaceDelegations = useMutation({ + mutationFn: async ({ + grantId, + currentDelegations, + agentIds, + }: { + grantId: string; + currentDelegations: Array<{ id: string; agentId: string }>; + agentIds: string[]; + }) => { + const desired = new Set(agentIds); + const existing = new Map(currentDelegations.map((delegation) => [delegation.agentId, delegation])); + await Promise.all([ + ...currentDelegations + .filter((delegation) => !desired.has(delegation.agentId)) + .map((delegation) => toolsApi.revokeConnectionGrantDelegation( + connectionId, + grantId, + delegation.id, + )), + ...agentIds + .filter((agentId) => !existing.has(agentId)) + .map((agentId) => toolsApi.createConnectionGrantDelegation(connectionId, grantId, agentId)), + ]); + }, + onSuccess: () => { + invalidateGrants(); + pushToast({ + title: "Autonomous access saved", + body: "Only the agents you selected can use your identity in autonomous runs.", + tone: "success", + }); + }, + onError: (error) => { + invalidateGrants(); + pushToast({ + title: "Couldn't save autonomous access", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); + }, + }); const removeApp = useMutation({ mutationFn: () => toolsApi.archiveConnection(connectionId), onSuccess: () => { @@ -472,6 +526,18 @@ export function AppDetail() { onUpdateConfig={(config) => updateConfig.mutate(config)} oauthStartDisabled={startOAuth.isPending} onStartOAuth={() => startOAuth.mutate()} + personalGrant={(grantsQuery.data?.grants ?? []).find((grant) => + grant.kind === "user" && grant.subjectUserId === sessionQuery.data?.user?.id + )} + agents={agents} + delegationLoading={grantsQuery.isLoading || sessionQuery.isLoading || agentsQuery.isLoading} + delegationError={grantsQuery.isError || sessionQuery.isError || agentsQuery.isError} + delegationPending={replaceDelegations.isPending} + onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({ + grantId: grant.id, + currentDelegations: grant.delegations ?? [], + agentIds, + })} /> void; oauthStartDisabled: boolean; + personalGrant?: ConnectionGrant; + agents?: AgentMultiSelectOption[]; + delegationLoading?: boolean; + delegationError?: boolean; + delegationPending?: boolean; + onReplaceDelegations?: (grant: ConnectionGrant, agentIds: string[]) => void; }) { const description = galleryEntry?.description ?? null; const oauth = connection.config?.oauth; @@ -58,11 +71,76 @@ export function SetupPanel({ onStart={onStartOAuth} /> )} + {connection.credentialPolicy === "per_user" && onReplaceDelegations && ( + + )} ); } +function PersonalDelegationSection({ + grant, + agents, + loading, + error, + pending, + onReplace, +}: { + grant?: ConnectionGrant; + agents: AgentMultiSelectOption[]; + loading: boolean; + error: boolean; + pending: boolean; + onReplace: (grant: ConnectionGrant, agentIds: string[]) => void; +}) { + if (loading) { + return

Loading autonomous access…

; + } + if (error) { + return

Couldn’t load autonomous access.

; + } + if (!grant || grant.status !== "active") return null; + + const delegatedAgentIds = new Set((grant.delegations ?? []).map((delegation) => delegation.agentId)); + const delegationCount = delegatedAgentIds.size; + + return ( +
+
+

Your identity

+

+ Select the named agents that may use your identity in autonomous runs. +

+
+ + Only selected agents can use this personal identity without you present. +

+ )} + onSave={(next) => onReplace(grant, [...next])} + /> +
+ ); +} + function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) { const raw = connection.config?.methodConfig; const config = raw && typeof raw === "object" && !Array.isArray(raw)