blob: 179b7ca3a5f7911f1bc4d9a44897ad9b45b66d06 [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.
#
import json
import unittest
from unittest import mock
from parameterized import param
from parameterized import parameterized
from apache_beam.utils.annotations import BeamDeprecationWarning
from apache_beam.utils.secret import GcpHsmGeneratedSecret
from apache_beam.utils.secret import GcpSecret
from apache_beam.utils.secret import RawSecret
from apache_beam.utils.secret import Secret
try:
from google.cloud import secretmanager
except ImportError:
secretmanager = None # type: ignore[assignment]
class SecretTest(unittest.TestCase):
@parameterized.expand([
param(
secret_string='type:GcpSecret;version_name:my_secret/versions/latest',
secret=GcpSecret('my_secret/versions/latest')),
param(
secret_string='type:GcpSecret;version_name:foo',
secret=GcpSecret('foo')),
param(
secret_string='type:gcpsecreT;version_name:my_secret/versions/latest',
secret=GcpSecret('my_secret/versions/latest')),
])
def test_secret_manager_parses_correctly(self, secret_string, secret):
self.assertEqual(secret, Secret.parse_secret_option(secret_string))
@parameterized.expand([
param(
secret_string='version_name:foo',
exception_str='must contain a valid type parameter'),
param(
secret_string='type:gcpsecreT',
exception_str='Secret name must be specified in secret spec'),
param(
secret_string='type:gcpsecreT;version_name:foo;extra:val',
exception_str='Invalid secret parameter extra'),
])
def test_secret_manager_throws_on_invalid(self, secret_string, exception_str):
with self.assertRaisesRegex(Exception, exception_str):
Secret.parse_secret_option(secret_string)
@unittest.skipIf(secretmanager is None, 'GCP dependencies are not installed')
class GcpSecretTest(unittest.TestCase):
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_gcp_secret_success(self, mock_client_cls):
mock_client = mock.MagicMock()
mock_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"secret-payload-value"
mock_client.access_secret_version.return_value = mock_response
spec_dict = {"name": "my-secret", "version": "1", "project": "my-project"}
secret = GcpSecret.from_dict(spec_dict)
secret_val = secret.get_str(cacheSecret=True)
self.assertEqual(secret_val, "secret-payload-value")
secret_bytes = secret.get_bytes(cacheSecret=True)
self.assertEqual(secret_bytes, b"secret-payload-value")
mock_client.access_secret_version.assert_called_once_with(
request={"name": "projects/my-project/secrets/my-secret/versions/1"})
# Second call with cacheSecret=True should return cached value without calling client again
mock_client.reset_mock()
secret_val_cached = secret.get_str(cacheSecret=True)
self.assertEqual(secret_val_cached, "secret-payload-value")
mock_client.access_secret_version.assert_not_called()
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_gcp_secret_get_bytes_uncached(self, mock_client_cls):
mock_client = mock.MagicMock()
mock_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"secret-payload-value"
mock_client.access_secret_version.return_value = mock_response
spec_dict = {"name": "my-secret", "project": "my-project"}
secret = GcpSecret.from_dict(spec_dict)
secret_bytes = secret.get_bytes()
self.assertEqual(secret_bytes, b"secret-payload-value")
self.assertIsNone(secret._cached_secret_bytes)
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_gcp_secret_getstate_clears_cached_secret(self, mock_client_cls):
mock_client = mock.MagicMock()
mock_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"secret-payload-value"
mock_client.access_secret_version.return_value = mock_response
spec_dict = {"name": "my-secret", "project": "my-project"}
secret = GcpSecret.from_dict(spec_dict)
# Cache the secret in memory
secret.get_str(cacheSecret=True)
self.assertEqual(secret._cached_secret_bytes, b"secret-payload-value")
# When pickled / getstate is called during pipeline submission
state = secret.__getstate__()
self.assertIsNone(state["_cached_secret_bytes"])
@mock.patch.dict("os.environ", {"GOOGLE_CLOUD_PROJECT": "env-project-123"})
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_gcp_secret_env_project_fallback(self, mock_client_cls):
mock_client = mock.MagicMock()
mock_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"env-secret-val"
mock_client.access_secret_version.return_value = mock_response
# Project omitted from spec
spec_dict = {"name": "env-secret", "version": "latest"}
secret = GcpSecret.from_dict(spec_dict)
secret_val = secret.get_str(cacheSecret=False)
self.assertEqual(secret_val, "env-secret-val")
self.assertEqual(secret.get_bytes(cacheSecret=False), b"env-secret-val")
mock_client.access_secret_version.assert_called_with(
request={
"name": "projects/env-project-123/secrets/env-secret/versions/latest"
})
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_gcp_secret_failure_raises_exception(self, mock_client_cls):
mock_client = mock.MagicMock()
mock_client_cls.return_value = mock_client
mock_client.access_secret_version.side_effect = RuntimeError(
"Permission denied or secret not found")
spec_dict = {"name": "non-existent-secret", "project": "my-project"}
secret = GcpSecret.from_dict(spec_dict)
with self.assertRaises(RuntimeError) as ctx:
secret.get_str(cacheSecret=False)
self.assertIn("Permission denied or secret not found", str(ctx.exception))
@mock.patch.dict("os.environ", {}, clear=True)
@mock.patch("google.auth.default", side_effect=Exception("No ADC"))
def test_ill_formed_missing_project_raises_value_error(
self, mock_auth_default):
spec_dict = {"name": "my-secret"}
with self.assertRaises(ValueError) as ctx:
GcpSecret.from_dict(spec_dict)
self.assertIn("Could not resolve GCP project ID", str(ctx.exception))
def test_ill_formed_missing_secret_name_raises_value_error(self):
spec_dict = {"project": "my-project"}
with self.assertRaises(ValueError) as ctx:
GcpSecret.from_dict(spec_dict)
self.assertIn("Secret name must be specified", str(ctx.exception))
@unittest.skipIf(secretmanager is None, 'GCP dependencies are not installed')
class GcpHsmGeneratedSecretTest(unittest.TestCase):
def setUp(self):
self.mock_secret_manager_client = mock.MagicMock()
self.mock_kms_client = mock.MagicMock()
# Patch the clients
self.secretmanager_patcher = mock.patch(
'google.cloud.secretmanager.SecretManagerServiceClient',
return_value=self.mock_secret_manager_client)
self.kms_patcher = mock.patch(
'google.cloud.kms.KeyManagementServiceClient',
return_value=self.mock_kms_client)
self.os_urandom_patcher = mock.patch('os.urandom', return_value=b'0' * 32)
self.hkdf_patcher = mock.patch(
'cryptography.hazmat.primitives.kdf.hkdf.HKDF.derive',
return_value=b'derived_key')
self.secretmanager_patcher.start()
self.kms_patcher.start()
self.os_urandom_patcher.start()
self.hkdf_patcher.start()
def tearDown(self):
self.secretmanager_patcher.stop()
self.kms_patcher.stop()
self.os_urandom_patcher.stop()
self.hkdf_patcher.stop()
def test_happy_path_secret_creation(self):
from google.api_core import exceptions as api_exceptions
project_id = 'test-project'
location_id = 'global'
key_ring_id = 'test-key-ring'
key_id = 'test-key'
job_name = 'test-job'
secret = GcpHsmGeneratedSecret(
project_id, location_id, key_ring_id, key_id, job_name)
# Mock responses for secret creation path
self.mock_secret_manager_client.access_secret_version.side_effect = [
api_exceptions.NotFound('not found'), # first check
api_exceptions.NotFound('not found'), # second check
mock.MagicMock(payload=mock.MagicMock(data=b'derived_key'))
]
self.mock_kms_client.encrypt.return_value = mock.MagicMock(
ciphertext=b'encrypted_nonce')
secret_bytes = secret.get_secret_bytes()
self.assertEqual(secret_bytes, b'derived_key')
# Assertions on mocks
secret_version_path = (
f'projects/{project_id}/secrets/{secret._secret_version_name}'
'/versions/1')
self.mock_secret_manager_client.access_secret_version.assert_any_call(
request={'name': secret_version_path})
self.assertEqual(
self.mock_secret_manager_client.access_secret_version.call_count, 3)
self.mock_secret_manager_client.create_secret.assert_called_once()
self.mock_kms_client.encrypt.assert_called_once()
self.mock_secret_manager_client.add_secret_version.assert_called_once()
def test_secret_already_exists(self):
from google.api_core import exceptions as api_exceptions
project_id = 'test-project'
location_id = 'global'
key_ring_id = 'test-key-ring'
key_id = 'test-key'
job_name = 'test-job'
secret = GcpHsmGeneratedSecret(
project_id, location_id, key_ring_id, key_id, job_name)
# Mock responses for secret creation path
self.mock_secret_manager_client.access_secret_version.side_effect = [
api_exceptions.NotFound('not found'),
api_exceptions.NotFound('not found'),
mock.MagicMock(payload=mock.MagicMock(data=b'derived_key'))
]
self.mock_secret_manager_client.create_secret.side_effect = (
api_exceptions.AlreadyExists('exists'))
self.mock_kms_client.encrypt.return_value = mock.MagicMock(
ciphertext=b'encrypted_nonce')
secret_bytes = secret.get_secret_bytes()
self.assertEqual(secret_bytes, b'derived_key')
# Assertions on mocks
self.mock_secret_manager_client.create_secret.assert_called_once()
self.mock_secret_manager_client.add_secret_version.assert_called_once()
def test_secret_version_already_exists(self):
project_id = 'test-project'
location_id = 'global'
key_ring_id = 'test-key-ring'
key_id = 'test-key'
job_name = 'test-job'
secret = GcpHsmGeneratedSecret(
project_id, location_id, key_ring_id, key_id, job_name)
self.mock_secret_manager_client.access_secret_version.return_value = (
mock.MagicMock(payload=mock.MagicMock(data=b'existing_dek')))
secret_bytes = secret.get_secret_bytes()
self.assertEqual(secret_bytes, b'existing_dek')
# Assertions
self.mock_secret_manager_client.access_secret_version.assert_called_once()
self.mock_secret_manager_client.create_secret.assert_not_called()
self.mock_secret_manager_client.add_secret_version.assert_not_called()
self.mock_kms_client.encrypt.assert_not_called()
def test_from_dict_success(self):
spec_dict = {
"project_id": "test-proj",
"location_id": "global",
"key_ring_id": "ring",
"key_id": "key",
"job_name": "my-job"
}
secret = GcpHsmGeneratedSecret.from_dict(spec_dict)
self.assertEqual(secret._project_id, "test-proj")
self.assertEqual(secret._location_id, "global")
self.assertEqual(secret._key_ring_id, "ring")
self.assertEqual(secret._key_id, "key")
self.assertEqual(secret._job_name, "my-job")
self.assertEqual(secret._secret_version_name, "HsmGeneratedSecret_my-job")
def test_from_dict_missing_params_raises_value_error(self):
spec_dict = {"project_id": "test-proj", "location_id": "global"}
with self.assertRaises(ValueError) as ctx:
GcpHsmGeneratedSecret.from_dict(spec_dict)
self.assertIn("Missing required parameter(s)", str(ctx.exception))
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_get_bytes_cached(self, mock_sm_client_cls):
mock_client = mock.MagicMock()
mock_sm_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"hsm-derived-key"
mock_client.access_secret_version.return_value = mock_response
secret = GcpHsmGeneratedSecret("p", "l", "r", "k", "j")
secret_bytes = secret.get_bytes(cacheSecret=True)
self.assertEqual(secret_bytes, b"hsm-derived-key")
# Second call uses cache
mock_client.reset_mock()
self.assertEqual(secret.get_bytes(cacheSecret=True), b"hsm-derived-key")
mock_client.access_secret_version.assert_not_called()
@mock.patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_getstate_clears_cached_secret(self, mock_sm_client_cls):
mock_client = mock.MagicMock()
mock_sm_client_cls.return_value = mock_client
mock_response = mock.MagicMock()
mock_response.payload.data = b"hsm-derived-key"
mock_client.access_secret_version.return_value = mock_response
secret = GcpHsmGeneratedSecret("p", "l", "r", "k", "j")
secret.get_bytes(cacheSecret=True)
self.assertEqual(secret._cached_secret_bytes, b"hsm-derived-key")
state = secret.__getstate__()
self.assertIsNone(state["_cached_secret_bytes"])
class RawSecretTest(unittest.TestCase):
def test_raw_secret_str(self):
secret = RawSecret("STATIC_SECRET_")
self.assertEqual(secret.get_str(cacheSecret=True), "STATIC_SECRET_")
self.assertEqual(secret.get_bytes(cacheSecret=True), b"STATIC_SECRET_")
def test_raw_secret_bytes(self):
secret = RawSecret(b"STATIC_BYTES_")
self.assertEqual(secret.get_str(cacheSecret=True), "STATIC_BYTES_")
self.assertEqual(secret.get_bytes(cacheSecret=True), b"STATIC_BYTES_")
class SecretFactoryTest(unittest.TestCase):
def test_secret_factory(self):
spec = json.dumps({"name": "test-secret", "project": "proj"})
# When provider is set to 'GoogleCloudSecretManager'
secret_gcp = Secret.from_json(
spec=spec, secret_manager="GoogleCloudSecretManager")
self.assertIsInstance(secret_gcp, GcpSecret)
# When spec is a valid JSON string
single_quoted_spec = "{\"name\": \"test-secret\", \"project\": \"proj\"}"
secret_single_quoted = Secret.from_json(
spec=single_quoted_spec, secret_manager="GoogleCloudSecretManager")
self.assertIsInstance(secret_single_quoted, GcpSecret)
self.assertEqual(
secret_single_quoted._version_name,
"projects/proj/secrets/test-secret/versions/latest")
# When spec is a single-quoted JSON string, we still allow it for convienence
# though it is not a valid JSON string.
single_quoted_spec = "{'name': 'test-secret', 'project': 'proj'}"
secret_single_quoted = Secret.from_json(
spec=single_quoted_spec, secret_manager="GoogleCloudSecretManager")
self.assertIsInstance(secret_single_quoted, GcpSecret)
self.assertEqual(
secret_single_quoted._version_name,
"projects/proj/secrets/test-secret/versions/latest")
# When provider is None or empty with plain string
secret_raw = Secret.from_json(spec="STATIC_SECRET_", secret_manager=None)
self.assertIsInstance(secret_raw, RawSecret)
# Unsupported provider raises ValueError
with self.assertRaises(ValueError):
Secret.from_json(spec="spec", secret_manager="unsupported_provider")
# Non-string spec raises TypeError
spec_dict = {"name": "test-secret"}
with self.assertRaises(TypeError):
Secret.from_json(
spec=spec_dict, # type: ignore[arg-type]
secret_manager="GoogleCloudSecretManager")
def test_secret_factory_hsm(self):
hsm_spec = json.dumps({
"project_id": "p",
"location_id": "l",
"key_ring_id": "r",
"key_id": "k",
"job_name": "j"
})
secret_hsm = Secret.from_json(
spec=hsm_spec, secret_manager="GoogleCloudHsmGeneratedSecretManager")
self.assertIsInstance(secret_hsm, GcpHsmGeneratedSecret)
self.assertEqual(secret_hsm._project_id, "p")
def test_json_secret_without_secret_manager_warning(self):
json_spec = json.dumps({"name": "my-secret", "project": "my-proj"})
with self.assertWarns(UserWarning):
secret = Secret.from_json(spec=json_spec, secret_manager=None)
self.assertIsInstance(secret, RawSecret)
def test_generate_secret_bytes(self):
key = Secret.generate_secret_bytes()
self.assertIsInstance(key, bytes)
self.assertTrue(len(key) > 0)
def test_equality(self):
raw1 = RawSecret("secret_value")
raw2 = RawSecret("secret_value")
raw3 = RawSecret("other_value")
self.assertEqual(raw1, raw2)
self.assertNotEqual(raw1, raw3)
self.assertNotEqual(raw1, "secret_value")
gcp1 = GcpSecret.from_dict({"name": "sec", "project": "proj"})
gcp2 = GcpSecret.from_dict({"name": "sec", "project": "proj"})
gcp3 = GcpSecret.from_dict({"name": "other", "project": "proj"})
self.assertEqual(gcp1, gcp2)
self.assertNotEqual(gcp1, gcp3)
self.assertNotEqual(gcp1, raw1)
hsm1 = GcpHsmGeneratedSecret("p", "l", "r", "k", "j")
hsm2 = GcpHsmGeneratedSecret("p", "l", "r", "k", "j")
hsm3 = GcpHsmGeneratedSecret("p", "l", "r", "k", "other")
self.assertEqual(hsm1, hsm2)
self.assertNotEqual(hsm1, hsm3)
self.assertNotEqual(hsm1, gcp1)
if __name__ == "__main__":
unittest.main()