Skip to content

feat: implement 2nd gen Cloud Functions declarative security deployment orchestration#10703

Open
inlined wants to merge 10 commits into
mainfrom
drifting-drift-springs-21h48
Open

feat: implement 2nd gen Cloud Functions declarative security deployment orchestration#10703
inlined wants to merge 10 commits into
mainfrom
drifting-drift-springs-21h48

Conversation

@inlined

@inlined inlined commented Jun 24, 2026

Copy link
Copy Markdown
Member

Description

This PR implements the deployment orchestration for 2nd generation Cloud Functions for Firebase declarative security (requiresRole).

It extends the CLI wire protocol, validates declared roles, handles the lifecycle of the managed service account (firebase-fn-<random-suffix>), automates IAM role grants/revocations before and after function deployment/deletion, and prompts operators for confirmations during role modifications.

Key changes:

  1. Discovery & Validation: Added discoverSecurityDetails in prepare.ts to identify when a codebase uses declarative security and ensure there are no conflicts with manually-defined custom service accounts.
  2. Etag / Cache Hashing: Salt-and-hash computed firebase-declarative-security-etag label on functions to allow non-operators to deploy unchanged codebases without setIamPolicy permissions.
  3. Execution/Fabrication: Created grantNewRoles (pre-upsert) and removeOldRoles (post-delete) phases in Fabricator to create/delete service accounts and grant/revoke roles.
  4. Prompts: Added promptForSecurityChanges to ask operators for confirmation on rollouts, modifications, or opt-outs.

Scenarios Tested

  • Full unit test coverage added in prepare.spec.ts, planner.spec.ts, and fabricator.spec.ts for all security validation, planning, and rollout states.
  • Run tests: npm test or npx mocha src/deploy/functions/prepare.spec.ts src/deploy/functions/release/planner.spec.ts src/deploy/functions/release/fabricator.spec.ts
  • Run linting: npm run lint

Sample Commands

firebase deploy --only functions

inlined added 9 commits June 16, 2026 18:08
…nt orchestration

### Description
This change introduces end-to-end deployment orchestration for declarative security on 2nd generation Cloud Functions.
Functions can declare necessary GCP and Firebase IAM roles via the Discovery API. During deployment planning, the CLI verifies IAM permissions, generates a Base38 Salted ETag, and dynamically prompts operators for new role grants or revocations. Deployments follow a zero-downtime rolling security sequence: Phase 0 grants new IAM roles before code upserts, and Phase 2 revokes old roles only after successful traffic migration and function deletes.

### Scenarios Tested
- Comprehensive unit tests (`declarativeSecurity.spec.ts`) validating short-circuiting on matching Base38 Salted ETags, verifying opt-out workflows, and interactive role diff prompting.
- Unit tests (`iam.spec.ts`, `resourceManager.spec.ts`) verifying literal GCP API helper interactions.
- Core deployment planner and fabricator integration specs (`planner.spec.ts`, `fabricator.spec.ts`).

### Sample Commands
`npm test`
…planner

### Description
This change re-architects declarative security to execute Discovery and IAM role fetching entirely inside `src/deploy/functions/prepare.ts`. Granular properties (`rolesToAdd`, `rolesToRemove`, `serviceAccountToCreate`, `serviceAccountToDelete`) are embedded directly into `CodebasePlan` during `createDeploymentPlan`. Interactive prompting is perfectly unified into `src/deploy/functions/prompts.ts`, and rolling security phases in `Fabricator` are renamed to `grantNewRoles` and `removeOldRoles`. Completely eliminates `declarativeSecurity.ts` and `SecurityPlan` standalone structs.

### Scenarios Tested
- 136 passing unit tests across `Fabricator`, `IAM`, and `ResourceManager` suites.
- Strictly validated zero double newlines or empty block comment lines across all modified JSDoc and source blocks.

### Sample Commands
`npm test`
…pecification

### Description
This change refactors `generateManagedServiceAccountName` to become generic by taking an explicit prefix (`firebase-fn`), ensuring utilities in `/gcp` are decoupled from Functions specifics. Additionally, it refines discoverSecurityDetails error messaging to perfectly match the design document specifications. Also purges all remaining extra newlines (e.g., empty comment block lines and spacing between JSDoc and exports).

### Scenarios Tested
- Run full unit and integration test suite (`npm test`) validating zero compile or parser regressions.
- Automated and manual audit confirming strict adherence to style guide.

### Sample Commands
`npm test`
### Description
Reverts the removal of empty lines within JSDoc block comments (` *`), as single empty lines within block comments are permitted by the style guide.
### Description
Comprehensively restores all single empty JSDoc comment lines (` *`) that were removed across the entire branch relative to `main`.
### Description
Renames the declarative security etag label from firebase-declarative-roles-etag to firebase-declarative-security-etag to match the design notes specification exactly.

### Scenarios Tested
- Run unit test suite: npx mocha src/deploy/functions/prepare.spec.ts src/deploy/functions/release/planner.spec.ts src/deploy/functions/release/fabricator.spec.ts
- Run linting: npm run lint

### Sample Commands
N/A

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces declarative security support and automated rolling IAM role grants/revocations for 2nd generation Cloud Functions. It adds discovery, planning, prompting, and fabrication phases to manage codebase-specific service accounts and their associated IAM roles dynamically. Feedback on the changes highlights two critical issues: first, interactive security prompts will cause deployments to hang in non-interactive environments (like CI/CD) unless options.nonInteractive is checked to throw an error; second, deploying with filters while opting out of declarative security can prematurely delete the managed service account, breaking functions not included in the filtered deployment.

Comment on lines +307 to +308
for (const [codebase, codebasePlan] of Object.entries(plan)) {
if (codebasePlan.serviceAccountToDelete) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In non-interactive environments (such as CI/CD pipelines), attempting to prompt the operator for confirmation will cause the deployment to hang or fail. We should check if 'options.nonInteractive' is true when there are security changes in the plan, and throw a clear 'FirebaseError' instructing the user to use the '--force' flag to bypass the prompts.

  for (const [codebase, codebasePlan] of Object.entries(plan)) {
    const hasChanges =
      !!codebasePlan.serviceAccountToDelete ||
      !!codebasePlan.serviceAccountToCreate ||
      !!(codebasePlan.rolesToAdd && codebasePlan.rolesToAdd.length > 0) ||
      !!(codebasePlan.rolesToRemove && codebasePlan.rolesToRemove.length > 0);
    if (hasChanges && options.nonInteractive) {
      throw new FirebaseError(
        `Declarative security changes for codebase ${codebase} require confirmation. Run with --force to bypass interactive prompts in non-interactive environments.`
      );
    }
    if (codebasePlan.serviceAccountToDelete) {

Comment thread src/deploy/functions/release/planner.ts Outdated
Comment on lines +173 to +175
} else if (existingManagedSA) {
serviceAccountToDelete = existingManagedSA;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When opting out of declarative security ('requiredRoles' is undefined) and deploying with filters (e.g., 'firebase deploy --only functions:func1'), 'serviceAccountToDelete' is set to 'existingManagedSA' and the service account is deleted. This immediately breaks any other functions in the codebase that still rely on the managed service account but were not included in the filtered deployment. We should only delete the service account if no filters are applied (i.e., a full deployment).

Suggested change
} else if (existingManagedSA) {
serviceAccountToDelete = existingManagedSA;
}
} else if (existingManagedSA && (!filters || filters.length === 0)) {
serviceAccountToDelete = existingManagedSA;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is necessary but it's worth having. But it can be simplified to !filters?.length

Comment thread src/gcp/iam.ts
.toString()
.padStart(10, "0");

const accountId = `${prefix}-${randomSuffix}`;
try {
const fab = new fabricator.Fabricator({
functionExecutor,
// Note: we don't need the temporary concurrency reduction of 2, because that quota limit is for deploys

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep this line

};

let resolveCreate: () => void;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this line

};

const scrapers: scraper.SourceTokenScraper[] = [];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this line

`Deleting managed service account ${plan.serviceAccountToDelete}...`,
);
await iam.deleteServiceAccount(this.projectId, plan.serviceAccountToDelete);
} else if (plan.rolesToRemove && plan.rolesToRemove.length > 0 && plan.managedServiceAccount) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer early exits to keeping the rest of a function nested.

};

const changesets = Object.values(plan);
for (const [codebase, codebasePlan] of Object.entries(plan)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Run this in parallel

* Mutates want Backend to populate managed service account and etag labels.
* Returns existing discovered roles, or undefined if no security changes needed.
*/
export async function discoverSecurityDetails(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, break this function up

for (const [codebase, codebasePlan] of Object.entries(plan)) {
if (codebasePlan.serviceAccountToDelete) {
const msg = `Deploying this code will opt out of declarative security for codebase ${codebase}. All functions which do not specify a custom service account will use a default service account on next deploy. As a cleanup, the managed service account ${codebasePlan.serviceAccountToDelete} will be deleted. Continue?`;
const confirmed = await confirm({ default: false, message: msg });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pass the options object for nonInteractive and force

throw new FirebaseError("Deployment canceled by user.");
}
} else if (
(codebasePlan.rolesToAdd && codebasePlan.rolesToAdd.length > 0) ||

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here and elsewhere use nullish coalescing. if (codebasePlan.rolesToAdd?.length || codebasePlan.rolesToRemove?.length)

Comment thread src/gcp/iam.ts
): Promise<string> {
const maxAttempts = 10;
for (let i = 0; i < maxAttempts; i++) {
const randomSuffix = Math.floor(Math.random() * 10000000000)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is possible, use a better random library. Otherwise leave a note about why this isn't actually a 'secure context'


for (let i = projectPolicy.bindings.length - 1; i >= 0; i--) {
const binding = projectPolicy.bindings[i];
if (rolesToRemove.includes(binding.role)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer early exits over nesting. E.g. if !includes, continue

…loys

### Description
Addresses PR review feedback:
1. Adds options.nonInteractive checks to promptForSecurityChanges to throw a FirebaseError rather than hanging when deploying in non-interactive mode.
2. Updates createDeploymentPlan to prevent deletion of the managed service account when deploying with filters, ensuring that non-deployed functions are not broken when opting out.

### Scenarios Tested
- Added unit tests in prompts.spec.ts verifying that promptForSecurityChanges throws FirebaseError in non-interactive mode and prompts successfully in interactive mode.
- Added unit tests in planner.spec.ts verifying that the service account is kept during filtered deployments and deleted during unfiltered deployments.

### Sample Commands
N/A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants