Skip to content

Commit cec9ba6

Browse files
authored
Merge pull request #2538 from trycompai/fix/cloud-reconnect-oauth-clear
improve(cloud): make GCP setup guidance dynamic and actionable
2 parents a53768a + 3a6692e commit cec9ba6

File tree

4 files changed

+300
-66
lines changed

4 files changed

+300
-66
lines changed

apps/api/src/cloud-security/providers/gcp-security.service.ts

Lines changed: 172 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,59 @@ const GCP_API_TO_SERVICE: Record<string, string[]> = {
151151
'iamcredentials.googleapis.com': ['iam'],
152152
};
153153

154+
type GcpSetupStepId =
155+
| 'enable_security_command_center_api'
156+
| 'enable_cloud_resource_manager_api'
157+
| 'enable_service_usage_api'
158+
| 'grant_findings_viewer_role';
159+
160+
type GcpSetupStep = {
161+
id: GcpSetupStepId;
162+
name: string;
163+
success: boolean;
164+
error?: string;
165+
actionUrl?: string;
166+
actionText?: string;
167+
};
168+
169+
const REQUIRED_GCP_API_STEPS: Array<{
170+
id: GcpSetupStepId;
171+
api: string;
172+
name: string;
173+
actionUrl: string;
174+
actionText: string;
175+
}> = [
176+
{
177+
id: 'enable_security_command_center_api',
178+
api: 'securitycenter.googleapis.com',
179+
name: 'Enable Security Command Center API',
180+
actionUrl:
181+
'https://console.cloud.google.com/apis/library/securitycenter.googleapis.com',
182+
actionText: 'Open API',
183+
},
184+
{
185+
id: 'enable_cloud_resource_manager_api',
186+
api: 'cloudresourcemanager.googleapis.com',
187+
name: 'Enable Cloud Resource Manager API',
188+
actionUrl:
189+
'https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com',
190+
actionText: 'Open API',
191+
},
192+
{
193+
id: 'enable_service_usage_api',
194+
api: 'serviceusage.googleapis.com',
195+
name: 'Enable Service Usage API',
196+
actionUrl:
197+
'https://console.cloud.google.com/apis/library/serviceusage.googleapis.com',
198+
actionText: 'Open API',
199+
},
200+
];
201+
202+
const FINDINGS_VIEWER_ACTION = {
203+
actionUrl: 'https://console.cloud.google.com/iam-admin/iam',
204+
actionText: 'Open IAM',
205+
};
206+
154207
@Injectable()
155208
export class GCPSecurityService {
156209
private readonly logger = new Logger(GCPSecurityService.name);
@@ -166,10 +219,10 @@ export class GCPSecurityService {
166219
projectId: string;
167220
}): Promise<{
168221
email: string | null;
169-
steps: Array<{ name: string; success: boolean; error?: string }>;
222+
steps: GcpSetupStep[];
170223
}> {
171224
const { accessToken, organizationId, projectId } = params;
172-
const steps: Array<{ name: string; success: boolean; error?: string }> = [];
225+
const steps: GcpSetupStep[] = [];
173226

174227
// Step 1: Get user email from OAuth token
175228
let email: string | null = null;
@@ -186,16 +239,10 @@ export class GCPSecurityService {
186239
}
187240

188241
// Step 2: Enable required APIs
189-
const requiredApis = [
190-
'securitycenter.googleapis.com',
191-
'cloudresourcemanager.googleapis.com',
192-
'serviceusage.googleapis.com',
193-
];
194-
195-
for (const api of requiredApis) {
242+
for (const stepDef of REQUIRED_GCP_API_STEPS) {
196243
try {
197244
const resp = await fetch(
198-
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services/${api}:enable`,
245+
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services/${stepDef.api}:enable`,
199246
{
200247
method: 'POST',
201248
headers: {
@@ -206,16 +253,35 @@ export class GCPSecurityService {
206253
},
207254
);
208255
if (resp.ok || resp.status === 409) {
209-
steps.push({ name: `Enable ${api.split('.')[0]}`, success: true });
256+
steps.push({
257+
id: stepDef.id,
258+
name: stepDef.name,
259+
success: true,
260+
actionUrl: stepDef.actionUrl,
261+
actionText: stepDef.actionText,
262+
});
210263
} else {
211264
const err = await resp.text();
212-
steps.push({ name: `Enable ${api.split('.')[0]}`, success: false, error: this.extractGcpError(err) });
265+
steps.push({
266+
id: stepDef.id,
267+
name: stepDef.name,
268+
success: false,
269+
error: this.getEnableApiErrorMessage(stepDef.api, err),
270+
actionUrl: stepDef.actionUrl,
271+
actionText: stepDef.actionText,
272+
});
213273
}
214274
} catch (err) {
215275
steps.push({
216-
name: `Enable ${api.split('.')[0]}`,
276+
id: stepDef.id,
277+
name: stepDef.name,
217278
success: false,
218-
error: err instanceof Error ? err.message : String(err),
279+
error:
280+
err instanceof Error
281+
? this.getEnableApiErrorMessage(stepDef.api, err.message)
282+
: this.getEnableApiErrorMessage(stepDef.api, String(err)),
283+
actionUrl: stepDef.actionUrl,
284+
actionText: stepDef.actionText,
219285
});
220286
}
221287
}
@@ -238,7 +304,13 @@ export class GCPSecurityService {
238304

239305
if (!getPolicyResp.ok) {
240306
const err = await getPolicyResp.text();
241-
steps.push({ name: 'Grant Findings Viewer role', success: false, error: this.extractGcpError(err) });
307+
steps.push({
308+
id: 'grant_findings_viewer_role',
309+
name: 'Grant Findings Viewer role',
310+
success: false,
311+
error: this.getFindingsViewerErrorMessage(err),
312+
...FINDINGS_VIEWER_ACTION,
313+
});
242314
} else {
243315
const policy = await getPolicyResp.json() as {
244316
version?: number;
@@ -253,7 +325,12 @@ export class GCPSecurityService {
253325
// Check if binding already exists
254326
const existing = bindings.find((b) => b.role === role);
255327
if (existing && existing.members.includes(member)) {
256-
steps.push({ name: 'Grant Findings Viewer role', success: true });
328+
steps.push({
329+
id: 'grant_findings_viewer_role',
330+
name: 'Grant Findings Viewer role',
331+
success: true,
332+
...FINDINGS_VIEWER_ACTION,
333+
});
257334
} else {
258335
// Add the binding
259336
if (existing) {
@@ -278,37 +355,109 @@ export class GCPSecurityService {
278355
);
279356

280357
if (setPolicyResp.ok) {
281-
steps.push({ name: 'Grant Findings Viewer role', success: true });
358+
steps.push({
359+
id: 'grant_findings_viewer_role',
360+
name: 'Grant Findings Viewer role',
361+
success: true,
362+
...FINDINGS_VIEWER_ACTION,
363+
});
282364
} else {
283365
const err = await setPolicyResp.text();
284-
steps.push({ name: 'Grant Findings Viewer role', success: false, error: this.extractGcpError(err) });
366+
steps.push({
367+
id: 'grant_findings_viewer_role',
368+
name: 'Grant Findings Viewer role',
369+
success: false,
370+
error: this.getFindingsViewerErrorMessage(err),
371+
...FINDINGS_VIEWER_ACTION,
372+
});
285373
}
286374
}
287375
}
288376
} catch (err) {
289377
steps.push({
378+
id: 'grant_findings_viewer_role',
290379
name: 'Grant Findings Viewer role',
291380
success: false,
292-
error: err instanceof Error ? err.message : String(err),
381+
error:
382+
err instanceof Error
383+
? this.getFindingsViewerErrorMessage(err.message)
384+
: this.getFindingsViewerErrorMessage(String(err)),
385+
...FINDINGS_VIEWER_ACTION,
293386
});
294387
}
295388
} else if (!email) {
296-
steps.push({ name: 'Grant Findings Viewer role', success: false, error: 'Could not detect your email address' });
389+
steps.push({
390+
id: 'grant_findings_viewer_role',
391+
name: 'Grant Findings Viewer role',
392+
success: false,
393+
error:
394+
'Could not identify your Google account email. Reconnect GCP and approve profile/email access.',
395+
...FINDINGS_VIEWER_ACTION,
396+
});
297397
} else {
298-
steps.push({ name: 'Grant Findings Viewer role', success: false, error: 'Organization ID not detected yet' });
398+
steps.push({
399+
id: 'grant_findings_viewer_role',
400+
name: 'Grant Findings Viewer role',
401+
success: false,
402+
error: 'Organization ID not detected yet.',
403+
...FINDINGS_VIEWER_ACTION,
404+
});
299405
}
300406

301407
this.logger.log(`GCP auto-setup: ${steps.filter((s) => s.success).length}/${steps.length} steps succeeded`);
302408
return { email, steps };
303409
}
304410

305411
private extractGcpError(raw: string): string {
412+
let message = raw;
306413
try {
307414
const parsed = JSON.parse(raw) as { error?: { message?: string } };
308-
return parsed.error?.message ?? raw.slice(0, 200);
415+
message = parsed.error?.message ?? raw;
309416
} catch {
310-
return raw.slice(0, 200);
417+
message = raw;
418+
}
419+
return message
420+
.replace(/\s*Help Token:\s*[\w-]+/gi, '')
421+
.replace(/\s+/g, ' ')
422+
.trim()
423+
.slice(0, 240);
424+
}
425+
426+
private getEnableApiErrorMessage(apiName: string, raw: string): string {
427+
const message = this.extractGcpError(raw);
428+
429+
if (
430+
/permission denied|does not have permission|forbidden|PERMISSION_DENIED/i.test(
431+
message,
432+
)
433+
) {
434+
return `Your account cannot enable ${apiName}. Ask a project owner/editor to enable it.`;
435+
}
436+
437+
return message || `Failed to enable ${apiName}.`;
438+
}
439+
440+
private getFindingsViewerErrorMessage(raw: string): string {
441+
const message = this.extractGcpError(raw);
442+
443+
if (/getIamPolicy|resourcemanager\.organizations\.getIamPolicy/i.test(message)) {
444+
return 'Your account cannot read organization IAM policy. Ask a GCP organization admin to grant roles/securitycenter.findingsViewer.';
311445
}
446+
447+
if (
448+
/setIamPolicy|resourcemanager\.organizations\.setIamPolicy/i.test(message)
449+
) {
450+
return 'Your account cannot grant org IAM roles. Ask a GCP organization admin to grant roles/securitycenter.findingsViewer.';
451+
}
452+
453+
if (/permission denied|does not have permission|forbidden|PERMISSION_DENIED/i.test(message)) {
454+
return 'Your account does not have organization IAM permissions required for auto-setup. Ask a GCP organization admin to grant roles/securitycenter.findingsViewer.';
455+
}
456+
457+
return (
458+
message ||
459+
'Unable to grant Findings Viewer role automatically. Ask a GCP organization admin to grant roles/securitycenter.findingsViewer.'
460+
);
312461
}
313462

314463
/**
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import { describe, expect, it, vi, beforeEach } from 'vitest';
3+
4+
const mockPost = vi.fn();
5+
6+
vi.mock('@/hooks/use-api', () => ({
7+
useApi: () => ({
8+
post: mockPost,
9+
}),
10+
}));
11+
12+
vi.mock('sonner', () => ({
13+
toast: {
14+
error: vi.fn(),
15+
success: vi.fn(),
16+
message: vi.fn(),
17+
},
18+
}));
19+
20+
import { GcpSetupGuide } from './GcpSetupGuide';
21+
22+
describe('GcpSetupGuide', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
it('renders actionable failed setup steps from API response', async () => {
28+
mockPost.mockResolvedValue({
29+
data: {
30+
email: 'user@example.com',
31+
organizationId: '123456789',
32+
steps: [
33+
{
34+
id: 'enable_security_command_center_api',
35+
name: 'Enable Security Command Center API',
36+
success: false,
37+
error: 'Permission denied',
38+
actionUrl:
39+
'https://console.cloud.google.com/apis/library/securitycenter.googleapis.com',
40+
actionText: 'Open API',
41+
},
42+
{
43+
id: 'grant_findings_viewer_role',
44+
name: 'Grant Findings Viewer role',
45+
success: false,
46+
error: 'Need org admin role',
47+
actionUrl: 'https://console.cloud.google.com/iam-admin/iam',
48+
actionText: 'Open IAM',
49+
},
50+
],
51+
},
52+
});
53+
54+
render(
55+
<GcpSetupGuide
56+
connectionId="conn_1"
57+
hasOrgId={false}
58+
onRunScan={vi.fn()}
59+
isScanning={false}
60+
/>,
61+
);
62+
63+
await waitFor(() =>
64+
expect(screen.getByText('Some steps need manual setup:')).toBeInTheDocument(),
65+
);
66+
67+
expect(
68+
screen.getAllByText('Enable Security Command Center API').length,
69+
).toBeGreaterThan(0);
70+
expect(screen.getAllByText('Grant Findings Viewer role').length).toBeGreaterThan(0);
71+
expect(screen.getByRole('link', { name: /Open API/i })).toHaveAttribute(
72+
'href',
73+
'https://console.cloud.google.com/apis/library/securitycenter.googleapis.com',
74+
);
75+
expect(screen.getByRole('link', { name: /Open IAM/i })).toHaveAttribute(
76+
'href',
77+
'https://console.cloud.google.com/iam-admin/iam',
78+
);
79+
expect(screen.getByRole('button', { name: 'Retry setup' })).toBeInTheDocument();
80+
});
81+
});

0 commit comments

Comments
 (0)