diff --git a/CHANGELOG.md b/CHANGELOG.md index f95a3649cec..fc6c30a63c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ +- [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 Pub/Sub emulator to version 0.8.36. - [fixed] Clean up managed service accounts when opting out of declarative security alongside a filtered codebase deploy. 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 fd77f6808f0..1c266e62d9d 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1483,6 +1483,7 @@ describe("prepare", () => { describe("discoverSecurityDetails", () => { let testIamPermissionsStub: sinon.SinonStub; + let checkApiStub: sinon.SinonStub; beforeEach(() => { testIamPermissionsStub = sinon @@ -1490,6 +1491,7 @@ 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); }); afterEach(() => { @@ -1671,5 +1673,151 @@ 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.resolves(false); + + 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.include("cloudresourcemanager.googleapis.com"); + expect(error?.message).to.include( + "gcloud services enable iam.googleapis.com 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.withArgs("test-project", "iam.googleapis.com").resolves(false); + + 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", + ); + }); + + it("should throw actionable error when only cloudresourcemanager API is disabled", async () => { + checkApiStub + .withArgs("test-project", "cloudresourcemanager.googleapis.com") + .resolves(false); + + 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", + ); + }); + + 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 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 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); + + 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; + }); + }); }); }); diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 33f68d74dd4..c633fa7932f 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -160,6 +160,8 @@ export async function discoverSecurityDetails( }; } + await ensure.checkDeclarativeSecurityApisEnabled(projectId, codebase); + let managedSA = existingManagedSA; if (!managedSA) { const saToCreate = await iam.generateManagedServiceAccountName(projectId, "firebase-fn");