@@ -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 ( )
155208export 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 * H e l p T o k e n : \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+ / p e r m i s s i o n d e n i e d | d o e s n o t h a v e p e r m i s s i o n | f o r b i d d e n | P E R M I S S I O N _ D E N I E D / 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 ( / g e t I a m P o l i c y | r e s o u r c e m a n a g e r \. o r g a n i z a t i o n s \. g e t I a m P o l i c y / 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+ / s e t I a m P o l i c y | r e s o u r c e m a n a g e r \. o r g a n i z a t i o n s \. s e t I a m P o l i c y / 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 ( / p e r m i s s i o n d e n i e d | d o e s n o t h a v e p e r m i s s i o n | f o r b i d d e n | P E R M I S S I O N _ D E N I E D / 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 /**
0 commit comments