diff --git a/packages/db/src/migrations/0183_connection_user_authorization_state.sql b/packages/db/src/migrations/0183_connection_user_authorization_state.sql new file mode 100644 index 0000000000..d6ca6f02fa --- /dev/null +++ b/packages/db/src/migrations/0183_connection_user_authorization_state.sql @@ -0,0 +1,6 @@ +ALTER TABLE "tool_oauth_states" ADD COLUMN "subject_user_id" text;--> statement-breakpoint +ALTER TABLE "tool_oauth_states" ADD COLUMN "requested_scopes" jsonb;--> statement-breakpoint +ALTER TABLE "tool_oauth_states" ADD COLUMN "return_to" text;--> statement-breakpoint +ALTER TABLE "tool_oauth_states" ADD COLUMN "issue_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_oauth_states" ADD COLUMN "interaction_id" uuid;--> statement-breakpoint +CREATE INDEX "tool_oauth_states_subject_user_idx" ON "tool_oauth_states" USING btree ("company_id", "subject_user_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 0a06c52f30..d701d6b235 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1268,6 +1268,13 @@ "when": 1784592000000, "tag": "0182_connections_v3_schema_core", "breakpoints": true + }, + { + "idx": 183, + "version": "7", + "when": 1784653200000, + "tag": "0183_connection_user_authorization_state", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 5c3e4213fa..a9881d1e8d 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -220,6 +220,11 @@ export const toolOauthStates = pgTable( createdByActorType: text("created_by_actor_type"), createdByActorId: text("created_by_actor_id"), createdBySessionId: text("created_by_session_id"), + subjectUserId: text("subject_user_id"), + requestedScopes: jsonb("requested_scopes").$type(), + returnTo: text("return_to"), + issueId: uuid("issue_id"), + interactionId: uuid("interaction_id"), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 488d2c4ef6..e76366fe0d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1777,6 +1777,8 @@ export { toolTrustRuleBatchApprovalSchema, toolTrustRuleScopeSchema, connectionTokenRequestSchema, + connectionTokenSubjectSchema, + startConnectionAuthorizationSchema, toolConnectionTestCallSchema, toolPolicyTestRequestSchema, importMcpJsonSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 982f68c31d..33f40ee74a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -400,12 +400,19 @@ export type { ToolConnectionInstallSnapshot, ToolConnectionInstallTargetType, ConnectionTokenAttribution, + ConnectionRecoverableErrorCode, + ConnectionRecoverableErrorPayload, ConnectionTokenIssuance, ConnectionTokenMintedResponse, ConnectionTokenRequest, ConnectionTokenResponse, ConnectionTokenScope, + ConnectionTokenSubject, ConnectionTokenUseEnvLeaseResponse, + ConnectionUsageDailyBucket, + ConnectionUsageResponse, + StartConnectionAuthorizationRequest, + StartConnectionAuthorizationResponse, ToolConnection, ToolConnectionHealthStatus, ToolConnectionAuthKind, @@ -498,6 +505,7 @@ export type { ToolConnectionTestCallStatus, ToolConnectionTestCallStatusPhase, } from "./tool-access.js"; +export { CONNECTION_RECOVERABLE_ERROR_CODES } from "./tool-access.js"; export type { IssueWorkProduct, IssueWorkProductType, diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 8dd42f5a9c..bd85144268 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -191,10 +191,31 @@ export interface ToolConnectionInstallSnapshot { } export type ConnectionTokenScope = string | string[]; +export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: string }; + +export const CONNECTION_RECOVERABLE_ERROR_CODES = [ + "user_authorization_required", + "grant_revoked", + "needs_reauthorization", + "installation_required", + "connection_not_installed", + "subject_not_permitted", +] as const; + +export type ConnectionRecoverableErrorCode = typeof CONNECTION_RECOVERABLE_ERROR_CODES[number]; + +export interface ConnectionRecoverableErrorPayload { + code: ConnectionRecoverableErrorCode; + connection: { uid: string }; + subject?: ConnectionTokenSubject; + remediation?: Record; +} export interface ConnectionTokenRequest { + subject?: ConnectionTokenSubject; scope?: ConnectionTokenScope; requestedTtlSeconds?: number; + grantId?: string; } export interface ConnectionTokenAttribution { @@ -208,6 +229,11 @@ export interface ConnectionTokenAttribution { export interface ConnectionTokenMintedResponse { status: "minted"; connectionId: string; + connection: { id: string; uid: string }; + grantId: string; + providerTenantId?: string; + externalSubject?: string; + metadata?: Record; path: "exchange"; token: string; tokenType: "Bearer" | string; @@ -221,6 +247,8 @@ export interface ConnectionTokenUseEnvLeaseResponse { status: "use_env_lease"; code: "use_env_lease"; connectionId: string; + connection: { id: string; uid: string }; + grantId: string; path: "static"; message: string; scope: string[]; @@ -229,6 +257,29 @@ export interface ConnectionTokenUseEnvLeaseResponse { export type ConnectionTokenResponse = ConnectionTokenMintedResponse | ConnectionTokenUseEnvLeaseResponse; +export interface StartConnectionAuthorizationRequest { + subjectUserId: string; + scopes?: string[]; + returnTo?: string; +} + +export interface StartConnectionAuthorizationResponse { + url: string; +} + +export interface ConnectionUsageDailyBucket { + date: string; + issuances: { total: number; byOutcome: Record; byPath: Record }; + invocations: { total: number; byRiskLevel: Record }; + deliveries: { received: number; forwarded: number }; +} + +export interface ConnectionUsageResponse { + connection: { id: string; uid: string }; + range: "7d" | "30d"; + buckets: ConnectionUsageDailyBucket[]; +} + export interface ConnectionTokenIssuance { id: string; companyId: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b6586dd05b..38bb67c9a7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -793,6 +793,8 @@ export { connectionTokenIssuancePathSchema, connectionTokenRequestSchema, connectionTokenScopeSchema, + connectionTokenSubjectSchema, + startConnectionAuthorizationSchema, createToolTrustRuleFromActionRequestSchema, revokeToolTrustRuleSchema, toolPolicyTestRequestSchema, diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index ad8763f0af..3ed4ed968a 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -1,12 +1,30 @@ import { describe, expect, it } from "vitest"; import { + connectionTokenRequestSchema, createToolConnectionSchema, + startConnectionAuthorizationSchema, toolCredentialSecretRefSchema, toolRedactedValueSummarySchema, toolTransportConfigSchema, } from "./tool-access.js"; describe("tool access validators", () => { + it("defaults connection token subjects to app", () => { + expect(connectionTokenRequestSchema.parse({})).toEqual({ subject: { type: "app" } }); + }); + + it("accepts user subjects, grant selection, and authorization input", () => { + const request = connectionTokenRequestSchema.parse({ + subject: { type: "user", userId: "user-123" }, + grantId: "11111111-1111-4111-8111-111111111111", + }); + expect(request.subject).toEqual({ type: "user", userId: "user-123" }); + expect(startConnectionAuthorizationSchema.parse({ subjectUserId: "user-123", scopes: ["read"] })).toEqual({ + subjectUserId: "user-123", + scopes: ["read"], + }); + }); + it("accepts multi-key credential annotations", () => { const parsed = toolCredentialSecretRefSchema.parse({ secretId: "11111111-1111-4111-8111-111111111111", diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 83e3b86e69..fc2f2f7964 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -210,9 +210,22 @@ export const connectionTokenScopeSchema = z.union([ z.array(z.string().trim().min(1).max(240)).max(100), ]); +export const connectionTokenSubjectSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("app") }).strict(), + z.object({ type: z.literal("user"), userId: z.string().trim().min(1).max(500) }).strict(), +]); + export const connectionTokenRequestSchema = z.object({ + subject: connectionTokenSubjectSchema.optional().default({ type: "app" }), scope: connectionTokenScopeSchema.optional(), requestedTtlSeconds: z.number().int().positive().max(86_400).optional(), + grantId: z.string().uuid().optional(), +}).strict(); + +export const startConnectionAuthorizationSchema = z.object({ + subjectUserId: z.string().trim().min(1).max(500), + scopes: z.array(z.string().trim().min(1).max(240)).max(100).optional(), + returnTo: z.string().trim().max(2000).optional(), }).strict(); export type ConnectionTokenRequestInput = z.infer; diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index f5b3897936..d3e8f799a4 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -9,6 +9,7 @@ import { companies, companyMemberships, companySecretBindings, + connectionGrants, connectionTokenIssuances, companySecrets, companySecretVersions, @@ -495,7 +496,7 @@ describeEmbeddedPostgres("tool access service", () => { }); const res = await request(app) - .post(`/api/agents/me/connections/${connection.id}/token`) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta", requestedTtlSeconds: 5000 }); @@ -503,6 +504,8 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.body).toMatchObject({ status: "minted", connectionId: connection.id, + connection: { id: connection.id, uid: connection.uid }, + grantId: expect.any(String), path: "exchange", token: "child-pages-token", tokenType: "Bearer", @@ -539,6 +542,157 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id, { + parentScopes: ["staging", "production"], + }); + const productionSecret = await secretService(db).create(company.id, { + provider: "local_encrypted", + name: `Production broker parent ${randomUUID()}`, + key: `broker.production.${randomUUID()}`, + value: "production-deploy-token", + }); + await db.update(toolConnections).set({ + config: { + ...connection.config, + tokenBroker: { + ...(connection.config.tokenBroker as Record), + parentCredentialConfigPath: "credentials.production_token", + }, + }, + credentialSecretRefs: [ + ...connection.credentialSecretRefs, + { + secretId: productionSecret.id, + versionSelector: "latest", + configPath: "credentials.production_token", + required: true, + label: "Production deploy token", + keyScope: "production", + }, + ], + updatedAt: new Date(), + }).where(eq(toolConnections.id, connection.id)); + await db.insert(companySecretBindings).values({ + companyId: company.id, + secretId: productionSecret.id, + targetType: "tool_connection", + targetId: connection.id, + configPath: "credentials.production_token", + }); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ + token: "unexpected-production-token", + expiresAt: new Date(Date.now() + 900_000).toISOString(), + scope: "staging", + }), + } as Response); + + const res = await request(app) + .post(`/api/agents/me/connections/${connection.id}/token`) + .send({ scope: "staging" }); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ code: "parent_credential_missing" }); + expect(fetchMock).not.toHaveBeenCalled(); + const productionSecretEvents = await db.select().from(secretAccessEvents).where(and( + eq(secretAccessEvents.consumerId, connection.id), + eq(secretAccessEvents.configPath, "credentials.production_token"), + )); + expect(productionSecretEvents).toHaveLength(0); + + fetchMock.mockClear(); + fetchMock.mockImplementation(async (_url, init) => { + expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer production-deploy-token" })); + return { + ok: true, + status: 201, + json: async () => ({ + token: "production-child-token", + expiresAt: new Date(Date.now() + 900_000).toISOString(), + scope: "production", + }), + } as Response; + }); + + const productionRes = await request(app) + .post(`/api/agents/me/connections/${connection.id}/token`) + .send({ scope: ["production"] }); + + expect(productionRes.status).toBe(200); + expect(productionRes.body).toMatchObject({ token: "production-child-token", scope: ["production"] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("returns typed subject errors and rejects revoked grants immediately", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + + const denied = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .send({ subject: { type: "user", userId: "someone-else" } }); + expect(denied.status).toBe(403); + expect(denied.body).toMatchObject({ + code: "subject_not_permitted", + connection: { uid: connection.uid }, + subject: { type: "user", userId: "someone-else" }, + }); + + const missing = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .send({ subject: { type: "user", userId: "user-for-run" } }); + expect(missing.status).toBe(409); + expect(missing.body).toMatchObject({ code: "user_authorization_required", remediation: { action: "start_authorization" } }); + + const service = toolAccessService(db); + const grant = await service.addConnectionInstallation(connection.id, { isDefault: false }); + await service.revokeConnectionGrant(connection.id, grant.id); + const revoked = await request(app) + .post(`/api/agents/me/connections/${connection.id}/token`) + .send({ grantId: grant.id }); + expect(revoked.status).toBe(409); + expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id }); + }); + + it("returns daily connection usage buckets", async () => { + const company = await createCompany(db); + const { connection } = await createBrokerConnection(db, company.id); + const service = toolAccessService(db); + await db.insert(connectionTokenIssuances).values({ + companyId: company.id, + applicationId: connection.applicationId, + connectionId: connection.id, + agentId: (await createAgent(db, company.id)).id, + path: "exchange", + requestedScope: [], + issuedScope: [], + outcome: "success", + }); + await db.insert(toolInvocations).values({ + companyId: company.id, + connectionId: connection.id, + toolName: "fixture", + riskLevel: "write", + }); + const usage = await service.getConnectionUsage(connection.uid, "7d", company.id); + expect(usage.connection).toEqual({ id: connection.id, uid: connection.uid }); + expect(usage.buckets.at(-1)).toMatchObject({ + issuances: { total: 1, byOutcome: { success: 1 }, byPath: { exchange: 1 } }, + invocations: { total: 1, byRiskLevel: { write: 1 } }, + }); + }); + it("rejects connection token minting after the heartbeat run completes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); @@ -2722,6 +2876,92 @@ describeEmbeddedPostgres("tool access service", () => { expect(updated.transportConfig).toEqual(updated.config); }); + it("creates and resolves an agent-initiated user authorization grant card", async () => { + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { issue, run } = await createIssueAndRun(db, company.id, agent.id); + const service = toolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack user auth" }); + + const workspaceStarted = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "workspace-owner" }, + }); + const workspaceState = new URL(workspaceStarted.authorizationUrl).searchParams.get("state")!; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + if (href === "https://slack.com/api/oauth.v2.access") { + const body = init?.body as URLSearchParams; + const userAuthorization = body.get("code") === "user-authorization-code"; + return { + ok: true, + status: 200, + json: async () => ({ + access_token: userAuthorization ? "user-access-token" : "workspace-access-token", + refresh_token: userAuthorization ? "user-refresh-token" : "workspace-refresh-token", + expires_in: 3600, + }), + } as Response; + } + if (href === "https://mcp.slack.com/mcp") { + return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } }); + } + throw new Error(`unexpected fetch ${href}`); + }); + await service.completeOAuthCallback({ + state: workspaceState, + code: "workspace-authorization-code", + redirectUri: "https://paperclip.example/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "workspace-owner" }, + }); + const [workspaceConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + const workspaceSecretIds = workspaceConnection.credentialSecretRefs.map((ref) => ref.secretId).sort(); + + const started = await service.startAuthorizationForAgent({ + companyId: company.id, + connectionId: connected.connectionId, + agentId: agent.id, + runId: run.id, + subjectUserId: "user-for-run", + scopes: ["users:read"], + redirectUri: "https://paperclip.example/api/tools/oauth/callback", + }); + const authorizationUrl = new URL(started.authorizationUrl); + expect(authorizationUrl.searchParams.get("scope")).toBe("users:read"); + + const [state] = await db.select().from(toolOauthStates); + expect(state).toMatchObject({ subjectUserId: "user-for-run", issueId: issue.id, requestedScopes: ["users:read"] }); + const [interaction] = await db.select().from(issueThreadInteractions); + expect(interaction).toMatchObject({ + issueId: issue.id, + kind: "request_confirmation", + status: "pending", + title: "Connect your account", + }); + expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } }); + + await service.completeOAuthCallback({ + state: state.state, + code: "user-authorization-code", + redirectUri: "https://paperclip.example/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "user-for-run" }, + }); + + const [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.connectionId, connected.connectionId), + eq(connectionGrants.subjectUserId, "user-for-run"), + )); + expect(grant).toMatchObject({ kind: "user", status: "active" }); + expect(grant.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual(["oauth.access_token", "oauth.refresh_token"]); + expect(grant.credentialSecretRefs.map((ref) => ref.secretId).sort()).not.toEqual(workspaceSecretIds); + const [unchangedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect(unchangedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual(workspaceSecretIds); + const [resolved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id)); + expect(resolved).toMatchObject({ status: "accepted", result: { version: 1, outcome: "accepted" } }); + }); + it("starts and completes OAuth app sign-in with PKCE state and secret-backed tokens", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 4656482ac8..8937dfdcbc 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -83,6 +83,14 @@ export function errorHandler( ? err.details as Record : null; const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details); + const structuredConnectionError = new Set([ + "user_authorization_required", + "grant_revoked", + "needs_reauthorization", + "installation_required", + "connection_not_installed", + "subject_not_permitted", + ]).has(typeof details?.code === "string" ? details.code : ""); recordResponsibleUserDenialFromHttpError(req, details); if (err.status >= 500) { attachErrorContext( @@ -98,7 +106,12 @@ export function errorHandler( error: err.message, ...(typeof details?.code === "string" ? { code: details.code } : {}), ...(redactedSkillPolicyDenial && typeof details?.reason === "string" ? { reason: details.reason } : {}), - ...(typeof details?.remediation === "string" ? { remediation: details.remediation } : {}), + ...(typeof details?.remediation === "string" || (structuredConnectionError && details?.remediation && typeof details.remediation === "object") + ? { remediation: details.remediation } + : {}), + ...(structuredConnectionError && details?.connection ? { connection: details.connection } : {}), + ...(structuredConnectionError && details?.subject ? { subject: details.subject } : {}), + ...(structuredConnectionError && typeof details?.grantId === "string" ? { grantId: details.grantId } : {}), ...(!redactedSkillPolicyDenial && err.details ? { details: err.details } : {}), }); return; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 48e4ea047f..ce2a05b685 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -173,6 +173,7 @@ import { updateToolApplicationSchema, createToolConnectionSchema, connectionTokenRequestSchema, + startConnectionAuthorizationSchema, createToolStdioCommandTemplateSchema, disableToolStdioCommandTemplateSchema, finishToolAppSchema, @@ -772,7 +773,12 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/tool-applications/{applicationId}", "GET /api/companies/{companyId}/tools/connections", "POST /api/companies/{companyId}/tools/connections", + "POST /api/companies/{companyId}/tools/connections/{connectionId}/start-authorization", "GET /api/tool-connections/{connectionId}", + "GET /api/tool-connections/{connectionId}/grants", + "POST /api/tool-connections/{connectionId}/grants/installations", + "DELETE /api/tool-connections/{connectionId}/grants/{grantId}", + "GET /api/tool-connections/{connectionId}/usage", "PATCH /api/tool-connections/{connectionId}", "DELETE /api/tool-connections/{connectionId}", "POST /api/tool-connections/{connectionId}/health-check", @@ -783,6 +789,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "GET /api/tool-connections/{connectionId}/test-agents", "POST /api/tool-connections/{connectionId}/test-calls", "GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}", + "POST /api/agents/me/connections/{connectionId}/start-authorization", "POST /api/agents/me/connections/{connectionId}/token", "POST /api/tools/oauth/{connectionId}/start", "GET /api/tools/oauth/callback", @@ -5975,6 +5982,51 @@ registerCurrentRoute({ summary: "Get a tool connection", }); +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/tools/connections/{connectionId}/start-authorization", + tags: ["tool-access"], + summary: "Start user authorization for a tool connection", + body: startConnectionAuthorizationSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/agents/me/connections/{connectionId}/start-authorization", + tags: ["tool-access"], + summary: "Start user authorization for an agent tool connection", + body: startConnectionAuthorizationSchema, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/tool-connections/{connectionId}/grants", + tags: ["tool-access"], + summary: "List tool connection grants", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/tool-connections/{connectionId}/grants/installations", + tags: ["tool-access"], + summary: "Add an installation grant to a tool connection", + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/tool-connections/{connectionId}/grants/{grantId}", + tags: ["tool-access"], + summary: "Revoke a tool connection grant", +}); + +registerCurrentRoute({ + method: "get", + path: "/api/tool-connections/{connectionId}/usage", + tags: ["tool-access"], + summary: "Get tool connection usage", +}); + registerCurrentRoute({ method: "get", path: "/api/tool-connections/{connectionId}/installs", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 7915afd490..4469846a1e 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -28,6 +28,7 @@ import { importMcpJsonSchema, putToolConnectionInstallsSchema, connectionTokenRequestSchema, + startConnectionAuthorizationSchema, revokeToolTrustRuleSchema, reorderToolPoliciesSchema, toolPolicyTestRequestSchema, @@ -174,6 +175,24 @@ export function toolAccessRoutes( await assertBoardToolPermission(req, companyId, "tools:manage_runtime"); } + router.post("/agents/me/connections/:connectionId/start-authorization", validate(startConnectionAuthorizationSchema), async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) { + res.status(401).json({ error: "Active agent run authentication required" }); + return; + } + const result = await svc.startAuthorizationForAgent({ + companyId: req.actor.companyId, + connectionId: req.params.connectionId as string, + agentId: req.actor.agentId, + runId: req.actor.runId, + subjectUserId: req.body.subjectUserId, + scopes: req.body.scopes, + returnTo: req.body.returnTo, + redirectUri: oauthRedirectUri(), + }); + res.json({ url: result.authorizationUrl }); + }); + router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => { if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { res.status(401).json({ error: "Agent authentication required" }); @@ -267,6 +286,27 @@ export function toolAccessRoutes( } }); + router.post( + "/companies/:companyId/tools/connections/:connectionId/start-authorization", + validate(startConnectionAuthorizationSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertToolAppMutationAccess(req, companyId); + if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) { + throw forbidden("Board users may only authorize their own connection subject"); + } + const existing = await svc.getConnection(req.params.connectionId as string, companyId); + const result = await svc.startOAuth(companyId, existing.id, { + redirectUri: oauthRedirectUri(), + actor: getActorInfo(req), + subjectUserId: req.body.subjectUserId, + scopes: req.body.scopes, + returnTo: req.body.returnTo, + }); + res.json({ url: result.authorizationUrl }); + }, + ); + router.post("/tools/oauth/:connectionId/start", async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); assertToolAppMutationAccess(req, existing.companyId); @@ -523,6 +563,67 @@ export function toolAccessRoutes( res.json(connection); }); + router.get("/tool-connections/:connectionId/grants", async (req, res) => { + assertBoard(req); + const connection = await svc.getConnection(req.params.connectionId as string); + if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); + assertCompanyAccess(req, connection.companyId); + res.json(await svc.listConnectionGrants(connection.id, connection.companyId)); + }); + + router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => { + assertBoard(req); + const connection = await svc.getConnection(req.params.connectionId as string); + await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const body = req.body && typeof req.body === "object" ? req.body as Record : {}; + const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : []; + const providerTenant = body.providerTenant && typeof body.providerTenant === "object" + ? body.providerTenant as { name?: string; externalId?: string } + : undefined; + const grant = await svc.addConnectionInstallation(connection.id, { + providerTenant, + credentialSecretRefs, + isDefault: body.isDefault === true, + }, getActorInfo(req)); + await logActivity(db, { + companyId: connection.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "tool_connection.grant_added", + entityType: "connection_grant", + entityId: grant.id, + details: { connectionId: connection.id, kind: grant.kind }, + }); + res.status(201).json(grant); + }); + + router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => { + assertBoard(req); + const connection = await svc.getConnection(req.params.connectionId as string); + await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req)); + await logActivity(db, { + companyId: connection.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "tool_connection.grant_revoked", + entityType: "connection_grant", + entityId: grant.id, + details: { connectionId: connection.id, kind: grant.kind }, + }); + res.json(grant); + }); + + router.get("/tool-connections/:connectionId/usage", async (req, res) => { + assertBoard(req); + const connection = await svc.getConnection(req.params.connectionId as string); + if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); + assertCompanyAccess(req, connection.companyId); + const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null; + if (!range) throw badRequest("Usage range must be 7d or 30d"); + res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId)); + }); + router.get("/tool-connections/:connectionId/installs", async (req, res) => { assertBoard(req); const connection = await svc.getConnection(req.params.connectionId as string); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 3ea6c9455e..0abcf38019 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -5,12 +5,14 @@ import type { Db } from "@paperclipai/db"; import { activityLog, agents, + connectionGrants, connectionTokenIssuances, authUsers, companySecretBindings, companySecrets, heartbeatRuns, issues, + issueThreadInteractions, plugins, projects, routines, @@ -2421,15 +2423,45 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (input.catalogEntryId) await assertCatalogEntry(companyId, input.catalogEntryId); } - async function getConnectionRow(connectionId: string, companyId?: string) { + async function getConnectionRow(idOrUid: string, companyId?: string) { + const identifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(idOrUid) + ? eq(toolConnections.id, idOrUid) + : eq(toolConnections.uid, idOrUid); const where = companyId - ? and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId)) - : eq(toolConnections.id, connectionId); + ? and(identifier, eq(toolConnections.companyId, companyId)) + : identifier; const [row] = await db.select().from(toolConnections).where(where); if (!row) throw notFound("Tool connection not found"); return row; } + async function ensureDefaultWorkspaceGrant(connection: typeof toolConnections.$inferSelect) { + const [existing] = await db + .select() + .from(connectionGrants) + .where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "workspace"), + eq(connectionGrants.isDefault, true), + )) + .limit(1); + if (existing) return existing; + const [created] = await db + .insert(connectionGrants) + .values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "workspace", + credentialSecretRefs: connection.credentialSecretRefs, + status: "active", + isDefault: true, + }) + .returning(); + if (!created) throw new Error("Failed to create default connection grant"); + return created; + } + async function getProfileRow(profileId: string, companyId?: string) { const where = companyId ? and(eq(toolProfiles.id, profileId), eq(toolProfiles.companyId, companyId)) @@ -3906,8 +3938,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} label: string; value: string; actor?: ActorInfo; + existingRefs?: typeof connectionGrants.$inferSelect.credentialSecretRefs; }) { - const existing = oauthSecretRef(input.connection, input.configPath); + const existing = input.existingRefs === undefined + ? oauthSecretRef(input.connection, input.configPath) + : input.existingRefs.find((ref) => ref.configPath === input.configPath); if (existing) { await secrets.rotate(existing.secretId, { value: input.value }, actorForSecret(input.actor)); return existing; @@ -4703,7 +4738,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function startOAuth( companyId: string, connectionId: string, - input: { redirectUri: string; actor: ActorInfo }, + input: { redirectUri: string; actor: ActorInfo; subjectUserId?: string; scopes?: string[]; returnTo?: string; issueId?: string }, ): Promise { const connection = await getConnectionRow(connectionId, companyId); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); @@ -4731,6 +4766,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} createdByActorType: binding.actorType, createdByActorId: binding.actorId, createdBySessionId: binding.sessionId, + subjectUserId: input.subjectUserId, + requestedScopes: input.scopes, + returnTo: input.returnTo, + issueId: input.issueId, expiresAt, }); @@ -4741,7 +4780,55 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} authorizationUrl.searchParams.set("state", state); authorizationUrl.searchParams.set("code_challenge", base64UrlSha256(codeVerifier)); authorizationUrl.searchParams.set("code_challenge_method", "S256"); - if (endpoints.scopes.length > 0) authorizationUrl.searchParams.set("scope", endpoints.scopes.join(" ")); + const authorizationScopes = input.scopes ?? endpoints.scopes; + if (authorizationScopes.length > 0) authorizationUrl.searchParams.set("scope", authorizationScopes.join(" ")); + + if (input.subjectUserId && input.issueId && binding.actorType === "agent") { + const idempotencyKey = `connection-authorization:${connection.id}:${input.subjectUserId}`; + const payload = { + version: 1 as const, + prompt: `Connect your account to ${connection.name}`, + acceptLabel: "Open authorization", + rejectLabel: "Not now", + detailsMarkdown: "Authorization is required before this agent can act on your behalf.", + target: { + type: "custom" as const, + key: `connection:${connection.uid}:user:${input.subjectUserId}`, + revisionId: state, + label: `Connect ${connection.name}`, + href: authorizationUrl.toString(), + }, + }; + const [existingInteraction] = await db.select().from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, companyId), + eq(issueThreadInteractions.issueId, input.issueId), + eq(issueThreadInteractions.idempotencyKey, idempotencyKey), + )).limit(1); + const [interaction] = existingInteraction + ? await db.update(issueThreadInteractions).set({ + status: "pending", + payload, + result: null, + resolvedAt: null, + updatedAt: new Date(), + }).where(eq(issueThreadInteractions.id, existingInteraction.id)).returning() + : await db.insert(issueThreadInteractions).values({ + companyId, + issueId: input.issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + idempotencyKey, + sourceRunId: binding.actorType === "agent" ? input.actor.sessionId ?? null : null, + title: "Connect your account", + summary: `Connect ${connection.name} to continue`, + createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, + payload, + }).returning(); + if (interaction) { + await db.update(toolOauthStates).set({ interactionId: interaction.id }).where(eq(toolOauthStates.state, state)); + } + } const nextConfig = { ...connection.config, @@ -4797,7 +4884,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .limit(1); if (!stateRow) throw badRequest("OAuth state was not found or has already been used"); if (stateRow.expiresAt.getTime() <= Date.now()) throw badRequest("OAuth state has expired"); - assertSameOAuthActor(stateRow, input.actor); + if (stateRow.subjectUserId) { + if (input.actor?.actorType !== "user" || input.actor.actorId !== stateRow.subjectUserId) { + throw forbidden("OAuth callback user does not match the requested subject"); + } + } else { + assertSameOAuthActor(stateRow, input.actor); + } await db.delete(toolOauthStates).where(eq(toolOauthStates.state, input.state)); let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId); @@ -4815,6 +4908,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} codeVerifier: stateRow.codeVerifier, code: input.code, }); + const [existingUserGrant] = stateRow.subjectUserId + ? await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, stateRow.subjectUserId), + )).limit(1) + : [undefined]; + const subjectCredentialSecretRefs = stateRow.subjectUserId + ? existingUserGrant?.credentialSecretRefs ?? [] + : connection.credentialSecretRefs; const accessRef = await createOrRotateOAuthSecret({ companyId: connection.companyId, connection, @@ -4822,9 +4926,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} label: "OAuth access token", value: token.accessToken, actor: input.actor, + existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined, }); const nextCredentialSecretRefs = [ - ...connection.credentialSecretRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"), + ...subjectCredentialSecretRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"), accessRef, ]; if (token.refreshToken) { @@ -4835,12 +4940,63 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} label: "OAuth refresh token", value: token.refreshToken, actor: input.actor, + existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined, })); } else { - const existingRefreshRef = oauthSecretRef(connection, "oauth.refresh_token"); + const existingRefreshRef = subjectCredentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token"); if (existingRefreshRef) nextCredentialSecretRefs.push(existingRefreshRef); } const expiresAt = token.expiresIn ? new Date(Date.now() + token.expiresIn * 1000).toISOString() : null; + if (stateRow.subjectUserId) { + const grantValues = { + credentialSecretRefs: nextCredentialSecretRefs, + status: "active" as const, + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: new Date(), + }; + if (existingUserGrant) { + await db.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id)); + } else { + await db.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "user", + subjectUserId: stateRow.subjectUserId, + ...grantValues, + isDefault: false, + createdByUserId: stateRow.subjectUserId, + }); + } + if (stateRow.interactionId) { + await db.update(issueThreadInteractions).set({ + status: "accepted", + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: stateRow.subjectUserId, + resolvedAt: new Date(), + updatedAt: new Date(), + }).where(and( + eq(issueThreadInteractions.id, stateRow.interactionId), + eq(issueThreadInteractions.companyId, connection.companyId), + )); + } + const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId)); + if (!application) throw new Error("OAuth connection application was not found"); + const catalog = (await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, connection.companyId), + eq(toolCatalogEntries.connectionId, connection.id), + ))).map(toCatalogEntry); + return { + connectionId: connection.id, + application: toApplication(application), + connection: toConnection(connection), + catalog, + actions: groupedActions(catalog), + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] }, + auth: null, + }; + } const nextConfig = { ...connection.config, oauth: { @@ -5106,6 +5262,35 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} startOAuth, + startAuthorizationForAgent: async (input: { + companyId: string; + connectionId: string; + agentId: string; + runId: string; + subjectUserId: string; + scopes?: string[]; + returnTo?: string; + redirectUri: string; + }) => { + const runContext = await loadBrokerRunContext(input); + const connection = await getConnectionRow(input.connectionId, input.companyId); + if (!runContext.responsibleUserId || runContext.responsibleUserId !== input.subjectUserId) { + throw new HttpError(403, "The agent run cannot start authorization for the requested user", { + code: "subject_not_permitted", + connection: { uid: connection.uid }, + subject: { type: "user", userId: input.subjectUserId }, + }); + } + return startOAuth(input.companyId, connection.id, { + redirectUri: input.redirectUri, + actor: { actorType: "agent", actorId: input.agentId }, + subjectUserId: input.subjectUserId, + scopes: input.scopes, + returnTo: input.returnTo, + issueId: runContext.issueId ?? undefined, + }); + }, + peekOAuthState, completeOAuthCallback, @@ -5424,6 +5609,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} credentialRefs: input.credentialRefs ?? [], credentialSecretRefs: input.credentialSecretRefs ?? [], }).returning(); + await ensureDefaultWorkspaceGrant(row); await syncCredentialBindings(row); await ensureRuntimeSlot(row); return toConnection(row); @@ -5435,6 +5621,111 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return connection; }, + listConnectionGrants: async (idOrUid: string, companyId?: string) => { + const connection = await getConnectionRow(idOrUid, companyId); + const grants = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + )).orderBy(desc(connectionGrants.isDefault), desc(connectionGrants.updatedAt)); + return { connection: { id: connection.id, uid: connection.uid }, grants }; + }, + + addConnectionInstallation: async (idOrUid: string, input: { + providerTenant?: { name?: string; externalId?: string }; + credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs; + isDefault?: boolean; + }, actor?: ActorInfo) => { + const connection = await getConnectionRow(idOrUid); + await assertSecretRefs(connection.companyId, input.credentialSecretRefs ?? []); + if (input.isDefault) { + await db.update(connectionGrants).set({ isDefault: false, updatedAt: new Date() }).where(and( + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "workspace"), + )); + } + const binding = actorBinding(actor); + const [grant] = await db.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "workspace", + providerTenant: input.providerTenant, + credentialSecretRefs: input.credentialSecretRefs ?? [], + status: "active", + isDefault: input.isDefault ?? false, + createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, + createdByUserId: binding.actorType === "user" ? binding.actorId : null, + }).returning(); + if (!grant) throw new Error("Failed to create connection installation"); + return grant; + }, + + revokeConnectionGrant: async (idOrUid: string, grantId: string, actor?: ActorInfo) => { + const connection = await getConnectionRow(idOrUid); + const binding = actorBinding(actor); + const [grant] = await db.update(connectionGrants).set({ + status: "revoked", + isDefault: false, + revokedAt: new Date(), + revokedByAgentId: binding.actorType === "agent" ? binding.actorId : null, + revokedByUserId: binding.actorType === "user" ? binding.actorId : null, + updatedAt: new Date(), + }).where(and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + )).returning(); + if (!grant) throw notFound("Connection grant not found"); + return grant; + }, + + getConnectionUsage: async (idOrUid: string, range: "7d" | "30d", companyId?: string) => { + const connection = await getConnectionRow(idOrUid, companyId); + const days = range === "30d" ? 30 : 7; + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - days + 1); + const [issuances, invocations] = await Promise.all([ + db.select({ createdAt: connectionTokenIssuances.createdAt, outcome: connectionTokenIssuances.outcome, path: connectionTokenIssuances.path }) + .from(connectionTokenIssuances).where(and( + eq(connectionTokenIssuances.companyId, connection.companyId), + eq(connectionTokenIssuances.connectionId, connection.id), + gte(connectionTokenIssuances.createdAt, start), + )), + db.select({ createdAt: toolInvocations.createdAt, riskLevel: toolInvocations.riskLevel }) + .from(toolInvocations).where(and( + eq(toolInvocations.companyId, connection.companyId), + eq(toolInvocations.connectionId, connection.id), + gte(toolInvocations.createdAt, start), + )), + ]); + const buckets = Array.from({ length: days }, (_, offset) => { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + offset); + return { + date: date.toISOString().slice(0, 10), + issuances: { total: 0, byOutcome: {} as Record, byPath: {} as Record }, + invocations: { total: 0, byRiskLevel: {} as Record }, + deliveries: { received: 0, forwarded: 0 }, + }; + }); + const byDate = new Map(buckets.map((bucket) => [bucket.date, bucket])); + for (const row of issuances) { + const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10)); + if (!bucket) continue; + bucket.issuances.total += 1; + bucket.issuances.byOutcome[row.outcome] = (bucket.issuances.byOutcome[row.outcome] ?? 0) + 1; + bucket.issuances.byPath[row.path] = (bucket.issuances.byPath[row.path] ?? 0) + 1; + } + for (const row of invocations) { + const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10)); + if (!bucket) continue; + const riskLevel = row.riskLevel ?? "unknown"; + bucket.invocations.total += 1; + bucket.invocations.byRiskLevel[riskLevel] = (bucket.invocations.byRiskLevel[riskLevel] ?? 0) + 1; + } + return { connection: { id: connection.id, uid: connection.uid }, range, buckets }; + }, + listConnectionInstalls, putConnectionInstalls: async ( @@ -6284,11 +6575,81 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }); }; - const fail = async (status: number, message: string, outcome: ConnectionTokenIssuanceOutcome, errorCode: string, details: Record = {}) => { + const fail = async (status: number, message: string, outcome: ConnectionTokenIssuanceOutcome, errorCode: string, details: Record = {}): Promise => { await recordFailure(outcome, errorCode, details); throw new HttpError(status, message, { code: errorCode, path, ...details }); }; + const subject = input.body.subject ?? { type: "app" as const }; + if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) { + await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", { + connection: { uid: connection.uid }, + subject, + }); + } + + let grant: typeof connectionGrants.$inferSelect; + if (subject.type === "user") { + const conditions = [ + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, subject.userId), + ]; + if (input.body.grantId) conditions.push(eq(connectionGrants.id, input.body.grantId)); + [grant] = await db.select().from(connectionGrants).where(and(...conditions)).limit(1); + if (!grant) { + await fail(409, "User authorization is required", "denied", "user_authorization_required", { + connection: { uid: connection.uid }, + subject, + remediation: { action: "start_authorization" }, + }); + } + } else if (input.body.grantId) { + [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.id, input.body.grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "workspace"), + )).limit(1); + if (!grant) { + await fail(409, "The requested installation is not available", "denied", "installation_required", { + connection: { uid: connection.uid }, + subject, + remediation: { action: "add_installation" }, + }); + } + } else { + grant = await ensureDefaultWorkspaceGrant(connection); + } + + if (grant.status !== "active") { + const code = grant.status === "needs_reauthorization" ? "needs_reauthorization" : "grant_revoked"; + await fail(409, "The selected connection grant is not active", "denied", code, { + connection: { uid: connection.uid }, + subject, + grantId: grant.id, + remediation: { action: "reauthorize" }, + }); + } + const requestedScopeSelectors = new Set(requestedScope); + const matchingScopedRefs = grant.credentialSecretRefs.filter( + (ref) => ref.keyScope && requestedScopeSelectors.has(ref.keyScope), + ); + const selectedCredentialSecretRefs = matchingScopedRefs.length > 0 + ? grant.credentialSecretRefs.filter((ref) => !ref.keyScope || requestedScopeSelectors.has(ref.keyScope)) + : grant.credentialSecretRefs.filter((ref) => !ref.keyScope); + const rotateBefore = Date.now() + 14 * 24 * 60 * 60 * 1000; + const expiringRef = selectedCredentialSecretRefs.find((ref) => ref.expiresAt && Date.parse(ref.expiresAt) <= rotateBefore); + if (expiringRef && connection.healthStatus !== "degraded") { + await db.update(toolConnections).set({ + healthStatus: "degraded", + healthMessage: `Rotate ${expiringRef.label ?? expiringRef.configPath} before it expires.`, + updatedAt: new Date(), + }).where(eq(toolConnections.id, connection.id)); + } + const credentialConnection = { ...connection, credentialSecretRefs: selectedCredentialSecretRefs }; + if (!connection.enabled || connection.status !== "active") { await fail(409, "Connection is not active", "denied", "connection_not_active", { connectionStatus: connection.status, @@ -6390,6 +6751,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} status: "use_env_lease", code: "use_env_lease", connectionId: connection.id, + connection: { id: connection.id, uid: connection.uid }, + grantId: grant.id, path: "static", message: "This connection uses static credentials. Use an audited environment lease projection instead.", scope: issuedScope, @@ -6404,7 +6767,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} try { const minted = await mintExchangeConnectionToken({ - connection, + connection: credentialConnection, application, agentId: input.agentId, runId: input.runId, @@ -6435,6 +6798,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} outcome: "success", metadata: { tokenRef: tokenHash, tokenType: minted.tokenType }, }); + await db.update(connectionGrants).set({ lastUsedAt: new Date(), updatedAt: new Date() }).where(eq(connectionGrants.id, grant.id)); await auditConnectionTokenIssuance({ companyId: connection.companyId, connectionId: connection.id, @@ -6447,6 +6811,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return { status: "minted", connectionId: connection.id, + connection: { id: connection.id, uid: connection.uid }, + grantId: grant.id, + providerTenantId: grant.providerTenant?.externalId, path: "exchange", token: minted.token, tokenType: minted.tokenType,