diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts new file mode 100644 index 000000000..fc8627ec4 --- /dev/null +++ b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts @@ -0,0 +1,480 @@ +// `gcp-security.service.ts` pulls in heavy SCC/IAM clients via the +// constructor flow at import time. We only need to test the OAuth-fetch +// based project-detection paths, so stub @db (Prisma client) before +// importing the service. +jest.mock('@db', () => ({ db: {} })); + +import { GCPSecurityService } from './gcp-security.service'; + +/** + * Helper: build a Response-like object for a single page of the GCP + * v1/projects list endpoint. Mirrors the exact shape the real API + * returns so the production code path is exercised verbatim. + */ +function gcpPage(opts: { + projects: Array<{ projectId: string; name: string; projectNumber: string }>; + nextPageToken?: string; +}): { ok: true; json: () => Promise } { + return { + ok: true, + json: async () => ({ + projects: opts.projects, + ...(opts.nextPageToken ? { nextPageToken: opts.nextPageToken } : {}), + }), + }; +} + +function makeProject(suffix: string) { + return { + projectId: `proj-${suffix}`, + name: `Project ${suffix}`, + projectNumber: `100${suffix}`, + }; +} + +describe('GCPSecurityService — project detection', () => { + let service: GCPSecurityService; + let fetchMock: jest.Mock; + const originalFetch = global.fetch; + + beforeEach(() => { + fetchMock = jest.fn(); + // @ts-expect-error replacing global fetch with a mock for these tests + global.fetch = fetchMock; + service = new GCPSecurityService(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + // ─── Pagination through nextPageToken ────────────────────────────────── + + describe('listProjectsPaginated (via detectProjects)', () => { + it('returns all projects from a single page when no nextPageToken is set', async () => { + fetchMock.mockResolvedValueOnce( + gcpPage({ projects: [makeProject('1'), makeProject('2')] }), + ); + + const result = await service.detectProjects('token'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { id: 'proj-1', name: 'Project 1', number: '1001' }, + { id: 'proj-2', name: 'Project 2', number: '1002' }, + ]); + }); + + it('follows nextPageToken across multiple pages until exhaustion', async () => { + // Three pages — this is the Greg scenario: a customer with more + // accessible projects than fit in a single page. Pre-fix, only + // the first page came back and the folder-nested production + // projects on later pages were silently dropped. + fetchMock + .mockResolvedValueOnce( + gcpPage({ + projects: [makeProject('1'), makeProject('2')], + nextPageToken: 'page-2-token', + }), + ) + .mockResolvedValueOnce( + gcpPage({ + projects: [makeProject('3'), makeProject('4')], + nextPageToken: 'page-3-token', + }), + ) + .mockResolvedValueOnce( + gcpPage({ + projects: [makeProject('5')], + // no nextPageToken → end of pagination + }), + ); + + const result = await service.detectProjects('token'); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(result.map((p) => p.id)).toEqual([ + 'proj-1', + 'proj-2', + 'proj-3', + 'proj-4', + 'proj-5', + ]); + }); + + it('passes pageToken through to subsequent requests', async () => { + fetchMock + .mockResolvedValueOnce( + gcpPage({ + projects: [makeProject('1')], + nextPageToken: 'token-for-page-2', + }), + ) + .mockResolvedValueOnce(gcpPage({ projects: [makeProject('2')] })); + + await service.detectProjects('access-token-xyz'); + + const secondCallUrl = fetchMock.mock.calls[1]?.[0] as string; + expect(secondCallUrl).toContain('pageToken=token-for-page-2'); + }); + + it('returns the projects collected so far when a mid-pagination page fails', async () => { + // Mid-pagination 500 from GCP — we keep what we got rather than + // throwing and blanking the picker. Matches the prior failure + // posture of "best-effort results". + fetchMock + .mockResolvedValueOnce( + gcpPage({ + projects: [makeProject('1'), makeProject('2')], + nextPageToken: 'page-2', + }), + ) + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => 'Service Unavailable', + }); + + const result = await service.detectProjects('token'); + + expect(result.map((p) => p.id)).toEqual(['proj-1', 'proj-2']); + // The fallback path doesn't fire because direct returned ≥1. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('stops paginating at the safety cap (1000 projects) even if more pages remain', async () => { + // Five 200-project pages → exactly 1000 → cap hit. + const bigPage = (start: number, count: number, more: boolean) => + gcpPage({ + projects: Array.from({ length: count }, (_, i) => + makeProject(String(start + i)), + ), + ...(more ? { nextPageToken: `next-${start + count}` } : {}), + }); + fetchMock + .mockResolvedValueOnce(bigPage(1, 200, true)) + .mockResolvedValueOnce(bigPage(201, 200, true)) + .mockResolvedValueOnce(bigPage(401, 200, true)) + .mockResolvedValueOnce(bigPage(601, 200, true)) + .mockResolvedValueOnce(bigPage(801, 200, true)) + // Sixth page never requested because cap hit. + .mockResolvedValueOnce(bigPage(1001, 200, false)); + + const result = await service.detectProjects('token'); + + expect(result).toHaveLength(1000); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + }); + + // ─── detectProjectsForOrg: org direct + folder-tree projects ────────── + + /** + * Build a Response-like for the v2/folders endpoint. + */ + function foldersPage(opts: { + folders: string[]; // folder IDs (numeric) + nextPageToken?: string; + }): { ok: true; json: () => Promise } { + return { + ok: true, + json: async () => ({ + folders: opts.folders.map((id) => ({ name: `folders/${id}` })), + ...(opts.nextPageToken ? { nextPageToken: opts.nextPageToken } : {}), + }), + }; + } + + describe('detectProjectsForOrg', () => { + it("only queries projects whose parent is this org's direct children OR a folder inside this org's tree (cubic P2)", async () => { + // Greg's exact scenario: + // org 43356919874 (propper.ai) + // ├── folder 9724350536 (propper) + // │ ├── propperai-prod + // │ └── propperai-demo + // └── org-root-1 (direct org child) + // + // The folder-nested arm previously used `parent.type:folder` + // alone, which would have ALSO returned projects under folders + // in OTHER orgs the caller had access to. The fix enumerates + // folders under THIS org and queries each by ID, matching GCP's + // documented happy-path filter shape and properly scoping the + // result. + const seenFolderIdsQueried: string[] = []; + fetchMock.mockImplementation(async (url: string) => { + // Folder enumeration: top-level folders under the org. + if (url.includes('v2/folders') && url.includes('organizations%2F43356919874')) { + return foldersPage({ folders: ['9724350536'] }); + } + // Folder enumeration: sub-folders (none in this tree). + if (url.includes('v2/folders') && url.includes('folders%2F9724350536')) { + return foldersPage({ folders: [] }); + } + // Direct org children projects. + if (url.includes('v1/projects') && url.includes('parent.id%3A43356919874')) { + return gcpPage({ + projects: [ + { projectId: 'org-root-1', name: 'Root Project', projectNumber: '111' }, + ], + }); + } + // Per-folder project queries — extract folder ID from filter. + if (url.includes('v1/projects') && url.includes('parent.type%3Afolder')) { + const m = url.match(/parent\.id%3A(\d+)/); + if (m) seenFolderIdsQueried.push(m[1]); + if (m && m[1] === '9724350536') { + return gcpPage({ + projects: [ + { projectId: 'propperai-prod', name: 'Propper Prod', projectNumber: '222' }, + { projectId: 'propperai-demo', name: 'Propper Demo', projectNumber: '333' }, + ], + }); + } + return gcpPage({ projects: [] }); + } + throw new Error(`Unexpected fetch URL in test: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '43356919874'); + + const ids = result.map((p) => p.id).sort(); + expect(ids).toEqual(['org-root-1', 'propperai-demo', 'propperai-prod']); + // ONLY this org's folder was queried — cubic P2 fix. + expect(seenFolderIdsQueried).toEqual(['9724350536']); + }); + + it('recursively traverses nested folders (org → folder → sub-folder → projects)', async () => { + // Layout: + // org 1000 + // └── folder 2000 (top) + // └── folder 3000 (nested) + // └── project deep-prod + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v2/folders') && url.includes('organizations%2F1000')) { + return foldersPage({ folders: ['2000'] }); + } + if (url.includes('v2/folders') && url.includes('folders%2F2000')) { + return foldersPage({ folders: ['3000'] }); + } + if (url.includes('v2/folders') && url.includes('folders%2F3000')) { + return foldersPage({ folders: [] }); + } + if (url.includes('v1/projects') && url.includes('parent.id%3A1000')) { + return gcpPage({ projects: [] }); // no direct org children + } + if (url.includes('v1/projects') && url.includes('parent.id%3A3000')) { + return gcpPage({ + projects: [ + { projectId: 'deep-prod', name: 'Deep', projectNumber: '999' }, + ], + }); + } + if (url.includes('v1/projects') && url.includes('parent.id%3A2000')) { + return gcpPage({ projects: [] }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '1000'); + expect(result.map((p) => p.id)).toEqual(['deep-prod']); + }); + + it('dedupes when the same project would appear in both arms', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v2/folders') && url.includes('organizations%2F123')) { + return foldersPage({ folders: ['folder-a'] }); + } + if (url.includes('v2/folders') && url.includes('folders%2Ffolder-a')) { + return foldersPage({ folders: [] }); + } + if (url.includes('v1/projects') && url.includes('parent.id%3A123')) { + return gcpPage({ projects: [makeProject('shared')] }); + } + if (url.includes('v1/projects') && url.includes('parent.id%3Afolder-a')) { + return gcpPage({ + projects: [makeProject('shared'), makeProject('unique')], + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '123'); + expect(result.map((p) => p.id)).toEqual(['proj-shared', 'proj-unique']); + }); + + it('returns empty array when the org has no direct projects and no folders', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v2/folders')) return foldersPage({ folders: [] }); + if (url.includes('v1/projects')) return gcpPage({ projects: [] }); + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', 'empty-org'); + expect(result).toEqual([]); + }); + + it('still returns direct-arm projects when the folder arm throws entirely (no-regression guarantee)', async () => { + // If GCP's v2/folders endpoint throws or returns 4xx, the folder + // arm collapses to [] gracefully — the direct arm still works + // and we are at minimum no worse than prod. + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v2/folders')) { + throw new Error('simulated v2/folders network failure'); + } + if (url.includes('parent.id%3A555')) { + return gcpPage({ + projects: [ + { projectId: 'direct-only', name: 'Direct Only', projectNumber: '777' }, + ], + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '555'); + expect(result).toEqual([ + { id: 'direct-only', name: 'Direct Only', number: '777' }, + ]); + }); + + it('still returns folder-arm projects when the direct arm throws', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v1/projects') && url.includes('parent.id%3A666') && !url.includes('parent.type%3Afolder')) { + throw new Error('simulated direct-arm failure'); + } + if (url.includes('v2/folders') && url.includes('organizations%2F666')) { + return foldersPage({ folders: ['folder-x'] }); + } + if (url.includes('v2/folders')) return foldersPage({ folders: [] }); + if (url.includes('v1/projects') && url.includes('parent.id%3Afolder-x')) { + return gcpPage({ + projects: [ + { projectId: 'folder-only', name: 'Folder Only', projectNumber: '888' }, + ], + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '666'); + expect(result).toEqual([ + { id: 'folder-only', name: 'Folder Only', number: '888' }, + ]); + }); + + it('caps concurrent folder→projects queries to avoid GCP throttling (cubic P2)', async () => { + // Pathological tenant: 20 folders under the org. Without a + // concurrency cap, all 20 project-list calls would fire at once, + // and any 429s would be silently treated as "empty folder", + // dropping projects from the picker. + const FOLDER_COUNT = 20; + const folderIds = Array.from( + { length: FOLDER_COUNT }, + (_, i) => `f${i}`, + ); + + let inFlight = 0; + let maxInFlight = 0; + const pending: Array<() => void> = []; + + fetchMock.mockImplementation((url: string) => { + if ( + url.includes('v2/folders') && + url.includes('organizations%2Fbig-org') + ) { + return Promise.resolve(foldersPage({ folders: folderIds })); + } + if (url.includes('v2/folders')) { + return Promise.resolve(foldersPage({ folders: [] })); // no sub-folders + } + if ( + url.includes('v1/projects') && + url.includes('parent.id%3Abig-org') && + !url.includes('parent.type%3Afolder') + ) { + return Promise.resolve(gcpPage({ projects: [] })); // direct arm + } + if ( + url.includes('v1/projects') && + url.includes('parent.type%3Afolder') + ) { + // Per-folder project queries — hold the response so we can + // observe concurrency. + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + return new Promise((resolve) => { + pending.push(() => { + inFlight--; + resolve(gcpPage({ projects: [] })); + }); + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = service.detectProjectsForOrg('token', 'big-org'); + + // Wait for the concurrency cap to be reached. setTimeout(0) drains + // the full microtask queue and lets pending I/O settle, which is + // necessary because the folder enumeration does 21 sequential + // fetches (1 org-level + 20 per-folder) before per-folder project + // queries can begin. + const start = Date.now(); + while (Date.now() - start < 2000 && inFlight < 5) { + await new Promise((r) => setTimeout(r, 5)); + } + + // The cap is 5 — observed peak must not exceed it. + expect(maxInFlight).toBeGreaterThan(0); + expect(maxInFlight).toBeLessThanOrEqual(5); + + // Drain — release pending project queries one at a time so the + // remaining folder workers can pick up the next IDs. + while (pending.length > 0 || inFlight > 0) { + const resolver = pending.shift(); + if (resolver) resolver(); + await new Promise((r) => setTimeout(r, 5)); + } + await promise; + + // After full drain, every folder's project query must have run + // exactly once. Confirms the concurrency cap didn't prematurely + // truncate the work. + const perFolderCalls = fetchMock.mock.calls.filter( + (c) => + typeof c[0] === 'string' && + c[0].includes('v1/projects') && + c[0].includes('parent.type%3Afolder'), + ).length; + expect(perFolderCalls).toBe(FOLDER_COUNT); + }); + + it('isolates per-folder query failures so one bad folder does not blank the rest', async () => { + // Two folders, the first one's project list throws. The second + // one should still return its projects. + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('v2/folders') && url.includes('organizations%2F777')) { + return foldersPage({ folders: ['bad-folder', 'good-folder'] }); + } + if (url.includes('v2/folders')) return foldersPage({ folders: [] }); + if (url.includes('v1/projects') && url.includes('parent.id%3A777') && !url.includes('parent.type%3Afolder')) { + return gcpPage({ projects: [] }); + } + if (url.includes('v1/projects') && url.includes('parent.id%3Abad-folder')) { + throw new Error('bad folder query exploded'); + } + if (url.includes('v1/projects') && url.includes('parent.id%3Agood-folder')) { + return gcpPage({ + projects: [ + { projectId: 'good-proj', name: 'Good', projectNumber: '101' }, + ], + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const result = await service.detectProjectsForOrg('token', '777'); + expect(result.map((p) => p.id)).toEqual(['good-proj']); + }); + }); +}); diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.ts b/apps/api/src/cloud-security/providers/gcp-security.service.ts index 79f1a844f..13160b9c0 100644 --- a/apps/api/src/cloud-security/providers/gcp-security.service.ts +++ b/apps/api/src/cloud-security/providers/gcp-security.service.ts @@ -803,76 +803,180 @@ export class GCPSecurityService { /** * Detect active GCP projects scoped to a specific organization. - * Returns only projects whose parent is the given org ID. + * Returns: + * - projects whose IMMEDIATE parent is the given org ID, AND + * - projects nested inside any folder the user has access to. + * + * Background: GCP's `v1/projects` list endpoint does not support a + * "descendants of org" query — `parent.id:` only matches direct + * children, so customers whose production projects live under a + * folder (org → folder → project, a common SOC2-friendly layout) + * never see those projects in our picker. We compensate by also + * listing every accessible folder-nested project; the user's IAM + * scope already limits what they can see. */ async detectProjectsForOrg( accessToken: string, organizationId: string, ): Promise> { - const params = new URLSearchParams({ - pageSize: '50', - filter: `lifecycleState:ACTIVE AND parent.id:${organizationId}`, - }); - const response = await fetch( - `https://cloudresourcemanager.googleapis.com/v1/projects?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }, - ); + // Two arms, isolated via Promise.allSettled so a failure on one + // cannot blank the picker: + // 1. Direct org children: existing behavior. + // 2. Folder-nested projects, scoped to THIS org's folder tree: + // recursively enumerate folders under the org, then query + // each with `parent.type:folder AND parent.id:`, + // which is GCP's documented happy path (uses the alternate + // consistent search index). + // + // The folder arm is properly org-scoped — a user with access to + // projects in OTHER organizations will not see those projects + // here. This honors the "ForOrg" contract. + const [directResult, folderResult] = await Promise.allSettled([ + this.listProjectsPaginated( + accessToken, + `lifecycleState:ACTIVE AND parent.id:${organizationId}`, + ), + this.listProjectsInOrgFolderTree(accessToken, organizationId), + ]); - if (!response.ok) { + const directChildren = + directResult.status === 'fulfilled' ? directResult.value : []; + const folderNested = + folderResult.status === 'fulfilled' ? folderResult.value : []; + + if (directResult.status === 'rejected') { this.logger.warn( - `Failed to list GCP projects for org ${organizationId}: ${await response.text()}`, + `GCP detectProjectsForOrg(${organizationId}): direct arm threw — ${directResult.reason}`, + ); + } + if (folderResult.status === 'rejected') { + this.logger.warn( + `GCP detectProjectsForOrg(${organizationId}): folder arm threw — ${folderResult.reason}`, ); - return []; } - const data = await response.json(); - return ( - (data.projects ?? []) as Array<{ - projectId: string; - name: string; - projectNumber: string; - }> - ).map((p) => ({ - id: p.projectId, - name: p.name, - number: p.projectNumber, - })); + const seen = new Set(); + const merged: Array<{ id: string; name: string; number: string }> = []; + for (const p of [...directChildren, ...folderNested]) { + if (seen.has(p.id)) continue; + seen.add(p.id); + merged.push(p); + } + + this.logger.log( + `GCP detectProjectsForOrg(${organizationId}): ${directChildren.length} direct + ${folderNested.length} folder-nested → ${merged.length} unique`, + ); + return merged; } /** - * Auto-detect active GCP projects accessible by the OAuth token. - * Tries a direct project list first; if empty (common for org-centric accounts), - * lists projects under each accessible organization (parent filter). + * Recursively enumerate all folders under an org, then list projects + * inside each folder. The per-folder project query uses the paired + * `parent.type:folder AND parent.id:` filter, which is the + * shape GCP explicitly documents for by-parent project queries + * ("the filter must contain both a parent.type and a parent.id + * restriction") and which triggers their alternate consistent index. + * + * Per-folder failures are isolated: if one folder's project list + * fails (transient 5xx, permission edge case), the rest still + * succeed. */ - async detectProjects( + private async listProjectsInOrgFolderTree( accessToken: string, + organizationId: string, ): Promise> { - const mapRow = (p: { - projectId: string; - name: string; - projectNumber: string; - }) => ({ - id: p.projectId, - name: p.name, - number: p.projectNumber, - }); + const folderIds = await this.listFoldersUnderOrg( + accessToken, + organizationId, + ); + if (folderIds.length === 0) return []; + + // Bound the fan-out: an unbounded `Promise.all(folderIds.map(...))` + // can trigger GCP rate limiting (`429 Too Many Requests`) on + // tenants with many folders. Because `listProjectsPaginated` + // returns the projects collected so far on a non-OK response, a + // throttled folder query LOOKS like an empty folder to the caller + // — silently truncating the picker with no visible error. A modest + // concurrency limit avoids the problem entirely. + const perFolder = await mapWithConcurrency( + folderIds, + FOLDER_QUERY_CONCURRENCY, + (folderId) => + this.listProjectsPaginated( + accessToken, + `lifecycleState:ACTIVE AND parent.type:folder AND parent.id:${folderId}`, + ).catch((err) => { + this.logger.warn( + `GCP folder ${folderId}: project list failed — ${err instanceof Error ? err.message : String(err)}`, + ); + return []; + }), + ); + + return perFolder.flat(); + } - const listProjectsWithFilter = async ( - filter: string, - ): Promise< - Array<{ id: string; name: string; number: string }> - > => { + /** + * Breadth-first walk of the folder tree under an organization. + * Returns every folder ID the caller has visibility into, no matter + * how deeply nested. Bounded by SAFE_MAX_FOLDERS to keep API usage + * predictable in pathological cases. + */ + private async listFoldersUnderOrg( + accessToken: string, + organizationId: string, + ): Promise { + const SAFE_MAX_FOLDERS = 500; + + const collected: string[] = []; + const seenParents = new Set(); + const queue: string[] = [`organizations/${organizationId}`]; + + while (queue.length > 0 && collected.length < SAFE_MAX_FOLDERS) { + const parent = queue.shift(); + if (!parent || seenParents.has(parent)) continue; + seenParents.add(parent); + + const children = await this.listChildFolders(accessToken, parent); + for (const folderId of children) { + if (collected.includes(folderId)) continue; + collected.push(folderId); + queue.push(`folders/${folderId}`); + if (collected.length >= SAFE_MAX_FOLDERS) { + this.logger.warn( + `GCP folder enumeration: hit safety cap of ${SAFE_MAX_FOLDERS} folders for org ${organizationId}`, + ); + break; + } + } + } + + return collected; + } + + /** + * One paginated call to `v2/folders?parent=` returning the + * immediate child folder IDs (stripped of the "folders/" prefix). + * Errors are non-fatal — log and return what we collected so far so + * one bad page doesn't kill the whole tree walk. + */ + private async listChildFolders( + accessToken: string, + parent: string, + ): Promise { + const PAGE_SIZE = 100; + const collected: string[] = []; + let pageToken: string | undefined; + + do { const params = new URLSearchParams({ - pageSize: '50', - filter, + parent, + pageSize: String(PAGE_SIZE), }); + if (pageToken) params.set('pageToken', pageToken); + const response = await fetch( - `https://cloudresourcemanager.googleapis.com/v1/projects?${params.toString()}`, + `https://cloudresourcemanager.googleapis.com/v2/folders?${params.toString()}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -882,24 +986,40 @@ export class GCPSecurityService { ); if (!response.ok) { - const errorText = await response.text(); this.logger.warn( - `Failed to list GCP projects (filter=${filter}): ${errorText}`, + `Failed to list child folders of ${parent}: ${await response.text()}`, ); - return []; + return collected; } - const data = await response.json(); - return ( - (data.projects ?? []) as Array<{ - projectId: string; - name: string; - projectNumber: string; - }> - ).map(mapRow); - }; + const data = (await response.json()) as { + folders?: Array<{ name: string }>; + nextPageToken?: string; + }; + + for (const f of data.folders ?? []) { + // f.name has shape "folders/123456". + const id = f.name.replace(/^folders\//, ''); + collected.push(id); + } + pageToken = data.nextPageToken; + } while (pageToken); + + return collected; + } - const direct = await listProjectsWithFilter('lifecycleState:ACTIVE'); + /** + * Auto-detect active GCP projects accessible by the OAuth token. + * Tries a direct project list first; if empty (common for org-centric accounts), + * lists projects under each accessible organization (parent filter). + */ + async detectProjects( + accessToken: string, + ): Promise> { + const direct = await this.listProjectsPaginated( + accessToken, + 'lifecycleState:ACTIVE', + ); if (direct.length > 0) { this.logger.log( `GCP detectProjects: ${direct.length} project(s) via direct list`, @@ -919,7 +1039,8 @@ export class GCPSecurityService { const merged: Array<{ id: string; name: string; number: string }> = []; for (const org of orgs) { - const underOrg = await listProjectsWithFilter( + const underOrg = await this.listProjectsPaginated( + accessToken, `lifecycleState:ACTIVE AND parent.id:${org.id}`, ); for (const p of underOrg) { @@ -945,6 +1066,95 @@ export class GCPSecurityService { return merged; } + /** + * Paginated wrapper around GCP's `cloudresourcemanager.projects.list`. + * + * The v1 list endpoint paginates via `nextPageToken`. The previous + * implementation requested a single page with `pageSize=50` and never + * followed `nextPageToken`, which silently truncated the result for + * any customer with more than ~50 accessible projects (large orgs, + * accounts with many sandboxes/Gemini default projects, etc.) and + * caused critical production projects to be missing from our picker. + * + * Behavior: + * - Follows `nextPageToken` until exhaustion. + * - Uses `pageSize=200` (well under GCP's 500 max) to keep + * round-trips low. + * - Stops at `SAFE_MAX_PROJECTS=1000` to bound API usage; if a + * customer legitimately has more accessible projects, they + * should narrow with a filter rather than load all of them in + * the picker. + * - On non-OK response from any page, logs and returns what was + * collected so far instead of throwing — matches the prior + * failure mode (UI gets best-effort results) and prevents one + * transient page error from blanking the whole picker. + */ + private async listProjectsPaginated( + accessToken: string, + filter: string, + ): Promise> { + const PAGE_SIZE = 200; + const SAFE_MAX_PROJECTS = 1000; + + const collected: Array<{ id: string; name: string; number: string }> = []; + let pageToken: string | undefined; + let pages = 0; + + do { + const params = new URLSearchParams({ + pageSize: String(PAGE_SIZE), + filter, + }); + if (pageToken) params.set('pageToken', pageToken); + + const response = await fetch( + `https://cloudresourcemanager.googleapis.com/v1/projects?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + this.logger.warn( + `Failed to list GCP projects (filter="${filter}", page=${pages + 1}, collected=${collected.length}): ${await response.text()}`, + ); + return collected; + } + + const data = (await response.json()) as { + projects?: Array<{ + projectId: string; + name: string; + projectNumber: string; + }>; + nextPageToken?: string; + }; + + for (const p of data.projects ?? []) { + collected.push({ + id: p.projectId, + name: p.name, + number: p.projectNumber, + }); + } + + pageToken = data.nextPageToken; + pages++; + + if (collected.length >= SAFE_MAX_PROJECTS) { + this.logger.warn( + `GCP projects: hit safety cap of ${SAFE_MAX_PROJECTS} for filter="${filter}" — consider a narrower filter if more results are needed`, + ); + break; + } + } while (pageToken); + + return collected; + } + /** * Detect which GCP services the customer actually uses by querying * the Service Usage API for each project. Maps GCP API names to @@ -1254,3 +1464,42 @@ export class GCPSecurityService { return map[gcpSeverity] ?? 'medium'; } } + +/** + * Max simultaneous in-flight folder→projects queries when expanding an + * organization's folder tree. GCP's `cloudresourcemanager` quota + * (~600 read req/min/user) is well above this, but a small cap keeps us + * comfortably below throttling thresholds even for tenants with deep + * folder hierarchies, and prevents bursts that could starve other + * concurrent GCP work on the same account. + */ +const FOLDER_QUERY_CONCURRENCY = 5; + +/** + * Map `items` through `fn` with at most `concurrency` promises in flight + * at any moment. Preserves input order in the result array. No deps — + * inlined here because the only call site is the GCP folder fan-out. + */ +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + + const worker = async (): Promise => { + while (true) { + const idx = cursor++; + if (idx >= items.length) return; + results[idx] = await fn(items[idx]); + } + }; + + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + () => worker(), + ); + await Promise.all(workers); + return results; +}