Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/deploy/functions/release/fabricator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import * as runV2NS from "../../../gcp/runv2";
import * as cloudtasksNS from "../../../gcp/cloudtasks";
import * as backend from "../backend";
import * as utils from "../../../utils";
import { FIREBASE_FRAMEWORKS_CODEBASE_PREFIX } from "../../../frameworks/constants";
import * as scraper from "./sourceTokenScraper";
import * as planner from "./planner";
import * as v2events from "../../../functions/events/v2";
Expand Down Expand Up @@ -76,6 +78,7 @@
run.setIamPolicy.rejects(new Error("unexpected run.setIamPolicy"));
run.setInvokerCreate.rejects(new Error("unexpected run.setInvokerCreate"));
run.setInvokerUpdate.rejects(new Error("unexpected run.setInvokerUpdate"));
run.ensureInvokerPublic.rejects(new Error("unexpected run.ensureInvokerPublic"));
run.replaceService.rejects(new Error("unexpected run.replaceService"));
run.updateService.rejects(new Error("Unexpected run.updateService"));
runv2.createService.rejects(new Error("unexpected runv2.createService"));
Expand Down Expand Up @@ -112,6 +115,7 @@
object: "object",
generation: 42,
};
const frameworksCodebase = `${FIREBASE_FRAMEWORKS_CODEBASE_PREFIX}site`;
const ctorArgs: fabricator.FabricatorArgs = {
executor: new executor.InlineExecutor(),
functionExecutor: new executor.InlineExecutor(),
Expand All @@ -121,6 +125,10 @@
sourceUrl: "https://example.com",
storage: storage,
},
[frameworksCodebase]: {
sourceUrl: "https://example.com",
storage: storage,
},
},
appEngineLocation: "us-central1",
projectNumber: "1234567",
Expand Down Expand Up @@ -447,7 +455,7 @@
it("handles topics that already exist", async () => {
pubsub.createTopic.callsFake(() => {
const err = new Error("Already exists");
(err as any).status = 409;

Check warning on line 458 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 458 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});
gcfv2.createFunction.resolves({ name: "op", done: false });
Expand Down Expand Up @@ -522,7 +530,7 @@
eventarc.createChannel.callsFake(({ name }) => {
expect(name).to.equal("channel");
const err = new Error("Already exists");
(err as any).status = 409;

Check warning on line 533 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 533 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});
gcfv2.createFunction.resolves({ name: "op", done: false });
Expand Down Expand Up @@ -588,7 +596,7 @@
eventarc.getChannel.resolves(undefined);
eventarc.createChannel.callsFake(() => {
const err = new Error("🤷‍♂️");
(err as any).status = 400;

Check warning on line 599 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 599 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});

Expand Down Expand Up @@ -984,6 +992,45 @@
expect(run.setInvokerUpdate).to.not.have.been.called;
});

it("ensures a frameworks SSR function stays publicly invokable when no invoker is declared", async () => {
gcfv2.updateFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
run.ensureInvokerPublic.resolves();
const ep = endpoint(
{ httpsTrigger: {} },
{ platform: "gcfv2", codebase: frameworksCodebase },
);

await fab.updateV2Function(ep, new scraper.SourceTokenScraper());
expect(run.ensureInvokerPublic).to.have.been.calledWith("service");
expect(run.setInvokerUpdate).to.not.have.been.called;
});

it("warns but does not fail the deploy when ensuring a frameworks function's invoker fails", async () => {
gcfv2.updateFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
run.ensureInvokerPublic.rejects(new Error("permission denied"));
const warn = sinon.stub(utils, "logLabeledWarning");
const ep = endpoint(
{ httpsTrigger: {} },
{ platform: "gcfv2", codebase: frameworksCodebase },
);

await expect(fab.updateV2Function(ep, new scraper.SourceTokenScraper())).to.not.be.rejected;
expect(run.ensureInvokerPublic).to.have.been.calledWith("service");
expect(warn).to.have.been.calledOnce;
expect(warn.firstCall.args[1]).to.contain("publicly invokable");
});

it("does not ensure public invoker for non-frameworks functions", async () => {
gcfv2.updateFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
const ep = endpoint({ httpsTrigger: {} }, { platform: "gcfv2" });

await fab.updateV2Function(ep, new scraper.SourceTokenScraper());
expect(run.ensureInvokerPublic).to.not.have.been.called;
});

it("updates invoker to public on Node updates when explicitly null", async () => {
gcfv2.updateFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
Expand Down Expand Up @@ -1775,7 +1822,7 @@
expect(deleteEndpoint).to.not.have.been.called;

// Resolve the create operation
resolveCreate!();

Check warning on line 1825 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion

await applyPlanPromise;

Expand Down Expand Up @@ -1817,7 +1864,7 @@

describe("createRunFunction", () => {
it("creates a Cloud Run service with correct configuration", async () => {
runv2.createService.resolves({ uri: "https://service", name: "service" } as any);

Check warning on line 1867 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 1867 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Service | undefined`
run.setInvokerCreate.resolves();

const ep = endpoint(
Expand Down Expand Up @@ -1858,7 +1905,7 @@
});

it("always sets callable triggers to public on creation", async () => {
runv2.createService.resolves({ uri: "https://service", name: "service" } as any);

Check warning on line 1908 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Service | undefined`
run.setInvokerCreate.resolves();

const ep = endpoint(
Expand Down
24 changes: 24 additions & 0 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as scheduler from "../../../gcp/cloudscheduler";
import * as utils from "../../../utils";
import * as services from "../services";
import { getDataConnectP4SA } from "../services/dataconnect";
import { isFrameworksManagedCodebase } from "../../../frameworks/constants";
import { AUTH_BLOCKING_EVENTS } from "../../../functions/events/v1";
import * as gce from "../../../gcp/computeEngine";
import { getHumanFriendlyPlatformName } from "../functionsDeployHelper";
Expand Down Expand Up @@ -626,6 +627,29 @@ export class Fabricator {
await this.executor
.run(() => run.setInvokerUpdate(endpoint.project, serviceName, invoker!))
.catch(rethrowAs(endpoint, "set invoker"));
} else if (
backend.isHttpsTriggered(endpoint) &&
isFrameworksManagedCodebase(endpoint.codebase)
) {
// Frameworks-managed SSR functions are fronted by Firebase Hosting, which
// invokes them anonymously, so they must remain publicly invokable. They
// declare no invoker, so the branch above leaves IAM untouched and a binding
// removed out-of-band (e.g. by an org policy) is never restored. Re-assert
// allUsers additively — preserving any members the user added — and warn
// rather than fail the deploy when something blocks it.
// See: https://cloud.google.com/run/docs/authenticating/public
// See: https://github.com/firebase/firebase-tools/issues/10631
await this.executor
.run(() => run.ensureInvokerPublic(serviceName))
.catch((err: unknown) => {
utils.logLabeledWarning(
"functions",
`Unable to make the SSR function ${endpoint.id} publicly invokable. Requests served ` +
`by it will return 403 until allUsers is granted the Cloud Run Invoker role. ` +
`See https://cloud.google.com/run/docs/authenticating/public`,
);
logger.debug(`Failed to ensure ${serviceName} is publicly invokable: ${err}`);
});
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/frameworks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ export const ALLOWED_SSR_REGIONS = [

export const I18N_ROOT = "/";

/**
* Prefix of the Cloud Functions codebase that the web frameworks integration
* generates for a site's SSR function. See generateSSRCodebaseId.
*/
export const FIREBASE_FRAMEWORKS_CODEBASE_PREFIX = "firebase-frameworks-";

/** Whether a codebase is one the web frameworks integration manages for SSR. */
export function isFrameworksManagedCodebase(codebase: string | undefined): boolean {
return !!codebase?.startsWith(FIREBASE_FRAMEWORKS_CODEBASE_PREFIX);
}

export function GET_DEFAULT_BUILD_TARGETS() {
return Promise.resolve(["production", "development"]);
}
Expand Down
3 changes: 2 additions & 1 deletion src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
DEFAULT_REGION,
DEFAULT_SHOULD_USE_DEV_MODE_HANDLE,
FIREBASE_ADMIN_VERSION,
FIREBASE_FRAMEWORKS_CODEBASE_PREFIX,
FIREBASE_FRAMEWORKS_VERSION,
FIREBASE_FUNCTIONS_VERSION,
GET_DEFAULT_BUILD_TARGETS,
Expand Down Expand Up @@ -111,7 +112,7 @@ function memoizeBuild(
* during hosting deploy.
*/
export function generateSSRCodebaseId(site: string) {
return `firebase-frameworks-${site}`;
return `${FIREBASE_FRAMEWORKS_CODEBASE_PREFIX}${site}`;
}

/**
Expand Down
73 changes: 73 additions & 0 deletions src/gcp/run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,79 @@ describe("run", () => {
});
});
});
describe("ensureInvokerPublic", () => {
let sandbox: sinon.SinonSandbox;
let getIamPolicyStub: sinon.SinonStub;
let setIamPolicyStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
getIamPolicyStub = sandbox.stub(run, "getIamPolicy");
setIamPolicyStub = sandbox.stub(run, "setIamPolicy").resolves();
});

afterEach(() => {
sandbox.restore();
});

it("should add allUsers when the service has no policy", async () => {
getIamPolicyStub.resolves({});

await run.ensureInvokerPublic("service");

expect(setIamPolicyStub).to.be.calledOnceWith("service", {
bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }],
etag: "",
version: 3,
});
});

it("should add allUsers additively, preserving other members and roles", async () => {
getIamPolicyStub.resolves({
bindings: [
{ role: "random-role", members: ["user:pineapple"] },
{
role: "roles/run.invoker",
members: ["serviceAccount:sa@project.iam.gserviceaccount.com"],
},
],
etag: "1234",
version: 3,
});

await run.ensureInvokerPublic("service");

expect(setIamPolicyStub).to.be.calledOnceWith("service", {
bindings: [
{ role: "random-role", members: ["user:pineapple"] },
{
role: "roles/run.invoker",
members: ["serviceAccount:sa@project.iam.gserviceaccount.com", "allUsers"],
},
],
etag: "1234",
version: 3,
});
});

it("should be a no-op when allUsers already has the invoker role", async () => {
getIamPolicyStub.resolves({
bindings: [
{
role: "roles/run.invoker",
members: ["serviceAccount:sa@project.iam.gserviceaccount.com", "allUsers"],
},
],
etag: "1234",
version: 3,
});

await run.ensureInvokerPublic("service");

expect(setIamPolicyStub).to.not.be.called;
});
});

describe("updateService", () => {
let service: run.Service;
let serviceIsResolved: sinon.SinonStub;
Expand Down
32 changes: 32 additions & 0 deletions src/gcp/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,38 @@ export async function setInvokerUpdate(
await setIamPolicy(serviceName, policy, httpClient);
}

/**
* Ensures a service is publicly invokable by granting allUsers the invoker role.
* Unlike setInvokerUpdate this is additive: it preserves any invoker members the
* user already added and is a no-op when allUsers already has the role, so it can
* re-assert public access without overwriting deliberate IAM changes.
* @param serviceName Fully qualified name of the Service.
* @throws {@link FirebaseError} when the IAM policy fails to be read or set.
*/
export async function ensureInvokerPublic(serviceName: string): Promise<void> {
const invokerRole = "roles/run.invoker";
const currentPolicy: IamPolicy = await exports.getIamPolicy(serviceName);
const currentInvokerBinding = currentPolicy.bindings?.find(
(binding) => binding.role === invokerRole,
);
if (currentInvokerBinding?.members?.includes("allUsers")) {
return;
}

const bindings = (currentPolicy.bindings || []).filter((binding) => binding.role !== invokerRole);
bindings.push({
role: invokerRole,
members: [...(currentInvokerBinding?.members || []), "allUsers"],
});

const policy: iam.Policy = {
bindings: bindings,
etag: currentPolicy.etag || "",
version: 3,
};
await exports.setIamPolicy(serviceName, policy);
}

/**
* Fetches recent logs for a given Cloud Run service using the Cloud Logging API.
* @param projectId The Google Cloud project ID.
Expand Down
Loading