diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.test.ts index e61d6e6845..9e514032ed 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.test.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from '@jest/globals'; import type { ReviewActor } from '@kilocode/app-shared/provider-review'; import { createBitbucketInteractiveClient, + type BitbucketInteractiveBrokerRequest, type BitbucketInteractiveMetadata, type BitbucketInteractiveRequest, type BitbucketInteractiveServiceSuccess, @@ -76,6 +77,111 @@ const json = (body: unknown, status = 200, headers = {}) => }); describe('server-only Bitbucket interactive broker client', () => { + const sourceRequest = { + operation: 'file', + source: { + pullRequestId: 7, + workspaceUuid: '123e4567-e89b-12d3-a456-426614174098', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174099', + }, + params: { + path: { + workspace: 'acme', + repo_slug: 'widgets', + commit: '0123456789abcdef0123456789abcdef01234567', + path: 'src/file.ts', + }, + }, + } satisfies BitbucketInteractiveBrokerRequest<'file'>; + + it.each(['file', 'fileMetadata'] as const)( + 'forwards the narrow source %s contract while retaining destination authorization', + async operation => { + const sent: unknown[] = []; + const data = + operation === 'file' + ? 'fork content' + : { + type: 'commit_file', + path: 'src/file.ts', + size: 12, + commit: { hash: sourceRequest.params.path.commit }, + attributes: [], + }; + const result = await createBitbucketInteractiveClient({ + ...options, + fetch: async (_url, init) => { + if (new Headers(init?.headers).get('authorization') !== 'Bearer internal-token-fixture') + return json({}, 403); + sent.push(JSON.parse(String(init?.body))); + return json({ success: true, result: { status: 200, data }, metadata }); + }, + }).execute({ ...sourceRequest, operation }); + expect(sent).toEqual([ + { + ...options.workspace, + ...options.repository, + request: { ...sourceRequest, operation }, + }, + ]); + expect(result).toEqual({ status: 200, data, metadata }); + expect(JSON.stringify({ sent, result })).not.toContain('internal-token-fixture'); + } + ); + + it.each([ + 'invalid_request', + 'not_connected', + 'integration_mismatch', + 'workspace_mismatch', + 'repository_mismatch', + 'conflict', + 'insufficient_permissions', + 'not_found', + 'rate_limited', + 'temporarily_unavailable', + 'provider_unavailable', + 'authentication_rejected', + ] as const)('retains sanitized source failure %s without automatic retries', async reason => { + let requests = 0; + const error = await createBitbucketInteractiveClient({ + ...options, + fetch: async () => { + requests += 1; + return json({ success: false, reason }, 200, { authorization: 'provider-token-fixture' }); + }, + }) + .execute(sourceRequest) + .catch(error => error); + expect(error).toMatchObject({ code: reason, message: reason }); + expect(requests).toBe(1); + expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain( + 'provider-token-fixture' + ); + expect(error).not.toHaveProperty('request'); + expect(error).not.toHaveProperty('response'); + expect(error).not.toHaveProperty('cause'); + }); + + it('does not expose an unrecognized source failure or its credential-bearing cause', async () => { + const error = await createBitbucketInteractiveClient({ + ...options, + fetch: async () => + json({ + success: false, + reason: 'provider-token-fixture', + cause: { token: 'provider-token-fixture' }, + }), + }) + .execute(sourceRequest) + .catch(error => error); + expect(error).toMatchObject({ code: 'invalid_response' }); + expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain( + 'provider-token-fixture' + ); + expect(error).not.toHaveProperty('cause'); + }); + it('sends exact identity and exposes workspace-token facts without credential objects', async () => { const sent: { url: string; body: unknown; redirect?: RequestRedirect }[] = []; const result = await createBitbucketInteractiveClient({ diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.ts b/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.ts index 61999a6f23..0168ae7f31 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/interactive-client.ts @@ -6,6 +6,8 @@ import { GIT_TOKEN_SERVICE_API_URL } from '@/lib/config.server'; import { generateInternalServiceToken, TOKEN_EXPIRY } from '@/lib/tokens'; import { BitbucketInteractiveMetadataSchema, + type BitbucketInteractiveBrokerRequest, + type BitbucketInteractiveSourceSelector, type BitbucketInteractiveData, type BitbucketInteractiveMetadata, type BitbucketInteractiveOperation, @@ -24,6 +26,8 @@ import type { } from './token-service-client'; export type { + BitbucketInteractiveBrokerRequest, + BitbucketInteractiveSourceSelector, BitbucketInteractiveMetadata, BitbucketInteractiveRequest, BitbucketInteractiveResponse, @@ -95,7 +99,7 @@ export function createBitbucketInteractiveClient(options: { }) { return { async execute( - request: BitbucketInteractiveRequest + request: BitbucketInteractiveBrokerRequest ): Promise>> { if (!options.organizationId || !options.actorUserId) throw new BitbucketInteractiveClientError('invalid_request'); diff --git a/packages/worker-utils/src/internal-service-token-audiences.test.ts b/packages/worker-utils/src/internal-service-token-audiences.test.ts index 934d718684..2eab11a50f 100644 --- a/packages/worker-utils/src/internal-service-token-audiences.test.ts +++ b/packages/worker-utils/src/internal-service-token-audiences.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { signKiloToken, verifyKiloToken } from './kilo-token.js'; +import { BITBUCKET_INTERACTIVE_AUDIENCE } from './internal-service-token-audiences.js'; import { GITLAB_CREDENTIAL_BROKER_AUDIENCE as RootGitLabCredentialBrokerAudience } from './index.js'; import { BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE, @@ -10,6 +12,30 @@ import { } from './internal-service-token-audiences.js'; describe('internal service token audiences', () => { + it('prevents interactive assertions from authorizing legacy endpoints', async () => { + const secret = 'test-secret-that-is-at-least-32-characters'; + const { token } = await signKiloToken({ + userId: 'actor', + pepper: null, + secret, + expiresInSeconds: 60, + audience: BITBUCKET_INTERACTIVE_AUDIENCE, + }); + await expect( + verifyKiloToken(token, secret, { audience: BITBUCKET_INTERACTIVE_AUDIENCE }) + ).resolves.toMatchObject({ kiloUserId: 'actor' }); + for (const audience of [ + undefined, + BITBUCKET_REPOSITORY_LIST_AUDIENCE, + BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE, + BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, + BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE, + GITLAB_CREDENTIAL_BROKER_AUDIENCE, + ]) { + await expect(verifyKiloToken(token, secret, { audience })).rejects.toThrow(); + } + }); + it('keeps Bitbucket operations purpose-bound and mutually distinct', () => { const audiences = [ BITBUCKET_REPOSITORY_LIST_AUDIENCE, diff --git a/packages/worker-utils/src/internal-service-token-audiences.ts b/packages/worker-utils/src/internal-service-token-audiences.ts index 5e8dd015f7..f4c3dfc100 100644 --- a/packages/worker-utils/src/internal-service-token-audiences.ts +++ b/packages/worker-utils/src/internal-service-token-audiences.ts @@ -1,4 +1,5 @@ export const BITBUCKET_REPOSITORY_LIST_AUDIENCE = 'git-token-service:bitbucket-repositories'; +export const BITBUCKET_INTERACTIVE_AUDIENCE = 'git-token-service:bitbucket-interactive-review'; export const BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE = 'git-token-service:bitbucket-code-review:pull-request'; export const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE = diff --git a/services/git-token-service/src/bitbucket-interactive-api.test.ts b/services/git-token-service/src/bitbucket-interactive-api.test.ts index 00b4b492c9..dffd2f5630 100644 --- a/services/git-token-service/src/bitbucket-interactive-api.test.ts +++ b/services/git-token-service/src/bitbucket-interactive-api.test.ts @@ -296,6 +296,42 @@ describe('Bitbucket generated SDK boundary', () => { expect(effects).toBe(0); }); + it.each(['file', 'createComment', 'deleteBranch'] as const)( + 'never resolves a broker source selector inside the exact SDK scope: %s', + async operation => { + let effects = 0; + const operationRequest = + operation === 'file' + ? { + operation, + params: { + path: { ...branches.params.path, commit: 'a'.repeat(40), path: 'file.ts' }, + }, + } + : operation === 'createComment' + ? comment + : { + operation, + params: { path: { ...branches.params.path, name: 'feature' } }, + }; + const request = { + ...operationRequest, + source: { + pullRequestId: 7, + workspaceUuid: '123e4567-e89b-12d3-a456-426614174098', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174099', + }, + }; + await expect( + api(async () => { + effects += 1; + return json({ id: 91 }); + }).execute(request as BitbucketInteractiveRequest) + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(effects).toBe(0); + } + ); + it.each([ { ...comment, body: null }, { ...comment, body: undefined }, @@ -559,6 +595,153 @@ describe('Bitbucket generated SDK boundary', () => { }); }); +describe('UUID-addressed merge task locations', () => { + const scope = { + kind: 'repository' as const, + workspace: '{123e4567-e89b-12d3-a456-426614174031}', + repository: '{123e4567-e89b-12d3-a456-426614174032}', + }; + const options = { + scope, + accessToken: token, + canonicalTaskRepository: { workspace: 'acme', repository: 'widgets' }, + }; + const request = { + ...merge, + params: { + path: { workspace: scope.workspace, repo_slug: scope.repository, pull_request_id: 7 }, + }, + }; + const mergeUrl = + 'https://api.bitbucket.org/2.0/repositories/%7B123e4567-e89b-12d3-a456-426614174031%7D/%7B123e4567-e89b-12d3-a456-426614174032%7D/pullrequests/7/merge'; + const uuidTaskUrl = `${mergeUrl}/task-status/task-1`; + + it.each([ + ['canonical header', taskUrl, null, true], + ['canonical body', taskUrl, { task_status_url: taskUrl }, true], + [ + 'canonical UUID task', + `${prUrl}/merge/task-status/%7B123e4567-e89b-12d3-a456-426614174099%7D`, + null, + true, + ], + ['UUID with mapping', uuidTaskUrl, null, true], + ['legacy UUID without mapping', uuidTaskUrl, null, false], + ] as const)( + 'retains the %s after a UUID-addressed merge', + async (_name, location, data, mapped) => { + const result = await createBitbucketInteractiveApi({ + ...options, + canonicalTaskRepository: mapped ? options.canonicalTaskRepository : undefined, + fetch: async (url, init) => { + if (url !== mergeUrl || init?.method !== 'POST' || init.redirect !== 'manual') + return json({}, 404); + return data === null + ? new Response(null, { status: 202, headers: { location } }) + : json(data, 202, { location }); + }, + }).execute(request); + expect(result).toEqual({ status: 202, location, data }); + expect(JSON.stringify(result)).not.toContain(token); + } + ); + + it('rejects a canonical task without a verified alias mapping', async () => { + await expect( + createBitbucketInteractiveApi({ + scope, + accessToken: token, + fetch: async () => new Response(null, { status: 202, headers: { location: taskUrl } }), + }).execute(request) + ).rejects.toMatchObject({ code: 'invalid_response' }); + }); + + it.each([ + taskUrl.replace('/acme/', '/foreign/'), + taskUrl.replace('/widgets/', '/other/'), + uuidTaskUrl.replace('426614174032', '426614174099'), + uuidTaskUrl.replace('426614174031', '426614174098'), + taskUrl.replace('/widgets/', `/%7B123e4567-e89b-12d3-a456-426614174032%7D/`), + taskUrl.replace('/7/', '/8/'), + taskUrl.replace('task-1', ''), + `${taskUrl}/child`, + taskUrl.replace('task-1', 'task%2Fother'), + taskUrl.replace('task-1', 'task%252Fother'), + taskUrl.replace('task-1', '%2e%2e'), + taskUrl.replace('task-1', 'task%5Cother'), + taskUrl.replace('task-1', '%ZZ'), + taskUrl.replace('task-1', 'x'.repeat(256)), + taskUrl.replace('/acme/', '/%61cme/'), + taskUrl.replace('/widgets/', '/%77idgets/'), + taskUrl.replace('/widgets/', '/widgets/../widgets/'), + taskUrl.replace('api.bitbucket.org', 'api.bitbucket.org.evil.example'), + taskUrl.replace('https:', 'http:'), + taskUrl.replace('api.bitbucket.org', 'api.bitbucket.org:444'), + taskUrl.replace('https://', 'https://user:private-provider-token@'), + `${taskUrl}?page=2`, + `${taskUrl}?access_token=${token}`, + `${taskUrl}#fragment`, + ])('rejects an unbound or unsafe task location %s', async location => { + const error = await createBitbucketInteractiveApi({ + ...options, + fetch: async () => new Response(null, { status: 202, headers: { location } }), + }) + .execute(request) + .catch(error => error); + expect(error).toMatchObject({ code: 'invalid_response' }); + expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain(token); + }); + + it.each([ + { workspace: '../acme', repository: 'widgets' }, + { workspace: 'acme', repository: 'widgets/other' }, + ])('rejects an unsafe server alias %#', canonicalTaskRepository => { + expect(() => createBitbucketInteractiveApi({ ...options, canonicalTaskRepository })).toThrow( + 'invalid_request' + ); + }); + + it.each([ + { kind: 'workspace', workspace: scope.workspace }, + { kind: 'repository', workspace: 'acme', repository: 'widgets' }, + ] as const)('requires an immutable repository scope for aliases %#', scope => { + expect(() => createBitbucketInteractiveApi({ ...options, scope })).toThrow('invalid_request'); + }); + + it('rejects a request-selected alias before dispatch', async () => { + let effects = 0; + await expect( + createBitbucketInteractiveApi({ + ...options, + fetch: async () => { + effects += 1; + return new Response(null, { status: 202, headers: { location: taskUrl } }); + }, + }).execute({ + ...request, + canonicalTaskRepository: options.canonicalTaskRepository, + } as BitbucketInteractiveRequest<'merge'>) + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(effects).toBe(0); + }); + + it('does not extend the alias mapping to pagination', async () => { + await expect( + createBitbucketInteractiveApi({ + ...options, + fetch: async () => + json({ + values: [], + next: 'https://api.bitbucket.org/2.0/repositories/acme/widgets/refs/branches?pagelen=50&page=2', + }), + }).execute({ + operation: 'branches', + params: { path: { workspace: scope.workspace, repo_slug: scope.repository } }, + }) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); + }); +}); + describe('SDK credential boundary', () => { it.each(['access_token', 'oauth_token', 'Authorization', 'callback'])( 'rejects credential or unknown query key %s before dispatch', diff --git a/services/git-token-service/src/bitbucket-interactive-api.ts b/services/git-token-service/src/bitbucket-interactive-api.ts index 5a94e55c02..00211dfbd5 100644 --- a/services/git-token-service/src/bitbucket-interactive-api.ts +++ b/services/git-token-service/src/bitbucket-interactive-api.ts @@ -60,15 +60,37 @@ export type BitbucketInteractiveOperation = keyof typeof operations; type Protocol = NonNullable< paths[(typeof operations)[K][1]][(typeof operations)[K][0]] >; +const canonicalUuid = z.string().refine(value => normalizeBitbucketUuid(value) === value); +export const BitbucketInteractiveSourceSelectorSchema = z.strictObject({ + pullRequestId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + workspaceUuid: canonicalUuid, + repositoryUuid: canonicalUuid, +}); +export type BitbucketInteractiveSourceSelector = z.infer< + typeof BitbucketInteractiveSourceSelectorSchema +>; export type BitbucketInteractiveRequest< K extends BitbucketInteractiveOperation = BitbucketInteractiveOperation, > = K extends BitbucketInteractiveOperation ? { operation: K; - params: Protocol['parameters']; + // Bitbucket's global fields parameter expands condensed PR repository identities. + // https://developer.atlassian.com/cloud/bitbucket/rest/intro/#partial-response + params: K extends 'pullRequest' + ? Omit['parameters'], 'query'> & { query?: { fields?: string } } + : Protocol['parameters']; next?: string; } & RequestBodyOption> : never; +// The broker alone resolves this selector. Paths still identify the authorized destination; +// path.commit pins the expected full source SHA. Omission retains destination-only behavior. +export type BitbucketInteractiveBrokerRequest< + K extends BitbucketInteractiveOperation = BitbucketInteractiveOperation, +> = K extends BitbucketInteractiveOperation + ? BitbucketInteractiveRequest & { + source?: K extends 'file' | 'fileMetadata' ? BitbucketInteractiveSourceSelector : never; + } + : never; type ProtocolData = NonNullable< FetchResponse, object, 'application/json'>['data'] >; @@ -190,6 +212,18 @@ export const BitbucketInteractiveRequestSchema = z.strictObject({ body: z.json().optional(), next: z.string().min(1).max(4096).optional(), }); +export const BitbucketInteractiveBrokerRequestSchema = BitbucketInteractiveRequestSchema.extend({ + source: BitbucketInteractiveSourceSelectorSchema.optional(), +}).refine( + request => + request.source === undefined || + ((request.operation === 'file' || request.operation === 'fileMetadata') && + request.body === undefined && + request.next === undefined && + request.params.query === undefined && + typeof request.params.path.commit === 'string' && + /^[0-9a-fA-F]{40}$/.test(request.params.path.commit)) +); const pageSchema = z .object({ values: z.array(z.unknown()).max(50), @@ -233,12 +267,21 @@ function validIdentity(value: string): boolean { export function createBitbucketInteractiveApi(options: { scope: BitbucketInteractiveScope; accessToken: string; + // Server-verified aliases for 202 merge-task locations only, never request paths. + canonicalTaskRepository?: { workspace: string; repository: string }; fetch?: typeof fetch; requestTimeoutMs?: number; }) { + const canonicalTaskRepository = options.canonicalTaskRepository; if ( !validIdentity(options.scope.workspace) || - (options.scope.kind === 'repository' && !validIdentity(options.scope.repository)) + (options.scope.kind === 'repository' && !validIdentity(options.scope.repository)) || + (canonicalTaskRepository && + (options.scope.kind !== 'repository' || + normalizeBitbucketUuid(options.scope.workspace) === null || + normalizeBitbucketUuid(options.scope.repository) === null || + !validIdentity(canonicalTaskRepository.workspace) || + !validIdentity(canonicalTaskRepository.repository))) ) { throw new BitbucketInteractiveError('invalid_request'); } @@ -405,11 +448,21 @@ export function createBitbucketInteractiveApi(options: { const task = new URL(candidate); assertBitbucketUrl(candidate, task.pathname); if (task.search !== '') throw new Error('location_query'); - const prefix = `${expected.pathname}/task-status/`; + const prefixes = [`${expected.pathname}/task-status/`]; + if (response.status === 202 && canonicalTaskRepository) { + prefixes.push( + new URL( + `${BITBUCKET_API_ROOT}/repositories/${encodeURIComponent(canonicalTaskRepository.workspace)}/${encodeURIComponent(canonicalTaskRepository.repository)}/pullrequests/${pathParams.pull_request_id}/merge/task-status/` + ).pathname + ); + } if ( response.status === 202 - ? !task.pathname.startsWith(prefix) || - !validIdentity(decodeURIComponent(task.pathname.slice(prefix.length))) + ? !prefixes.some( + prefix => + task.pathname.startsWith(prefix) && + validIdentity(decodeURIComponent(task.pathname.slice(prefix.length))) + ) : task.pathname !== expected.pathname && !task.pathname.startsWith(`${expected.pathname}/`) ) diff --git a/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts b/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts index 9a94cbad61..c9a8086a4b 100644 --- a/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts +++ b/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BitbucketApiError, type BitbucketRepository } from './bitbucket-api.js'; import { listBitbucketRepositories, + resolveBitbucketCapabilitySubject, resolveBitbucketToken, selectCachedBitbucketRepository, } from './bitbucket-runtime-token-resolver.js'; @@ -78,26 +79,48 @@ describe('Bitbucket runtime token resolver', () => { expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); }); - it('releases only the opaque token after exact cached repository validation', async () => { - const deps = dependencies(); + it.each([ + ['legacy', {}], + ['pinned', { expectedIntegrationId: authorization.integrationId }], + ] as const)( + 'returns the authorized Workspace Access Token identity for a %s request', + async (_name, pin) => { + const deps = dependencies(); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams(pin); - await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ - success: true, - token: 'ATCT-runtime-token', - }); - expect(deps.authorizationService.getAuthorization).toHaveBeenCalledWith({ - userId: 'member-1', - orgId: organizationId, - }); - expect(deps.findCachedRepository).toHaveBeenCalledWith({ - integrationId: authorization.integrationId, - organizationId, - workspace: authorization.workspace, - repositoryUuid, - }); - expect(deps.listRepositories).not.toHaveBeenCalled(); - expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); - }); + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: true, + token: 'ATCT-runtime-token', + integrationId: authorization.integrationId, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: true, + subject: { + integrationId: authorization.integrationId, + workspaceUuid, + workspaceSlug: 'acme', + repositoryUuid, + repositoryFullName: 'acme/widgets', + token: 'ATCT-runtime-token', + }, + }); + expect(deps.authorizationService.getAuthorization).toHaveBeenCalledWith({ + userId: 'member-1', + orgId: organizationId, + }); + expect(deps.findCachedRepository).toHaveBeenCalledWith({ + integrationId: authorization.integrationId, + organizationId, + workspace: authorization.workspace, + repositoryUuid, + }); + expect(deps.listRepositories).not.toHaveBeenCalled(); + expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); + } + ); it('lists repositories through the same organization-only static authorization', async () => { const deps = dependencies(); @@ -138,25 +161,65 @@ describe('Bitbucket runtime token resolver', () => { }); }); - it('uses the cached repository and Cloud Agent validity threshold for OAuth tokens', async () => { + it.each([ + ['legacy', {}], + ['pinned', { expectedIntegrationId: oauthAuthorization.integrationId }], + ] as const)( + 'returns the authorized OAuth identity and retains the validity threshold for a %s request', + async (_name, pin) => { + const deps = dependencies(); + deps.authorizationService.getAuthorization.mockResolvedValue({ status: 'not_connected' }); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams(pin); + + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: true, + token: 'oauth-runtime-token', + integrationId: oauthAuthorization.integrationId, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: true, + subject: { + integrationId: oauthAuthorization.integrationId, + workspaceUuid, + workspaceSlug: 'acme', + repositoryUuid, + repositoryFullName: 'acme/widgets', + token: 'oauth-runtime-token', + }, + }); + expect(deps.oauthAuthorizationService.getAuthorization).toHaveBeenCalledWith( + { userId: 'member-1', orgId: organizationId }, + BITBUCKET_CLOUD_AGENT_MINIMUM_VALIDITY_MS + ); + expect(deps.findCachedRepository).toHaveBeenCalledWith({ + integrationId: oauthAuthorization.integrationId, + organizationId, + workspace: oauthAuthorization.workspace, + repositoryUuid, + }); + } + ); + + it('rejects a Workspace Access Token pin after selecting the OAuth integration', async () => { const deps = dependencies(); deps.authorizationService.getAuthorization.mockResolvedValue({ status: 'not_connected' }); deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams({ expectedIntegrationId: authorization.integrationId }); - await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ - success: true, - token: 'oauth-runtime-token', + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: false, + reason: 'integration_mismatch', }); - expect(deps.oauthAuthorizationService.getAuthorization).toHaveBeenCalledWith( - { userId: 'member-1', orgId: organizationId }, - BITBUCKET_CLOUD_AGENT_MINIMUM_VALIDITY_MS - ); - expect(deps.findCachedRepository).toHaveBeenCalledWith({ - integrationId: oauthAuthorization.integrationId, - organizationId, - workspace: oauthAuthorization.workspace, - repositoryUuid, + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: false, + reason: 'integration_mismatch', }); + expect(deps.findCachedRepository).not.toHaveBeenCalled(); }); it('does not fall back to OAuth while a Workspace Access Token needs attention', async () => { @@ -204,6 +267,25 @@ describe('Bitbucket runtime token resolver', () => { } ); + it.each([ + 'not_connected', + 'reconnect_required', + 'insufficient_permissions', + 'temporarily_unavailable', + ] as const)('returns authorization failure %s without identity or credentials', async status => { + const deps = dependencies(); + deps.authorizationService.getAuthorization.mockResolvedValue({ status }); + + await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ + success: false, + reason: status, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason: status }); + expect(deps.findCachedRepository).not.toHaveBeenCalled(); + }); + it.each([ ['not_connected', 'not_connected'], ['repository_not_found', 'repository_not_found'], @@ -220,6 +302,9 @@ describe('Bitbucket runtime token resolver', () => { success: false, reason, }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason }); expect(deps.listRepositories).not.toHaveBeenCalled(); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); } @@ -229,15 +314,19 @@ describe('Bitbucket runtime token resolver', () => { [{ workspaceUuid: '123e4567-e89b-12d3-a456-426614174099' }, 'workspace_mismatch'], [{ repositoryUrl: 'https://bitbucket.org/other/widgets.git' }, 'workspace_mismatch'], [{ repositoryUrl: 'https://user@bitbucket.org/acme/widgets.git' }, 'invalid_request'], - [{ expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }, 'integration_mismatch'], + [{ expectedIntegrationId: oauthAuthorization.integrationId }, 'integration_mismatch'], ] as const)( 'fails before provider access when request identity drifts %#', async (overrides, reason) => { const deps = dependencies(); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); await expect( resolveBitbucketToken({} as CloudflareEnv, tokenParams(overrides), deps) ).resolves.toEqual({ success: false, reason }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(overrides), deps) + ).resolves.toEqual({ success: false, reason }); expect(deps.findCachedRepository).not.toHaveBeenCalled(); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); } @@ -255,6 +344,9 @@ describe('Bitbucket runtime token resolver', () => { success: false, reason: 'repository_mismatch', }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); }); }); diff --git a/services/git-token-service/src/bitbucket-runtime-token-resolver.ts b/services/git-token-service/src/bitbucket-runtime-token-resolver.ts index f39d45b1b8..2fc20ca4dc 100644 --- a/services/git-token-service/src/bitbucket-runtime-token-resolver.ts +++ b/services/git-token-service/src/bitbucket-runtime-token-resolver.ts @@ -39,7 +39,7 @@ export type GetBitbucketTokenParams = { }; export type GetBitbucketTokenResult = - | { success: true; token: string } + | { success: true; token: string; integrationId: string } | { success: false; reason: @@ -398,7 +398,11 @@ export async function resolveBitbucketToken( ): Promise { const resolved = await resolveBitbucketAuthorizedRepository(env, params, dependencyOverrides); if (!resolved.success) return resolved; - return { success: true, token: resolved.authorization.token }; + return { + success: true, + token: resolved.authorization.token, + integrationId: resolved.authorization.integrationId, + }; } export type BitbucketCapabilitySubject = { diff --git a/services/git-token-service/src/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts index 90b11fafe8..03715c98d0 100644 --- a/services/git-token-service/src/github-session-capability.test.ts +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -86,6 +86,77 @@ describe('GitHubSessionCapabilityCodec', () => { expect(decoded).not.toHaveProperty('integrationId'); }); + describe.each([undefined, claims.outboundContainerId])('container %s', outboundContainerId => { + it.each([ + { type: 'user', id: 'oauth/personal-owner' }, + { type: 'org', id: claims.orgId }, + ] as const)( + 'seals the $type integration owner separately from the session context', + integrationOwner => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...unboundClaims } = claims; + const decoded = codec.decode( + codec.issue({ + ...unboundClaims, + userId: 'oauth/personal-owner', + ...(outboundContainerId === undefined ? {} : { outboundContainerId }), + integrationOwner, + }) + ); + + expect(decoded).toMatchObject({ + userId: 'oauth/personal-owner', + orgId: claims.orgId, + integrationId: claims.integrationId, + integrationOwner, + owner: 'acme', + repo: 'widgets', + identity: claims.identity, + }); + } + ); + + it.each([ + ['unknown owner type', { type: 'team', id: claims.orgId }], + ['empty Personal owner', { type: 'user', id: '' }], + ['invalid organization ID', { type: 'org', id: 'not-a-uuid' }], + ['unknown owner field', { type: 'user', id: 'user_1', orgId: claims.orgId }], + ])('rejects a decrypted claim with %s', (_name, integrationOwner) => { + const version = outboundContainerId === undefined ? 1 : 2; + const serializedClaims = JSON.stringify({ + ...claims, + purpose: 'github_scm_session', + version, + outboundContainerId, + integrationOwner, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgh${version}.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + expect(() => new GitHubSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects an owner claim without its integration pin', () => { + const version = outboundContainerId === undefined ? 1 : 2; + const serializedClaims = JSON.stringify({ + ...claims, + purpose: 'github_scm_session', + version, + outboundContainerId, + integrationId: undefined, + integrationOwner: { type: 'user', id: 'user_1' }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgh${version}.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + expect(() => new GitHubSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + }); + it.each([ ['legacy unbound v1', 'kgh1.', 1, 2 * 60 * 60 * 1000, false], ['container-bound v2', 'kgh2.', 2, 4 * 60 * 60 * 1000, true], diff --git a/services/git-token-service/src/github-session-capability.ts b/services/git-token-service/src/github-session-capability.ts index 13b890b3e9..774445c61f 100644 --- a/services/git-token-service/src/github-session-capability.ts +++ b/services/git-token-service/src/github-session-capability.ts @@ -1,6 +1,12 @@ import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; import { Buffer } from 'node:buffer'; import { z } from 'zod'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; + +export const GitHubIntegrationOwnerSchema: z.ZodType = z.discriminatedUnion('type', [ + z.object({ type: z.literal('user'), id: z.string().min(1) }).strict(), + z.object({ type: z.literal('org'), id: z.string().uuid() }).strict(), +]); const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; const BOUND_CAPABILITY_PREFIX = 'kgh2.'; @@ -42,6 +48,7 @@ const GitHubSessionCapabilityClaimsBaseSchema = z.object({ userId: z.string().min(1), orgId: z.string().uuid().optional(), integrationId: z.string().uuid().optional(), + integrationOwner: GitHubIntegrationOwnerSchema.optional(), owner: GitHubPathPartSchema, repo: GitHubPathPartSchema, source: z.enum(['user', 'installation']), @@ -62,6 +69,7 @@ const GitHubSessionCapabilityClaimsSchema = z GitHubBoundSessionCapabilityClaimsSchema, ]) .refine(claims => claims.expiresAt > claims.issuedAt) + .refine(claims => claims.integrationOwner === undefined || claims.integrationId !== undefined) .refine( claims => claims.expiresAt - claims.issuedAt <= getGitHubSessionCapabilityLifetimeMs(claims.version) @@ -74,6 +82,7 @@ export type GitHubSessionCapabilitySubject = { outboundContainerId?: string; orgId?: string; integrationId?: string; + integrationOwner?: Owner; owner: string; repo: string; source: GitHubAuthSource; diff --git a/services/git-token-service/src/gitlab-runtime-token-resolver.test.ts b/services/git-token-service/src/gitlab-runtime-token-resolver.test.ts index 3621f61bbe..c972b73f91 100644 --- a/services/git-token-service/src/gitlab-runtime-token-resolver.test.ts +++ b/services/git-token-service/src/gitlab-runtime-token-resolver.test.ts @@ -1,45 +1,302 @@ -import { describe, expect, it, vi } from 'vitest'; -import { resolveGitLabRuntimeToken } from './gitlab-runtime-token-resolver.js'; - -describe('resolveGitLabRuntimeToken one-way credentials', () => { - it('resolves an OAuth integration through the unified resolver and fences only its stable ID', async () => { - const integrationId = '123e4567-e89b-12d3-a456-426614174011'; - const credentialResolver = { - resolveCredential: vi.fn().mockResolvedValue({ - status: 'available', - token: 'encrypted-oauth-token', - instanceUrl: 'https://gitlab.example.com', - glabIsOAuth2: true, - integrationId, - credentialId: '123e4567-e89b-12d3-a456-426614174012', - credentialVersion: 4, - source: { type: 'integration' }, +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + resolveGitLabRuntimeToken, + type GetGitLabTokenParams, +} from './gitlab-runtime-token-resolver.js'; +import type { AuthorizedGitLabIntegration } from './gitlab-lookup-service.js'; +import type { GitLabCredentialBroker } from './gitlab-credential-broker.js'; + +const first: AuthorizedGitLabIntegration = { + integrationId: '123e4567-e89b-12d3-a456-426614174011', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { gitlab_instance_url: 'https://gitlab.example.com/gitlab' }, +}; +const second = { ...first, integrationId: '123e4567-e89b-12d3-a456-426614174012' }; +const repositoryUrl = 'https://gitlab.example.com/gitlab/acme/nested/widgets.git'; +const owner = { userId: 'oauth/owner' }; +const notFound = { success: false, reason: 'no_integration_found' } as const; + +function dependencies(integrations: AuthorizedGitLabIntegration[] = [first]) { + const authorized = (actor: GetGitLabTokenParams) => + actor.userId === owner.userId && actor.orgId === undefined; + return { + lookupService: { + findGitLabIntegration: vi.fn(async (actor: GetGitLabTokenParams, id?: string) => { + const integration = authorized(actor) + ? integrations.find(item => id === undefined || item.integrationId === id) + : undefined; + return integration ? { success: true as const, ...integration } : notFound; }), + findAuthorizedGitLabIntegrations: vi.fn(async (actor: GetGitLabTokenParams) => + authorized(actor) && integrations.length + ? { success: true as const, integrations } + : notFound + ), + }, + credentialResolver: { + hasProjectCredentialCandidates: vi.fn().mockResolvedValue(true), + resolveCredential: vi.fn( + async (_actor, selector) => { + const integration = integrations.find( + item => item.integrationId === selector.integrationId + ); + if (!integration) return { status: 'not_connected' as const }; + return { + status: 'available' as const, + integrationId: integration.integrationId, + token: + integration.integrationId === first.integrationId ? 'first-token' : 'second-token', + instanceUrl: integration.metadata.gitlab_instance_url ?? 'https://gitlab.com', + glabIsOAuth2: selector.credential === 'integration', + credentialId: '123e4567-e89b-12d3-a456-426614174099', + credentialVersion: 4, + source: + selector.credential === 'integration' + ? { type: 'integration' as const } + : { type: 'project' as const, projectId: selector.projectId }, + }; + } + ), + }, + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('resolveGitLabRuntimeToken exact identity', () => { + it('keeps a unique old-form OAuth request and fences its stable credential ID', async () => { + await expect(resolveGitLabRuntimeToken(owner, dependencies())).resolves.toEqual({ + success: true, + token: 'first-token', + instanceUrl: 'https://gitlab.example.com/gitlab', + glabIsOAuth2: true, + integrationId: first.integrationId, + source: { type: 'integration', credentialId: '123e4567-e89b-12d3-a456-426614174099' }, + }); + }); + + it('selects the pinned integration instead of the first authorized integration', async () => { + await expect( + resolveGitLabRuntimeToken( + { ...owner, repositoryUrl, expectedIntegrationId: second.integrationId }, + dependencies([first, second]) + ) + ).resolves.toMatchObject({ + success: true, + token: 'second-token', + integrationId: second.integrationId, + }); + }); + + it.each([undefined, repositoryUrl])('rejects ambiguous old-form identity for %s', async url => { + const deps = dependencies([first, second]); + await expect( + resolveGitLabRuntimeToken({ ...owner, repositoryUrl: url }, deps) + ).resolves.toEqual({ success: false, reason: 'ambiguous_integration' }); + expect(deps.credentialResolver.resolveCredential).not.toHaveBeenCalled(); + }); + + it('resolves an absent pin by the authorized host and full nested path', async () => { + const otherHost = { + ...first, + metadata: { gitlab_instance_url: 'https://other.example.com/gitlab' }, }; - const lookupService = { - findGitLabIntegration: vi.fn().mockResolvedValue({ + await expect( + resolveGitLabRuntimeToken({ ...owner, repositoryUrl }, dependencies([otherHost, second])) + ).resolves.toMatchObject({ + success: true, + token: 'second-token', + instanceUrl: 'https://gitlab.example.com/gitlab', + }); + }); + + it.each([{}, { expectedIntegrationId: second.integrationId }])( + 'resolves an authorized instance subpath with request fields %j', + async pin => { + const integration = { + ...second, + metadata: { gitlab_instance_url: 'https://gitlab.example.com/gitlab+enterprise' }, + }; + await expect( + resolveGitLabRuntimeToken( + { + ...owner, + ...pin, + repositoryUrl: 'https://gitlab.example.com/gitlab+enterprise/acme/widgets.git', + }, + dependencies([first, integration]) + ) + ).resolves.toMatchObject({ success: true, - integrationId, - integrationType: 'oauth', - accountId: '42', - accountLogin: 'octocat', - metadata: { gitlab_instance_url: 'https://gitlab.example.com' }, - }), - findAuthorizedGitLabIntegrations: vi.fn(), - }; + token: 'second-token', + integrationId: second.integrationId, + instanceUrl: 'https://gitlab.example.com/gitlab+enterprise', + }); + } + ); + + it.each([ + 'https://gitlab.example.com/gitlab+enterprise/acme/wid+gets.git', + 'https://gitlab.example.com/gitlab+enterprise/acme/%2e%2e/widgets.git', + ])('keeps invalid projects rejected below an authorized instance subpath: %s', async url => { + await expect( + resolveGitLabRuntimeToken( + { ...owner, repositoryUrl: url }, + dependencies([ + { + ...first, + metadata: { gitlab_instance_url: 'https://gitlab.example.com/gitlab+enterprise' }, + }, + ]) + ) + ).resolves.toEqual({ success: false, reason: 'invalid_repository_url' }); + }); + + it.each([{}, { expectedIntegrationId: first.integrationId }])( + 'preserves invalid URL error precedence over failed authorization: %j', + async pin => { + await expect( + resolveGitLabRuntimeToken( + { ...owner, ...pin, repositoryUrl: 'not-a-url' }, + dependencies([]) + ) + ).resolves.toEqual({ success: false, reason: 'invalid_repository_url' }); + } + ); + it('rejects ambiguous old-form identity under an authorized instance subpath', async () => { + const metadata = { gitlab_instance_url: 'https://gitlab.example.com/gitlab+enterprise' }; await expect( - resolveGitLabRuntimeToken({ userId: 'user-1' }, { lookupService, credentialResolver }) - ).resolves.toEqual({ + resolveGitLabRuntimeToken( + { + ...owner, + repositoryUrl: 'https://gitlab.example.com/gitlab+enterprise/acme/widgets.git', + }, + dependencies([ + { ...first, metadata }, + { ...second, metadata }, + ]) + ) + ).resolves.toEqual({ success: false, reason: 'ambiguous_integration' }); + }); + + it.each([ + { userId: 'wrong-personal-owner' }, + { ...owner, orgId: '123e4567-e89b-12d3-a456-426614174030' }, + ])('retains owner authorization with a pin: %j', async actor => { + const deps = dependencies(); + await expect( + resolveGitLabRuntimeToken({ ...actor, expectedIntegrationId: first.integrationId }, deps) + ).resolves.toEqual(notFound); + expect(deps.credentialResolver.resolveCredential).not.toHaveBeenCalled(); + }); + + it('does not substitute a different integration when the pinned integration is inactive', async () => { + const deps = dependencies([second]); + await expect( + resolveGitLabRuntimeToken({ ...owner, expectedIntegrationId: first.integrationId }, deps) + ).resolves.toEqual(notFound); + expect(deps.credentialResolver.resolveCredential).not.toHaveBeenCalled(); + }); + + it.each([ + 'https://other.example.com/gitlab/acme/nested/widgets.git', + 'https://gitlab.example.com/gitlab-other/acme/nested/widgets.git', + ])('rejects a pinned host or subpath collision: %s', async url => { + const deps = dependencies(); + await expect( + resolveGitLabRuntimeToken( + { ...owner, repositoryUrl: url, expectedIntegrationId: first.integrationId }, + deps + ) + ).resolves.toEqual({ success: false, reason: 'no_matching_integration' }); + expect(deps.credentialResolver.resolveCredential).not.toHaveBeenCalled(); + }); + + it('preserves temporary credential failures instead of treating them as an empty authorization', async () => { + const deps = dependencies(); + deps.credentialResolver.resolveCredential.mockResolvedValueOnce({ + status: 'temporarily_unavailable', + }); + await expect(resolveGitLabRuntimeToken(owner, deps)).resolves.toEqual({ + success: false, + reason: 'token_refresh_failed', + }); + }); + + it.each(['https://gitlab.example.com', 'https://other.example.com/gitlab'])( + 'rejects credential instance rebinding to %s', + async instanceUrl => { + const deps = dependencies(); + deps.credentialResolver.resolveCredential.mockResolvedValueOnce({ + status: 'available', + token: 'foreign-token', + integrationId: first.integrationId, + instanceUrl, + glabIsOAuth2: true, + source: { type: 'integration' }, + }); + await expect( + resolveGitLabRuntimeToken( + { ...owner, repositoryUrl, expectedIntegrationId: first.integrationId }, + deps + ) + ).resolves.toEqual({ success: false, reason: 'no_matching_integration' }); + } + ); + + it('does not select a legacy project token while another candidate remains unresolved', async () => { + const deps = dependencies([first, second]); + deps.credentialResolver.resolveCredential.mockResolvedValueOnce({ + status: 'temporarily_unavailable', + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => Response.json({ id: 73 })) + ); + await expect( + resolveGitLabRuntimeToken({ ...owner, repositoryUrl, createdOnPlatform: 'code-review' }, deps) + ).resolves.toEqual({ success: false, reason: 'token_refresh_failed' }); + }); + + it('keeps no authorized integration distinct from credential failure', async () => { + await expect(resolveGitLabRuntimeToken(owner, dependencies([]))).resolves.toEqual(notFound); + }); + + it('pins project-token resolution without evaluating a different authorized integration', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ id: 73 })); + vi.stubGlobal('fetch', fetch); + await expect( + resolveGitLabRuntimeToken( + { + ...owner, + repositoryUrl, + createdOnPlatform: 'code-review', + expectedIntegrationId: second.integrationId, + }, + dependencies([first, second]) + ) + ).resolves.toMatchObject({ success: true, - token: 'encrypted-oauth-token', - instanceUrl: 'https://gitlab.example.com', - glabIsOAuth2: true, - integrationId, - source: { - type: 'integration', - credentialId: '123e4567-e89b-12d3-a456-426614174012', - }, + token: 'second-token', + integrationId: second.integrationId, + source: { type: 'project', projectId: 73 }, }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('keeps ambiguous legacy project credentials rejected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => Response.json({ id: 73 })) + ); + await expect( + resolveGitLabRuntimeToken( + { ...owner, repositoryUrl, createdOnPlatform: 'code-review' }, + dependencies([first, second]) + ) + ).resolves.toEqual({ success: false, reason: 'ambiguous_integration' }); }); }); diff --git a/services/git-token-service/src/gitlab-runtime-token-resolver.ts b/services/git-token-service/src/gitlab-runtime-token-resolver.ts index 5c6b6cde42..cb402d008c 100644 --- a/services/git-token-service/src/gitlab-runtime-token-resolver.ts +++ b/services/git-token-service/src/gitlab-runtime-token-resolver.ts @@ -3,6 +3,7 @@ import type { GitLabCredentialBroker } from './gitlab-credential-broker.js'; import { isValidGitLabRepositoryUrl, matchGitLabRepositoryToIntegration, + normalizeGitLabInstanceUrl, type GitLabLookupService, type GitLabRepositoryMatch, } from './gitlab-lookup-service.js'; @@ -14,6 +15,7 @@ import { export type GetGitLabTokenParams = { userId: string; orgId?: string; + expectedIntegrationId?: string; repositoryUrl?: string; createdOnPlatform?: string; }; @@ -232,14 +234,58 @@ export async function resolveGitLabRuntimeToken( params: GetGitLabTokenParams, dependencies: GitLabRuntimeTokenDependencies ): Promise { + const repositoryUrl = params.repositoryUrl; + if (params.createdOnPlatform === 'code-review' && !repositoryUrl) { + return { success: false, reason: 'repository_url_required' }; + } + // The generic check includes the instance prefix. Ordinary requests validate the + // project relative to an authorized integration before returning this failure. + const repositoryUrlFailure: GetGitLabTokenFailure | undefined = + repositoryUrl !== undefined && !isValidGitLabRepositoryUrl(repositoryUrl) + ? { success: false, reason: 'invalid_repository_url' } + : undefined; + if (params.createdOnPlatform === 'code-review' && repositoryUrlFailure) { + return repositoryUrlFailure; + } + // Old raw-token and capability requests omit the pin. Keep their authorized lookup + // until no old clients/records remain and the 30-day ledger window has expired. + const pinned = + params.expectedIntegrationId === undefined + ? undefined + : await dependencies.lookupService.findGitLabIntegration( + params, + params.expectedIntegrationId + ); + if (pinned && !pinned.success) return repositoryUrlFailure ?? pinned; + const authorized = pinned?.success + ? { success: true as const, integrations: [pinned] } + : await dependencies.lookupService.findAuthorizedGitLabIntegrations(params); + if (!authorized.success) return repositoryUrlFailure ?? authorized; + const integrations = + repositoryUrl === undefined + ? authorized.integrations + : authorized.integrations.filter( + integration => matchGitLabRepositoryToIntegration(repositoryUrl, integration) !== null + ); + if (integrations.length === 0) { + return repositoryUrlFailure ?? { success: false, reason: 'no_matching_integration' }; + } + if (params.createdOnPlatform !== 'code-review') { - const integration = await dependencies.lookupService.findGitLabIntegration(params); - if (!integration.success) return integration; + if (integrations.length !== 1) return { success: false, reason: 'ambiguous_integration' }; + const integration = integrations[0]; const credential = await dependencies.credentialResolver.resolveCredential(params, { credential: 'integration', integrationId: integration.integrationId, }); if (credential.status !== 'available') return mapCredentialFailure(credential.status); + if ( + credential.integrationId !== integration.integrationId || + (repositoryUrl !== undefined && + normalizeGitLabInstanceUrl(credential.instanceUrl) !== + matchGitLabRepositoryToIntegration(repositoryUrl, integration)?.instanceUrl) + ) + return { success: false, reason: 'no_matching_integration' }; return { success: true, token: credential.token, @@ -250,17 +296,10 @@ export async function resolveGitLabRuntimeToken( }; } - const repositoryUrl = params.repositoryUrl; if (!repositoryUrl) return { success: false, reason: 'repository_url_required' }; - if (!isValidGitLabRepositoryUrl(repositoryUrl)) { - return { success: false, reason: 'invalid_repository_url' }; - } - const authorized = await dependencies.lookupService.findAuthorizedGitLabIntegrations(params); - if (!authorized.success) return authorized; - const matches = authorized.integrations + const matches = integrations .map(integration => matchGitLabRepositoryToIntegration(repositoryUrl, integration)) .filter((match): match is GitLabRepositoryMatch => match !== null); - if (matches.length === 0) return { success: false, reason: 'no_matching_integration' }; const evaluations = await Promise.all( matches.map(match => @@ -271,10 +310,8 @@ export async function resolveGitLabRuntimeToken( evaluation.status === 'qualified' ? [evaluation.candidate] : [] ); if (qualified.length > 1) return { success: false, reason: 'ambiguous_integration' }; - if (qualified.length === 0) { - const tokenFailure = evaluations.find(evaluation => evaluation.status === 'token_failed'); - if (tokenFailure?.status === 'token_failed') return tokenFailure.failure; - } + const tokenFailure = evaluations.find(evaluation => evaluation.status === 'token_failed'); + if (tokenFailure?.status === 'token_failed') return tokenFailure.failure; if (evaluations.some(evaluation => evaluation.status === 'lookup_failed')) { return { success: false, reason: 'project_lookup_failed' }; } diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index b1735c6993..0e3c8a4b1a 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1,7 +1,15 @@ import { signKiloToken } from '@kilocode/worker-utils'; +import * as dbClient from '@kilocode/db/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as GitLabCredentialBrokerHandlerModule from './gitlab-credential-broker-handler.js'; import type * as GitLabLookupServiceModule from './gitlab-lookup-service.js'; +import type * as InteractiveReviewHandlerModule from './interactive-review-handler.js'; +import type * as InstallationLookupServiceModule from './installation-lookup-service.js'; +import { GitHubSessionCapabilityCodec } from './github-session-capability.js'; +import type { + FindInstallationParams, + ManagedInstallationLookupSuccess, +} from './installation-lookup-service.js'; const serviceMocks = vi.hoisted(() => ({ findInstallationId: vi.fn(), @@ -20,6 +28,7 @@ const serviceMocks = vi.hoisted(() => ({ listBitbucketRepositories: vi.fn(), resolveBitbucketToken: vi.fn(), resolveBitbucketCapabilitySubject: vi.fn(), + handleBitbucketInteractiveReview: vi.fn(), })); vi.mock('cloudflare:workers', () => ({ @@ -97,10 +106,22 @@ vi.mock('./bitbucket-runtime-token-resolver.js', () => ({ resolveBitbucketCapabilitySubject: serviceMocks.resolveBitbucketCapabilitySubject, })); +vi.mock('./interactive-review-handler.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + handleBitbucketInteractiveReview: serviceMocks.handleBitbucketInteractiveReview, + }; +}); + import gitTokenServiceWorker, { GitTokenRPCEntrypoint } from './index.js'; beforeEach(() => { serviceMocks.hasGitLabProjectCredentialCandidates.mockReset().mockResolvedValue(false); + serviceMocks.findAuthorizedGitLabIntegrations.mockReset().mockImplementation(async actor => { + const integration = await serviceMocks.findGitLabIntegration(actor); + return integration.success ? { success: true, integrations: [integration] } : integration; + }); serviceMocks.resolveGitLabCredential.mockReset().mockImplementation(async (actor, selector) => { const latestIntegrationLookup = serviceMocks.findGitLabIntegration.mock.results.at(-1)?.value; const latestAuthorizedLookup = @@ -158,6 +179,178 @@ beforeEach(() => { }); }); +describe('Bitbucket interactive HTTP boundary', () => { + const secret = 'test-secret-that-is-at-least-32-characters'; + const audience = 'git-token-service:bitbucket-interactive-review'; + const organizationId = '123e4567-e89b-12d3-a456-426614174030'; + const input = { + integrationId: '123e4567-e89b-12d3-a456-426614174033', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174031', + workspaceSlug: 'acme', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174032', + repositoryFullName: 'acme/widgets', + request: { + operation: 'createComment', + params: { path: { workspace: 'acme', repo_slug: 'widgets', pull_request_id: 7 } }, + body: { content: { raw: '' } }, + }, + }; + const env = { NEXTAUTH_SECRET: secret } as CloudflareEnv; + async function send( + options: { + audience?: string | null; + personal?: boolean; + body?: string; + path?: string; + method?: string; + headers?: Record; + } = {} + ) { + const { token } = await signKiloToken({ + userId: 'oauth/member', + pepper: null, + secret, + expiresInSeconds: 60, + audience: options.audience === null ? undefined : (options.audience ?? audience), + extra: options.personal ? undefined : { organizationId }, + }); + return gitTokenServiceWorker.fetch( + new Request( + `https://service.test${options.path ?? '/internal/bitbucket/interactive-review'}`, + { + method: options.method ?? 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + ...(options.method === 'GET' ? {} : { body: options.body ?? JSON.stringify(input) }), + } + ), + env + ); + } + beforeEach(() => { + serviceMocks.handleBitbucketInteractiveReview + .mockReset() + .mockImplementation(async (_env, actor) => ({ + success: true, + result: { status: 201, data: { id: 91 } }, + metadata: { + actorUserId: actor.userId, + organizationId: actor.orgId, + integrationId: input.integrationId, + instanceUrl: 'https://bitbucket.org', + providerActor: { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: input.workspaceUuid, + workspaceSlug: input.workspaceSlug, + }, + grants: { scopes: ['pullrequest'] }, + }, + })); + }); + + it('dispatches with verified claims and retains allowlisted metadata in a no-store response', async () => { + const response = await send(); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toMatchObject({ + success: true, + result: { status: 201, data: { id: 91 } }, + metadata: { actorUserId: 'oauth/member', organizationId }, + }); + }); + + it.each([ + [{ method: 'GET' }, 405], + [{ audience: null }, 401], + [{ audience: 'git-token-service:bitbucket-repositories' }, 401], + [{ personal: true }, 403], + [{ body: '{' }, 400], + [{ headers: { 'Content-Type': 'text/plain' } }, 400], + [{ body: JSON.stringify({ ...input, actorUserId: 'attacker' }) }, 400], + [{ body: JSON.stringify({ ...input, metadata: { actorUserId: 'attacker' } }) }, 400], + ] as const)('does not cache or dispatch invalid requests %#', async (options, status) => { + const response = await send(options); + expect(response.status).toBe(status); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(serviceMocks.handleBitbucketInteractiveReview).not.toHaveBeenCalled(); + }); + + it.each([256_000, 256_001])( + 'enforces the separate streamed byte limit at %i bytes', + async bytes => { + const raw = 'x'.repeat(bytes - new TextEncoder().encode(JSON.stringify(input)).byteLength); + const response = await send({ + body: JSON.stringify({ + ...input, + request: { ...input.request, body: { content: { raw } } }, + }), + }); + expect(response.status).toBe(bytes === 256_000 ? 200 : 413); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toMatchObject( + bytes === 256_000 ? { success: true } : { success: false, reason: 'request_too_large' } + ); + } + ); + + it('rejects oversized declared length before handler dispatch', async () => { + const response = await send({ headers: { 'Content-Length': '256001' } }); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ success: false, reason: 'request_too_large' }); + expect(serviceMocks.handleBitbucketInteractiveReview).not.toHaveBeenCalled(); + }); + + it.each([ + ['/internal/gitlab/credentials', 'git-token-service:gitlab-credentials'], + ['/internal/github-user-authorizations/token', 'git-token-service:github-user-access-token'], + [ + '/internal/bitbucket/code-review/pull-request', + 'git-token-service:bitbucket-code-review:pull-request', + ], + [ + '/internal/bitbucket/code-review/webhooks/ensure', + 'git-token-service:bitbucket-code-review:webhook-ensure', + ], + [ + '/internal/bitbucket/code-review/webhooks/delete', + 'git-token-service:bitbucket-code-review:webhook-delete', + ], + ])('retains the 16,000-byte limit on %s', async (path, audience) => { + const response = await send({ path, audience, body: '{}'.padEnd(16_001, ' ') }); + expect(response.status).toBe(400); + }); + + it('does not expose sibling paths', async () => { + expect((await send({ path: '/internal/bitbucket/interactive-review/other' })).status).toBe(404); + }); + + it('sanitizes handler failure and prevents caching', async () => { + serviceMocks.handleBitbucketInteractiveReview.mockRejectedValue(new Error('provider-secret')); + const response = await send(); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ + success: false, + reason: 'temporarily_unavailable', + }); + }); + + it('prevents caching when authentication is unavailable', async () => { + const response = await gitTokenServiceWorker.fetch( + new Request('https://service.test/internal/bitbucket/interactive-review', { + method: 'POST', + headers: { Authorization: 'Bearer assertion' }, + }), + { NEXTAUTH_SECRET: '' } as CloudflareEnv + ); + expect(response.status).toBe(503); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ error: 'authentication_unavailable' }); + }); +}); + describe('Bitbucket repository-list HTTP authorization', () => { const jwtSecret = 'test-secret-that-is-at-least-32-characters'; const env = { NEXTAUTH_SECRET: jwtSecret } as CloudflareEnv; @@ -367,25 +560,67 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { return result.capability; } - it('issues an opaque capability and the canonical git URL', async () => { - const result = await createService().issueBitbucketSessionCapability(issueParams); - expect(result).toEqual({ - success: true, - capability: expect.stringMatching(/^kbb1\./), - gitUrl: 'https://bitbucket.org/acme/widgets.git', - }); - if (result.success) { + it.each([ + ['legacy request', {}], + ['matching pin', { expectedIntegrationId: subject.integrationId }], + // The RPC projection must trust the resolver, not echo a caller's claimed pin. + ['different caller pin', { expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }], + ] as const)( + 'returns the capability, canonical URL, and resolved integration for %s', + async (_name, pin) => { + const result = await createService().issueBitbucketSessionCapability({ + ...issueParams, + ...pin, + }); + expect(result).toEqual({ + success: true, + capability: expect.stringMatching(/^kbb1\./), + gitUrl: 'https://bitbucket.org/acme/widgets.git', + integrationId: subject.integrationId, + }); + if (!result.success) throw new Error(`issue failed: ${result.reason}`); expect(result.capability).not.toContain(subject.token); + const { BitbucketSessionCapabilityCodec } = await import('./bitbucket-session-capability.js'); + const claims = new BitbucketSessionCapabilityCodec( + Buffer.alloc(32, 7).toString('base64') + ).decode(result.capability); + expect(claims).toMatchObject({ + integrationId: subject.integrationId, + workspaceUuid: subject.workspaceUuid, + repositoryUuid: subject.repositoryUuid, + repositoryFullName: 'acme/widgets', + outboundContainerId: 'outbound-container-1', + }); } - }); + ); - it('propagates a resolution failure from issue', async () => { + it.each([ + 'invalid_request', + 'not_connected', + 'reconnect_required', + 'temporarily_unavailable', + 'insufficient_permissions', + 'integration_mismatch', + 'workspace_mismatch', + 'repository_not_found', + 'repository_mismatch', + ])('preserves issue failure %s without exposing identity or credentials', async reason => { serviceMocks.resolveBitbucketCapabilitySubject .mockReset() - .mockResolvedValue({ success: false, reason: 'reconnect_required' }); - await expect(createService().issueBitbucketSessionCapability(issueParams)).resolves.toEqual({ + .mockResolvedValue({ success: false, reason }); + await expect( + createService().issueBitbucketSessionCapability({ + ...issueParams, + expectedIntegrationId: subject.integrationId, + }) + ).resolves.toEqual({ success: false, reason }); + }); + + it('exposes no resolved identity when capability configuration fails', async () => { + const service = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + await expect(service.issueBitbucketSessionCapability(issueParams)).resolves.toEqual({ success: false, - reason: 'reconnect_required', + reason: 'capability_configuration_error', }); }); @@ -445,6 +680,22 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); }); + it('rejects redemption when the integration changes without token rotation', async () => { + const capability = await issueCapability(); + serviceMocks.resolveBitbucketCapabilitySubject.mockResolvedValue({ + success: true, + subject: { ...subject, integrationId: '123e4567-e89b-12d3-a456-426614174099' }, + }); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/widgets.git/git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + }); + it.each([ // Single-encoded traversal is caught by the raw check. ['https://bitbucket.org/acme/widgets.git/%2e%2e/git-upload-pack'], @@ -466,42 +717,64 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { }); describe('GitTokenRPCEntrypoint Bitbucket runtime authorization', () => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const params = { + userId: 'user-1', + orgId: '123e4567-e89b-12d3-a456-426614174030', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + repositoryUrl: 'https://bitbucket.org/acme/widgets.git', + }; + it('requires an organization before invoking the reachable V1 resolver', async () => { serviceMocks.resolveBitbucketToken.mockReset(); await expect( - createService().getBitbucketToken({ - userId: 'user-1', - workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', - repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', - repositoryUrl: 'https://bitbucket.org/acme/widgets.git', - }) + createService().getBitbucketToken({ ...params, orgId: undefined }) ).resolves.toEqual({ success: false, reason: 'invalid_request' }); expect(serviceMocks.resolveBitbucketToken).not.toHaveBeenCalled(); }); - it('returns only the opaque token from an integration-fenced V1 resolution', async () => { + it.each([ + ['legacy request', {}], + ['matching pin', { expectedIntegrationId: integrationId }], + // The resolver owns authorization; this projection must never echo the request pin. + ['different caller pin', { expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }], + ] as const)('returns only the token and resolved integration for %s', async (_name, pin) => { serviceMocks.resolveBitbucketToken.mockReset().mockResolvedValue({ success: true, token: 'ATCT-runtime-token', - integrationId: 'must-not-cross-rpc', + integrationId, credentialId: 'must-not-cross-rpc', credentialVersion: 7, }); - const params = { - userId: 'user-1', - orgId: '123e4567-e89b-12d3-a456-426614174030', - workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', - repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', - repositoryUrl: 'https://bitbucket.org/acme/widgets.git', - expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174022', - }; - await expect(createService().getBitbucketToken(params)).resolves.toEqual({ + await expect(createService().getBitbucketToken({ ...params, ...pin })).resolves.toEqual({ success: true, token: 'ATCT-runtime-token', + integrationId, + }); + expect(serviceMocks.resolveBitbucketToken).toHaveBeenCalledWith(expect.anything(), { + ...params, + ...pin, }); - expect(serviceMocks.resolveBitbucketToken).toHaveBeenCalledWith(expect.anything(), params); + }); + + it.each([ + 'invalid_request', + 'not_connected', + 'reconnect_required', + 'temporarily_unavailable', + 'insufficient_permissions', + 'integration_mismatch', + 'workspace_mismatch', + 'repository_not_found', + 'repository_mismatch', + ])('preserves token failure %s without exposing identity or credentials', async reason => { + serviceMocks.resolveBitbucketToken.mockReset().mockResolvedValue({ success: false, reason }); + await expect( + createService().getBitbucketToken({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual({ success: false, reason }); }); }); @@ -515,37 +788,51 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { vi.clearAllMocks(); }); - it('mints repository-scoped tokens after resolving an authorized installation', async () => { - serviceMocks.findInstallationId.mockResolvedValue({ - success: true, - installationId: '123', - accountLogin: 'old-owner', - githubAppType: 'lite', - }); - serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); - serviceMocks.getToken.mockResolvedValue('installation-wide-token'); + it.each([ + undefined, + '00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000099', + ])( + 'returns a scoped token and the resolved identity rather than the caller pin %s', + async expectedIntegrationId => { + serviceMocks.findInstallationId.mockResolvedValue({ + success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user-1' }, + installationId: '123', + accountLogin: 'old-owner', + githubAppType: 'lite', + }); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + serviceMocks.getToken.mockResolvedValue('installation-wide-token'); - const result = await createService().getTokenForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - }); + const result = await createService().getTokenForRepo({ + githubRepo: 'renamed-owner/repository', + userId: 'user-1', + expectedIntegrationId, + }); - expect(result).toEqual({ - success: true, - token: 'scoped-token', - installationId: '123', - accountLogin: 'old-owner', - appType: 'lite', - }); - expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); - expect(serviceMocks.getToken).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ + success: true, + token: 'scoped-token', + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user-1' }, + installationId: '123', + accountLogin: 'old-owner', + appType: 'lite', + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); + expect(serviceMocks.getToken).not.toHaveBeenCalled(); + } + ); it('repairs stale login metadata after a lookup miss before minting a token', async () => { serviceMocks.findInstallationId .mockResolvedValueOnce({ success: false, reason: 'no_installation_found' }) .mockResolvedValueOnce({ success: true, + integrationId: 'integration-1', + integrationOwner: { type: 'user', id: 'user-1' }, installationId: '123', accountLogin: 'renamed-owner', githubAppType: 'standard', @@ -571,7 +858,11 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { userId: 'user-1', }); - expect(result).toMatchObject({ success: true, token: 'scoped-token' }); + expect(result).toMatchObject({ + success: true, + token: 'scoped-token', + integrationId: 'integration-1', + }); expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); expect(consoleLog).toHaveBeenCalledWith( JSON.stringify({ @@ -694,6 +985,8 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { it('does not fall back to an installation-wide token when scoped minting fails', async () => { serviceMocks.findInstallationId.mockResolvedValue({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '123', accountLogin: 'old-owner', githubAppType: 'standard', @@ -706,57 +999,681 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { expect(serviceMocks.getToken).not.toHaveBeenCalled(); }); - it('repairs stale login metadata within the expected integration fence', async () => { - const params = { - githubRepo: 'acme/repository', - userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }; - serviceMocks.findInstallationId - .mockResolvedValueOnce({ success: false, reason: 'integration_mismatch' }) - .mockResolvedValueOnce({ - success: true, - installationId: '123', - accountLogin: 'acme', - githubAppType: 'standard', - }); - serviceMocks.findRefreshCandidates.mockResolvedValue({ - success: true, - candidates: [ - { + it.each([undefined, '00000000-0000-4000-8000-000000000001'])( + 'repairs stale login metadata within the expected integration fence for owner scope %s', + async orgId => { + const params = { + githubRepo: 'acme/repository', + userId: 'user-1', + orgId, + expectedIntegrationId: '00000000-0000-4000-8000-000000000002', + }; + serviceMocks.findInstallationId + .mockResolvedValueOnce({ success: false, reason: 'integration_mismatch' }) + .mockResolvedValueOnce({ + success: true, integrationId: params.expectedIntegrationId, + integrationOwner: + orgId === undefined ? { type: 'user', id: 'user-1' } : { type: 'org', id: orgId }, installationId: '123', - accountLogin: 'old-acme', + accountLogin: 'acme', githubAppType: 'standard', - }, - ], + }); + serviceMocks.findRefreshCandidates.mockResolvedValue({ + success: true, + candidates: [ + { + integrationId: params.expectedIntegrationId, + installationId: '123', + accountLogin: 'old-acme', + githubAppType: 'standard', + }, + ], + }); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await expect(createService().getTokenForRepo(params)).resolves.toMatchObject({ + success: true, + token: 'scoped-token', + integrationId: params.expectedIntegrationId, + }); + expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); + expect(serviceMocks.findRefreshCandidates).toHaveBeenCalledWith(params); + expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith( + params.expectedIntegrationId, + 'acme' + ); + } + ); +}); + +const outboundContainerId = 'outbound-container-1'; + +describe('GitTokenRPCEntrypoint GitHub launch integration identity', () => { + const integration: ManagedInstallationLookupSuccess = { + success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, + }; + const userAuthor = { name: 'octocat', email: '1+octocat@users.noreply.github.com' }; + const installationAuthor = { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }; + const codec = new GitHubSessionCapabilityCodec(Buffer.alloc(32, 7).toString('base64')); + + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findInstallationId.mockResolvedValue(integration); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue(integration); + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [] }); + serviceMocks.getTokenForRepo.mockResolvedValue('installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'user-token', + gitAuthor: userAuthor, + }); + }); + + describe.each([undefined, integration.integrationId, '00000000-0000-4000-8000-000000000099'])( + 'caller pin %s', + expectedIntegrationId => { + it.each([false, true])( + 'returns and seals the authorized identity with user authorization %s', + async allowUserAuthorization => { + const params = { + githubRepo: 'acme/repo', + userId: 'user_1', + expectedIntegrationId, + allowUserAuthorization, + outboundContainerId, + }; + const expectedMetadata = { + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + ...(allowUserAuthorization + ? { source: 'user', gitAuthor: userAuthor, commitCoAuthor: installationAuthor } + : { source: 'installation', gitAuthor: installationAuthor }), + }; + const service = createService(); + await expect(service.getCloudAgentAuthForRepo(params)).resolves.toEqual({ + ...expectedMetadata, + githubToken: allowUserAuthorization ? 'user-token' : 'installation-token', + }); + const issued = await service.issueGitHubSessionCapability(params); + expect(issued).toEqual({ ...expectedMetadata, capability: expect.any(String) }); + if (!issued.success) throw new Error('Expected capability'); + expect(codec.decode(issued.capability)).toMatchObject({ + integrationId: integration.integrationId, + owner: 'acme', + repo: 'repo', + }); + expect(issued).not.toHaveProperty('githubToken'); + } + ); + } + ); + + it.each([ + 'no_user_authorization', + 'revoked', + 'refresh_failed', + 'insufficient_user_access', + 'credential_unreadable', + 'credential_configuration_error', + ])('retains the resolved identity when user selection falls back for %s', async reason => { + serviceMocks.selectUserAuthorization.mockResolvedValue({ selected: false, reason }); + await expect( + createService().getCloudAgentAuthForRepo({ + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + }) + ).resolves.toEqual({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + githubToken: 'installation-token', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: installationAuthor, + fallbackReason: reason, }); - serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); - serviceMocks.updateAccountLogin.mockResolvedValue(true); - serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); - vi.spyOn(console, 'log').mockImplementation(() => {}); + }); - await expect(createService().getTokenForRepo(params)).resolves.toMatchObject({ + it.each(['lite_installation', 'insufficient_user_access'])( + 'retains the resolved identity for the installation fallback %s', + async fallbackReason => { + const githubAppType = fallbackReason === 'lite_installation' ? 'lite' : 'standard'; + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + githubAppType, + permissions: { contents: 'read' }, + }); + await expect( + createService().getCloudAgentAuthForRepo({ + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + }) + ).resolves.toMatchObject({ + success: true, + integrationId: integration.integrationId, + githubToken: 'installation-token', + source: 'installation', + appType: githubAppType, + fallbackReason, + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + + describe.each([undefined, '00000000-0000-4000-8000-000000000001'])('owner scope %s', orgId => { + const actor = { userId: 'user_1', ...(orgId === undefined ? {} : { orgId }) }; + + it.each([false, true])( + 'retains the exact integration after managed login repair with user authorization %s', + async allowUserAuthorization => { + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: false, + reason: 'integration_mismatch', + }); + serviceMocks.findRefreshCandidates.mockResolvedValue({ + success: true, + candidates: [ + { + integrationId: integration.integrationId, + installationId: '123', + accountLogin: 'old-acme', + githubAppType: 'standard', + }, + ], + }); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await expect( + createService().getCloudAgentAuthForRepo({ + ...actor, + githubRepo: 'acme/repo', + expectedIntegrationId: integration.integrationId, + allowUserAuthorization, + }) + ).resolves.toMatchObject({ + success: true, + integrationId: integration.integrationId, + githubToken: allowUserAuthorization ? 'user-token' : 'installation-token', + source: allowUserAuthorization ? 'user' : 'installation', + }); + } + ); + + describe.each([undefined, outboundContainerId])('capability container %s', containerId => { + const container = containerId === undefined ? {} : { outboundContainerId: containerId }; + + it.each([false, true])( + 'pins a legacy request through token refresh with user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...actor, + ...container, + githubRepo: 'acme/repo', + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + expect(issued.integrationId).toBe(integration.integrationId); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === integration.integrationId + ? integration + : { success: false, reason: 'integration_mismatch' } + ); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: userAuthor, + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer refreshed-user-token' + : 'Bearer refreshed-installation-token', + }); + } + ); + + it.each([false, true])( + 'rejects replacement after an unpinned request with user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...actor, + ...container, + githubRepo: 'acme/repo', + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === undefined + ? { ...integration, integrationId: '00000000-0000-4000-8000-000000000099' } + : { success: false, reason: 'integration_mismatch' } + ); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'redeems an old unpinned capability with user authorization %s', + async allowUserAuthorization => { + const capability = codec.issue({ + ...actor, + ...container, + owner: 'acme', + repo: 'repo', + source: allowUserAuthorization ? 'user' : 'installation', + identity: { + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: allowUserAuthorization ? userAuthor : installationAuthor, + ...(allowUserAuthorization ? { commitCoAuthor: installationAuthor } : {}), + }, + }); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === undefined + ? integration + : { success: false, reason: 'integration_mismatch' } + ); + const service = createService(); + await expect( + service.redeemGitHubSessionCapability({ + capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer user-token' + : 'Bearer installation-token', + }); + if (containerId === undefined) { + await expect( + service.redeemGitHubSessionCapability({ + capability, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/other/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + } + } + ); + }); + }); + + async function usePersonalInstallationLookup() { + const { InstallationLookupService } = await vi.importActual< + typeof InstallationLookupServiceModule + >('./installation-lookup-service.js'); + const row = { + id: integration.integrationId, + platform_installation_id: '123', + platform_account_login: 'acme', + github_app_type: 'standard', + integration_status: 'active', + owned_by_organization_id: null, + owned_by_user_id: 'user_1', + repository_access: 'all', + repositories: null, + permissions: { contents: 'write', pull_requests: 'write' }, + }; + const query = { + from: vi.fn(() => query), + leftJoin: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(async () => [row]), + }; + vi.spyOn(dbClient, 'getWorkerDb').mockReturnValue({ select: () => query } as never); + const lookup = new InstallationLookupService({ + HYPERDRIVE: { connectionString: 'postgres://test' }, + } as CloudflareEnv); + serviceMocks.findInstallationId.mockImplementation((params: FindInstallationParams) => + lookup.findInstallationId(params) + ); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + (params: FindInstallationParams) => lookup.findManagedInstallationForRepo(params) + ); + return row; + } + + it('keeps an authorized legacy Personal fallback redeemable in an organization session', async () => { + await usePersonalInstallationLookup(); + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + orgId: '00000000-0000-4000-8000-000000000001', + outboundContainerId, + }); + expect(issued).toMatchObject({ success: true, integrationId: integration.integrationId }); + if (!issued.success) throw new Error('Expected capability'); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: true, authorization: 'Bearer installation-token' }); + }); + + const organizationSession = { + githubRepo: 'acme/repo', + userId: 'user_1', + orgId: '00000000-0000-4000-8000-000000000001', + }; + + it('retains the resolved Personal owner across raw-token retries in an organization session', async () => { + await usePersonalInstallationLookup(); + const service = createService(); + const resolved = await service.getTokenForRepo(organizationSession); + expect(resolved).toEqual({ success: true, - token: 'scoped-token', + token: 'installation-token', + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + appType: 'standard', }); - expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); - expect(serviceMocks.findRefreshCandidates).toHaveBeenCalledWith(params); - expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith( - params.expectedIntegrationId, - 'acme' + if (!resolved.success) throw new Error('Expected raw token'); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + await expect( + service.getTokenForRepo({ + ...organizationSession, + expectedIntegrationId: resolved.integrationId, + expectedIntegrationOwner: resolved.integrationOwner, + }) + ).resolves.toEqual({ ...resolved, token: 'refreshed-installation-token' }); + }); + + describe.each([undefined, outboundContainerId])('Personal fallback container %s', containerId => { + const container = containerId === undefined ? {} : { outboundContainerId: containerId }; + + it.each([false, true])( + 'retains the owner across capability retries with user authorization %s', + async allowUserAuthorization => { + await usePersonalInstallationLookup(); + const service = createService(); + const params = { ...organizationSession, ...container, allowUserAuthorization }; + const issued = await service.issueGitHubSessionCapability(params); + expect(issued).toMatchObject({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + if (!issued.success) throw new Error('Expected capability'); + expect(codec.decode(issued.capability)).toMatchObject({ + orgId: organizationSession.orgId, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + const retried = await service.issueGitHubSessionCapability({ + ...params, + expectedIntegrationId: issued.integrationId, + expectedIntegrationOwner: issued.integrationOwner, + }); + expect(retried).toMatchObject({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + if (!retried.success) throw new Error('Expected retry capability'); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: userAuthor, + }); + for (const { capability } of [issued, retried]) { + await expect( + service.redeemGitHubSessionCapability({ + capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer refreshed-user-token' + : 'Bearer refreshed-installation-token', + }); + } + } + ); + + it.each([false, true])( + 'preserves old unpinned fallback and organization-only pin semantics with user authorization %s', + async allowUserAuthorization => { + const row = await usePersonalInstallationLookup(); + const subject = { + userId: organizationSession.userId, + orgId: organizationSession.orgId, + ...container, + owner: 'acme', + repo: 'repo', + source: allowUserAuthorization ? ('user' as const) : ('installation' as const), + identity: { + installationId: '123', + accountLogin: 'acme', + appType: 'standard' as const, + gitAuthor: allowUserAuthorization ? userAuthor : installationAuthor, + ...(allowUserAuthorization ? { commitCoAuthor: installationAuthor } : {}), + }, + }; + const service = createService(); + const request = { + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }; + const expected = { + success: true, + authorization: allowUserAuthorization ? 'Bearer user-token' : 'Bearer installation-token', + }; + await expect( + service.redeemGitHubSessionCapability({ + ...request, + capability: codec.issue(subject), + }) + ).resolves.toEqual(expected); + const pinned = codec.issue({ ...subject, integrationId: integration.integrationId }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: pinned }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + const personalPinned = codec.issue({ + ...subject, + orgId: undefined, + integrationId: integration.integrationId, + }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: personalPinned }) + ).resolves.toEqual(expected); + Object.assign(row, { + owned_by_organization_id: organizationSession.orgId, + owned_by_user_id: null, + }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: pinned }) + ).resolves.toEqual(expected); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: personalPinned }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } ); }); -}); -const outboundContainerId = 'outbound-container-1'; + it.each([ + [ + 'transferred to the session organization', + { + owned_by_organization_id: organizationSession.orgId, + owned_by_user_id: null, + }, + ], + ['transferred to another user', { owned_by_user_id: 'oauth/another-user' }], + ['replaced', { id: '00000000-0000-4000-8000-000000000099' }], + ['suspended', { integration_status: 'suspended' }], + [ + 'removed from the repository', + { + repository_access: 'selected', + repositories: [{ full_name: 'acme/other-repo' }], + }, + ], + ])( + 'rejects raw retries and redemption after the Personal integration is %s', + async (_name, change) => { + const row = await usePersonalInstallationLookup(); + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...organizationSession, + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected capability'); + Object.assign(row, change); + await expect( + service.getTokenForRepo({ + ...organizationSession, + expectedIntegrationId: issued.integrationId, + expectedIntegrationOwner: issued.integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'rejects a changed integration owner with identical provider identity and user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + integrationOwner: { type: 'org', id: organizationSession.orgId }, + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'rejects a changed integration with identical provider identity and user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + integrationId: '00000000-0000-4000-8000-000000000099', + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([ + ['database_not_configured', 'database_not_configured'], + ['invalid_repo_format', 'invalid_repo_format'], + ['no_installation_found', 'no_installation_found'], + ['invalid_org_id', 'invalid_org_id'], + ['integration_mismatch', 'integration_mismatch'], + ['ambiguous_installation', 'no_installation_found'], + ])('preserves the public error for lookup failure %s', async (reason, publicReason) => { + serviceMocks.findInstallationId.mockResolvedValue({ success: false, reason }); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ success: false, reason }); + const service = createService(); + const params = { githubRepo: 'acme/repo', userId: 'user_1' }; + const failure = { success: false, reason: publicReason }; + await expect(service.getTokenForRepo(params)).resolves.toEqual(failure); + await expect(service.getCloudAgentAuthForRepo(params)).resolves.toEqual(failure); + await expect(service.issueGitHubSessionCapability(params)).resolves.toEqual(failure); + }); +}); describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { beforeEach(() => { vi.clearAllMocks(); serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '123', accountLogin: 'acme', githubAppType: 'standard', @@ -841,6 +1758,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { userId: 'user_1', orgId, expectedIntegrationId, + expectedIntegrationOwner: { type: 'user', id: 'user_1' }, githubRepo: 'acme/repo', }); }); @@ -877,6 +1795,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { userId: 'user_1', orgId: '00000000-0000-4000-8000-000000000001', expectedIntegrationId: '00000000-0000-4000-8000-000000000002', + expectedIntegrationOwner: { type: 'user', id: 'user_1' }, githubRepo: 'acme/repo', }); expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); @@ -1366,6 +2285,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '456', accountLogin: 'acme', githubAppType: 'standard', @@ -1643,6 +2564,120 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { }); }); + it('preserves the selected integration in raw tokens, capability issuance, and redemption', async () => { + const integrationId = 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145e'; + const actor = { userId: 'oauth/pinned-user', orgId: '123e4567-e89b-12d3-a456-426614174030' }; + const instanceUrl = 'https://gitlab.example.com/gitlab'; + serviceMocks.findGitLabIntegration.mockImplementation(async (context, pin) => + context.userId === actor.userId && context.orgId === actor.orgId && pin === integrationId + ? { + success: true, + integrationId, + integrationType: 'oauth', + accountId: '73', + accountLogin: 'selected-provider-user', + metadata: { auth_type: 'oauth', gitlab_instance_url: instanceUrl }, + } + : { success: false, reason: 'no_integration_found' } + ); + serviceMocks.getGitLabToken.mockResolvedValue({ + success: true, + token: 'selected-token', + instanceUrl, + }); + const params = { + ...actor, + expectedIntegrationId: integrationId, + gitUrl: `${instanceUrl}/acme/nested/widgets.git`, + outboundContainerId, + }; + const service = createService(); + await expect( + service.getGitLabToken({ ...params, repositoryUrl: params.gitUrl }) + ).resolves.toMatchObject({ success: true, token: 'selected-token', integrationId }); + const issued = await service.issueGitLabSessionCapability(params); + expect(issued).toMatchObject({ + success: true, + integrationId, + instanceOrigin: instanceUrl, + projectPath: 'acme/nested/widgets', + identity: { accountId: '73', accountLogin: 'selected-provider-user' }, + }); + if (!issued.success) throw new Error('Expected capability'); + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: `${instanceUrl}/api/v4/projects/acme%2Fnested%2Fwidgets`, + }) + ).resolves.toEqual({ success: true, headers: { authorization: 'Bearer selected-token' } }); + }); + + it.each([undefined, outboundContainerId])( + 'issues an old-form capability for an authorized instance subpath with container %s', + async containerId => { + const instanceUrl = 'https://gitlab.example.com/gitlab+enterprise'; + const integration: GitLabLookupServiceModule.GitLabLookupSuccess = { + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145e', + integrationType: 'oauth', + accountId: '73', + accountLogin: 'selected-provider-user', + metadata: { auth_type: 'oauth', gitlab_instance_url: instanceUrl }, + }; + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValue({ + success: true, + integrations: [ + { + ...integration, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + metadata: { gitlab_instance_url: 'https://gitlab.example.com/gitlab' }, + }, + integration, + ], + }); + serviceMocks.findGitLabIntegration.mockImplementation(async (actor, pin) => + actor.userId === 'oauth/legacy-user' && pin === integration.integrationId + ? integration + : { success: false, reason: 'no_integration_found' } + ); + serviceMocks.getGitLabToken.mockImplementation(async integrationId => + integrationId === integration.integrationId + ? { success: true, token: 'selected-subpath-token', instanceUrl } + : { success: false, reason: 'no_token' } + ); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + userId: 'oauth/legacy-user', + gitUrl: 'https://gitlab.example.com/gitlab+enterprise/acme/widgets.git', + ...(containerId === undefined ? {} : { outboundContainerId: containerId }), + }); + + expect(issued).toMatchObject({ + success: true, + integrationId: integration.integrationId, + instanceOrigin: instanceUrl, + instanceHost: 'gitlab.example.com', + projectPath: 'acme/widgets', + identity: { accountId: '73', accountLogin: 'selected-provider-user' }, + }); + expect(JSON.stringify(issued)).not.toContain('selected-subpath-token'); + if (!issued.success) throw new Error('Expected capability'); + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + ...(containerId === undefined ? {} : { outboundContainerId: containerId }), + requestMethod: 'GET', + requestUrl: `${instanceUrl}/api/v4/projects/acme%2Fwidgets/merge_requests/42`, + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer selected-subpath-token' }, + }); + } + ); + it.each([ ['https://gitlab.com/acme/widgets.git', 'https://gitlab.com', 'gitlab.com', 'acme/widgets'], [ diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index aff0480b1b..0fb4e56e8b 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from '@kilocode/encryption'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; import { BITBUCKET_REPOSITORY_LIST_AUDIENCE, extractBearerToken, @@ -6,6 +7,7 @@ import { } from '@kilocode/worker-utils'; import { BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE, + BITBUCKET_INTERACTIVE_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, @@ -79,6 +81,11 @@ import { BitbucketEnsureWebhookRequestSchema, BitbucketPullRequestRequestSchema, } from './bitbucket-code-review-service.js'; +import { + BitbucketInteractiveHttpRequestSchema, + handleBitbucketInteractiveReview, +} from './interactive-review-handler.js'; +import { BITBUCKET_INTERACTIVE_REQUEST_MAX_BYTES } from './bitbucket-safe-transport.js'; import { KiloSessionCapabilityCodec, KiloSessionCapabilityError, @@ -96,11 +103,15 @@ export type GetTokenForRepoParams = { userId: string; orgId?: string; expectedIntegrationId?: string; + // Supply the resolved owner with the pin; orgId remains the session context. + expectedIntegrationOwner?: Owner; }; export type GetTokenForRepoSuccess = { success: true; token: string; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; appType: GitHubAppType; @@ -139,6 +150,8 @@ export type GetCloudAgentAuthForRepoParams = GetTokenForRepoParams & { export type GetCloudAgentAuthForRepoSuccess = { success: true; githubToken: string; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; appType: GitHubAppType; @@ -251,7 +264,7 @@ export type IssueBitbucketSessionCapabilityParams = { }; type BitbucketTokenFailureReason = Extract['reason']; export type IssueBitbucketSessionCapabilityResult = - | { success: true; capability: string; gitUrl: string } + | { success: true; capability: string; gitUrl: string; integrationId: string } | { success: false; reason: BitbucketTokenFailureReason | 'capability_configuration_error' }; export type RedeemBitbucketSessionCapabilityParams = { @@ -351,15 +364,17 @@ async function resolveSecret(secret: SecretsStoreSecret | string): Promise { +async function readBoundedInternalJsonRequest( + request: Request, + maxBytes = INTERNAL_REQUEST_MAX_BYTES +): Promise { const contentType = request.headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase(); if (contentType !== 'application/json' || !request.body) throw new Error('invalid_request'); const contentLength = request.headers.get('Content-Length'); if (contentLength) { - if (!/^[0-9]+$/.test(contentLength) || Number(contentLength) > INTERNAL_REQUEST_MAX_BYTES) { - throw new Error('invalid_request'); - } + if (!/^[0-9]+$/.test(contentLength)) throw new Error('invalid_request'); + if (Number(contentLength) > maxBytes) throw new Error('request_too_large'); } const reader = request.body.getReader(); @@ -371,13 +386,13 @@ async function readBoundedInternalJsonRequest(request: Request): Promise INTERNAL_REQUEST_MAX_BYTES) { + if (totalBytes > maxBytes) { try { await reader.cancel(); } catch { // The request remains rejected when cancellation itself fails. } - throw new Error('invalid_request'); + throw new Error('request_too_large'); } chunks.push(chunk.value); } @@ -834,6 +849,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -868,6 +885,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { installation.repoName, installation.githubAppType ), + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -891,6 +910,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, githubToken: selection.token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -921,9 +942,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { ? { outboundContainerId: params.outboundContainerId } : {}), ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), - ...(params.expectedIntegrationId !== undefined - ? { integrationId: params.expectedIntegrationId } - : {}), + integrationId: auth.integrationId, + integrationOwner: auth.integrationOwner, ...repository, source: auth.source, identity: this.getSessionIdentity(auth), @@ -934,6 +954,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, capability, + integrationId: auth.integrationId, + integrationOwner: auth.integrationOwner, installationId: auth.installationId, accountLogin: auth.accountLogin, appType: auth.appType, @@ -968,12 +990,15 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { : validateLegacyGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims); if (upstreamFailure) return { success: false, reason: upstreamFailure }; - const authParams = { + const authParams: GetTokenForRepoParams = { userId: claims.userId, ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), ...(claims.integrationId !== undefined ? { expectedIntegrationId: claims.integrationId } : {}), + ...(claims.integrationOwner !== undefined + ? { expectedIntegrationOwner: claims.integrationOwner } + : {}), githubRepo: `${claims.owner}/${claims.repo}`, }; let auth: GetCloudAgentAuthForRepoResult | null; @@ -992,6 +1017,14 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { if (!auth || !auth.success || auth.source !== claims.source) { return { success: false, reason: 'source_unavailable' }; } + if ( + (claims.integrationId !== undefined && auth.integrationId !== claims.integrationId) || + (claims.integrationOwner !== undefined && + (auth.integrationOwner.type !== claims.integrationOwner.type || + auth.integrationOwner.id !== claims.integrationOwner.id)) + ) { + return { success: false, reason: 'integration_mismatch' }; + } if (!this.matchesSessionIdentity(claims.identity, auth)) { return { success: false, reason: 'identity_mismatch' }; } @@ -1043,6 +1076,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, githubToken: selection.token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -1099,7 +1134,9 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { async getBitbucketToken(params: GetBitbucketTokenParams): Promise { if (!params.orgId) return { success: false, reason: 'invalid_request' }; const result = await resolveBitbucketToken(this.env, params); - return result.success ? { success: true, token: result.token } : result; + return result.success + ? { success: true, token: result.token, integrationId: result.integrationId } + : result; } async issueBitbucketSessionCapability( @@ -1134,6 +1171,7 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { success: true, capability, gitUrl: `https://bitbucket.org/${subject.repositoryFullName}.git`, + integrationId: subject.integrationId, }; } catch { return { success: false, reason: 'capability_configuration_error' }; @@ -1424,11 +1462,10 @@ export default { async fetch(request: Request, env: ServiceHttpEnv): Promise { const url = new URL(request.url); const isGitLabCredentialBroker = url.pathname === GITLAB_CREDENTIAL_BROKER_PATH; - // Credential-bearing endpoints must never be cached, including on their - // shared early-return error paths (405/401/503). The GitHub user-access - // token endpoint joins the GitLab private endpoints here. + const isBitbucketInteractive = url.pathname === '/internal/bitbucket/interactive-review'; + // Private endpoints must not cache successful responses or early errors. const privateNoStoreHeaders = - isGitLabCredentialBroker || url.pathname === USER_ACCESS_TOKEN_PATH + isGitLabCredentialBroker || isBitbucketInteractive || url.pathname === USER_ACCESS_TOKEN_PATH ? { 'Cache-Control': 'no-store' } : undefined; const codeReviewAudience = bitbucketCodeReviewAudiences.get(url.pathname); @@ -1437,6 +1474,7 @@ export default { url.pathname !== USER_ACCESS_TOKEN_PATH && url.pathname !== BITBUCKET_REPOSITORIES_PATH && url.pathname !== GITLAB_CREDENTIAL_BROKER_PATH && + !isBitbucketInteractive && !codeReviewAudience ) { return new Response(null, { status: 404 }); @@ -1471,8 +1509,9 @@ export default { let authorization: Awaited>; try { - const audience = - url.pathname === BITBUCKET_REPOSITORIES_PATH + const audience = isBitbucketInteractive + ? BITBUCKET_INTERACTIVE_AUDIENCE + : url.pathname === BITBUCKET_REPOSITORIES_PATH ? BITBUCKET_REPOSITORY_LIST_AUDIENCE : url.pathname === GITLAB_CREDENTIAL_BROKER_PATH ? GITLAB_CREDENTIAL_BROKER_AUDIENCE @@ -1487,6 +1526,53 @@ export default { ); } + if (isBitbucketInteractive) { + if (!authorization.organizationId) { + return Response.json( + { error: 'organization_required' }, + { status: 403, headers: privateNoStoreHeaders } + ); + } + let body: unknown; + try { + body = await readBoundedInternalJsonRequest( + request, + BITBUCKET_INTERACTIVE_REQUEST_MAX_BYTES + ); + } catch (error) { + const oversized = error instanceof Error && error.message === 'request_too_large'; + return Response.json( + { success: false, reason: oversized ? 'request_too_large' : 'invalid_request' }, + { status: oversized ? 413 : 400, headers: privateNoStoreHeaders } + ); + } + const parsed = BitbucketInteractiveHttpRequestSchema.safeParse(body); + if (!parsed.success) { + return Response.json( + { success: false, reason: 'invalid_request' }, + { status: 400, headers: privateNoStoreHeaders } + ); + } + try { + return Response.json( + await handleBitbucketInteractiveReview( + env, + { + userId: authorization.kiloUserId, + orgId: authorization.organizationId, + }, + parsed.data + ), + { headers: privateNoStoreHeaders } + ); + } catch { + return Response.json( + { success: false, reason: 'temporarily_unavailable' }, + { headers: privateNoStoreHeaders } + ); + } + } + if (url.pathname === BITBUCKET_REPOSITORIES_PATH) { if (!authorization.organizationId) { return Response.json({ error: 'organization_required' }, { status: 403 }); diff --git a/services/git-token-service/src/installation-lookup-service.behavior.test.ts b/services/git-token-service/src/installation-lookup-service.behavior.test.ts index 751f843b8c..20cba9d59a 100644 --- a/services/git-token-service/src/installation-lookup-service.behavior.test.ts +++ b/services/git-token-service/src/installation-lookup-service.behavior.test.ts @@ -6,13 +6,17 @@ vi.mock('@kilocode/db/client', () => ({ getWorkerDb: vi.fn(), })); +const integrationId = '00000000-0000-4000-8000-000000000002'; +const otherIntegrationId = '00000000-0000-4000-8000-000000000003'; +const orgId = '00000000-0000-4000-8000-000000000001'; + type InstallationRow = { - id?: string; + id: string; platform_installation_id: string; platform_account_login: string | null; github_app_type: 'standard' | 'lite' | null; owned_by_organization_id: string | null; - owned_by_user_id?: string | null; + owned_by_user_id: string | null; integration_status?: string; repository_access?: string | null; repositories?: { full_name: string }[] | null; @@ -58,16 +62,20 @@ describe('InstallationLookupService', () => { it('fails closed when multiple active personal installations match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'old-owner', github_app_type: 'standard', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, { + id: otherIntegrationId, platform_installation_id: '200', platform_account_login: 'other-owner', github_app_type: 'lite', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); @@ -87,10 +95,9 @@ describe('InstallationLookupService', () => { platform_account_login: 'pre-rename-owner', github_app_type: null, owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, - ]) as unknown as { - findRefreshCandidates(params: { githubRepo: string; userId: string }): Promise; - }; + ]); const result = await service.findRefreshCandidates({ githubRepo: 'renamed-owner/repository', @@ -142,10 +149,12 @@ describe('InstallationLookupService', () => { it('resolves an exact-login integration using the legacy standard app type', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', github_app_type: null, owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); @@ -156,6 +165,8 @@ describe('InstallationLookupService', () => { expect(result).toEqual({ success: true, + integrationId, + integrationOwner: { type: 'user', id: 'user-1' }, installationId: '100', accountLogin: 'renamed-owner', githubAppType: 'standard', @@ -165,10 +176,12 @@ describe('InstallationLookupService', () => { it('rejects selected repository metadata for a different owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', github_app_type: 'standard', owned_by_organization_id: null, + owned_by_user_id: 'user-1', repository_access: 'selected', repositories: [{ full_name: 'other-owner/repository' }], permissions: { contents: 'write', pull_requests: 'write' }, @@ -186,23 +199,27 @@ describe('InstallationLookupService', () => { it('fails closed when organization and personal installations both match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: 'org-installation', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, { + id: otherIntegrationId, platform_installation_id: 'personal-installation', platform_account_login: 'personal-owner', github_app_type: 'lite', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); const result = await service.findInstallationId({ githubRepo: 'renamed-owner/repository', userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', + orgId, }); expect(result).toEqual({ success: false, reason: 'ambiguous_installation' }); @@ -211,125 +228,211 @@ describe('InstallationLookupService', () => { it('fails closed when multiple active organization installations match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: 'org-installation-1', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, { + id: otherIntegrationId, platform_installation_id: 'org-installation-2', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, ]); const result = await service.findInstallationId({ githubRepo: 'renamed-owner/repository', userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', + orgId, }); expect(result).toEqual({ success: false, reason: 'ambiguous_installation' }); }); - it('resolves the exact active organization integration with repository access', async () => { - const integrationId = '00000000-0000-4000-8000-000000000002'; - const orgId = '00000000-0000-4000-8000-000000000001'; - const service = createService([ - { - id: integrationId, - platform_installation_id: '100', - platform_account_login: 'renamed-owner', - github_app_type: 'standard', - integration_status: 'active', - owned_by_organization_id: orgId, - owned_by_user_id: null, - repository_access: 'selected', - repositories: [{ full_name: 'renamed-owner/repository' }], - permissions: { contents: 'write', pull_requests: 'write' }, - }, - ]); - - await expect( - service.findManagedInstallationForRepo({ + describe.each(['findInstallationId', 'findManagedInstallationForRepo'] as const)('%s', method => { + describe.each([undefined, orgId])('owner scope %s', requestedOrgId => { + const params = { githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId, - expectedIntegrationId: integrationId, - }) - ).resolves.toEqual({ - success: true, - installationId: '100', - accountLogin: 'renamed-owner', - githubAppType: 'standard', - repoName: 'repository', - permissions: { contents: 'write', pull_requests: 'write' }, - }); - }); - - it.each<[string, Partial]>([ - ['wrong organization', { owned_by_organization_id: '00000000-0000-4000-8000-000000000099' }], - ['personal row', { owned_by_organization_id: null, owned_by_user_id: 'user-1' }], - ['suspended row', { integration_status: 'suspended' }], - ['repository owner mismatch', { platform_account_login: 'other-owner' }], - [ - 'selected repository mismatch', - { repositories: [{ full_name: 'renamed-owner/other-repository' }] }, - ], - ])('rejects an expected integration with %s', async (_reason, override) => { - const integrationId = '00000000-0000-4000-8000-000000000002'; - const orgId = '00000000-0000-4000-8000-000000000001'; - const service = createService([ - { + userId: 'oauth/personal-owner', + ...(requestedOrgId === undefined ? {} : { orgId: requestedOrgId }), + }; + const row: InstallationRow = { id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', - github_app_type: 'standard', + github_app_type: 'lite', integration_status: 'active', - owned_by_organization_id: orgId, - owned_by_user_id: null, + owned_by_organization_id: requestedOrgId ?? null, + owned_by_user_id: requestedOrgId === undefined ? params.userId : null, repository_access: 'selected', - repositories: [{ full_name: 'renamed-owner/repository' }], - permissions: null, - ...override, - }, - ]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId, - expectedIntegrationId: integrationId, - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); - }); - - it('does not accept an expected integration without an organization', async () => { - const service = createService([]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); - expect(getWorkerDb).not.toHaveBeenCalled(); - }); - - it('returns integration_mismatch when the expected row is not visible to the fenced query', async () => { - const service = createService([]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + repositories: [{ full_name: 'Renamed-Owner/Repository' }], + permissions: { contents: 'read' }, + }; + const integrationOwner = + requestedOrgId === undefined + ? { type: 'user' as const, id: params.userId } + : { type: 'org' as const, id: requestedOrgId }; + const expectedSuccess = { + success: true, + integrationId, + integrationOwner, + installationId: '100', + accountLogin: 'renamed-owner', + githubAppType: 'lite', + ...(method === 'findManagedInstallationForRepo' + ? { repoName: 'repository', permissions: { contents: 'read' } } + : {}), + }; + + it('returns the resolved identity for a legacy unpinned request', async () => { + await expect(createService([row])[method](params)).resolves.toEqual(expectedSuccess); + }); + + it.each(['selected', 'all'])( + 'resolves an exact active integration with %s access', + async repositoryAccess => { + const service = createService([{ ...row, repository_access: repositoryAccess }]); + await expect( + service[method]({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual(expectedSuccess); + } + ); + + it('returns the database owner for a legacy organization session and its explicit retry', async () => { + const service = createService([row]); + await expect(service[method]({ ...params, orgId })).resolves.toEqual(expectedSuccess); + await expect( + service[method]({ + ...params, + orgId, + expectedIntegrationId: integrationId, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual(expectedSuccess); + }); + + it('requires an integration pin with an explicit owner', async () => { + await expect( + createService([row])[method]({ + ...params, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects an explicit owner outside the caller or session context', async () => { + await expect( + createService([row])[method]({ + ...params, + ...(integrationOwner.type === 'user' + ? { userId: 'oauth/another-user' } + : { orgId: undefined }), + expectedIntegrationId: integrationId, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects an explicit organization owner from another session organization', async () => { + const otherOrgId = '00000000-0000-4000-8000-000000000099'; + await expect( + createService([ + { + ...row, + owned_by_organization_id: otherOrgId, + owned_by_user_id: null, + }, + ])[method]({ + ...params, + orgId, + expectedIntegrationId: integrationId, + expectedIntegrationOwner: { type: 'org', id: otherOrgId }, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects unrecognized fields in an explicit owner', async () => { + const expectedIntegrationOwner = { ...integrationOwner, unknown: true }; + await expect( + createService([row])[method]({ + ...params, + expectedIntegrationId: integrationId, + expectedIntegrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it.each<[string, Partial]>([ + ['different integration', { id: otherIntegrationId }], + [ + 'wrong organization', + { owned_by_organization_id: '00000000-0000-4000-8000-000000000099' }, + ], + ['wrong personal owner', { owned_by_user_id: 'oauth/another-user' }], + ['suspended row', { integration_status: 'suspended' }], + ['repository owner mismatch', { platform_account_login: 'other-owner' }], + [ + 'selected repository mismatch', + { repositories: [{ full_name: 'renamed-owner/other-repository' }] }, + ], + ['missing repository access', { repository_access: null }], + ])('rejects an expected integration with %s', async (_reason, override) => { + for (const expectedIntegrationOwner of [undefined, integrationOwner]) { + await expect( + createService([{ ...row, ...override }])[method]({ + ...params, + ...(expectedIntegrationOwner === undefined ? {} : { orgId }), + expectedIntegrationId: integrationId, + expectedIntegrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + }); + + it('excludes the other owner scope even when the integration ID matches', async () => { + const otherOwner = + requestedOrgId === undefined + ? { owned_by_organization_id: orgId, owned_by_user_id: null } + : { owned_by_organization_id: null, owned_by_user_id: params.userId }; + await expect( + createService([{ ...row, ...otherOwner }])[method]({ + ...params, + expectedIntegrationId: integrationId, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('returns integration_mismatch when the expected row is not visible to the authorized query', async () => { + await expect( + createService([])[method]({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('preserves the missing-installation error for legacy requests', async () => { + await expect(createService([])[method](params)).resolves.toEqual({ + success: false, + reason: 'no_installation_found', + }); + }); + + it('rejects ambiguous legacy integration selection', async () => { + await expect( + createService([row, { ...row, id: otherIntegrationId }])[method](params) + ).resolves.toEqual({ success: false, reason: 'ambiguous_installation' }); + }); + + it('rejects an invalid integration pin before querying', async () => { + await expect( + createService([row])[method]({ ...params, expectedIntegrationId: 'not-a-uuid' }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + expect(getWorkerDb).not.toHaveBeenCalled(); + }); + }); }); it.each(['owner/repository/extra', 'owner/', '/repository', 'owner//repository', 'owner'])( diff --git a/services/git-token-service/src/installation-lookup-service.test.ts b/services/git-token-service/src/installation-lookup-service.test.ts index 8d8c147261..1e270548ef 100644 --- a/services/git-token-service/src/installation-lookup-service.test.ts +++ b/services/git-token-service/src/installation-lookup-service.test.ts @@ -57,23 +57,83 @@ describe('buildInstallationLookupQuery', () => { expect(query.params).toContain(10); }); - it('uses a supplied integration ID as an exact organization authorization fence', () => { - const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); - const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; - const query = buildManagedInstallationLookupQuery(db, { - ...params, - expectedIntegrationId, - }).toSQL(); - - expect(query.sql).toContain('"platform_integrations"."id" ='); - expect(query.sql).toContain('"platform_integrations"."integration_status" ='); - expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" ='); - expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" is null'); - expect(query.sql).toContain('"organization_memberships"."id" is not null'); - expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" ='); - expect(query.params).toContain(expectedIntegrationId); - expect(query.params).toContain(params.orgId); - expect(query.params).toContain('renamed-owner'); - expect(query.params).toContain(1); + describe.each([ + ['raw token', buildInstallationLookupQuery], + ['managed auth', buildManagedInstallationLookupQuery], + ['login repair', buildInstallationRefreshCandidatesQuery], + ] as const)('%s authorization', (_path, buildLookupQuery) => { + it.each([undefined, { type: 'org', id: params.orgId }] as const)( + 'requires the exact organization owner, membership, and an unblocked user with selector %s', + expectedIntegrationOwner => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + ...params, + expectedIntegrationId, + expectedIntegrationOwner, + }).toSQL(); + + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).toContain('"organization_memberships"."id" is not null'); + expect(query.sql).toContain('"organization_memberships"."kilo_user_id" ='); + expect(query.sql).toContain('exists (select'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params).toContain(params.orgId); + expect(query.params).toContain('active'); + } + ); + + it('requires the exact Personal owner and excludes organization and blocked-user access', () => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + githubRepo: params.githubRepo, + userId: 'oauth/personal-owner', + expectedIntegrationId, + }).toSQL(); + + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" is null'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).toContain('"platform_integrations"."platform_installation_id" is not null'); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).not.toContain('"organization_memberships"."id" is not null'); + expect(query.sql).not.toContain('false'); + expect(query.params).toContain('oauth/personal-owner'); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params).toContain('active'); + }); + + it('retains session membership and blocked-user guards for an explicit Personal pin', () => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + ...params, + userId: 'oauth/personal-owner', + expectedIntegrationId, + expectedIntegrationOwner: { type: 'user', id: 'oauth/personal-owner' }, + }).toSQL(); + + expect(query.sql).toContain('exists (select'); + expect(query.sql).toContain('"organization_memberships"."kilo_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" is null'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).not.toContain(' or '); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params.filter(param => param === params.orgId)).toHaveLength(1); + expect(query.params.filter(param => param === 'oauth/personal-owner')).toHaveLength(4); + expect(query.params).toContain('active'); + }); }); }); diff --git a/services/git-token-service/src/installation-lookup-service.ts b/services/git-token-service/src/installation-lookup-service.ts index df690cc1a2..ef8126a22a 100644 --- a/services/git-token-service/src/installation-lookup-service.ts +++ b/services/git-token-service/src/installation-lookup-service.ts @@ -6,23 +6,24 @@ import { kilocode_users, } from '@kilocode/db/schema'; import { eq, and, exists, isNull, isNotNull, or, sql } from 'drizzle-orm'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; +import { GitHubIntegrationOwnerSchema } from './github-session-capability.js'; export type FindInstallationParams = { githubRepo: string; userId: string; orgId?: string; expectedIntegrationId?: string; + expectedIntegrationOwner?: Owner; }; const InstallationLookupResultSchema = z.object({ + id: z.string(), platform_installation_id: z.string(), platform_account_login: z.string().nullable(), github_app_type: z.enum(['standard', 'lite']).nullable().optional(), owned_by_organization_id: z.string().nullable(), -}); - -const InstallationRefreshCandidateSchema = InstallationLookupResultSchema.extend({ - id: z.string(), + owned_by_user_id: z.string().nullable(), }); const ManagedInstallationLookupResultSchema = InstallationLookupResultSchema.extend({ @@ -40,13 +41,14 @@ const ManagedInstallationLookupResultSchema = InstallationLookupResultSchema.ext const ExactManagedInstallationLookupResultSchema = ManagedInstallationLookupResultSchema.extend({ id: z.string().uuid(), integration_status: z.string(), - owned_by_user_id: z.string().nullable(), }); const MAX_INSTALLATION_LOGIN_REFRESH_CANDIDATES = 10; export type InstallationLookupSuccess = { success: true; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; githubAppType: 'standard' | 'lite'; @@ -86,6 +88,23 @@ export type ManagedInstallationLookupResult = | InstallationLookupFailure | { success: false; reason: 'repository_not_installed' }; +function getExpectedIntegrationOwner(params: FindInstallationParams): Owner { + return ( + params.expectedIntegrationOwner ?? + (params.orgId === undefined + ? { type: 'user', id: params.userId } + : { type: 'org', id: params.orgId }) + ); +} + +function getIntegrationOwner(row: z.infer): Owner { + return GitHubIntegrationOwnerSchema.parse( + row.owned_by_organization_id === null + ? { type: 'user', id: row.owned_by_user_id } + : { type: 'org', id: row.owned_by_organization_id } + ); +} + function buildAuthorizedInstallationsQuery( db: WorkerDb, params: FindInstallationParams, @@ -109,17 +128,23 @@ function buildAuthorizedInstallationsQuery( ) ) ); + const expectedOwner = getExpectedIntegrationOwner(params); const exactIntegrationOwner = params.expectedIntegrationId === undefined ? undefined - : params.orgId === undefined - ? sql`false` - : and( - eq(platform_integrations.id, params.expectedIntegrationId), - eq(platform_integrations.owned_by_organization_id, params.orgId), - isNull(platform_integrations.owned_by_user_id), - isNotNull(organization_memberships.id) - ); + : and( + eq(platform_integrations.id, params.expectedIntegrationId), + expectedOwner.type === 'user' + ? and( + eq(platform_integrations.owned_by_user_id, expectedOwner.id), + isNull(platform_integrations.owned_by_organization_id) + ) + : and( + eq(platform_integrations.owned_by_organization_id, expectedOwner.id), + isNull(platform_integrations.owned_by_user_id), + isNotNull(organization_memberships.id) + ) + ); const legacyAuthorizedOwner = params.expectedIntegrationId === undefined ? or( @@ -228,12 +253,24 @@ export class InstallationLookupService { if ( params.expectedIntegrationId !== undefined && - (params.orgId === undefined || - !z.string().uuid().safeParse(params.expectedIntegrationId).success) + !z.string().uuid().safeParse(params.expectedIntegrationId).success ) { return { success: false, reason: 'integration_mismatch' }; } + if (params.expectedIntegrationOwner !== undefined) { + const owner = GitHubIntegrationOwnerSchema.safeParse(params.expectedIntegrationOwner); + if ( + !owner.success || + params.expectedIntegrationId === undefined || + (owner.data.type === 'user' + ? owner.data.id !== params.userId + : owner.data.id !== params.orgId) + ) { + return { success: false, reason: 'integration_mismatch' }; + } + } + const repoParts = params.githubRepo.split('/'); if (repoParts.length !== 2 || repoParts.some(part => part.length === 0)) { return { success: false, reason: 'invalid_repo_format' }; @@ -258,6 +295,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.data.id, + integrationOwner: getIntegrationOwner(selected.data), installationId: selected.data.platform_installation_id, accountLogin: selected.data.platform_account_login ?? '', githubAppType: selected.data.github_app_type ?? 'standard', @@ -284,6 +323,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.id, + integrationOwner: getIntegrationOwner(selected), installationId: selected.platform_installation_id, accountLogin: selected.platform_account_login ?? '', githubAppType: selected.github_app_type ?? 'standard', @@ -302,7 +343,7 @@ export class InstallationLookupService { return { success: true, candidates: rows.map(row => { - const parsed = InstallationRefreshCandidateSchema.parse(row); + const parsed = InstallationLookupResultSchema.parse(row); return { integrationId: parsed.id, installationId: parsed.platform_installation_id, @@ -348,6 +389,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.data.id, + integrationOwner: getIntegrationOwner(selected.data), installationId: selected.data.platform_installation_id, accountLogin: selected.data.platform_account_login ?? '', githubAppType: selected.data.github_app_type ?? 'standard', @@ -385,6 +428,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.id, + integrationOwner: getIntegrationOwner(selected), installationId: selected.platform_installation_id, accountLogin: selected.platform_account_login ?? '', githubAppType: selected.github_app_type ?? 'standard', @@ -397,11 +442,17 @@ export class InstallationLookupService { selected: z.infer, params: FindInstallationParams ): boolean { + const expectedOwner = getExpectedIntegrationOwner(params); + const matchesOwner = + expectedOwner.type === 'user' + ? selected.owned_by_user_id === expectedOwner.id && + selected.owned_by_organization_id === null + : selected.owned_by_organization_id === expectedOwner.id && + selected.owned_by_user_id === null; if ( selected.id !== params.expectedIntegrationId || selected.integration_status !== 'active' || - selected.owned_by_organization_id !== params.orgId || - selected.owned_by_user_id !== null || + !matchesOwner || selected.platform_account_login?.toLowerCase() !== params.githubRepo.split('/')[0]?.toLowerCase() ) { diff --git a/services/git-token-service/src/interactive-review-handler.test.ts b/services/git-token-service/src/interactive-review-handler.test.ts new file mode 100644 index 0000000000..20fc760ff0 --- /dev/null +++ b/services/git-token-service/src/interactive-review-handler.test.ts @@ -0,0 +1,1241 @@ +import { getWorkerDb } from '@kilocode/db/client'; +import type * as DbClientModule from '@kilocode/db/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as BitbucketRuntimeTokenResolverModule from './bitbucket-runtime-token-resolver.js'; +import type { BitbucketInteractiveBrokerRequest } from './bitbucket-interactive-api.js'; +import { + buildBitbucketInteractiveIntegrationQuery, + handleBitbucketInteractiveReview, +} from './interactive-review-handler.js'; + +const mocks = vi.hoisted(() => ({ rows: vi.fn(), resolve: vi.fn(), invalidate: vi.fn() })); +vi.mock('@kilocode/db/client', async importOriginal => { + const actual = await importOriginal(); + const query = { + select: () => query, + from: () => query, + innerJoin: () => query, + leftJoin: () => query, + where: () => query, + limit: () => ({ then: (resolve: (rows: unknown) => void) => mocks.rows().then(resolve) }), + }; + return { + ...actual, + getWorkerDb: (url: string) => (url === 'test' ? query : actual.getWorkerDb(url)), + }; +}); +vi.mock('./bitbucket-runtime-token-resolver.js', async importOriginal => ({ + ...(await importOriginal()), + resolveBitbucketCapabilitySubject: mocks.resolve, +})); +vi.mock('./bitbucket-workspace-access-token-authorization-service.js', () => ({ + BitbucketWorkspaceAccessTokenAuthorizationService: class { + invalidateAuthorization = mocks.invalidate; + }, +})); + +const owner = { userId: 'oauth/member', orgId: '123e4567-e89b-12d3-a456-426614174030' }; +const target = { + integrationId: '123e4567-e89b-12d3-a456-426614174033', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174031', + workspaceSlug: 'acme', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174032', + repositoryFullName: 'acme/widgets', +}; +const readScopes = ['account', 'pullrequest', 'repository', 'repository:write', 'webhook']; +const integration = { + ...target, + integrationType: 'workspace_access_token', + accessId: 'credential-1', + accessVersion: 7, + accessScopes: readScopes, + scopes: readScopes, + oauthId: 'oauth-credential', + actorId: 'provider-user', + actorLogin: 'provider-login', + repositoriesSyncedAt: '2026-04-29 01:16:12.945+00', + repositories: [ + { + id: target.repositoryUuid, + name: 'Widgets', + full_name: target.repositoryFullName, + private: true, + }, + ], +}; +const request = { + operation: 'pullRequest', + params: { path: { workspace: 'acme', repo_slug: 'widgets', pull_request_id: 7 } }, +}; +const env = { HYPERDRIVE: { connectionString: 'test' } } as CloudflareEnv; +const sourceSelector = { + pullRequestId: 7, + workspaceUuid: '123e4567-e89b-12d3-a456-426614174098', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174099', +}; +const sourceCommit = '0123456789abcdef0123456789abcdef01234567'; +const fork = { + type: 'pullrequest', + id: 7, + source: { + commit: { hash: sourceCommit }, + repository: { + uuid: `{${sourceSelector.repositoryUuid}}`, + full_name: 'fork/widgets', + workspace: { uuid: `{${sourceSelector.workspaceUuid}}`, slug: 'fork' }, + }, + }, + destination: { + repository: { + uuid: `{${target.repositoryUuid}}`, + full_name: target.repositoryFullName, + workspace: { uuid: `{${target.workspaceUuid}}`, slug: target.workspaceSlug }, + }, + }, +}; +const sourceFileRequest = { + operation: 'file', + params: { + path: { workspace: 'acme', repo_slug: 'widgets', commit: sourceCommit, path: 'src/file.ts' }, + }, + source: sourceSelector, +} satisfies BitbucketInteractiveBrokerRequest<'file'>; +const destinationApiPath = `/2.0/repositories/%7B${target.workspaceUuid}%7D/%7B${target.repositoryUuid}%7D`; +const sourceApiPath = `/2.0/repositories/%7B${sourceSelector.workspaceUuid}%7D/%7B${sourceSelector.repositoryUuid}%7D`; +const sourceFileMetadata = { + type: 'commit_file', + path: 'src/file.ts', + size: 17, + attributes: [], + commit: { hash: sourceCommit }, +}; +const providerFetch = vi.fn(); +const run = (input: unknown = { ...target, request }, actor = owner) => + handleBitbucketInteractiveReview(env, actor, input); + +beforeEach(() => { + mocks.rows.mockReset().mockResolvedValue([integration]); + mocks.resolve.mockReset().mockResolvedValue({ + success: true, + subject: { + ...target, + repositoryFullName: target.repositoryFullName, + token: 'provider-secret', + }, + }); + mocks.invalidate.mockReset().mockResolvedValue(undefined); + providerFetch.mockReset().mockImplementation(async () => Response.json(fork)); + vi.stubGlobal('fetch', providerFetch); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('interactive Bitbucket authorization', () => { + it.each([ + ['workspace_access_token', 'file'], + ['workspace_access_token', 'fileMetadata'], + ['oauth', 'file'], + ['oauth', 'fileMetadata'], + ] as const)( + 'reads immutable fork %s %s through the authorized destination pull request', + async (integrationType, operation) => { + mocks.rows.mockResolvedValue([{ ...integration, integrationType }]); + const visits: string[] = []; + providerFetch.mockImplementation(async (url, options) => { + const endpoint = new URL(String(url)); + visits.push(endpoint.pathname); + if ( + options.method !== 'GET' || + new Headers(options.headers).get('authorization') !== 'Bearer provider-secret' + ) + return Response.json({}, { status: 403 }); + if (endpoint.pathname === `${destinationApiPath}/pullrequests/7`) { + return Response.json( + endpoint.searchParams.get('fields') === + '+source.repository.workspace,+destination.repository.workspace' + ? fork + : { + ...fork, + source: { + ...fork.source, + repository: { ...fork.source.repository, workspace: undefined }, + }, + } + ); + } + if (endpoint.pathname !== `${sourceApiPath}/src/${sourceCommit}/src%2Ffile.ts`) + return Response.json({}, { status: 404 }); + return endpoint.searchParams.get('format') === 'meta' + ? Response.json(sourceFileMetadata) + : new Response('const value = 1;\n', { headers: { 'content-type': 'text/plain' } }); + }); + const result = await run({ ...target, request: { ...sourceFileRequest, operation } }); + expect(result, JSON.stringify(result)).toMatchObject({ + success: true, + result: { + status: 200, + data: operation === 'file' ? 'const value = 1;\n' : sourceFileMetadata, + }, + metadata: { + actorUserId: owner.userId, + organizationId: owner.orgId, + integrationId: target.integrationId, + providerActor: + integrationType === 'oauth' + ? { credentialKind: 'bitbucketOAuth', actor: { id: integration.actorId } } + : { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: target.workspaceUuid, + }, + grants: { scopes: readScopes }, + }, + }); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + expect(visits).toEqual([ + `${destinationApiPath}/pullrequests/7`, + `${sourceApiPath}/src/${sourceCommit}/src%2Ffile.ts`, + ]); + } + ); + + it('queries the exact active organization integration for an unblocked member or administrator', () => { + const query = buildBitbucketInteractiveIntegrationQuery( + getWorkerDb('postgres://unused:unused@localhost:0/unused'), + owner, + target.integrationId + ).toSQL(); + for (const guard of [ + '"platform_integrations"."id" =', + '"owned_by_organization_id" =', + '"owned_by_user_id" is null', + '"integration_status" =', + '"auth_invalid_at" is null', + '"blocked_reason" is null', + '"organization_memberships"."id" is not null', + '"kilocode_users"."is_admin" =', + ]) + expect(query.sql).toContain(guard); + expect(query.params).toEqual( + expect.arrayContaining([ + owner.userId, + owner.orgId, + target.integrationId, + 'active', + 'bitbucket', + ]) + ); + expect(query.sql).not.toContain('token_encrypted'); + }); + + it('reads a fork through the exact destination and returns the actual OAuth actor without credentials', async () => { + mocks.rows.mockResolvedValue([{ ...integration, integrationType: 'oauth' }]); + const result = await run(); + expect(result).toEqual({ + success: true, + result: { status: 200, data: fork }, + metadata: { + actorUserId: owner.userId, + organizationId: owner.orgId, + integrationId: target.integrationId, + instanceUrl: 'https://bitbucket.org', + providerActor: { + credentialKind: 'bitbucketOAuth', + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: 'provider-user', + login: 'provider-login', + displayName: null, + avatarUrl: null, + }, + }, + grants: { scopes: readScopes }, + }, + }); + expect(providerFetch.mock.calls[0][0]).toContain( + `/repositories/%7B${target.workspaceUuid}%7D/%7B${target.repositoryUuid}%7D/pullrequests/7` + ); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + }); + + it('keeps an authorized empty page and workspace principal distinct from an authorization failure', async () => { + providerFetch.mockResolvedValue(Response.json({ values: [] })); + const result = await run({ + ...target, + request: { + operation: 'pullRequests', + params: { path: { workspace: 'acme', repo_slug: 'widgets' } }, + }, + }); + expect(result).toMatchObject({ + success: true, + result: { status: 200, data: { values: [] } }, + metadata: { + providerActor: { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: target.workspaceUuid, + workspaceSlug: 'acme', + }, + grants: { scopes: readScopes }, + }, + }); + if (!result.success) throw new Error('Expected authorization'); + expect(result.metadata.providerActor).not.toHaveProperty('actor'); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + }); + + it.each([ + [ + { ...target, workspaceUuid: '123e4567-e89b-12d3-a456-426614174099', request }, + 'workspace_mismatch', + ], + [{ ...target, repositoryUuid: '123e4567-e89b-12d3-a456-426614174099', request }, 'not_found'], + [ + { ...target, integrationId: '123e4567-e89b-12d3-a456-426614174099', request }, + 'integration_mismatch', + ], + [{ ...target, request, instanceUrl: 'https://attacker.example' }, 'invalid_request'], + [ + { ...target, request, metadata: { grants: { scopes: ['pullrequest:write'] } } }, + 'invalid_request', + ], + [ + { + ...target, + request: { ...request, params: { path: { ...request.params.path, workspace: 'fork' } } }, + }, + 'repository_mismatch', + ], + ])( + 'rejects mismatched identity or caller metadata before provider access %#', + async (input, reason) => { + await expect(run(input)).resolves.toEqual({ success: false, reason }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + } + ); + + it('rejects Personal Bitbucket before credential access', async () => { + await expect( + run(undefined, { userId: owner.userId, orgId: undefined } as unknown as typeof owner) + ).resolves.toEqual({ success: false, reason: 'invalid_request' }); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'wrong organization', + { ...owner, orgId: '123e4567-e89b-12d3-a456-426614174099' }, + '"owned_by_organization_id" =', + '123e4567-e89b-12d3-a456-426614174099', + ], + ['inactive integration', owner, '"integration_status" =', 'active'], + [ + 'blocked user', + { ...owner, userId: 'blocked-user' }, + '"blocked_reason" is null', + 'blocked-user', + ], + ] as const)('denies %s before credential resolution', async (_case, actor, guard, parameter) => { + const query = buildBitbucketInteractiveIntegrationQuery( + getWorkerDb('postgres://unused:unused@localhost:0/unused'), + actor, + target.integrationId + ).toSQL(); + expect(query.sql).toContain(guard); + expect(query.params).toContain(parameter); + mocks.rows.mockResolvedValue([]); + await expect(run(undefined, actor)).resolves.toEqual({ + success: false, + reason: 'not_connected', + }); + expect(mocks.resolve).not.toHaveBeenCalled(); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it.each(['approve', 'unapprove', 'requestChanges', 'removeChangeRequest', 'merge'])( + 'does not infer the %s grant from repository write access', + async operation => { + await expect(run({ ...target, request: { ...request, operation } })).resolves.toEqual({ + success: false, + reason: 'insufficient_permissions', + }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + } + ); + + it('permits comments with legacy read grants and the interactive body budget', async () => { + providerFetch.mockResolvedValue(Response.json({ id: 91 }, { status: 201 })); + await expect( + run({ + ...target, + request: { + ...request, + operation: 'createComment', + body: { content: { raw: 'x'.repeat(17_000) } }, + }, + }) + ).resolves.toMatchObject({ + success: true, + result: { status: 201, data: { id: 91 } }, + metadata: { grants: { scopes: readScopes } }, + }); + }); + + it.each([{ accessVersion: 8 }, { accessId: 'replacement' }, { repositories: [] }])( + 'fences a credential generation or cached identity change during resolution %#', + async change => { + mocks.rows + .mockResolvedValueOnce([integration]) + .mockResolvedValueOnce([{ ...integration, ...change }]); + const result = await run(); + expect(result).toEqual({ + success: false, + reason: 'repositories' in change ? 'not_found' : 'reconnect_required', + }); + expect(providerFetch).not.toHaveBeenCalled(); + } + ); + + it('rejects an arbitrary pagination host without forwarding the credential', async () => { + await expect( + run({ + ...target, + request: { + operation: 'pullRequests', + params: { path: { workspace: 'acme', repo_slug: 'widgets' } }, + next: 'https://attacker.example/page/2', + }, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_pagination' }); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it('rejects a multibyte body above 256,000 bytes before authorization', async () => { + await expect( + run({ + ...target, + request: { + ...request, + operation: 'createComment', + body: { content: { raw: 'é'.repeat(128_000) } }, + }, + }) + ).resolves.toEqual({ success: false, reason: 'request_too_large' }); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it.each([ + [403, 'insufficient_permissions'], + [429, 'rate_limited'], + [503, 'provider_unavailable'], + ])('preserves provider failure %s without retrying a mutation', async (status, reason) => { + providerFetch.mockResolvedValue(new Response(null, { status: Number(status) })); + await expect( + run({ + ...target, + request: { ...request, operation: 'createComment', body: { content: { raw: 'comment' } } }, + }) + ).resolves.toEqual({ success: false, reason }); + expect(providerFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + [{ oauthId: 'replacement' }, 'pullRequest', 'reconnect_required'], + [{ actorId: 'replacement-actor' }, 'pullRequest', 'reconnect_required'], + [{ scopes: readScopes }, 'approve', 'insufficient_permissions'], + ] as const)( + 'rejects OAuth identity or grant changes during resolution %#', + async (change, operation, reason) => { + const oauth = { + ...integration, + integrationType: 'oauth', + scopes: [...readScopes, 'pullrequest:write'], + }; + mocks.rows.mockResolvedValueOnce([oauth]).mockResolvedValueOnce([{ ...oauth, ...change }]); + await expect(run({ ...target, request: { ...request, operation } })).resolves.toEqual({ + success: false, + reason, + }); + expect(providerFetch).not.toHaveBeenCalled(); + } + ); + + it('generation-fences credential invalidation after provider rejection', async () => { + const generations = new Map([ + [7, 'active'], + [8, 'active'], + ]); + mocks.invalidate.mockImplementation(async (authorization, reason) => { + if ( + authorization.credentialId === 'credential-1' && + authorization.organizationId === owner.orgId && + reason === 'provider_rejected' + ) { + generations.set(authorization.credentialVersion, 'reconnect_required'); + } + }); + providerFetch.mockResolvedValue(new Response(null, { status: 401 })); + await expect(run()).resolves.toEqual({ success: false, reason: 'authentication_rejected' }); + expect([...generations]).toEqual([ + [7, 'reconnect_required'], + [8, 'active'], + ]); + expect(providerFetch).toHaveBeenCalledTimes(1); + }); + + it('rejects source branch deletion through destination access for a fork', async () => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: [...readScopes, 'pullrequest:write'] }, + ]); + await expect( + run({ + ...target, + request: { ...request, operation: 'merge', body: { close_source_branch: true } }, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(providerFetch.mock.calls.map(([, options]) => options.method)).toEqual(['GET']); + }); + + it.each([ + ['approve', 200], + ['unapprove', 204], + ['requestChanges', 200], + ['removeChangeRequest', 204], + ['merge', 200], + ] as const)('permits %s only with the actual PR-write grant', async (operation, status) => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: [...readScopes, 'pullrequest:write'] }, + ]); + providerFetch.mockResolvedValue( + status === 204 ? new Response(null, { status }) : Response.json({ id: 7 }, { status }) + ); + await expect(run({ ...target, request: { ...request, operation } })).resolves.toMatchObject({ + success: true, + result: { status, data: status === 204 ? null : { id: 7 } }, + metadata: { grants: { scopes: [...readScopes, 'pullrequest:write'] } }, + }); + }); + + it('does not inherit source deletion from the pull request when merging a fork', async () => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: [...readScopes, 'pullrequest:write'] }, + ]); + let sourceBranchExists = true; + providerFetch.mockImplementation(async (_url, options) => { + const body = JSON.parse(typeof options.body === 'string' ? options.body : '{}'); + if (body.close_source_branch !== false) sourceBranchExists = false; + return Response.json({ id: 7, state: 'MERGED' }); + }); + await expect( + run({ ...target, request: { ...request, operation: 'merge' } }) + ).resolves.toMatchObject({ success: true, result: { data: { state: 'MERGED' } } }); + expect(sourceBranchExists).toBe(true); + }); + + it('permits source deletion when the source is the authorized repository', async () => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: [...readScopes, 'pullrequest:write'] }, + ]); + providerFetch + .mockResolvedValueOnce(Response.json({ ...fork, source: fork.destination })) + .mockResolvedValueOnce(Response.json({ id: 7, state: 'MERGED' })); + await expect( + run({ + ...target, + request: { ...request, operation: 'merge', body: { close_source_branch: true } }, + }) + ).resolves.toMatchObject({ success: true, result: { data: { state: 'MERGED' } } }); + expect(providerFetch.mock.calls.map(([, options]) => options.method)).toEqual(['GET', 'POST']); + }); + + it('never authorizes a direct source write through the destination repository', async () => { + await expect( + run({ + ...target, + request: { + operation: 'deleteBranch', + params: { path: { workspace: 'fork', repo_slug: 'widgets', name: 'feature' } }, + }, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(providerFetch).not.toHaveBeenCalled(); + }); +}); + +describe('authorized canonical merge task locations', () => { + const canonicalTaskUrl = + 'https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/7/merge/task-status/task-1'; + const mergeUrl = `https://api.bitbucket.org${destinationApiPath}/pullrequests/7/merge`; + const uuidTaskUrl = `${mergeUrl}/task-status/task-1`; + const writeScopes = [...readScopes, 'pullrequest:write']; + const mergeRequest = { ...request, operation: 'merge' }; + + beforeEach(() => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: writeScopes, scopes: writeScopes }, + ]); + }); + + it.each([ + ['workspace_access_token', 'canonical', canonicalTaskUrl], + ['oauth', 'canonical', canonicalTaskUrl], + ['workspace_access_token', 'UUID', uuidTaskUrl], + ['oauth', 'UUID', uuidTaskUrl], + ] as const)( + 'retains the %s %s task and polls its exact UUID path', + async (integrationType, _name, location) => { + mocks.rows.mockResolvedValue([ + { ...integration, integrationType, accessScopes: writeScopes, scopes: writeScopes }, + ]); + const effects: string[] = []; + providerFetch.mockImplementation(async (url, options) => { + if ( + options.redirect !== 'manual' || + new Headers(options.headers).get('authorization') !== 'Bearer provider-secret' + ) + return Response.json({}, { status: 403 }); + if (url === mergeUrl && options.method === 'POST') { + effects.push('merge'); + return new Response(null, { status: 202, headers: { location } }); + } + if (url === uuidTaskUrl && options.method === 'GET') { + effects.push('poll'); + return Response.json({ task_status: 'PENDING' }); + } + return Response.json({}, { status: 404 }); + }); + const accepted = await run({ ...target, request: mergeRequest }); + expect(accepted).toMatchObject({ + success: true, + result: { status: 202, location, data: null }, + metadata: { integrationId: target.integrationId, grants: { scopes: writeScopes } }, + }); + if (!accepted.success || accepted.result.status !== 202) + throw new Error('Expected an accepted merge task'); + const taskId = new URL(accepted.result.location).pathname.split('/').at(-1); + if (!taskId) throw new Error('Expected a task identity'); + const polled = await run({ + ...target, + request: { + operation: 'mergeTask', + params: { path: { ...request.params.path, task_id: taskId } }, + }, + }); + expect(polled).toMatchObject({ + success: true, + result: { status: 200, data: { task_status: 'PENDING' } }, + }); + expect(effects).toEqual(['merge', 'poll']); + expect(JSON.stringify([accepted, polled])).not.toContain('provider-secret'); + } + ); + + it.each([ + canonicalTaskUrl.replace('/acme/', '/foreign/'), + canonicalTaskUrl.replace('/widgets/', '/other/'), + uuidTaskUrl.replace(target.repositoryUuid, sourceSelector.repositoryUuid), + uuidTaskUrl.replace(target.workspaceUuid, sourceSelector.workspaceUuid), + canonicalTaskUrl.replace('/7/', '/8/'), + canonicalTaskUrl.replace('task-1', ''), + `${canonicalTaskUrl}/child`, + canonicalTaskUrl.replace('task-1', 'task%2Fother'), + canonicalTaskUrl.replace('/widgets/', '/%77idgets/'), + canonicalTaskUrl.replace('api.bitbucket.org', 'attacker.example'), + canonicalTaskUrl.replace('https://', 'https://user:provider-secret@'), + `${canonicalTaskUrl}?access_token=provider-secret`, + `${canonicalTaskUrl}#fragment`, + ])('rejects a foreign or unsafe canonical task %s without retrying', async location => { + providerFetch.mockImplementation( + async () => new Response(null, { status: 202, headers: { location } }) + ); + const result = await run({ ...target, request: mergeRequest }); + expect(result).toEqual({ success: false, reason: 'invalid_response' }); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + }); + + it.each([302, 307])('rejects a canonical task redirect (%s) without replay', async status => { + providerFetch.mockImplementation( + async () => new Response(null, { status, headers: { location: canonicalTaskUrl } }) + ); + await expect(run({ ...target, request: mergeRequest })).resolves.toEqual({ + success: false, + reason: 'redirect_rejected', + }); + expect(providerFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + ...target, + request: mergeRequest, + canonicalTaskRepository: { workspace: 'foreign', repository: 'widgets' }, + }, + { + ...target, + request: { + ...mergeRequest, + canonicalTaskRepository: { workspace: 'foreign', repository: 'widgets' }, + }, + }, + ])('rejects a client-selected canonical repository before authorization %#', async input => { + await expect(run(input)).resolves.toEqual({ success: false, reason: 'invalid_request' }); + expect(mocks.resolve).not.toHaveBeenCalled(); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it('does not authorize a task through a cached slug reused by another UUID', async () => { + mocks.rows.mockResolvedValue([ + { + ...integration, + accessScopes: writeScopes, + repositories: [{ ...integration.repositories[0], id: sourceSelector.repositoryUuid }], + }, + ]); + await expect(run({ ...target, request: mergeRequest })).resolves.toEqual({ + success: false, + reason: 'not_found', + }); + expect(mocks.resolve).not.toHaveBeenCalled(); + expect(providerFetch).not.toHaveBeenCalled(); + }); +}); + +describe('destination-authorized source reads', () => { + const abbreviatedCommit = sourceCommit.slice(0, 12); + const abbreviatedFork = { + ...fork, + source: { ...fork.source, commit: { hash: abbreviatedCommit } }, + }; + + it.each(['file', 'fileMetadata'] as const)( + 'resolves abbreviated fork revisions before reading %s at the full SHA', + async operation => { + const visits: string[] = []; + providerFetch.mockImplementation(async (url, options) => { + const endpoint = new URL(String(url)); + visits.push(endpoint.pathname); + if (options.method !== 'GET') return Response.json({}, { status: 405 }); + if (endpoint.pathname === `${destinationApiPath}/pullrequests/7`) + return Response.json(abbreviatedFork); + if (endpoint.pathname === `${sourceApiPath}/commit/${abbreviatedCommit}`) + return Response.json({ hash: sourceCommit.toUpperCase() }); + if (endpoint.pathname !== `${sourceApiPath}/src/${sourceCommit}/src%2Ffile.ts`) + return Response.json({}, { status: 404 }); + return endpoint.searchParams.get('format') === 'meta' + ? Response.json(sourceFileMetadata) + : new Response('fork content'); + }); + await expect( + run({ ...target, request: { ...sourceFileRequest, operation } }) + ).resolves.toMatchObject({ + success: true, + result: { status: 200, data: operation === 'file' ? 'fork content' : sourceFileMetadata }, + }); + expect(visits).toEqual([ + `${destinationApiPath}/pullrequests/7`, + `${sourceApiPath}/commit/${abbreviatedCommit}`, + `${sourceApiPath}/src/${sourceCommit}/src%2Ffile.ts`, + ]); + } + ); + + it.each([ + [ + 'same prefix, different revision', + { hash: `${abbreviatedCommit}${'f'.repeat(28)}` }, + 'conflict', + ], + ['missing hash', {}, 'invalid_response'], + ['still abbreviated', { hash: abbreviatedCommit }, 'invalid_response'], + ['different prefix', { hash: 'f'.repeat(40) }, 'invalid_response'], + ] as const)( + 'rejects resolved source commit %s without reading file content', + async (_name, commit, reason) => { + providerFetch + .mockResolvedValueOnce(Response.json(abbreviatedFork)) + .mockResolvedValueOnce(Response.json(commit)); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason, + }); + expect(providerFetch.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([ + `${destinationApiPath}/pullrequests/7`, + `${sourceApiPath}/commit/${abbreviatedCommit}`, + ]); + } + ); + + it('preserves provider denial while resolving an abbreviated source revision', async () => { + providerFetch + .mockResolvedValueOnce(Response.json(abbreviatedFork)) + .mockResolvedValueOnce(new Response(null, { status: 403 })); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason: 'insufficient_permissions', + }); + expect(providerFetch.mock.calls).toHaveLength(2); + }); + + it.each([ + [{ integrationId: sourceSelector.repositoryUuid }, 'integration_mismatch'], + [{ workspaceUuid: sourceSelector.workspaceUuid }, 'workspace_mismatch'], + [{ repositoryUuid: sourceSelector.repositoryUuid }, 'not_found'], + [{ repositoryFullName: 'acme/other' }, 'repository_mismatch'], + ] as const)( + 'requires the original destination identity before reading the PR %#', + async (change, reason) => { + await expect(run({ ...target, ...change, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason, + }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + } + ); + + it('requires destination membership before resolving a source selector', async () => { + mocks.rows.mockResolvedValue([]); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason: 'not_connected', + }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it.each([ + ['workspace_access_token', { accessVersion: 8 }, 'reconnect_required'], + ['workspace_access_token', { accessId: 'replacement' }, 'reconnect_required'], + [ + 'workspace_access_token', + { accessScopes: ['repository', 'pullrequest'] }, + 'insufficient_permissions', + ], + ['oauth', { oauthId: 'replacement' }, 'reconnect_required'], + ['oauth', { actorId: 'replacement' }, 'reconnect_required'], + ['oauth', { scopes: ['repository', 'pullrequest'] }, 'insufficient_permissions'], + ] as const)( + 'fences %s credential or grant changes before source reads %#', + async (integrationType, change, reason) => { + const initial = { ...integration, integrationType }; + mocks.rows + .mockResolvedValueOnce([initial]) + .mockResolvedValueOnce([{ ...initial, ...change }]); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason, + }); + expect(providerFetch).not.toHaveBeenCalled(); + } + ); + + it('rejects a cached destination slug reused by another UUID', async () => { + mocks.rows.mockResolvedValue([ + { + ...integration, + repositories: [{ ...integration.repositories[0], id: sourceSelector.repositoryUuid }], + }, + ]); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason: 'not_found', + }); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing PR', {}, 'invalid_response'], + ['wrong PR type', { ...fork, type: 'repository' }, 'invalid_response'], + ['malformed PR ID', { ...fork, id: '7' }, 'invalid_response'], + ['wrong PR ID', { ...fork, id: 8 }, 'repository_mismatch'], + ['missing destination', { ...fork, destination: null }, 'invalid_response'], + [ + 'reused destination slug', + { + ...fork, + destination: { + repository: { ...fork.destination.repository, uuid: fork.source.repository.uuid }, + }, + }, + 'repository_mismatch', + ], + [ + 'wrong destination name', + { + ...fork, + destination: { repository: { ...fork.destination.repository, full_name: 'acme/other' } }, + }, + 'repository_mismatch', + ], + [ + 'wrong destination workspace', + { + ...fork, + destination: { + repository: { + ...fork.destination.repository, + workspace: { + ...fork.destination.repository.workspace, + uuid: sourceSelector.workspaceUuid, + }, + }, + }, + }, + 'workspace_mismatch', + ], + ['missing source', { ...fork, source: null }, 'invalid_response'], + [ + 'missing source workspace', + { + ...fork, + source: { ...fork.source, repository: { ...fork.source.repository, workspace: undefined } }, + }, + 'invalid_response', + ], + [ + 'malformed source UUID', + { + ...fork, + source: { ...fork.source, repository: { ...fork.source.repository, uuid: 'fork/widgets' } }, + }, + 'invalid_response', + ], + [ + 'malformed workspace UUID', + { + ...fork, + source: { + ...fork.source, + repository: { + ...fork.source.repository, + workspace: { ...fork.source.repository.workspace, uuid: '../other' }, + }, + }, + }, + 'invalid_response', + ], + [ + 'inconsistent source workspace', + { + ...fork, + source: { + ...fork.source, + repository: { + ...fork.source.repository, + workspace: { ...fork.source.repository.workspace, slug: 'other' }, + }, + }, + }, + 'invalid_response', + ], + [ + 'source path traversal', + { + ...fork, + source: { + ...fork.source, + repository: { ...fork.source.repository, full_name: 'fork/../widgets' }, + }, + }, + 'invalid_response', + ], + [ + 'reused source slug', + { + ...fork, + source: { + ...fork.source, + repository: { ...fork.source.repository, uuid: target.repositoryUuid }, + }, + }, + 'repository_mismatch', + ], + [ + 'wrong source workspace', + { + ...fork, + source: { + ...fork.source, + repository: { + ...fork.source.repository, + workspace: { ...fork.source.repository.workspace, uuid: target.workspaceUuid }, + }, + }, + }, + 'workspace_mismatch', + ], + [ + 'stale source revision', + { ...fork, source: { ...fork.source, commit: { hash: 'b'.repeat(40) } } }, + 'conflict', + ], + [ + 'malformed provider revision', + { ...fork, source: { ...fork.source, commit: { hash: sourceCommit.slice(0, 6) } } }, + 'invalid_response', + ], + [ + 'stale abbreviated revision', + { ...fork, source: { ...fork.source, commit: { hash: 'b'.repeat(12) } } }, + 'conflict', + ], + [ + 'non-commit provider revision', + { ...fork, source: { ...fork.source, commit: { hash: 'main' } } }, + 'invalid_response', + ], + ] as const)('rejects %s without reading the source', async (_name, review, reason) => { + providerFetch.mockImplementation(async () => Response.json(review)); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason, + }); + expect(providerFetch.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([ + `${destinationApiPath}/pullrequests/7`, + ]); + }); + + it.each([ + null, + {}, + { ...sourceSelector, pullRequestId: 0 }, + { ...sourceSelector, pullRequestId: '7' }, + { ...sourceSelector, pullRequestId: Number.MAX_SAFE_INTEGER + 1 }, + { ...sourceSelector, workspaceUuid: 'fork' }, + { ...sourceSelector, repositoryUuid: '../widgets' }, + { ...sourceSelector, url: 'https://attacker.example' }, + ])('rejects a malformed source selector before provider access %#', async source => { + await expect(run({ ...target, request: { ...sourceFileRequest, source } })).resolves.toEqual({ + success: false, + reason: 'invalid_request', + }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it.each(['main', sourceCommit.slice(0, 12), 'a'.repeat(39), 'a'.repeat(41), '../main', 123])( + 'rejects non-immutable commit selector %s before authorization', + async commit => { + await expect( + run({ + ...target, + request: { + ...sourceFileRequest, + params: { path: { ...sourceFileRequest.params.path, commit } }, + }, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_request' }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + } + ); + + it.each([ + { next: 'https://attacker.example/page/2' }, + { next: `https://api.bitbucket.org${sourceApiPath}/src/${sourceCommit}/src%2Ffile.ts` }, + { body: { content: 'not-a-read' } }, + { params: { ...sourceFileRequest.params, query: { format: 'meta' } } }, + ])('rejects source next links, bodies, and query overrides %#', async change => { + await expect(run({ ...target, request: { ...sourceFileRequest, ...change } })).resolves.toEqual( + { success: false, reason: 'invalid_request' } + ); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it.each([ + { workspace: 'fork' }, + { workspace: `{${sourceSelector.workspaceUuid}}` }, + { repo_slug: `{${sourceSelector.repositoryUuid}}` }, + { repo_slug: 'other' }, + ])('never uses a caller-selected foreign scope for the source %#', async path => { + await expect( + run({ + ...target, + request: { + ...sourceFileRequest, + params: { path: { ...sourceFileRequest.params.path, ...path } }, + }, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it.each(['../file.ts', 'src/../file.ts', 'src\\file.ts', 'src//file.ts'])( + 'retains SDK path rejection for source file %s', + async path => { + await expect( + run({ + ...target, + request: { + ...sourceFileRequest, + params: { path: { ...sourceFileRequest.params.path, path } }, + }, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_request' }); + expect(providerFetch.mock.calls).toHaveLength(1); + } + ); + + it.each([ + 'createComment', + 'updateComment', + 'deleteComment', + 'resolveComment', + 'reopenComment', + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'merge', + 'deleteBranch', + ])('rejects source %s even with destination-looking paths and write grants', async operation => { + mocks.rows.mockResolvedValue([ + { ...integration, accessScopes: [...readScopes, 'pullrequest:write'] }, + ]); + const path = + operation === 'deleteBranch' + ? { workspace: 'acme', repo_slug: 'widgets', name: 'feature' } + : operation.endsWith('Comment') && operation !== 'createComment' + ? { ...request.params.path, comment_id: 91 } + : request.params.path; + const body = + operation === 'createComment' || operation === 'updateComment' + ? { content: { raw: 'comment' } } + : operation === 'merge' + ? { close_source_branch: false } + : undefined; + for (const attempt of [ + { operation, params: { path }, body, source: sourceSelector }, + { + operation, + params: { path: { ...path, commit: sourceCommit } }, + source: sourceSelector, + }, + ]) { + await expect(run({ ...target, request: attempt })).resolves.toEqual({ + success: false, + reason: 'invalid_request', + }); + } + expect(providerFetch).not.toHaveBeenCalled(); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it.each([ + request, + { operation: 'repository', params: { path: { workspace: 'acme', repo_slug: 'widgets' } } }, + { + operation: 'diff', + params: { + path: { + workspace: 'acme', + repo_slug: 'widgets', + spec: `${sourceCommit}..${'b'.repeat(40)}`, + }, + }, + }, + { + operation: 'commit', + params: { path: { workspace: 'acme', repo_slug: 'widgets', commit: sourceCommit } }, + }, + ])('rejects unrelated reads with a source selector %#', async unrelated => { + await expect( + run({ + ...target, + request: { + ...unrelated, + params: { + ...unrelated.params, + path: { ...unrelated.params.path, commit: sourceCommit }, + }, + source: sourceSelector, + }, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_request' }); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it.each([ + [403, 'insufficient_permissions'], + [404, 'not_found'], + [429, 'rate_limited'], + [503, 'provider_unavailable'], + ['lost', 'transport_failed'], + ['oversized', 'response_too_large'], + ['redirect', 'redirect_rejected'], + ] as const)( + 'preserves bounded source failure %s without retry or credential leakage', + async (status, reason) => { + providerFetch.mockResolvedValueOnce(Response.json(fork)).mockImplementation(async () => { + if (status === 'lost') throw new Error('provider-secret'); + if (status === 'oversized') + return new Response('provider-secret', { headers: { 'content-length': '1000001' } }); + if (status === 'redirect') + return new Response(null, { + status: 302, + headers: { location: 'https://attacker.example' }, + }); + return Response.json({ error: { message: 'provider-secret' } }, { status }); + }); + const result = await run({ ...target, request: sourceFileRequest }); + expect(result).toEqual({ success: false, reason }); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + expect(providerFetch.mock.calls.map(([, options]) => options.method)).toEqual(['GET', 'GET']); + } + ); + + it.each(['workspace_access_token', 'oauth'])( + 'keeps %s invalidation semantics after source rejection', + async integrationType => { + mocks.rows.mockResolvedValue([{ ...integration, integrationType }]); + const generations = new Map([ + [7, 'active'], + [8, 'active'], + ]); + mocks.invalidate.mockImplementation(async authorization => { + if (authorization.credentialId === integration.accessId) + generations.set(authorization.credentialVersion, 'reconnect_required'); + }); + providerFetch + .mockResolvedValueOnce(Response.json(fork)) + .mockResolvedValueOnce(new Response(null, { status: 401 })); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toEqual({ + success: false, + reason: 'authentication_rejected', + }); + expect([...generations]).toEqual([ + [7, integrationType === 'oauth' ? 'active' : 'reconnect_required'], + [8, 'active'], + ]); + expect(providerFetch.mock.calls).toHaveLength(2); + } + ); + + it('retains an empty source file instead of reporting missing content', async () => { + providerFetch + .mockResolvedValueOnce(Response.json(fork)) + .mockResolvedValueOnce(new Response('')); + await expect(run({ ...target, request: sourceFileRequest })).resolves.toMatchObject({ + success: true, + result: { status: 200, data: '' }, + }); + }); + + it('leaves file calls without a selector on the destination', async () => { + providerFetch.mockImplementation( + async url => + new Response( + new URL(String(url)).pathname === + `${destinationApiPath}/src/${sourceCommit}/src%2Ffile.ts` + ? 'destination content' + : 'wrong repository' + ) + ); + await expect( + run({ ...target, request: { operation: 'file', params: sourceFileRequest.params } }) + ).resolves.toMatchObject({ success: true, result: { data: 'destination content' } }); + expect(providerFetch.mock.calls).toHaveLength(1); + }); +}); diff --git a/services/git-token-service/src/interactive-review-handler.ts b/services/git-token-service/src/interactive-review-handler.ts new file mode 100644 index 0000000000..83447c038b --- /dev/null +++ b/services/git-token-service/src/interactive-review-handler.ts @@ -0,0 +1,412 @@ +import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; +import { + kilocode_users, + organization_memberships, + platform_integrations, + platform_oauth_credentials, + platform_access_token_credentials, +} from '@kilocode/db/schema'; +import { hasRequiredBitbucketWorkspaceAccessTokenScopes } from '@kilocode/worker-utils/bitbucket-workspace-access-token'; +import { and, eq, isNull, isNotNull, or } from 'drizzle-orm'; +import { z } from 'zod'; +import { BitbucketPullRequestRequestSchema } from './bitbucket-code-review-service.js'; +import { + BitbucketInteractiveMetadataSchema, + BitbucketInteractiveBrokerRequestSchema, + createBitbucketInteractiveApi, + type BitbucketInteractiveRequest, + type BitbucketInteractiveServiceSuccess, +} from './bitbucket-interactive-api.js'; +import { + BitbucketInteractiveError, + assertBitbucketRequestSize, + BITBUCKET_INTERACTIVE_REQUEST_MAX_BYTES, +} from './bitbucket-safe-transport.js'; +import { + resolveBitbucketCapabilitySubject, + selectCachedBitbucketRepository, + type GetBitbucketTokenResult, +} from './bitbucket-runtime-token-resolver.js'; +import { BitbucketWorkspaceAccessTokenAuthorizationService } from './bitbucket-workspace-access-token-authorization-service.js'; +import { normalizeBitbucketUuid } from './bitbucket-url.js'; + +export const BitbucketInteractiveHttpRequestSchema = BitbucketPullRequestRequestSchema.omit({ + owner: true, + pullRequestId: true, +}).extend({ request: BitbucketInteractiveBrokerRequestSchema }); + +const providerUuid = z.string().transform(normalizeBitbucketUuid).pipe(z.string()); +const sourceReadRepositorySchema = z + .object({ + uuid: providerUuid, + full_name: BitbucketPullRequestRequestSchema.shape.repositoryFullName, + workspace: z.object({ + uuid: providerUuid, + slug: BitbucketPullRequestRequestSchema.shape.workspaceSlug, + }), + }) + .refine(repository => repository.full_name.split('/')[0] === repository.workspace.slug); +const sourceReadPullRequestSchema = z.object({ + type: z.literal('pullrequest'), + id: BitbucketPullRequestRequestSchema.shape.pullRequestId, + source: z.object({ + repository: sourceReadRepositorySchema, + commit: z.object({ + hash: z + .string() + .regex(/^[0-9a-fA-F]{7,40}$/) + .toLowerCase(), + }), + }), + destination: z.object({ repository: sourceReadRepositorySchema }), +}); +type Owner = { userId: string; orgId: string }; +type Target = z.infer; +type FailureReason = + | BitbucketInteractiveError['code'] + | Exclude['reason'], 'repository_not_found'>; + +export function buildBitbucketInteractiveIntegrationQuery( + db: WorkerDb, + owner: Owner, + integrationId: string +) { + return db + .select({ + integrationId: platform_integrations.id, + integrationType: platform_integrations.integration_type, + workspaceUuid: platform_integrations.platform_account_id, + workspaceSlug: platform_integrations.platform_account_login, + scopes: platform_integrations.scopes, + repositories: platform_integrations.repositories, + repositoriesSyncedAt: platform_integrations.repositories_synced_at, + oauthId: platform_oauth_credentials.id, + actorId: platform_oauth_credentials.provider_subject_id, + actorLogin: platform_oauth_credentials.provider_subject_login, + accessId: platform_access_token_credentials.id, + accessVersion: platform_access_token_credentials.credential_version, + accessScopes: platform_access_token_credentials.provider_scopes, + }) + .from(platform_integrations) + .innerJoin( + kilocode_users, + and(eq(kilocode_users.id, owner.userId), isNull(kilocode_users.blocked_reason)) + ) + .leftJoin( + organization_memberships, + and( + eq( + organization_memberships.organization_id, + platform_integrations.owned_by_organization_id + ), + eq(organization_memberships.kilo_user_id, owner.userId) + ) + ) + .leftJoin( + platform_oauth_credentials, + and( + eq(platform_oauth_credentials.platform_integration_id, platform_integrations.id), + isNull(platform_oauth_credentials.revoked_at) + ) + ) + .leftJoin( + platform_access_token_credentials, + and( + eq(platform_access_token_credentials.platform_integration_id, platform_integrations.id), + isNull(platform_access_token_credentials.provider_resource_id) + ) + ) + .where( + and( + eq(platform_integrations.id, integrationId), + eq(platform_integrations.platform, 'bitbucket'), + eq(platform_integrations.owned_by_organization_id, owner.orgId), + isNull(platform_integrations.owned_by_user_id), + eq(platform_integrations.integration_status, 'active'), + isNull(platform_integrations.auth_invalid_at), + or(isNotNull(organization_memberships.id), eq(kilocode_users.is_admin, true)) + ) + ) + .limit(1); +} + +type Integration = Awaited>[number]; +function checkTarget(integration: Integration, target: Target): FailureReason | null { + if (integration.integrationId !== target.integrationId) return 'integration_mismatch'; + if ( + integration.workspaceUuid !== target.workspaceUuid || + integration.workspaceSlug !== target.workspaceSlug + ) + return 'workspace_mismatch'; + if ( + !integration.repositoriesSyncedAt || + !Number.isFinite(new Date(integration.repositoriesSyncedAt).getTime()) + ) + return 'temporarily_unavailable'; + const cached = selectCachedBitbucketRepository(integration.repositories, target); + if (cached.status !== 'available') + return cached.status === 'repository_not_found' ? 'not_found' : cached.status; + return cached.repository.fullName === target.repositoryFullName ? null : 'repository_mismatch'; +} + +const pullRequestWriteOperations = new Set([ + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'merge', +]); +function hasOperationScopes(scopes: readonly string[], operation: string): boolean { + return ( + hasRequiredBitbucketWorkspaceAccessTokenScopes(scopes) && + (!pullRequestWriteOperations.has(operation) || scopes.includes('pullrequest:write')) + ); +} + +export async function handleBitbucketInteractiveReview( + env: CloudflareEnv, + owner: Owner, + input: unknown +): Promise { + try { + const parsed = BitbucketInteractiveHttpRequestSchema.safeParse(input); + if (!parsed.success || !BitbucketPullRequestRequestSchema.shape.owner.safeParse(owner).success) + return { success: false, reason: 'invalid_request' }; + const target = parsed.data; + assertBitbucketRequestSize(JSON.stringify(target), BITBUCKET_INTERACTIVE_REQUEST_MAX_BYTES); + const { source, ...request } = target.request; + const path = request.params.path; + const repositorySlug = target.repositoryFullName.split('/')[1]; + if ( + target.repositoryFullName !== `${target.workspaceSlug}/${repositorySlug}` || + (path.workspace !== target.workspaceSlug && + normalizeBitbucketUuid(String(path.workspace)) !== target.workspaceUuid) || + (path.repo_slug !== repositorySlug && + normalizeBitbucketUuid(String(path.repo_slug)) !== target.repositoryUuid) + ) + return { success: false, reason: 'repository_mismatch' }; + if (!env.HYPERDRIVE) return { success: false, reason: 'temporarily_unavailable' }; + const query = buildBitbucketInteractiveIntegrationQuery( + getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }), + owner, + target.integrationId + ); + const [initial] = await query; + if (!initial) return { success: false, reason: 'not_connected' }; + const targetFailure = checkTarget(initial, target); + if (targetFailure) return { success: false, reason: targetFailure }; + const scopes = + initial.integrationType === 'workspace_access_token' ? initial.accessScopes : initial.scopes; + if (!hasOperationScopes(scopes ?? [], request.operation)) + return { success: false, reason: 'insufficient_permissions' }; + const resolved = await resolveBitbucketCapabilitySubject(env, { + ...owner, + expectedIntegrationId: target.integrationId, + workspaceUuid: target.workspaceUuid, + repositoryUuid: target.repositoryUuid, + repositoryUrl: `https://bitbucket.org/${target.repositoryFullName}.git`, + }); + if (!resolved.success) + return { + success: false, + reason: resolved.reason === 'repository_not_found' ? 'not_found' : resolved.reason, + }; + const { subject } = resolved; + const [current] = await query; + if (!current || current.integrationType !== initial.integrationType) + return { success: false, reason: 'reconnect_required' }; + const currentFailure = checkTarget(current, target); + if (currentFailure) return { success: false, reason: currentFailure }; + const workspaceToken = current.integrationType === 'workspace_access_token'; + if ( + workspaceToken + ? !current.accessId || + !current.accessVersion || + current.accessId !== initial.accessId || + current.accessVersion !== initial.accessVersion + : current.integrationType !== 'oauth' || + !current.oauthId || + current.oauthId !== initial.oauthId || + current.actorId !== initial.actorId + ) + return { success: false, reason: 'reconnect_required' }; + const metadata = BitbucketInteractiveMetadataSchema.safeParse({ + actorUserId: owner.userId, + organizationId: owner.orgId, + integrationId: subject.integrationId, + instanceUrl: 'https://bitbucket.org', + providerActor: workspaceToken + ? { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: subject.workspaceUuid, + workspaceSlug: subject.workspaceSlug, + } + : { + credentialKind: 'bitbucketOAuth', + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: current.actorId, + login: current.actorLogin, + displayName: null, + avatarUrl: null, + }, + }, + grants: { scopes: workspaceToken ? current.accessScopes : current.scopes }, + }); + if (!metadata.success) return { success: false, reason: 'reconnect_required' }; + if (!hasOperationScopes(metadata.data.grants.scopes, request.operation)) + return { success: false, reason: 'insufficient_permissions' }; + // Address the authorized repository by immutable UUID, never by a reused slug. + const scope = { + kind: 'repository' as const, + workspace: `{${subject.workspaceUuid}}`, + repository: `{${subject.repositoryUuid}}`, + }; + let api = createBitbucketInteractiveApi({ + scope, + accessToken: subject.token, + canonicalTaskRepository: { + workspace: subject.workspaceSlug, + repository: subject.repositoryFullName.split('/')[1], + }, + }); + let params: typeof request.params & { path: { workspace: string; repo_slug: string } } = { + ...request.params, + path: { ...path, workspace: scope.workspace, repo_slug: scope.repository }, + }; + try { + if (source) { + const review = await api.execute({ + operation: 'pullRequest', + params: { + path: { + workspace: scope.workspace, + repo_slug: scope.repository, + pull_request_id: source.pullRequestId, + }, + query: { fields: '+source.repository.workspace,+destination.repository.workspace' }, + }, + }); + const parsedReview = sourceReadPullRequestSchema.safeParse(review.data); + if (!parsedReview.success) return { success: false, reason: 'invalid_response' }; + const { source: providerSource, destination } = parsedReview.data; + if ( + parsedReview.data.id !== source.pullRequestId || + destination.repository.uuid !== target.repositoryUuid || + destination.repository.full_name !== target.repositoryFullName || + providerSource.repository.uuid !== source.repositoryUuid + ) + return { success: false, reason: 'repository_mismatch' }; + if ( + destination.repository.workspace.uuid !== target.workspaceUuid || + destination.repository.workspace.slug !== target.workspaceSlug || + providerSource.repository.workspace.uuid !== source.workspaceUuid + ) + return { success: false, reason: 'workspace_mismatch' }; + const expectedCommit = String(path.commit).toLowerCase(); + let sourceCommit = providerSource.commit.hash; + if (!expectedCommit.startsWith(sourceCommit)) return { success: false, reason: 'conflict' }; + // Only a validated PR can select this read scope. Never follow its links or source slugs. + const sourceScope = { + kind: 'repository' as const, + workspace: `{${providerSource.repository.workspace.uuid}}`, + repository: `{${providerSource.repository.uuid}}`, + }; + api = createBitbucketInteractiveApi({ scope: sourceScope, accessToken: subject.token }); + // Condensed PR commits can use short hashes. Resolve provider data, never caller abbreviations. + if (sourceCommit.length < 40) { + const commit = await api.execute({ + operation: 'commit', + params: { + path: { + workspace: sourceScope.workspace, + repo_slug: sourceScope.repository, + commit: sourceCommit, + }, + }, + }); + const resolved = z + .object({ + hash: z + .string() + .regex(/^[0-9a-fA-F]{40}$/) + .toLowerCase(), + }) + .safeParse(commit.data); + if (!resolved.success || !resolved.data.hash.startsWith(sourceCommit)) + return { success: false, reason: 'invalid_response' }; + sourceCommit = resolved.data.hash; + } + if (sourceCommit !== expectedCommit) return { success: false, reason: 'conflict' }; + params = { + ...request.params, + path: { + ...path, + workspace: sourceScope.workspace, + repo_slug: sourceScope.repository, + commit: sourceCommit, + }, + }; + } + let body = request.body; + if (request.operation === 'merge') { + // Omission inherits the PR's deletion preference. Require explicit deletion authorization. + const mergeBody = z + .object({ close_source_branch: z.boolean().default(false) }) + .catchall(z.json()) + .safeParse(body === undefined ? {} : body); + if (!mergeBody.success) return { success: false, reason: 'invalid_request' }; + body = mergeBody.data; + if (mergeBody.data.close_source_branch) { + if (typeof path.pull_request_id !== 'number') + return { success: false, reason: 'invalid_request' }; + const review = await api.execute({ + operation: 'pullRequest', + params: { path: { ...params.path, pull_request_id: path.pull_request_id } }, + }); + const source = z + .object({ source: z.object({ repository: z.object({ uuid: z.string() }) }) }) + .safeParse(review.data); + // Destination access never grants deletion in a fork. This read is not an atomic head guard. + if ( + !source.success || + normalizeBitbucketUuid(source.data.source.repository.uuid) !== target.repositoryUuid + ) + return { success: false, reason: 'repository_mismatch' }; + } + } + // The shared schema and SDK validate this operation's dynamic path/body before dispatch. + const result = await api.execute({ ...request, params, body } as BitbucketInteractiveRequest); + return { success: true, result, metadata: metadata.data }; + } catch (error) { + if ( + error instanceof BitbucketInteractiveError && + error.code === 'authentication_rejected' && + workspaceToken && + current.accessId && + current.accessVersion + ) { + await new BitbucketWorkspaceAccessTokenAuthorizationService(env).invalidateAuthorization( + { + status: 'available', + token: subject.token, + organizationId: owner.orgId, + integrationId: subject.integrationId, + credentialId: current.accessId, + credentialVersion: current.accessVersion, + providerScopes: metadata.data.grants.scopes, + workspace: { uuid: subject.workspaceUuid, slug: subject.workspaceSlug }, + }, + 'provider_rejected' + ); + } + throw error; + } + } catch (error) { + return { + success: false, + reason: error instanceof BitbucketInteractiveError ? error.code : 'temporarily_unavailable', + }; + } +}