pkg/firebase/secrets/secrets.go (110 lines of code) (raw):

// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package secrets provides functionality around formatting, fetching, and storing secrets in Secret Manager package secrets import ( "context" "fmt" "hash/crc32" "log" "regexp" "slices" "strings" "github.com/googleapis/gax-go/v2" apphostingschema "github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/apphostingschema" "github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/faherror" smpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1" ) // SecretManager is an interface for the Secret Manager API type SecretManager interface { GetSecretVersion(ctx context.Context, req *smpb.GetSecretVersionRequest, opts ...gax.CallOption) (*smpb.SecretVersion, error) AccessSecretVersion(ctx context.Context, req *smpb.AccessSecretVersionRequest, opts ...gax.CallOption) (*smpb.AccessSecretVersionResponse, error) } var ( latestSuffix = "latest" ) // Normalize converts the different possible secret formats provided by users // into one standard format of projects/p/secrets/s/versions/v. func Normalize(env []apphostingschema.EnvironmentVariable, projectID string) error { for i, ev := range env { if ev.Secret != "" { n, err := normalizeSecretFormat(ev.Secret, projectID) if err != nil { return fmt.Errorf("normalizing secret with key=%v and value=%v: %w", ev.Secret, ev.Secret, err) } env[i].Secret = n } } return nil } var ( patternBare = regexp.MustCompile(`^[^/@]+$`) patternBareVersioned = regexp.MustCompile(`^([^/@]+)@([0-9]+)$`) patternFull = regexp.MustCompile(`^projects/([^/]+)/secrets/([^/]+)$`) patternFullVersioned = regexp.MustCompile(`^projects/([^/]+)/secrets/([^/]+)/versions/([^/]+)$`) ) // Handles the following cases: // 1. "secretID" -> Extracts the specified secretID and uses "latest" for versionID // 2. "secretID@versionID" -> Extracts the specified secretID and versionID // 3. "projects/projectID/secrets/secretID" -> Uses "latest" for versionID // 4. "projects/projectID/secrets/secretID/versions/versionID" -> Uses as is func normalizeSecretFormat(firebaseSecret, projectID string) (string, error) { // Handle "secretID" if patternBare.MatchString(firebaseSecret) { return fmt.Sprintf("projects/%s/secrets/%s/versions/latest", projectID, firebaseSecret), nil } // Handle "secretID@versionID" if patternBareVersioned.MatchString(firebaseSecret) { matches := patternBareVersioned.FindStringSubmatch(firebaseSecret) return fmt.Sprintf("projects/%s/secrets/%s/versions/%s", projectID, matches[1], matches[2]), nil } // Handle "projects/projectID/secrets/secretID" if patternFull.MatchString(firebaseSecret) { return firebaseSecret + "/versions/latest", nil } // Handle "projects/projectID/secrets/secretID/versions/versionID" if patternFullVersioned.MatchString(firebaseSecret) { return firebaseSecret, nil } return "", faherror.ImproperSecretFormatError(firebaseSecret) } // PinVersions will determine the latest version for any secrets that require it and pin it to // that value for any subsequent steps. Requires that secrets are of the format 'projects/p/secrets/s/versions/v' func PinVersions(ctx context.Context, client SecretManager, env []apphostingschema.EnvironmentVariable) error { for i, ev := range env { if ev.Secret != "" && strings.HasSuffix(ev.Secret, latestSuffix) { n, err := getSecretVersion(ctx, client, ev.Secret) if err != nil { return faherror.MisconfiguredSecretError(ev.Secret, err) } env[i].Secret = n } } return nil } // GenerateBuildDereferencedEnvMap will return a mapping of environment variables to their dereferenced // secret values along with plain env vars only if they are scope to BUILD availability. Requires // that secrets are of the format 'projects/p/secrets/s/versions/v' func GenerateBuildDereferencedEnvMap(ctx context.Context, client SecretManager, env []apphostingschema.EnvironmentVariable) (map[string]string, error) { dereferencedEnvMap := map[string]string{} for _, ev := range env { if slices.Contains(ev.Availability, "BUILD") { if ev.Value != "" { dereferencedEnvMap[ev.Variable] = ev.Value } else if ev.Secret != "" { n, err := accessSecretVersion(ctx, client, ev.Secret) if err != nil { return nil, fmt.Errorf("calling AccessSecretVersion with name=%v: %w", ev.Secret, err) } dereferencedEnvMap[ev.Variable] = n } } } return dereferencedEnvMap, nil } // Get secret version metadata. Does NOT include the secret's sensitive data. func getSecretVersion(ctx context.Context, client SecretManager, name string) (string, error) { req := &smpb.GetSecretVersionRequest{ Name: name, } result, err := client.GetSecretVersion(ctx, req) if err != nil { return "", fmt.Errorf("getting secret version: %w", err) } log.Printf("Pinned secret %v to %v for the rest of the current build and run", name, result.Name) return result.Name, nil } // Access secret version. Includes secret's sensitive data. func accessSecretVersion(ctx context.Context, client SecretManager, name string) (string, error) { result, err := client.AccessSecretVersion(ctx, &smpb.AccessSecretVersionRequest{ Name: name, }) if err != nil { return "", fmt.Errorf("failed to access secret version %v: %w", name, err) } log.Printf("Accessed secret %v for the rest of the current build", name) // Verify the data checksum. crc32c := crc32.MakeTable(crc32.Castagnoli) checksum := int64(crc32.Checksum(result.Payload.Data, crc32c)) if checksum != *result.Payload.DataCrc32C { return "", fmt.Errorf("data corruption while accessing secret %v detected", name) } return string(result.Payload.Data), nil }