| # |
| # 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() |