Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 58 additions & 2 deletions src/deploy/functions/ensure.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +9,7 @@
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";

Expand Down Expand Up @@ -67,17 +68,17 @@

/**
* Checks for various warnings and API enablements needed based on the runtime
* of the deployed functions.

Check warning on line 71 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Expected only 0 line after block description
*
* @param projectId Project ID upon which to check enablement.
*/
export async function cloudBuildEnabled(projectId: string): Promise<void> {
try {
await ensure(projectId, cloudbuildOrigin(), "functions");
await ensureApiEnabled.ensure(projectId, cloudbuildOrigin(), "functions");
} catch (e: any) {

Check warning on line 78 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
if (isBillingError(e)) {

Check warning on line 79 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `{ context?: { body?: { error?: { details?: { type: string; reason?: string | undefined; violations?: { type: string; }[] | undefined; }[] | undefined; } | undefined; } | undefined; } | undefined; }`
throw nodeBillingError(projectId);
} else if (isPermissionError(e)) {

Check warning on line 81 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `{ context?: { body?: { error?: { status?: string | undefined; } | undefined; } | undefined; } | undefined; }`
throw nodePermissionError(projectId);
}

Expand All @@ -96,7 +97,7 @@
}
// BUG BUG BUG? Test whether we've resolved e.serviceAccount to be project-relative
// by this point.
const sa = e.serviceAccount || ((await module.exports.defaultServiceAccount(e)) as string);

Check warning on line 100 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 100 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .defaultServiceAccount on an `any` value
for (const s of e.secretEnvironmentVariables) {
const serviceAccounts = secretsToSa[s.secret] || new Set();
serviceAccounts.add(sa);
Expand Down Expand Up @@ -186,3 +187,58 @@
`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<void> {
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 },
);
}
}
148 changes: 148 additions & 0 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,11 @@
.to.be.rejectedWith(FirebaseError)
.then((error) => {
// Should always list latest runtimes
expect(error.message).to.include(latest("nodejs"));

Check warning on line 372 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
expect(error.message).to.include(latest("python"));

Check warning on line 373 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value

// Should never list a decommissioned runtime
expect(error.message).to.not.include("nodejs6");

Check warning on line 376 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
});
});

Expand Down Expand Up @@ -1011,7 +1011,7 @@
...ENDPOINT_BASE,
httpsTrigger: {},
};
const have: backend.Endpoint = JSON.parse(JSON.stringify(want));

Check warning on line 1014 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
have.timeoutSeconds = 120;

prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false);
Expand Down Expand Up @@ -1483,13 +1483,15 @@

describe("discoverSecurityDetails", () => {
let testIamPermissionsStub: sinon.SinonStub;
let checkApiStub: sinon.SinonStub;

beforeEach(() => {
testIamPermissionsStub = sinon
.stub(iam, "testIamPermissions")
.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(() => {
Expand Down Expand Up @@ -1671,5 +1673,151 @@
/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;
});
});
});
});
2 changes: 2 additions & 0 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading