blob: c9c13f1d60f3ae35ec354185987ecaddc16522fe [file]
#
# 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",
}