From e44a8118b4253dc2c0adbcb76fd5a2a2ec4ea7a5 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 12:17:36 +0900 Subject: [PATCH] Prompt required IAM permission during frameworks onboarding. --- src/gcp/cloudbuild.ts | 7 +++++ src/init/features/frameworks/repo.ts | 38 +++++++++++++++++++++++++--- src/utils.ts | 13 +++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/gcp/cloudbuild.ts b/src/gcp/cloudbuild.ts index 24b16f52ffa..0281ef04184 100644 --- a/src/gcp/cloudbuild.ts +++ b/src/gcp/cloudbuild.ts @@ -209,3 +209,10 @@ export async function deleteRepository( const res = await client.delete(name); return res.body; } + +/** + * Returns email associated with the Cloud Build Service Agent. + */ +export function serviceAgentEmail(projectNumber: string): string { + return `service-${projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com`; +} diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 617d3c9f5b2..288dcd05b37 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -1,12 +1,14 @@ import * as clc from "colorette"; import * as gcb from "../../../gcp/cloudbuild"; +import * as rm from "../../../gcp/resourceManager"; import * as poller from "../../../operation-poller"; import * as utils from "../../../utils"; import { cloudbuildOrigin } from "../../../api"; import { FirebaseError } from "../../../error"; import { logger } from "../../../logger"; import { promptOnce } from "../../../prompt"; +import { getProjectNumber } from "../../../getProjectNumber"; export interface ConnectionNameParts { projectId: string; @@ -81,6 +83,10 @@ export async function linkGitHubRepository( logger.info(clc.bold(`\n${clc.yellow("===")} Connect a GitHub repository`)); const existingConns = await listFrameworksConnections(projectId); if (existingConns.length < 1) { + const grantSuccess = await promptSecretManagerAdminGrant(projectId); + if (!grantSuccess) { + throw new FirebaseError("Insufficient IAM permissions to create a new connection to GitHub"); + } let oauthConn = await getOrCreateConnection(projectId, location, FRAMEWORKS_OAUTH_CONN_NAME); while (oauthConn.installationState.stage === "PENDING_USER_OAUTH") { oauthConn = await promptConnectionAuth(oauthConn); @@ -156,14 +162,40 @@ async function promptRepositoryUri( return { remoteUri, connection: remoteUriToConnection[remoteUri] }; } +async function promptSecretManagerAdminGrant(projectId: string): Promise { + const projectNumber = await getProjectNumber({ projectId }); + const cbsaEmail = gcb.serviceAgentEmail(projectNumber); + logger.info( + "To create a new GitHub connection, Secret Manager Admin role (roles/secretmanager.admin) is required on the Cloud Build Service Agent." + ); + const grant = await promptOnce({ + type: "confirm", + message: "Grant the required role to the Cloud Build Service Agent?", + }); + if (!grant) { + logger.info( + "You, or your project administrator, should run the following command to grant the required role:\n\n" + + `\tgcloud projects add-iam-policy-binding ${projectId} \\\n` + + `\t --member="serviceAccount:${cbsaEmail} \\\n` + + `\t --role="roles/secretmanager.admin\n` + ); + return false; + } + await rm.addServiceAccountToRoles(projectId, cbsaEmail, ["roles/secretmanager.admin"], true); + logger.info("Successfully granted the required role to the Cloud Build Service Agent!"); + return true; +} + async function promptConnectionAuth(conn: gcb.Connection): Promise { logger.info("You must authorize the Cloud Build GitHub app."); logger.info(); - logger.info("First, sign in to GitHub and authorize Cloud Build GitHub app:"); - const cleanup = await utils.openInBrowserPopup( + logger.info("Sign in to GitHub and authorize Cloud Build GitHub app:"); + const { url, cleanup } = await utils.openInBrowserPopup( conn.installationState.actionUri, "Authorize the GitHub app" ); + logger.info(`\t${url}`); + logger.info(); await promptOnce({ type: "input", message: "Press Enter once you have authorized the app", @@ -215,7 +247,7 @@ export async function getOrCreateConnection( try { conn = await gcb.getConnection(projectId, location, connectionId); } catch (err: unknown) { - if ((err as FirebaseError).status === 404) { + if ((err as any).status === 404) { conn = await createConnection(projectId, location, connectionId, githubConfig); } else { throw err; diff --git a/src/utils.ts b/src/utils.ts index e2932f50e28..cf33dd3e9ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -767,7 +767,10 @@ export async function openInBrowser(url: string): Promise { /** * Like openInBrowser but opens the url in a popup. */ -export async function openInBrowserPopup(url: string, buttonText: string): Promise<() => void> { +export async function openInBrowserPopup( + url: string, + buttonText: string +): Promise<{ url: string; cleanup: () => void }> { const popupPage = fs .readFileSync(path.join(__dirname, "../templates/popup.html"), { encoding: "utf-8" }) .replace("${url}", url) @@ -787,10 +790,12 @@ export async function openInBrowserPopup(url: string, buttonText: string): Promi server.listen(port); const popupPageUri = `http://localhost:${port}`; - logger.info(popupPageUri); await openInBrowser(popupPageUri); - return () => { - server.close(); + return { + url: popupPageUri, + cleanup: () => { + server.close(); + }, }; }