blob: 9e2772f70e968754ac7aa05a82e2a8ec40881dbb [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
from typing import Optional
from typing import 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",
}