| # |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You 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. |
| # |
| |
| """Interface and implementations for Secret providers in Apache Beam.""" |
| |
| import abc |
| import json |
| import logging |
| import os |
| import warnings |
| from typing import Any, Dict, Optional, Union |
| |
| _LOGGER = logging.getLogger(__name__) |
| |
| |
| class Secret(abc.ABC): |
| """A secret management class used for handling sensitive data. |
| |
| This class provides a generic interface for secret management. Implementations |
| of this class should handle fetching secrets from a secret management system. |
| """ |
| def __init__(self): |
| self._cached_secret_bytes: Optional[bytes] = None |
| |
| def get_str(self, cacheSecret: bool = False) -> str: |
| """Retrieve secret value as string. |
| |
| Args: |
| cacheSecret: If True, caches secret value in memory after first fetch. |
| |
| Returns: |
| The retrieved secret value as string. |
| """ |
| return self.get_bytes(cacheSecret=cacheSecret).decode("utf-8") |
| |
| def get_bytes(self, cacheSecret: bool = False) -> bytes: |
| """Retrieve secret value as bytes. |
| |
| Args: |
| cacheSecret: If True, caches secret value in memory after first fetch. |
| |
| Returns: |
| The retrieved secret value as bytes. |
| """ |
| if cacheSecret and getattr(self, '_cached_secret_bytes', None) is not None: |
| return self._cached_secret_bytes |
| |
| secret_val_bytes = self.get_secret_bytes() |
| |
| if cacheSecret: |
| self._cached_secret_bytes = secret_val_bytes |
| |
| return secret_val_bytes |
| |
| @abc.abstractmethod |
| def get_secret_bytes(self) -> bytes: |
| """Returns the secret as a byte string.""" |
| raise NotImplementedError() |
| |
| def __getstate__(self): |
| """Strip cached secrets before pickling for pipeline submission/transmission.""" |
| state = self.__dict__.copy() |
| state['_cached_secret_bytes'] = None |
| return state |
| |
| @staticmethod |
| def generate_secret_bytes() -> bytes: |
| """Generates a new secret key using Fernet.""" |
| from cryptography.fernet import Fernet |
| return Fernet.generate_key() |
| |
| @classmethod |
| def parse_secret_option(cls, secret: str) -> 'Secret': |
| """Parses a secret string and returns the appropriate secret type. |
| |
| The secret string should be formatted like: |
| 'type:<secret_type>;<secret_param>:<value>' |
| |
| For example, 'type:GcpSecret;version_name:my_secret/versions/latest' |
| would return a GcpSecret initialized with 'my_secret/versions/latest'. |
| """ |
| param_map = {} |
| for param in secret.split(';'): |
| parts = param.split(':') |
| if len(parts) == 2: |
| param_map[parts[0]] = parts[1] |
| |
| if 'type' not in param_map: |
| raise ValueError('Secret string must contain a valid type parameter') |
| |
| raw_type = param_map.pop('type') |
| secret_type = raw_type.lower() |
| secret_manager = _SECRET_TYPE_TO_SECRET_MANAGER.get(secret_type) |
| if not secret_manager: |
| raise ValueError( |
| f'Invalid secret type {secret_type}, currently only ' |
| 'GcpSecret and GcpHsmGeneratedSecret are supported') |
| |
| return cls.from_json(json.dumps(param_map), secret_manager) |
| |
| @classmethod |
| def from_json( |
| cls, spec: str, secret_manager: Optional[str] = None) -> 'Secret': |
| """Return a Secret instance based on secret_manager provider and secret specification. |
| |
| Args: |
| spec: Secret string (raw secret or JSON specification string). |
| secret_manager: Secret manager string (e.g. 'GoogleCloudSecretManager'). |
| |
| Returns: |
| An instance of Secret. |
| """ |
| if not isinstance(spec, str): |
| raise TypeError( |
| f"Secret 'spec' must be a string, got {type(spec).__name__}") |
| |
| secret_manager_name = ( |
| secret_manager.strip() |
| if secret_manager and secret_manager.strip() else None) |
| |
| spec_dict = None |
| try: |
| spec_dict = json.loads(spec) |
| if not isinstance(spec_dict, dict): |
| spec_dict = None |
| except Exception: |
| try: |
| import ast |
| spec_dict = ast.literal_eval(spec) |
| if not isinstance(spec_dict, dict): |
| spec_dict = None |
| except Exception: |
| pass |
| |
| if secret_manager_name: |
| secret_cls_entry = _SECRET_CLASSES.get(secret_manager_name.lower()) |
| if secret_cls_entry: |
| if isinstance(secret_cls_entry, str): |
| secret_cls = globals().get(secret_cls_entry, secret_cls_entry) |
| else: |
| secret_cls = secret_cls_entry |
| if isinstance(spec_dict, dict) and hasattr(secret_cls, 'from_dict'): |
| return secret_cls.from_dict(spec_dict) |
| elif isinstance(spec_dict, dict): |
| return secret_cls(**spec_dict) |
| else: |
| return secret_cls(spec) |
| else: |
| raise ValueError( |
| f"Unsupported secret manager: '{secret_manager_name}'. Currently supported options: 'GoogleCloudSecretManager', 'GoogleCloudHsmGeneratedSecretManager'." |
| ) |
| |
| # If secret_manager is not set or empty, check if spec is a JSON specification dict |
| if spec_dict is not None: |
| msg = ( |
| "The 'spec' parameter appears to be a JSON specification, but " |
| "'secret_manager' is not set. Defaulting to Raw.") |
| _LOGGER.warning(msg) |
| warnings.warn(msg, UserWarning) |
| |
| return RawSecret(spec) |
| |
| |
| class RawSecret(Secret): |
| """Secret implementation wrapping a raw secret string or bytes directly.""" |
| def __init__(self, secret: Union[str, bytes]): |
| super().__init__() |
| if isinstance(secret, str): |
| self._secret = secret.encode("utf-8") |
| else: |
| self._secret = secret |
| |
| def get_secret_bytes(self) -> bytes: |
| return self._secret |
| |
| def __eq__(self, other: Any) -> bool: |
| if not isinstance(other, RawSecret): |
| return False |
| return self._secret == other._secret |
| |
| |
| class GcpSecret(Secret): |
| """A secret manager implementation that retrieves secrets from Google Cloud |
| Secret Manager. |
| """ |
| def __init__(self, version_name: str): |
| """Initializes a GcpSecret object. |
| |
| Args: |
| version_name: The full version name of the secret in Google Cloud Secret |
| Manager. For example: |
| projects/<id>/secrets/<secret_name>/versions/1. |
| For more info, see |
| https://cloud.google.com/python/docs/reference/secretmanager/latest/google.cloud.secretmanager_v1beta1.services.secret_manager_service.SecretManagerServiceClient#google_cloud_secretmanager_v1beta1_services_secret_manager_service_SecretManagerServiceClient_access_secret_version |
| """ |
| super().__init__() |
| self._version_name = version_name |
| |
| @classmethod |
| def from_dict(cls, spec_dict: Dict[str, str]) -> 'GcpSecret': |
| """Initialize GcpSecret from a dictionary specification.""" |
| allowed_keys = {'version_name', 'name', 'project', 'version'} |
| invalid_keys = set(spec_dict.keys()) - allowed_keys |
| if invalid_keys: |
| raise ValueError( |
| f"Invalid secret parameter {', '.join(sorted(invalid_keys))}") |
| version_name = cls._parse_version_name(spec_dict) |
| return cls(version_name) |
| |
| @classmethod |
| def _parse_version_name(cls, spec_dict: Dict[str, str]) -> str: |
| if "version_name" in spec_dict: |
| return spec_dict["version_name"] |
| |
| secret_id = spec_dict.get("name") |
| if not secret_id: |
| raise ValueError("Secret name must be specified in secret spec.") |
| |
| # Resolve project ID from spec, environment variables, or Application Default Credentials |
| project_id = ( |
| spec_dict.get("project") or os.environ.get("GOOGLE_CLOUD_PROJECT") or |
| os.environ.get("GCP_PROJECT")) |
| |
| if not project_id: |
| try: |
| import google.auth |
| _, project_id = google.auth.default() |
| except Exception: |
| pass |
| |
| version_id = spec_dict.get("version", "latest") |
| |
| if not project_id: |
| raise ValueError( |
| f"Could not resolve GCP project ID for secret '{secret_id}'. " |
| "Please specify 'project' in the secret spec, set GOOGLE_CLOUD_PROJECT environment variable, " |
| "or configure Application Default Credentials.") |
| |
| return f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" |
| |
| def get_secret_bytes(self) -> bytes: |
| try: |
| from google.cloud import secretmanager |
| client = secretmanager.SecretManagerServiceClient() |
| response = client.access_secret_version( |
| request={"name": self._version_name}) |
| secret = response.payload.data |
| return secret |
| except Exception as e: |
| raise RuntimeError( |
| 'Failed to retrieve secret bytes for secret ' |
| f'{self._version_name} with exception {e}') |
| |
| def __eq__(self, secret): |
| return self._version_name == getattr(secret, '_version_name', None) |
| |
| |
| class GcpHsmGeneratedSecret(Secret): |
| """A secret manager implementation that generates a secret using a GCP HSM key |
| and stores it in Google Cloud Secret Manager. If the secret already exists, |
| it will be retrieved. |
| """ |
| def __init__( |
| self, |
| project_id: str, |
| location_id: str, |
| key_ring_id: str, |
| key_id: str, |
| job_name: str): |
| """Initializes a GcpHsmGeneratedSecret object. |
| |
| Args: |
| project_id: The GCP project ID. |
| location_id: The GCP location ID for the HSM key. |
| key_ring_id: The ID of the KMS key ring. |
| key_id: The ID of the KMS key. |
| job_name: The name of the job, used to generate a unique secret name. |
| """ |
| super().__init__() |
| self._project_id = project_id |
| self._location_id = location_id |
| self._key_ring_id = key_ring_id |
| self._key_id = key_id |
| self._job_name = job_name |
| self._secret_version_name = f'HsmGeneratedSecret_{job_name}' |
| |
| def __eq__(self, other: Any) -> bool: |
| if not isinstance(other, GcpHsmGeneratedSecret): |
| return False |
| return ( |
| self._project_id == other._project_id and |
| self._location_id == other._location_id and |
| self._key_ring_id == other._key_ring_id and |
| self._key_id == other._key_id and |
| getattr(self, '_job_name', None) == getattr(other, '_job_name', None)) |
| |
| @classmethod |
| def from_dict(cls, spec_dict: Dict[str, str]) -> 'GcpHsmGeneratedSecret': |
| """Initialize GcpHsmGeneratedSecret from a dictionary specification.""" |
| allowed_keys = { |
| 'project_id', 'location_id', 'key_ring_id', 'key_id', 'job_name' |
| } |
| missing = allowed_keys - set(spec_dict.keys()) |
| if missing: |
| raise ValueError( |
| f"Missing required parameter(s) for GcpHsmGeneratedSecret: {sorted(list(missing))}" |
| ) |
| invalid_keys = set(spec_dict.keys()) - allowed_keys |
| if invalid_keys: |
| raise ValueError( |
| f"Invalid secret parameter {', '.join(sorted(invalid_keys))}") |
| return cls( |
| project_id=spec_dict['project_id'], |
| location_id=spec_dict['location_id'], |
| key_ring_id=spec_dict['key_ring_id'], |
| key_id=spec_dict['key_id'], |
| job_name=spec_dict['job_name'], |
| ) |
| |
| def get_secret_bytes(self) -> bytes: |
| """Retrieves the secret bytes. |
| |
| If the secret version already exists in Secret Manager, it is retrieved. |
| Otherwise, a new secret and version are created. The new secret is |
| generated using the HSM key. |
| |
| Returns: |
| The secret as a byte string. |
| """ |
| try: |
| from google.api_core import exceptions as api_exceptions |
| from google.cloud import secretmanager |
| client = secretmanager.SecretManagerServiceClient() |
| |
| project_path = f"projects/{self._project_id}" |
| secret_path = f"{project_path}/secrets/{self._secret_version_name}" |
| # Since we may generate multiple versions when doing this on workers, |
| # just always take the first version added to maintain consistency. |
| secret_version_path = f"{secret_path}/versions/1" |
| |
| try: |
| response = client.access_secret_version( |
| request={"name": secret_version_path}) |
| return response.payload.data |
| except api_exceptions.NotFound: |
| # Don't bother logging yet, we'll only log if we actually add the |
| # secret version below |
| pass |
| |
| try: |
| client.create_secret( |
| request={ |
| "parent": project_path, |
| "secret_id": self._secret_version_name, |
| "secret": { |
| "replication": { |
| "automatic": {} |
| } |
| }, |
| }) |
| except api_exceptions.AlreadyExists: |
| # Don't bother logging yet, we'll only log if we actually add the |
| # secret version below |
| pass |
| |
| new_key = self.generate_dek() |
| try: |
| # Try one more time in case it was created while we were generating the |
| # DEK. |
| response = client.access_secret_version( |
| request={"name": secret_version_path}) |
| return response.payload.data |
| except api_exceptions.NotFound: |
| _LOGGER.info( |
| "Secret version %s not found. " |
| "Creating new secret and version.", |
| secret_version_path) |
| client.add_secret_version( |
| request={ |
| "parent": secret_path, "payload": { |
| "data": new_key |
| } |
| }) |
| response = client.access_secret_version( |
| request={"name": secret_version_path}) |
| return response.payload.data |
| |
| except Exception as e: |
| raise RuntimeError( |
| f'Failed to retrieve or create secret bytes for secret ' |
| f'{self._secret_version_name} with exception {e}') |
| |
| def generate_dek(self, dek_size: int = 32) -> bytes: |
| """Generates a new Data Encryption Key (DEK) using an HSM-backed key. |
| |
| This function follows a key derivation process that incorporates entropy |
| from the HSM-backed key into the nonce used for key derivation. |
| |
| Args: |
| dek_size: The size of the DEK to generate. |
| |
| Returns: |
| A new DEK of the specified size, url-safe base64-encoded. |
| """ |
| try: |
| import base64 |
| import os |
| |
| from cryptography.hazmat.primitives import hashes |
| from cryptography.hazmat.primitives.kdf.hkdf import HKDF |
| from google.cloud import kms |
| |
| # 1. Generate a random nonce (nonce_one) |
| nonce_one = os.urandom(dek_size) |
| |
| # 2. Use the HSM-backed key to encrypt nonce_one to create nonce_two |
| kms_client = kms.KeyManagementServiceClient() |
| key_path = kms_client.crypto_key_path( |
| self._project_id, self._location_id, self._key_ring_id, self._key_id) |
| response = kms_client.encrypt( |
| request={ |
| 'name': key_path, 'plaintext': nonce_one |
| }) |
| nonce_two = response.ciphertext |
| |
| # 3. Generate a Derivation Key (DK) |
| dk = os.urandom(dek_size) |
| |
| # 4. Use a KDF to derive the DEK using DK and nonce_two |
| hkdf = HKDF( |
| algorithm=hashes.SHA256(), |
| length=dek_size, |
| salt=nonce_two, |
| info=None, |
| ) |
| dek = hkdf.derive(dk) |
| return base64.urlsafe_b64encode(dek) |
| except Exception as e: |
| raise RuntimeError(f'Failed to generate DEK with exception {e}') |
| |
| |
| _SECRET_TYPE_TO_SECRET_MANAGER: Dict[str, str] = { |
| "gcpsecret": "GoogleCloudSecretManager", |
| "gcphsmgeneratedsecret": "GoogleCloudHsmGeneratedSecretManager", |
| } |
| |
| _SECRET_CLASSES: Dict[str, Any] = { |
| "googlecloudsecretmanager": "GcpSecret", |
| "googlecloudhsmgeneratedsecretmanager": "GcpHsmGeneratedSecret", |
| } |