Skip to content

Commit

Permalink
FAH ensure secret (#6940)
Browse files Browse the repository at this point in the history
* Chose service accounts dialog

* upsert secret

* Run formatter

* Fix field rename

* Formatter

* Fix refactoring bug

* PR feedback

* PR feedback

* Fix tests
  • Loading branch information
inlined authored Apr 1, 2024
1 parent 9a5534f commit 25673ea
Show file tree
Hide file tree
Showing 16 changed files with 399 additions and 64 deletions.
60 changes: 60 additions & 0 deletions src/apphosting/secrets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FirebaseError } from "../../error";
import * as gcsm from "../../gcp/secretManager";
import { FIREBASE_MANAGED } from "../../gcp/secretManager";
import { isFunctionsManaged } from "../../gcp/secretManager";
import * as utils from "../../utils";
import * as prompt from "../../prompt";

/**
* Ensures a secret exists for use with app hosting, optionally locked to a region.
* If a secret exists, we verify the user is not trying to change the region and verifies a secret
* is not being used for both functions and app hosting as their garbage collection is incompatible
* (client vs server-side).
* @returns true if a secret was created, false if a secret already existed, and null if a user aborts.
*/
export async function upsertSecret(
project: string,
secret: string,
location?: string,
): Promise<boolean | null> {
let existing: gcsm.Secret;
try {
existing = await gcsm.getSecret(project, secret);
} catch (err: any) {
if (err.status !== 404) {
throw new FirebaseError("Unexpected error loading secret", { original: err });
}
await gcsm.createSecret(project, secret, gcsm.labels("apphosting"), location);
return true;
}
const replication = existing.replication?.userManaged;
if (
location &&
(replication?.replicas?.length !== 1 || replication?.replicas?.[0]?.location !== location)
) {
utils.logLabeledError(
"apphosting",
"Secret replication policies cannot be changed after creation",
);
return null;
}
if (isFunctionsManaged(existing)) {
utils.logLabeledWarning(
"apphosting",
`Cloud Functions for Firebase currently manages versions of ${secret}. Continuing will disable ` +
"automatic deletion of old versions.",
);
const stopTracking = await prompt.confirm({
message: "Do you wish to continue?",
default: false,
});
if (!stopTracking) {
return null;
}
delete existing.labels[FIREBASE_MANAGED];
await gcsm.patchSecret(project, secret, existing.labels);
}
// TODO: consider whether we should prompt a user who has an unmanaged secret to enroll in version control.
// This may not be a great idea until version control is actually implemented.
return false;
}
5 changes: 3 additions & 2 deletions src/commands/apphosting-secrets-grantaccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Options } from "../options";
import { needProjectId, needProjectNumber } from "../projectUtils";
import { FirebaseError } from "../error";
import { requireAuth } from "../requireAuth";
import * as secrets from "../functions/secrets";
import * as secretManager from "../gcp/secretManager";
import { requirePermissions } from "../requirePermissions";
import * as apphosting from "../gcp/apphosting";
import { grantSecretAccess } from "../init/features/apphosting/secrets";
Expand All @@ -13,7 +13,7 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
.option("-l, --location <location>", "app backend location")
.option("-b, --backend <backend>", "app backend name")
.before(requireAuth)
.before(secrets.ensureApi)
.before(secretManager.ensureApi)
.before(apphosting.ensureApiEnabled)
.before(requirePermissions, [
"secretmanager.secrets.create",
Expand All @@ -27,6 +27,7 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);

// TODO: Consider reusing dialog in apphosting/secrets/dialogs.ts if backend (and location) is not set.
if (!options.location) {
throw new FirebaseError(
"Missing required flag --location. See firebase apphosting:secrets:grantaccess --help for more info",
Expand Down
4 changes: 2 additions & 2 deletions src/commands/functions-secrets-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Options } from "../options";
import { needProjectId } from "../projectUtils";
import { accessSecretVersion } from "../gcp/secretManager";
import { requireAuth } from "../requireAuth";
import * as secrets from "../functions/secrets";
import * as secretManager from "../gcp/secretManager";

export const command = new Command("functions:secrets:access <KEY>[@version]")
.description(
"Access secret value given secret and its version. Defaults to accessing the latest version.",
)
.before(requireAuth)
.before(secrets.ensureApi)
.before(secretManager.ensureApi)
.action(async (key: string, options: Options) => {
const projectId = needProjectId(options);
let [name, version] = key.split("@");
Expand Down
6 changes: 4 additions & 2 deletions src/commands/functions-secrets-destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getSecret,
getSecretVersion,
listSecretVersions,
ensureApi,
isFunctionsManaged,
} from "../gcp/secretManager";
import { promptOnce } from "../prompt";
import { logBullet, logWarning } from "../utils";
Expand All @@ -19,7 +21,7 @@ export const command = new Command("functions:secrets:destroy <KEY>[@version]")
.description("Destroy a secret. Defaults to destroying the latest version.")
.withForce("Destroys a secret without confirmation.")
.before(requireAuth)
.before(secrets.ensureApi)
.before(ensureApi)
.action(async (key: string, options: Options) => {
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);
Expand Down Expand Up @@ -70,7 +72,7 @@ export const command = new Command("functions:secrets:destroy <KEY>[@version]")
logBullet(`Destroyed secret version ${name}@${sv.versionId}`);

const secret = await getSecret(projectId, name);
if (secrets.isFirebaseManaged(secret)) {
if (isFunctionsManaged(secret)) {
const versions = await listSecretVersions(projectId, name);
if (versions.filter((v) => v.state === "ENABLED").length === 0) {
logBullet(`No active secret versions left. Destroying secret ${name}`);
Expand Down
4 changes: 2 additions & 2 deletions src/commands/functions-secrets-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { Options } from "../options";
import { needProjectId } from "../projectUtils";
import { listSecretVersions } from "../gcp/secretManager";
import { requirePermissions } from "../requirePermissions";
import * as secrets from "../functions/secrets";
import * as secretManager from "../gcp/secretManager";

export const command = new Command("functions:secrets:get <KEY>")
.description("Get metadata for secret and its versions")
.before(requireAuth)
.before(secrets.ensureApi)
.before(secretManager.ensureApi)
.before(requirePermissions, ["secretmanager.secrets.get"])
.action(async (key: string, options: Options) => {
const projectId = needProjectId(options);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/functions-secrets-prune.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as args from "../deploy/functions/args";
import * as backend from "../deploy/functions/backend";
import * as secrets from "../functions/secrets";
import * as secretManager from "../gcp/secretManager";

import { Command } from "../command";
import { Options } from "../options";
Expand All @@ -16,7 +17,7 @@ export const command = new Command("functions:secrets:prune")
.withForce("Destroys unused secrets without prompt")
.description("Destroys unused secrets")
.before(requireAuth)
.before(secrets.ensureApi)
.before(secretManager.ensureApi)
.before(requirePermissions, [
"cloudfunctions.functions.list",
"secretmanager.secrets.list",
Expand Down
6 changes: 4 additions & 2 deletions src/commands/functions-secrets-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
addVersion,
destroySecretVersion,
toSecretVersionResourceName,
isFunctionsManaged,
ensureApi,
} from "../gcp/secretManager";
import { check } from "../ensureApiEnabled";
import { requireAuth } from "../requireAuth";
Expand All @@ -26,7 +28,7 @@ export const command = new Command("functions:secrets:set <KEY>")
.description("Create or update a secret for use in Cloud Functions for Firebase.")
.withForce("Automatically updates functions to use the new secret.")
.before(requireAuth)
.before(secrets.ensureApi)
.before(ensureApi)
.before(requirePermissions, [
"secretmanager.secrets.create",
"secretmanager.secrets.get",
Expand Down Expand Up @@ -61,7 +63,7 @@ export const command = new Command("functions:secrets:set <KEY>")
const secretVersion = await addVersion(projectId, key, secretValue);
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);

if (!secrets.isFirebaseManaged(secret)) {
if (!isFunctionsManaged(secret)) {
logBullet(
"Please deploy your functions for the change to take effect by running:\n\t" +
clc.bold("firebase deploy --only functions"),
Expand Down
2 changes: 1 addition & 1 deletion src/deploy/functions/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as secretManager from "../../gcp/secretManager";
import { listBuckets } from "../../gcp/storage";
import { isCelExpression, resolveExpression } from "./cel";
import { FirebaseConfig } from "./args";
import { labels as secretLabels } from "../../functions/secrets";
import { labels as secretLabels } from "../../gcp/secretManager";

// A convenience type containing options for Prompt's select
interface ListItem {
Expand Down
59 changes: 28 additions & 31 deletions src/functions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import * as poller from "../operation-poller";
import * as gcfV1 from "../gcp/cloudfunctions";
import * as gcfV2 from "../gcp/cloudfunctionsv2";
import * as backend from "../deploy/functions/backend";
import * as ensureApiEnabled from "../ensureApiEnabled";
import { functionsOrigin, functionsV2Origin, secretManagerOrigin } from "../api";
import { functionsOrigin, functionsV2Origin } from "../api";
import {
createSecret,
destroySecretVersion,
getSecret,
getSecretVersion,
isAppHostingManaged,
listSecrets,
listSecretVersions,
parseSecretResourceName,
Expand All @@ -24,9 +24,8 @@ import { promptOnce } from "../prompt";
import { validateKey } from "./env";
import { logger } from "../logger";
import { assertExhaustive } from "../functional";
import { needProjectId } from "../projectUtils";

const FIREBASE_MANAGED = "firebase-managed";
import { isFunctionsManaged, FIREBASE_MANAGED } from "../gcp/secretManager";
import { labels } from "../gcp/secretManager";

// For mysterious reasons, importing the poller option in fabricator.ts leads to some
// value of the poller option to be undefined at runtime. I can't figure out what's going on,
Expand All @@ -51,36 +50,13 @@ type ProjectInfo = {
projectNumber: string;
};

/**
* Returns true if secret is managed by Firebase.
*/
export function isFirebaseManaged(secret: Secret): boolean {
return Object.keys(secret.labels || []).includes(FIREBASE_MANAGED);
}

/**
* Return labels to mark secret as managed by Firebase.
* @internal
*/
export function labels(): Record<string, string> {
return { [FIREBASE_MANAGED]: "true" };
}

function toUpperSnakeCase(key: string): string {
return key
.replace(/[.-]/g, "_")
.replace(/([a-z])([A-Z])/g, "$1_$2")
.toUpperCase();
}

/**
* Utility used in the "before" command annotation to enable the API.
*/
export function ensureApi(options: any): Promise<void> {
const projectId = needProjectId(options);
return ensureApiEnabled.ensure(projectId, secretManagerOrigin(), "secretmanager", true);
}

/**
* Validate and transform keys to match the convention recommended by Firebase.
*/
Expand Down Expand Up @@ -122,18 +98,39 @@ export async function ensureSecret(
): Promise<Secret> {
try {
const secret = await getSecret(projectId, name);
if (!isFirebaseManaged(secret)) {
if (isAppHostingManaged(secret)) {
logWarning(
"Your secret is managed by Firebase App Hosting. Continuing will disable automatic deletion of old versions.",
);
const stopTracking = await promptOnce(
{
name: "doNotTrack",
type: "confirm",
default: false,
message: "Do you wish to continue?",
},
options,
);
if (stopTracking) {
delete secret.labels[FIREBASE_MANAGED];
await patchSecret(secret.projectId, secret.name, secret.labels);
} else {
throw new Error(
"A secret cannot be managed by both Firebase App Hosting and Cloud Functions for Firebase",
);
}
} else if (!isFunctionsManaged(secret)) {
if (!options.force) {
logWarning(
"Your secret is not managed by Firebase. " +
"Your secret is not managed by Cloud Functions for Firebase. " +
"Firebase managed secrets are automatically pruned to reduce your monthly cost for using Secret Manager. ",
);
const confirm = await promptOnce(
{
name: "updateLabels",
type: "confirm",
default: true,
message: `Would you like to have your secret ${secret.name} managed by Firebase?`,
message: `Would you like to have your secret ${secret.name} managed by Cloud Functions for Firebase?`,
},
options,
);
Expand Down
Loading

0 comments on commit 25673ea

Please sign in to comment.