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
@@ -0,0 +1 @@
- [fixed] Clean up managed service accounts when opting out of declarative security alongside a filtered codebase deploy.
36 changes: 36 additions & 0 deletions src/deploy/functions/functionsDeployHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,4 +642,40 @@ describe("functionsDeployHelper", () => {
expect(collisions).to.be.empty;
});
});

describe("isCodebasePartiallyFiltered", () => {
it("should return false when filters is undefined or empty", () => {
expect(helper.isCodebasePartiallyFiltered("codebaseA")).to.be.false;
expect(helper.isCodebasePartiallyFiltered("codebaseA", [])).to.be.false;
});

it("should return false when a filter targets the whole codebase without idChunks", () => {
expect(helper.isCodebasePartiallyFiltered("codebaseA", [{ codebase: "codebaseA" }])).to.be
.false;
expect(
helper.isCodebasePartiallyFiltered("codebaseA", [{ codebase: "codebaseA", idChunks: [] }]),
).to.be.false;
});

it("should return false when filter with idChunks targets a different codebase", () => {
expect(
helper.isCodebasePartiallyFiltered("codebaseA", [
{ codebase: "codebaseA" },
{ codebase: "codebaseB", idChunks: ["funcB"] },
]),
).to.be.false;
});

it("should return true when filter with idChunks targets this codebase", () => {
expect(
helper.isCodebasePartiallyFiltered("codebaseA", [
{ codebase: "codebaseA", idChunks: ["funcA"] },
]),
).to.be.true;
});

it("should return true when filter with idChunks has no codebase (wildcard filter)", () => {
expect(helper.isCodebasePartiallyFiltered("codebaseA", [{ idChunks: ["funcA"] }])).to.be.true;
});
});
});
10 changes: 10 additions & 0 deletions src/deploy/functions/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ export function isEndpointFiltered(endpoint: backend.Endpoint, filters: Endpoint
return filters.some((filter) => endpointMatchesFilter(endpoint, filter));
}

/** Checks if a codebase has any intra-codebase (partial) filters targeting specific function IDs. */
export function isCodebasePartiallyFiltered(codebase: string, filters?: EndpointFilter[]): boolean {
if (!filters) {
return false;
}
return filters.some(
(f) => (!f.codebase || f.codebase === codebase) && !!f.idChunks && f.idChunks.length > 0,
);
}

/**
* Parses raw CLI filter strings for functions:delete into EndpointFilter objects.
*
Expand Down
8 changes: 2 additions & 6 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
endpointMatchesAnyFilter,
getEndpointFilters,
groupEndpointsByCodebase,
isCodebasePartiallyFiltered,
targetCodebases,
} from "./functionsDeployHelper";
import { logLabeledBullet, logLabeledWarning } from "../../utils";
Expand Down Expand Up @@ -107,12 +108,7 @@ export async function discoverSecurityDetails(
(e) => !!e.labels?.[DECLARATIVE_SECURITY_ETAG_LABEL],
)?.labels?.[DECLARATIVE_SECURITY_ETAG_LABEL];

const isPartiallyFiltered = !!(
filters &&
filters.some(
(f) => (!f.codebase || f.codebase === codebase) && f.idChunks && f.idChunks.length > 0,
)
);
const isPartiallyFiltered = isCodebasePartiallyFiltered(codebase, filters);
const isEnrolling = !!requiredRoles && !existingManagedSA;
const isUnenrolling = !requiredRoles && !!existingManagedSA && !!haveRolesEtag;

Expand Down
18 changes: 18 additions & 0 deletions src/deploy/functions/release/planner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,24 @@ describe("planner", () => {
);
});

it("deletes the service account when opting out with a full codebase deploy alongside a filtered deploy in another codebase", async () => {
const wantBackend = backend.empty();
const haveBackend = backend.of(func("id", "region"));

const plan = await planner.createDeploymentPlan({
wantBackend,
haveBackend,
codebase: "codebaseA",
projectId: "my-project",
filters: [{ codebase: "codebaseA" }, { codebase: "codebaseB", idChunks: ["funcB"] }],
existingManagedSA: "firebase-fn-123@my-project.iam.gserviceaccount.com",
});

expect(plan.serviceAccountToDelete).to.equal(
"firebase-fn-123@my-project.iam.gserviceaccount.com",
);
});

it("deletes the service account when opting out during an unfiltered deploy", async () => {
const wantBackend = backend.empty();
const haveBackend = backend.of(func("id", "region"));
Expand Down
9 changes: 3 additions & 6 deletions src/deploy/functions/release/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
EndpointFilter,
endpointMatchesAnyFilter,
getFunctionLabel,
isCodebasePartiallyFiltered,
} from "../functionsDeployHelper";
import { isFirebaseManaged } from "../../../deploymentTool";
import { FirebaseError } from "../../../error";
Expand Down Expand Up @@ -177,11 +178,7 @@ export async function createDeploymentPlan(args: PlanArgs): Promise<CodebasePlan
let serviceAccountToCreate: string | undefined;
let serviceAccountToDelete: string | undefined;

const isFiltered = !!(
filters &&
filters.some((f) => f.idChunks && f.idChunks.length > 0) &&
!deleteAll
);
const isPartiallyFiltered = isCodebasePartiallyFiltered(codebase, filters);

const hasWantEndpoints = backend.someEndpoint(wantBackend, () => true);

Expand All @@ -191,7 +188,7 @@ export async function createDeploymentPlan(args: PlanArgs): Promise<CodebasePlan
if (!existingManagedSA && managedSA) {
serviceAccountToCreate = managedSA;
}
} else if (existingManagedSA && !isFiltered) {
} else if (existingManagedSA && (!isPartiallyFiltered || deleteAll)) {
serviceAccountToDelete = existingManagedSA;
}

Expand Down
Loading