From 1b459395e07f6ac51147ead75a79dc5d78069237 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Wed, 9 Sep 2026 18:26:44 +0000 Subject: [PATCH 1/5] fix(functions): fail-fast with actionable error when declarative security APIs are disabled When deploying functions with declarative security (requiresRole), check that iam.googleapis.com and cloudresourcemanager.googleapis.com are enabled on the project before attempting discovery or SA provisioning. If either API is disabled, fail fast with an actionable error message providing both the exact gcloud services enable command and Google Cloud Console enablement URLs. Also handle downstream cached/race 403 SERVICE_DISABLED errors with cache eviction and friendly rethrowing. --- CHANGELOG.md | 1 + src/deploy/functions/prepare.spec.ts | 224 +++++++++++++++++++++++++++ src/deploy/functions/prepare.ts | 123 ++++++++++++++- 3 files changed, 347 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14861ae03c6..8fb795084b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [fixed] Fail fast with an actionable error and remediation instructions when declarative security APIs (IAM and Cloud Resource Manager) are disabled on the project. - Updated the Firebase SQL Connect local toolkit to v3.4.19, which includes the following changes: - [fixed] Bug fixes and performance improvements for the PostgreSQL emulator. - [fixed] Clean up managed service accounts when all functions in a codebase are deleted. diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index fd77f6808f0..339bc383208 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1483,6 +1483,8 @@ describe("prepare", () => { describe("discoverSecurityDetails", () => { let testIamPermissionsStub: sinon.SinonStub; + let checkApiStub: sinon.SinonStub; + let uncacheApiStub: sinon.SinonStub; beforeEach(() => { testIamPermissionsStub = sinon @@ -1490,6 +1492,8 @@ describe("prepare", () => { .resolves({ passed: true } as any); sinon.stub(iam, "generateManagedServiceAccountName").resolves("firebase-fn-123"); sinon.stub(resourcemanager, "getServiceAccountRoles").resolves([]); + checkApiStub = sinon.stub(ensureApiEnabled, "check").resolves(true); + uncacheApiStub = sinon.stub(ensureApiEnabled, "uncacheEnabledAPI"); }); afterEach(() => { @@ -1671,5 +1675,225 @@ describe("prepare", () => { /To ensure a whole codebase is migrated cleanly, you may not deploy only part of a codebase when opting into or out of declarative security/, ); }); + + describe("API enablement checks", () => { + it("should throw actionable error when both iam and cloudresourcemanager APIs are disabled", async () => { + checkApiStub.callsFake((projectId: string, api: string) => { + if (api === "iam.googleapis.com" || api === "cloudresourcemanager.googleapis.com") { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + let error: FirebaseError | undefined; + try { + await prepare.discoverSecurityDetails("default", want, have, "test-project"); + } catch (err) { + if (err instanceof FirebaseError) { + error = err; + } + } + + expect(error).to.be.instanceOf(FirebaseError); + expect(error!.message).to.include( + 'Cannot deploy functions with declarative security in codebase "default"', + ); + expect(error!.message).to.include("iam.googleapis.com"); + expect(error!.message).to.include("cloudresourcemanager.googleapis.com"); + expect(error!.message).to.include( + "gcloud services enable iam.googleapis.com cloudresourcemanager.googleapis.com --project test-project", + ); + expect(error!.message).to.include( + "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", + ); + expect(error!.message).to.include( + "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", + ); + expect(testIamPermissionsStub).to.not.have.been.called; + }); + + it("should throw actionable error when only iam API is disabled", async () => { + checkApiStub.callsFake((projectId: string, api: string) => { + if (api === "iam.googleapis.com") { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + let error: FirebaseError | undefined; + try { + await prepare.discoverSecurityDetails("default", want, have, "test-project"); + } catch (err) { + if (err instanceof FirebaseError) { + error = err; + } + } + + expect(error).to.be.instanceOf(FirebaseError); + expect(error!.message).to.include("iam.googleapis.com"); + expect(error!.message).to.not.include("cloudresourcemanager.googleapis.com"); + expect(error!.message).to.include( + "gcloud services enable iam.googleapis.com --project test-project", + ); + expect(error!.message).to.include( + "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", + ); + }); + + it("should throw actionable error when only cloudresourcemanager API is disabled", async () => { + checkApiStub.callsFake((projectId: string, api: string) => { + if (api === "cloudresourcemanager.googleapis.com") { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + let error: FirebaseError | undefined; + try { + await prepare.discoverSecurityDetails("default", want, have, "test-project"); + } catch (err) { + if (err instanceof FirebaseError) { + error = err; + } + } + + expect(error).to.be.instanceOf(FirebaseError); + expect(error!.message).to.include("cloudresourcemanager.googleapis.com"); + expect(error!.message).to.not.include("iam.googleapis.com"); + expect(error!.message).to.include( + "gcloud services enable cloudresourcemanager.googleapis.com --project test-project", + ); + expect(error!.message).to.include( + "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", + ); + }); + + it("should not check security APIs when codebase does not use declarative security", async () => { + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + const have = backend.empty(); + + await prepare.discoverSecurityDetails("default", want, have, "test-project"); + + expect(checkApiStub).to.not.have.been.calledWith("test-project", "iam.googleapis.com"); + expect(checkApiStub).to.not.have.been.calledWith( + "test-project", + "cloudresourcemanager.googleapis.com", + ); + }); + + it("should not block unenrollment even if security APIs are disabled", async () => { + checkApiStub.resolves(false); + + const e: backend.Endpoint = { + ...ENDPOINT, + serviceAccount: "firebase-fn-123@project.iam.gserviceaccount.com", + labels: { + "firebase-declarative-security-etag": "salt-etag", + }, + }; + const want = backend.of(e); + const have = backend.of({ + ...e, + labels: { ...e.labels }, + }); + + const result = await prepare.discoverSecurityDetails("default", want, have, "project"); + expect(result.existingManagedSA).to.equal( + "firebase-fn-123@project.iam.gserviceaccount.com", + ); + expect(e.serviceAccount).to.be.null; + }); + + it("should catch downstream testIamPermissions 403 error due to disabled cloudresourcemanager, uncache API, and throw actionable error", async () => { + checkApiStub.resolves(true); + const disabledError = new FirebaseError( + "HTTP Error: 403, Cloud Resource Manager API has not been used in project test-project before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudresourcemanager.googleapis.com/overview?project=test-project then retry.", + { status: 403 }, + ); + testIamPermissionsStub.rejects(disabledError); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + let error: FirebaseError | undefined; + try { + await prepare.discoverSecurityDetails("default", want, have, "test-project"); + } catch (err) { + if (err instanceof FirebaseError) { + error = err; + } + } + + expect(error).to.be.instanceOf(FirebaseError); + expect(uncacheApiStub).to.have.been.calledWith( + "test-project", + "cloudresourcemanager.googleapis.com", + ); + expect(error!.message).to.include( + "Cloud Resource Manager API (cloudresourcemanager.googleapis.com) is disabled on project test-project", + ); + expect(error!.message).to.include( + "gcloud services enable cloudresourcemanager.googleapis.com --project test-project", + ); + expect(error!.message).to.include( + "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", + ); + }); + }); + + describe("isServiceDisabledError", () => { + it("should return true when error details contains SERVICE_DISABLED", () => { + const err = { + context: { + body: { + error: { + details: [ + { + reason: "SERVICE_DISABLED", + metadata: { service: "cloudresourcemanager.googleapis.com" }, + }, + ], + }, + }, + }, + }; + expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be + .true; + expect(prepare.isServiceDisabledError(err, "iam.googleapis.com")).to.be.false; + }); + + it("should return true when error message matches disabled API format", () => { + const err = new Error( + "Cloud Resource Manager API has not been used in project 12345 before or it is disabled.", + ); + expect(prepare.isServiceDisabledError(err, "Cloud Resource Manager API")).to.be.true; + expect(prepare.isServiceDisabledError(err)).to.be.true; + }); + + it("should return false for unrelated errors", () => { + const err = new Error("Permission denied: user does not have permission"); + expect(prepare.isServiceDisabledError(err)).to.be.false; + expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be + .false; + }); + }); }); }); diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 343a3fb79c1..88722fbf5ca 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -69,6 +69,102 @@ import * as resourcemanager from "../../gcp/resourceManager"; export const EVENTARC_SOURCE_ENV = "EVENTARC_CLOUD_EVENT_SOURCE"; export const DECLARATIVE_SECURITY_ETAG_LABEL = "firebase-declarative-security-etag"; +const REQUIRED_SECURITY_APIS = [ + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", +] as const; + +/** + * Validates that the Google Cloud APIs required for Declarative Security are enabled. + * Fails fast with an actionable gcloud command and console URLs if either API is disabled. + */ +export async function checkDeclarativeSecurityApisEnabled( + projectId: string, + codebase: string, +): Promise { + const checks = await Promise.all( + REQUIRED_SECURITY_APIS.map((api) => + ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true), + ), + ); + const disabledApis = REQUIRED_SECURITY_APIS.filter((_, idx) => !checks[idx]); + + if (disabledApis.length > 0) { + const apiBulletList = disabledApis.map((api) => ` - ${clc.bold(api)}`).join("\n"); + const enableCmd = clc.bold( + `gcloud services enable ${disabledApis.join(" ")} --project ${projectId}`, + ); + const consoleLinks = disabledApis + .map((api) => ` - ${api}: ${ensureApiEnabled.enableApiURI(projectId, api)}`) + .join("\n"); + + throw new FirebaseError( + `Cannot deploy functions with declarative security in codebase "${codebase}". ` + + `The following required Google Cloud API(s) are not enabled on project ${clc.bold(projectId)}:\n` + + apiBulletList + + `\n\nDeclarative security requires these APIs to provision and configure managed service accounts and IAM roles.\n` + + `To enable them, run:\n\n` + + ` ${enableCmd}\n\n` + + `Or ask a project owner to enable them in the Google Cloud Console:\n` + + consoleLinks + + `\n`, + { exit: 1 }, + ); + } +} + +interface ServiceErrorDetail { + reason?: string; + metadata?: { + service?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ServiceErrorResponse { + message?: string; + context?: { + body?: { + error?: { + details?: ServiceErrorDetail[]; + }; + }; + }; + original?: ServiceErrorResponse; +} + +/** + * Checks whether an error is caused by a Google Cloud service/API being disabled. + */ +export function isServiceDisabledError(err: unknown, service?: string): boolean { + const errorObj = err as ServiceErrorResponse; + const message = typeof errorObj?.message === "string" ? errorObj.message : ""; + const details = + errorObj?.context?.body?.error?.details || + errorObj?.original?.context?.body?.error?.details || + []; + const hasServiceDisabledDetail = + Array.isArray(details) && + details.some( + (d: ServiceErrorDetail) => + d.reason === "SERVICE_DISABLED" && (!service || d.metadata?.service === service), + ); + if (hasServiceDisabledDetail) { + return true; + } + if (service) { + return ( + message.includes("has not been used in project") && + message.includes("before or it is disabled") && + message.includes(service) + ); + } + return ( + message.includes("has not been used in project") && message.includes("before or it is disabled") + ); +} + /** * Discovers and coordinates declarative security details for a codebase. * Mutates `want` Backend to populate managed service account and etag labels. @@ -164,6 +260,8 @@ export async function discoverSecurityDetails( }; } + await checkDeclarativeSecurityApisEnabled(projectId, codebase); + let managedSA = existingManagedSA; if (!managedSA) { const saToCreate = await iam.generateManagedServiceAccountName(projectId, "firebase-fn"); @@ -193,7 +291,30 @@ export async function discoverSecurityDetails( if (!existingManagedSA) { permissionsToTest.push("iam.serviceAccounts.create"); } - const iamResult = await iam.testIamPermissions(projectId, permissionsToTest); + let iamResult; + try { + iamResult = await iam.testIamPermissions(projectId, permissionsToTest); + } catch (err: unknown) { + const errOriginal = err instanceof Error ? err : undefined; + if (isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")) { + ensureApiEnabled.uncacheEnabledAPI(projectId, "cloudresourcemanager.googleapis.com"); + const crmConsoleUrl = ensureApiEnabled.enableApiURI( + projectId, + "cloudresourcemanager.googleapis.com", + ); + throw new FirebaseError( + `Cannot deploy functions with declarative security in codebase "${codebase}". ` + + `Cloud Resource Manager API (${clc.bold("cloudresourcemanager.googleapis.com")}) is disabled on project ${clc.bold(projectId)}.\n\n` + + `Declarative security requires this API to verify and update IAM policies.\n` + + `To enable it, run:\n\n` + + ` ${clc.bold(`gcloud services enable cloudresourcemanager.googleapis.com --project ${projectId}`)}\n\n` + + `Or ask a project owner to enable it in the Google Cloud Console:\n` + + ` - ${crmConsoleUrl}\n`, + { exit: 1, original: errOriginal }, + ); + } + throw err; + } if (!iamResult.passed) { if (!existingManagedSA) { throw new FirebaseError( From 495e7a0c863e15c96865ad90328a733a19cdbf1b Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Wed, 9 Sep 2026 20:46:06 +0000 Subject: [PATCH 2/5] fix(functions): handle restricted permissions in preflight and map API friendly names - Catch errors in checkDeclarativeSecurityApisEnabled to fail open when caller lacks Service Usage permissions - Add SERVICE_FRIENDLY_NAMES map in isServiceDisabledError for legacy GCP error messages - Add unit tests verifying both cases --- src/deploy/functions/prepare.spec.ts | 23 +++++++++++++++++++++++ src/deploy/functions/prepare.ts | 19 +++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 339bc383208..cbd693603ab 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1797,6 +1797,22 @@ describe("prepare", () => { ); }); + it("should not block deployment if caller lacks permission to check API enablement", async () => { + checkApiStub.rejects( + new FirebaseError("HTTP Error: 403, PERMISSION_DENIED on serviceusage.services.get", { + status: 403, + }), + ); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + const result = await prepare.discoverSecurityDetails("default", want, have, "test-project"); + expect(result.managedSA).to.equal("firebase-fn-123@test-project.iam.gserviceaccount.com"); + }); + it("should not block unenrollment even if security APIs are disabled", async () => { checkApiStub.resolves(false); @@ -1885,7 +1901,14 @@ describe("prepare", () => { "Cloud Resource Manager API has not been used in project 12345 before or it is disabled.", ); expect(prepare.isServiceDisabledError(err, "Cloud Resource Manager API")).to.be.true; + expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be + .true; expect(prepare.isServiceDisabledError(err)).to.be.true; + + const iamErr = new Error( + "Identity and Access Management API has not been used in project 12345 before or it is disabled.", + ); + expect(prepare.isServiceDisabledError(iamErr, "iam.googleapis.com")).to.be.true; }); it("should return false for unrelated errors", () => { diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 88722fbf5ca..24e9852fcf2 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -83,9 +83,14 @@ export async function checkDeclarativeSecurityApisEnabled( codebase: string, ): Promise { const checks = await Promise.all( - REQUIRED_SECURITY_APIS.map((api) => - ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true), - ), + REQUIRED_SECURITY_APIS.map(async (api) => { + try { + return await ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true); + } catch (err) { + logger.debug(`Silence error checking enablement for API ${api}: ${err}`); + return true; + } + }), ); const disabledApis = REQUIRED_SECURITY_APIS.filter((_, idx) => !checks[idx]); @@ -113,6 +118,11 @@ export async function checkDeclarativeSecurityApisEnabled( } } +const SERVICE_FRIENDLY_NAMES: Record = { + "cloudresourcemanager.googleapis.com": "Cloud Resource Manager", + "iam.googleapis.com": "Identity and Access Management", +}; + interface ServiceErrorDetail { reason?: string; metadata?: { @@ -154,10 +164,11 @@ export function isServiceDisabledError(err: unknown, service?: string): boolean return true; } if (service) { + const friendlyName = SERVICE_FRIENDLY_NAMES[service]; return ( message.includes("has not been used in project") && message.includes("before or it is disabled") && - message.includes(service) + (message.includes(service) || (!!friendlyName && message.includes(friendlyName))) ); } return ( From 3e94a1589f7dfc6f8d0c405ceda24cc2d2167b22 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Wed, 9 Sep 2026 21:31:25 +0000 Subject: [PATCH 3/5] refactor(functions): simplify declarative security API check by trusting cache Remove downstream 403 catch and isServiceDisabledError helper, trusting local cache consistency like the rest of the CLI. --- src/deploy/functions/prepare.spec.ts | 83 ---------------------------- src/deploy/functions/prepare.ts | 83 +--------------------------- 2 files changed, 1 insertion(+), 165 deletions(-) diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index cbd693603ab..b1e6afa6661 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1484,7 +1484,6 @@ describe("prepare", () => { describe("discoverSecurityDetails", () => { let testIamPermissionsStub: sinon.SinonStub; let checkApiStub: sinon.SinonStub; - let uncacheApiStub: sinon.SinonStub; beforeEach(() => { testIamPermissionsStub = sinon @@ -1493,7 +1492,6 @@ describe("prepare", () => { sinon.stub(iam, "generateManagedServiceAccountName").resolves("firebase-fn-123"); sinon.stub(resourcemanager, "getServiceAccountRoles").resolves([]); checkApiStub = sinon.stub(ensureApiEnabled, "check").resolves(true); - uncacheApiStub = sinon.stub(ensureApiEnabled, "uncacheEnabledAPI"); }); afterEach(() => { @@ -1836,87 +1834,6 @@ describe("prepare", () => { expect(e.serviceAccount).to.be.null; }); - it("should catch downstream testIamPermissions 403 error due to disabled cloudresourcemanager, uncache API, and throw actionable error", async () => { - checkApiStub.resolves(true); - const disabledError = new FirebaseError( - "HTTP Error: 403, Cloud Resource Manager API has not been used in project test-project before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudresourcemanager.googleapis.com/overview?project=test-project then retry.", - { status: 403 }, - ); - testIamPermissionsStub.rejects(disabledError); - - const e: backend.Endpoint = { ...ENDPOINT }; - const want = backend.of(e); - want.requiredRoles = ["roles/viewer"]; - const have = backend.empty(); - - let error: FirebaseError | undefined; - try { - await prepare.discoverSecurityDetails("default", want, have, "test-project"); - } catch (err) { - if (err instanceof FirebaseError) { - error = err; - } - } - - expect(error).to.be.instanceOf(FirebaseError); - expect(uncacheApiStub).to.have.been.calledWith( - "test-project", - "cloudresourcemanager.googleapis.com", - ); - expect(error!.message).to.include( - "Cloud Resource Manager API (cloudresourcemanager.googleapis.com) is disabled on project test-project", - ); - expect(error!.message).to.include( - "gcloud services enable cloudresourcemanager.googleapis.com --project test-project", - ); - expect(error!.message).to.include( - "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", - ); - }); - }); - - describe("isServiceDisabledError", () => { - it("should return true when error details contains SERVICE_DISABLED", () => { - const err = { - context: { - body: { - error: { - details: [ - { - reason: "SERVICE_DISABLED", - metadata: { service: "cloudresourcemanager.googleapis.com" }, - }, - ], - }, - }, - }, - }; - expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be - .true; - expect(prepare.isServiceDisabledError(err, "iam.googleapis.com")).to.be.false; - }); - - it("should return true when error message matches disabled API format", () => { - const err = new Error( - "Cloud Resource Manager API has not been used in project 12345 before or it is disabled.", - ); - expect(prepare.isServiceDisabledError(err, "Cloud Resource Manager API")).to.be.true; - expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be - .true; - expect(prepare.isServiceDisabledError(err)).to.be.true; - - const iamErr = new Error( - "Identity and Access Management API has not been used in project 12345 before or it is disabled.", - ); - expect(prepare.isServiceDisabledError(iamErr, "iam.googleapis.com")).to.be.true; - }); - - it("should return false for unrelated errors", () => { - const err = new Error("Permission denied: user does not have permission"); - expect(prepare.isServiceDisabledError(err)).to.be.false; - expect(prepare.isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")).to.be - .false; - }); }); }); }); diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 24e9852fcf2..2eaf2077f17 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -118,64 +118,6 @@ export async function checkDeclarativeSecurityApisEnabled( } } -const SERVICE_FRIENDLY_NAMES: Record = { - "cloudresourcemanager.googleapis.com": "Cloud Resource Manager", - "iam.googleapis.com": "Identity and Access Management", -}; - -interface ServiceErrorDetail { - reason?: string; - metadata?: { - service?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -interface ServiceErrorResponse { - message?: string; - context?: { - body?: { - error?: { - details?: ServiceErrorDetail[]; - }; - }; - }; - original?: ServiceErrorResponse; -} - -/** - * Checks whether an error is caused by a Google Cloud service/API being disabled. - */ -export function isServiceDisabledError(err: unknown, service?: string): boolean { - const errorObj = err as ServiceErrorResponse; - const message = typeof errorObj?.message === "string" ? errorObj.message : ""; - const details = - errorObj?.context?.body?.error?.details || - errorObj?.original?.context?.body?.error?.details || - []; - const hasServiceDisabledDetail = - Array.isArray(details) && - details.some( - (d: ServiceErrorDetail) => - d.reason === "SERVICE_DISABLED" && (!service || d.metadata?.service === service), - ); - if (hasServiceDisabledDetail) { - return true; - } - if (service) { - const friendlyName = SERVICE_FRIENDLY_NAMES[service]; - return ( - message.includes("has not been used in project") && - message.includes("before or it is disabled") && - (message.includes(service) || (!!friendlyName && message.includes(friendlyName))) - ); - } - return ( - message.includes("has not been used in project") && message.includes("before or it is disabled") - ); -} - /** * Discovers and coordinates declarative security details for a codebase. * Mutates `want` Backend to populate managed service account and etag labels. @@ -302,30 +244,7 @@ export async function discoverSecurityDetails( if (!existingManagedSA) { permissionsToTest.push("iam.serviceAccounts.create"); } - let iamResult; - try { - iamResult = await iam.testIamPermissions(projectId, permissionsToTest); - } catch (err: unknown) { - const errOriginal = err instanceof Error ? err : undefined; - if (isServiceDisabledError(err, "cloudresourcemanager.googleapis.com")) { - ensureApiEnabled.uncacheEnabledAPI(projectId, "cloudresourcemanager.googleapis.com"); - const crmConsoleUrl = ensureApiEnabled.enableApiURI( - projectId, - "cloudresourcemanager.googleapis.com", - ); - throw new FirebaseError( - `Cannot deploy functions with declarative security in codebase "${codebase}". ` + - `Cloud Resource Manager API (${clc.bold("cloudresourcemanager.googleapis.com")}) is disabled on project ${clc.bold(projectId)}.\n\n` + - `Declarative security requires this API to verify and update IAM policies.\n` + - `To enable it, run:\n\n` + - ` ${clc.bold(`gcloud services enable cloudresourcemanager.googleapis.com --project ${projectId}`)}\n\n` + - `Or ask a project owner to enable it in the Google Cloud Console:\n` + - ` - ${crmConsoleUrl}\n`, - { exit: 1, original: errOriginal }, - ); - } - throw err; - } + const iamResult = await iam.testIamPermissions(projectId, permissionsToTest); if (!iamResult.passed) { if (!existingManagedSA) { throw new FirebaseError( From 87c5f7d3e2a5d79ff99120b885feff4f394466ff Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Wed, 9 Sep 2026 21:38:55 +0000 Subject: [PATCH 4/5] style(functions): fix prettier and eslint warnings in prepare and prepare.spec --- src/deploy/functions/prepare.spec.ts | 29 ++++++++++++++-------------- src/deploy/functions/prepare.ts | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index b1e6afa6661..07a53be1c48 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1698,18 +1698,18 @@ describe("prepare", () => { } expect(error).to.be.instanceOf(FirebaseError); - expect(error!.message).to.include( + expect(error?.message).to.include( 'Cannot deploy functions with declarative security in codebase "default"', ); - expect(error!.message).to.include("iam.googleapis.com"); - expect(error!.message).to.include("cloudresourcemanager.googleapis.com"); - expect(error!.message).to.include( + expect(error?.message).to.include("iam.googleapis.com"); + expect(error?.message).to.include("cloudresourcemanager.googleapis.com"); + expect(error?.message).to.include( "gcloud services enable iam.googleapis.com cloudresourcemanager.googleapis.com --project test-project", ); - expect(error!.message).to.include( + expect(error?.message).to.include( "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", ); - expect(error!.message).to.include( + expect(error?.message).to.include( "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", ); expect(testIamPermissionsStub).to.not.have.been.called; @@ -1738,12 +1738,12 @@ describe("prepare", () => { } expect(error).to.be.instanceOf(FirebaseError); - expect(error!.message).to.include("iam.googleapis.com"); - expect(error!.message).to.not.include("cloudresourcemanager.googleapis.com"); - expect(error!.message).to.include( + expect(error?.message).to.include("iam.googleapis.com"); + expect(error?.message).to.not.include("cloudresourcemanager.googleapis.com"); + expect(error?.message).to.include( "gcloud services enable iam.googleapis.com --project test-project", ); - expect(error!.message).to.include( + expect(error?.message).to.include( "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", ); }); @@ -1771,12 +1771,12 @@ describe("prepare", () => { } expect(error).to.be.instanceOf(FirebaseError); - expect(error!.message).to.include("cloudresourcemanager.googleapis.com"); - expect(error!.message).to.not.include("iam.googleapis.com"); - expect(error!.message).to.include( + expect(error?.message).to.include("cloudresourcemanager.googleapis.com"); + expect(error?.message).to.not.include("iam.googleapis.com"); + expect(error?.message).to.include( "gcloud services enable cloudresourcemanager.googleapis.com --project test-project", ); - expect(error!.message).to.include( + expect(error?.message).to.include( "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", ); }); @@ -1833,7 +1833,6 @@ describe("prepare", () => { ); expect(e.serviceAccount).to.be.null; }); - }); }); }); diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 2eaf2077f17..ed193fb0a4f 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -87,7 +87,7 @@ export async function checkDeclarativeSecurityApisEnabled( try { return await ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true); } catch (err) { - logger.debug(`Silence error checking enablement for API ${api}: ${err}`); + logger.debug(`Silence error checking enablement for API ${api}: ${String(err)}`); return true; } }), From 31cfa8d0071ffdaa6794c72d048aafb3a12dc0a8 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Thu, 10 Sep 2026 01:34:28 +0000 Subject: [PATCH 5/5] refactor(functions): address review comments for declarative security API check - Move checkDeclarativeSecurityApisEnabled to src/deploy/functions/ensure.ts - Scope fail-open error handling specifically to HTTP 403 and PERMISSION_DENIED - Improve debug log message phrasing and preserve Winston error stack trace - Use Sinon .withArgs(...) in prepare.spec.ts and shorten expectation assertions - Add unit test verifying non-permission errors are re-thrown --- src/deploy/functions/ensure.ts | 60 +++++++++++++++++++++++++++- src/deploy/functions/prepare.spec.ts | 51 +++++++++-------------- src/deploy/functions/prepare.ts | 51 +---------------------- 3 files changed, 77 insertions(+), 85 deletions(-) diff --git a/src/deploy/functions/ensure.ts b/src/deploy/functions/ensure.ts index 3c9ffb67c71..006992fb40e 100644 --- a/src/deploy/functions/ensure.ts +++ b/src/deploy/functions/ensure.ts @@ -1,6 +1,6 @@ import * as clc from "colorette"; -import { ensure } from "../../ensureApiEnabled"; +import * as ensureApiEnabled from "../../ensureApiEnabled"; import { FirebaseError, isBillingError } from "../../error"; import { logLabeledBullet, logLabeledSuccess } from "../../utils"; import { checkServiceAgentRole, ensureServiceAgentRole } from "../../gcp/secretManager"; @@ -9,6 +9,7 @@ import { assertExhaustive } from "../../functional"; import { cloudbuildOrigin } from "../../api"; import * as backend from "./backend"; import { getDefaultServiceAccount } from "../../gcp/computeEngine"; +import { logger } from "../../logger"; const FAQ_URL = "https://firebase.google.com/support/faq#functions-runtime"; @@ -73,7 +74,7 @@ function isPermissionError(e: { context?: { body?: { error?: { status?: string } */ export async function cloudBuildEnabled(projectId: string): Promise { try { - await ensure(projectId, cloudbuildOrigin(), "functions"); + await ensureApiEnabled.ensure(projectId, cloudbuildOrigin(), "functions"); } catch (e: any) { if (isBillingError(e)) { throw nodeBillingError(projectId); @@ -186,3 +187,58 @@ export async function grantSecretAccess(args: { `ensured ${clc.bold(serviceAccounts.join(", "))} access to ${clc.bold(secret)}.`, ); } + +export const REQUIRED_SECURITY_APIS = [ + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", +] as const; + +/** + * Validates that the Google Cloud APIs required for Declarative Security are enabled. + * Fails fast with an actionable gcloud command and console URLs if either API is disabled. + */ +export async function checkDeclarativeSecurityApisEnabled( + projectId: string, + codebase: string, +): Promise { + const checks = await Promise.all( + REQUIRED_SECURITY_APIS.map(async (api) => { + try { + return await ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true); + } catch (err: unknown) { + const isPermissionDenied = + (err as { status?: number })?.status === 403 || + isPermissionError(err as { context?: { body?: { error?: { status?: string } } } }); + if (isPermissionDenied) { + logger.debug(`Silencing permission error checking enablement for API ${api}:`, err); + return true; + } + throw err; + } + }), + ); + const disabledApis = REQUIRED_SECURITY_APIS.filter((_, idx) => !checks[idx]); + + if (disabledApis.length > 0) { + const apiBulletList = disabledApis.map((api) => ` - ${clc.bold(api)}`).join("\n"); + const enableCmd = clc.bold( + `gcloud services enable ${disabledApis.join(" ")} --project ${projectId}`, + ); + const consoleLinks = disabledApis + .map((api) => ` - ${api}: ${ensureApiEnabled.enableApiURI(projectId, api)}`) + .join("\n"); + + throw new FirebaseError( + `Cannot deploy functions with declarative security in codebase "${codebase}". ` + + `The following required Google Cloud API(s) are not enabled on project ${clc.bold(projectId)}:\n` + + apiBulletList + + `\n\nDeclarative security requires these APIs to provision and configure managed service accounts and IAM roles.\n` + + `To enable them, run:\n\n` + + ` ${enableCmd}\n\n` + + `Or ask a project owner to enable them in the Google Cloud Console:\n` + + consoleLinks + + `\n`, + { exit: 1 }, + ); + } +} diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 07a53be1c48..1c266e62d9d 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1676,12 +1676,7 @@ describe("prepare", () => { describe("API enablement checks", () => { it("should throw actionable error when both iam and cloudresourcemanager APIs are disabled", async () => { - checkApiStub.callsFake((projectId: string, api: string) => { - if (api === "iam.googleapis.com" || api === "cloudresourcemanager.googleapis.com") { - return Promise.resolve(false); - } - return Promise.resolve(true); - }); + checkApiStub.resolves(false); const e: backend.Endpoint = { ...ENDPOINT }; const want = backend.of(e); @@ -1698,30 +1693,16 @@ describe("prepare", () => { } expect(error).to.be.instanceOf(FirebaseError); - expect(error?.message).to.include( - 'Cannot deploy functions with declarative security in codebase "default"', - ); expect(error?.message).to.include("iam.googleapis.com"); expect(error?.message).to.include("cloudresourcemanager.googleapis.com"); expect(error?.message).to.include( "gcloud services enable iam.googleapis.com cloudresourcemanager.googleapis.com --project test-project", ); - expect(error?.message).to.include( - "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", - ); - expect(error?.message).to.include( - "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", - ); expect(testIamPermissionsStub).to.not.have.been.called; }); it("should throw actionable error when only iam API is disabled", async () => { - checkApiStub.callsFake((projectId: string, api: string) => { - if (api === "iam.googleapis.com") { - return Promise.resolve(false); - } - return Promise.resolve(true); - }); + checkApiStub.withArgs("test-project", "iam.googleapis.com").resolves(false); const e: backend.Endpoint = { ...ENDPOINT }; const want = backend.of(e); @@ -1743,18 +1724,12 @@ describe("prepare", () => { expect(error?.message).to.include( "gcloud services enable iam.googleapis.com --project test-project", ); - expect(error?.message).to.include( - "https://console.cloud.google.com/apis/library/iam.googleapis.com?project=test-project", - ); }); it("should throw actionable error when only cloudresourcemanager API is disabled", async () => { - checkApiStub.callsFake((projectId: string, api: string) => { - if (api === "cloudresourcemanager.googleapis.com") { - return Promise.resolve(false); - } - return Promise.resolve(true); - }); + checkApiStub + .withArgs("test-project", "cloudresourcemanager.googleapis.com") + .resolves(false); const e: backend.Endpoint = { ...ENDPOINT }; const want = backend.of(e); @@ -1776,9 +1751,6 @@ describe("prepare", () => { expect(error?.message).to.include( "gcloud services enable cloudresourcemanager.googleapis.com --project test-project", ); - expect(error?.message).to.include( - "https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com?project=test-project", - ); }); it("should not check security APIs when codebase does not use declarative security", async () => { @@ -1811,6 +1783,19 @@ describe("prepare", () => { expect(result.managedSA).to.equal("firebase-fn-123@test-project.iam.gserviceaccount.com"); }); + it("should rethrow unexpected non-permission errors when checking API enablement", async () => { + checkApiStub.rejects(new Error("Network timeout")); + + const e: backend.Endpoint = { ...ENDPOINT }; + const want = backend.of(e); + want.requiredRoles = ["roles/viewer"]; + const have = backend.empty(); + + await expect( + prepare.discoverSecurityDetails("default", want, have, "test-project"), + ).to.be.rejectedWith(Error, "Network timeout"); + }); + it("should not block unenrollment even if security APIs are disabled", async () => { checkApiStub.resolves(false); diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index ed193fb0a4f..bdf9485203e 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -69,55 +69,6 @@ import * as resourcemanager from "../../gcp/resourceManager"; export const EVENTARC_SOURCE_ENV = "EVENTARC_CLOUD_EVENT_SOURCE"; export const DECLARATIVE_SECURITY_ETAG_LABEL = "firebase-declarative-security-etag"; -const REQUIRED_SECURITY_APIS = [ - "iam.googleapis.com", - "cloudresourcemanager.googleapis.com", -] as const; - -/** - * Validates that the Google Cloud APIs required for Declarative Security are enabled. - * Fails fast with an actionable gcloud command and console URLs if either API is disabled. - */ -export async function checkDeclarativeSecurityApisEnabled( - projectId: string, - codebase: string, -): Promise { - const checks = await Promise.all( - REQUIRED_SECURITY_APIS.map(async (api) => { - try { - return await ensureApiEnabled.check(projectId, api, "functions", /* silent= */ true); - } catch (err) { - logger.debug(`Silence error checking enablement for API ${api}: ${String(err)}`); - return true; - } - }), - ); - const disabledApis = REQUIRED_SECURITY_APIS.filter((_, idx) => !checks[idx]); - - if (disabledApis.length > 0) { - const apiBulletList = disabledApis.map((api) => ` - ${clc.bold(api)}`).join("\n"); - const enableCmd = clc.bold( - `gcloud services enable ${disabledApis.join(" ")} --project ${projectId}`, - ); - const consoleLinks = disabledApis - .map((api) => ` - ${api}: ${ensureApiEnabled.enableApiURI(projectId, api)}`) - .join("\n"); - - throw new FirebaseError( - `Cannot deploy functions with declarative security in codebase "${codebase}". ` + - `The following required Google Cloud API(s) are not enabled on project ${clc.bold(projectId)}:\n` + - apiBulletList + - `\n\nDeclarative security requires these APIs to provision and configure managed service accounts and IAM roles.\n` + - `To enable them, run:\n\n` + - ` ${enableCmd}\n\n` + - `Or ask a project owner to enable them in the Google Cloud Console:\n` + - consoleLinks + - `\n`, - { exit: 1 }, - ); - } -} - /** * Discovers and coordinates declarative security details for a codebase. * Mutates `want` Backend to populate managed service account and etag labels. @@ -213,7 +164,7 @@ export async function discoverSecurityDetails( }; } - await checkDeclarativeSecurityApisEnabled(projectId, codebase); + await ensure.checkDeclarativeSecurityApisEnabled(projectId, codebase); let managedSA = existingManagedSA; if (!managedSA) {