This is an automated email from the ASF dual-hosted git repository.
shunping pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new a9f808e4c45 [Python] Resolve AWS credentials through a secret manager
(`--aws_credentials_secret_manager`) (#40547)
a9f808e4c45 is described below
commit a9f808e4c45e66189ce8861e67839163762e2a3a
Author: Shunping Huang <[email protected]>
AuthorDate: Tue Oct 13 16:30:46 2026 -0400
[Python] Resolve AWS credentials through a secret manager
(`--aws_credentials_secret_manager`) (#40547)
* [Python] Add --aws_credentials_secret_manager to resolve AWS credentials
from a secret manager
In this case, --aws_access_key_id, --aws_secret_access_key and
--aws_session_token
can be secret specs instead of plain-text values.
* Refactor: Move AWS client option handling into shared client_options
module
* Update CHANGES.md for --aws_credentials_secret_manager
* Reformat
* Add connection-setting overrides to AWS client_options
Let callers override non-credential AWS client settings (e.g.
region_name, endpoint_url, verify, disable_ssl) on top of the --aws_*
options and the service overrides.
* Add unit test for AWS connection-setting overrides
* Address reviewer's feedback
---
CHANGES.md | 1 +
.../apache_beam/io/aws/clients/client_options.py | 177 +++++++++++++++++++++
.../apache_beam/io/aws/clients/s3/boto3_client.py | 19 +--
.../io/aws/clients/s3/boto3_client_test.py | 125 +++++++++++++++
.../python/apache_beam/options/pipeline_options.py | 27 +++-
5 files changed, 331 insertions(+), 18 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index d1910513da5..7693e65d78a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -74,6 +74,7 @@
* (Go) Pipeline-level `--resource_hints` are now forwarded to expansion
services, so they apply to cross-language transforms as they do in the Java and
Python SDKs. The `max_active_bundles_per_worker` hint name is now also accepted
by `--resource_hints` ([#23893](https://github.com/apache/beam/issues/23893)).
* ReadFromBigQuery now supports Lakehouse runtime catalog (BigLake metastore)
tables with `method=DIRECT_READ`, using 4-part
`project.catalog.namespace.table` identifiers. Previously
`project:catalog.namespace.table` was silently mis-parsed and tables that
report no `numBytes` failed to split (Python)
([#39597](https://github.com/apache/beam/issues/39597)).
* (Python) Added `AwsOptions` with `--aws_*` pipeline options (e.g.
`--aws_access_key_id`, `--aws_region_name`, `--aws_endpoint_url`) that set
defaults for all AWS service clients. The existing `--s3_*` options in
`S3Options` override these defaults for the S3 client only
([#40528](https://github.com/apache/beam/issues/40528)).
+* (Python) Added `--aws_credentials_secret_manager` so AWS credentials
(`--aws_access_key_id`, `--aws_secret_access_key`, `--aws_session_token` and
their `--s3_*` overrides) can be secret specs resolved through a secret manager
such as `GoogleCloudSecretManager`, instead of plain-text values
([#40528](https://github.com/apache/beam/issues/40528)).
## Breaking Changes
diff --git a/sdks/python/apache_beam/io/aws/clients/client_options.py
b/sdks/python/apache_beam/io/aws/clients/client_options.py
new file mode 100644
index 00000000000..4057886a152
--- /dev/null
+++ b/sdks/python/apache_beam/io/aws/clients/client_options.py
@@ -0,0 +1,177 @@
+#
+# 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.
+#
+
+"""Builds boto3 client arguments from :class:`AwsOptions`.
+
+Shared across AWS service clients (e.g. the S3 filesystem client) so that every
+boto3 client created by Beam uses the same credentials and connection settings.
+"""
+
+# pytype: skip-file
+
+import functools
+import types
+from collections.abc import Mapping
+from typing import Any
+from typing import Optional
+from typing import Union
+
+from apache_beam.options.pipeline_options import AwsOptions
+from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.utils.secret import Secret
+
+# Client settings (see AwsOptions.get_client_settings) that hold credentials.
+CREDENTIAL_SETTINGS = frozenset(
+ {'access_key_id', 'secret_access_key', 'session_token'})
+
+_TRUE_STRINGS = frozenset({'true', '1', 'yes', 'y', 'on'})
+_FALSE_STRINGS = frozenset({'false', '0', 'no', 'n', 'off', ''})
+
+
[email protected]_cache(maxsize=None)
+def get_connection_setting_defaults() -> Mapping[str, Any]:
+ """Returns the non-credential client settings and their default values.
+
+ The names (e.g. ``region_name``, ``endpoint_url``) come from
+ AwsOptions.get_client_settings, so new settings added to AwsOptions are
+ picked up automatically. The result is computed once and is read-only.
+ """
+ return types.MappingProxyType({
+ name: value
+ for name, value in AwsOptions(flags=[]).get_client_settings().items()
+ if name not in CREDENTIAL_SETTINGS
+ })
+
+
+def parse_connection_settings(settings: Mapping[str, Any]) -> dict[str, Any]:
+ """Validates connection settings, e.g. from a secret spec.
+
+ Values for boolean settings (e.g. ``disable_ssl``) may be strings such as
+ 'true' or 'false'.
+
+ Args:
+ settings: A dict of client setting names (without the ``aws_`` prefix) to
+ values.
+
+ Returns:
+ A dict of the settings with values converted to the setting types.
+
+ Raises:
+ ValueError: If a setting is a credential or not a known setting, or a
+ boolean value is invalid.
+ """
+ credentials = sorted(CREDENTIAL_SETTINGS.intersection(settings))
+ if credentials:
+ raise ValueError(
+ f"Invalid secret parameter {', '.join(credentials)}: AWS credentials "
+ "cannot be set here. Use the "
+ f"{', '.join('--aws_' + name for name in credentials)} pipeline "
+ "option instead.")
+ defaults = get_connection_setting_defaults()
+ unknown = sorted(set(settings) - set(defaults))
+ if unknown:
+ raise ValueError(f"Invalid secret parameter {', '.join(unknown)}")
+
+ parsed = {}
+ for name, value in settings.items():
+ if isinstance(defaults[name], bool) and isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in _TRUE_STRINGS:
+ value = True
+ elif lowered in _FALSE_STRINGS:
+ value = False
+ else:
+ raise ValueError(f'Invalid boolean value for {name}: {value}')
+ parsed[name] = value
+ return parsed
+
+
+def view_options(
+ options: Union[PipelineOptions, dict],
+ options_cls: type[PipelineOptions]) -> PipelineOptions:
+ """Returns an ``options_cls`` view of PipelineOptions or a dict of values."""
+ if isinstance(options, PipelineOptions):
+ return options.view_as(options_cls)
+ return options_cls(flags=[], **options)
+
+
+# S3IO (and therefore the S3 client) is created for almost every filesystem
+# operation, so cache parsed secrets per process to avoid calling the secret
+# manager each time. Note that a rotated secret is only picked up by new
+# worker processes.
[email protected]_cache(maxsize=32)
+def _fetch_secret(spec: str, secret_manager: Optional[str]) -> str:
+ return Secret.from_json(spec, secret_manager).get_str()
+
+
+def _parse_credential(value: Optional[str],
+ secret_manager: Optional[str]) -> Optional[str]:
+ """Parses a credential option with Secret.from_json.
+
+ With no secret_manager, Secret.from_json returns the value itself (as a
+ RawSecret), so plain credentials keep working.
+ """
+ if not value:
+ return value
+ return _fetch_secret(value, secret_manager)
+
+
+def get_client_kwargs(
+ options: Union[PipelineOptions, dict],
+ service_options_cls: Optional[type[PipelineOptions]] = None,
+ overrides: Optional[dict[str, Any]] = None) -> dict[str, Any]:
+ """Returns common boto3 client keyword arguments.
+
+ Settings come from the --aws_* defaults in AwsOptions, with the overrides
+ from ``service_options_cls`` applied (e.g. --s3_* in S3Options; see
+ AwsOptions.get_client_settings), and then ``overrides``. Credentials are then
+ resolved through --aws_credentials_secret_manager if it is set.
+ Service-specific settings that are not shared (e.g. --s3_api_version) are not
+ included.
+
+ Args:
+ options: PipelineOptions, or a dict of option names to values.
+ service_options_cls: Optional options class whose overrides apply, e.g.
+ S3Options. If None, only the --aws_* defaults are used.
+ overrides: Optional dict of connection settings (setting names without the
+ ``aws_`` prefix, e.g. ``region_name``) that take precedence over the
+ options. Values that are None are ignored. Credentials cannot be
+ overridden; see parse_connection_settings.
+ """
+ aws_options = view_options(options, AwsOptions)
+ service_options = (
+ view_options(options, service_options_cls)
+ if service_options_cls else None)
+ settings = aws_options.get_client_settings(service_options)
+ if overrides:
+ settings.update(
+ (name, value)
+ for name, value in parse_connection_settings(overrides).items()
+ if value is not None)
+ secret_manager = aws_options.aws_credentials_secret_manager
+ return {
+ 'aws_access_key_id': _parse_credential(
+ settings['access_key_id'], secret_manager),
+ 'aws_secret_access_key': _parse_credential(
+ settings['secret_access_key'], secret_manager),
+ 'aws_session_token': _parse_credential(
+ settings['session_token'], secret_manager),
+ 'region_name': settings['region_name'],
+ 'endpoint_url': settings['endpoint_url'],
+ 'use_ssl': not settings['disable_ssl'],
+ 'verify': settings['verify'],
+ }
diff --git a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
index 57c7c83f3ca..d4986eaad94 100644
--- a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
+++ b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
@@ -17,6 +17,7 @@
# pytype: skip-file
+from apache_beam.io.aws.clients import client_options
from apache_beam.io.aws.clients.s3 import messages
from apache_beam.options import pipeline_options
from apache_beam.utils import retry
@@ -47,26 +48,14 @@ class Client(object):
values. --s3_* options override the --aws_* defaults for S3.
"""
assert boto3 is not None, 'Missing boto3 requirement'
- if isinstance(options, pipeline_options.PipelineOptions):
- aws_options = options.view_as(pipeline_options.AwsOptions)
- s3_options = options.view_as(pipeline_options.S3Options)
- else:
- aws_options = pipeline_options.AwsOptions(flags=[], **options)
- s3_options = pipeline_options.S3Options(flags=[], **options)
-
- settings = aws_options.get_client_settings(s3_options)
+ s3_options = client_options.view_options(
+ options, pipeline_options.S3Options)
session = boto3.session.Session()
self.client = session.client(
service_name='s3',
- region_name=settings['region_name'],
api_version=s3_options.s3_api_version,
- use_ssl=not settings['disable_ssl'],
- verify=settings['verify'],
- endpoint_url=settings['endpoint_url'],
- aws_access_key_id=settings['access_key_id'],
- aws_secret_access_key=settings['secret_access_key'],
- aws_session_token=settings['session_token'])
+ **client_options.get_client_kwargs(options,
pipeline_options.S3Options))
self._download_request = None
self._download_stream = None
diff --git a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
index 490d7747294..c0c853632de 100644
--- a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
+++ b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
@@ -17,22 +17,31 @@
"""Tests for option handling in the boto3 S3 client."""
+import json
import unittest
from unittest import mock
from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.utils.secret import RawSecret
try:
import boto3 # pylint: disable=unused-import
+ from apache_beam.io.aws.clients import client_options
from apache_beam.io.aws.clients.s3 import boto3_client
except ImportError:
boto3_client = None
+try:
+ from google.cloud import secretmanager # pylint: disable=unused-import
+except ImportError:
+ secretmanager = None
+
@unittest.skipIf(boto3_client is None, 'boto3 is not installed')
class Boto3ClientOptionsTest(unittest.TestCase):
def setUp(self):
+ client_options._fetch_secret.cache_clear()
patcher = mock.patch('boto3.session.Session')
self.mock_session = patcher.start().return_value
self.addCleanup(patcher.stop)
@@ -111,8 +120,124 @@ class Boto3ClientOptionsTest(unittest.TestCase):
kwargs = self._client_kwargs()
self.assertIsNone(kwargs['aws_access_key_id'])
self.assertIsNone(kwargs['aws_secret_access_key'])
+ self.assertIsNone(kwargs['aws_session_token'])
self.assertTrue(kwargs['use_ssl'])
+ @mock.patch('apache_beam.utils.secret.Secret.from_json')
+ def test_credentials_parsed_by_secret_manager(self, mock_from_json):
+ key_id_spec = json.dumps({'name': 'key-id', 'project': 'p'})
+ secret_key_spec = json.dumps({'name': 'secret-key', 'project': 'p'})
+ token_spec = json.dumps({'name': 'token', 'project': 'p'})
+ resolved = {
+ key_id_spec: 'AKIDEXAMPLE',
+ secret_key_spec: 'SECRETEXAMPLE',
+ token_spec: 'TOKEN',
+ }
+ mock_from_json.side_effect = lambda spec, manager:
RawSecret(resolved[spec])
+
+ boto3_client.Client(
+ PipelineOptions([
+ '--aws_credentials_secret_manager=GoogleCloudSecretManager',
+ f'--aws_access_key_id={key_id_spec}',
+ f'--aws_secret_access_key={secret_key_spec}',
+ f'--aws_session_token={token_spec}',
+ ]))
+
+ kwargs = self._client_kwargs()
+ self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+ self.assertEqual(kwargs['aws_secret_access_key'], 'SECRETEXAMPLE')
+ self.assertEqual(kwargs['aws_session_token'], 'TOKEN')
+ mock_from_json.assert_has_calls([
+ mock.call(key_id_spec, 'GoogleCloudSecretManager'),
+ mock.call(secret_key_spec, 'GoogleCloudSecretManager'),
+ mock.call(token_spec, 'GoogleCloudSecretManager'),
+ ])
+
+ @mock.patch('apache_beam.utils.secret.Secret.from_json')
+ def test_secret_manager_applies_to_s3_credential_overrides(
+ self, mock_from_json):
+ resolved = {
+ 'projects/p/secrets/key-id/versions/1': 'AKIDEXAMPLE',
+ 'projects/p/secrets/secret-key/versions/1': 'SECRETEXAMPLE',
+ }
+ mock_from_json.side_effect = lambda spec, manager:
RawSecret(resolved[spec])
+
+ boto3_client.Client({
+ 'aws_credentials_secret_manager': 'GoogleCloudSecretManager',
+ 's3_access_key_id': 'projects/p/secrets/key-id/versions/1',
+ 's3_secret_access_key': 'projects/p/secrets/secret-key/versions/1',
+ })
+
+ kwargs = self._client_kwargs()
+ self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+ self.assertEqual(kwargs['aws_secret_access_key'], 'SECRETEXAMPLE')
+
+ @unittest.skipIf(secretmanager is None, 'GCP dependencies are not installed')
+ @mock.patch('google.cloud.secretmanager.SecretManagerServiceClient')
+ def test_gcp_secret_manager_end_to_end(self, mock_gcp_client):
+ payloads = {
+ 'projects/p/secrets/key-id/versions/latest': b'AKIDEXAMPLE',
+ 'projects/p/secrets/secret-key/versions/latest': b'SECRETEXAMPLE',
+ }
+
+ def access(request):
+ response = mock.MagicMock()
+ response.payload.data = payloads[request['name']]
+ return response
+
+ mock_gcp_client.return_value.access_secret_version.side_effect = access
+
+ boto3_client.Client(
+ PipelineOptions([
+ '--aws_credentials_secret_manager=GoogleCloudSecretManager',
+ '--aws_access_key_id={"name": "key-id", "project": "p"}',
+ '--aws_secret_access_key={"name": "secret-key", "project": "p"}',
+ ]))
+
+ kwargs = self._client_kwargs()
+ self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+ self.assertEqual(kwargs['aws_secret_access_key'], 'SECRETEXAMPLE')
+
+ @mock.patch('apache_beam.utils.secret.Secret.from_json')
+ def test_parsed_secrets_are_cached(self, mock_from_json):
+ mock_from_json.side_effect = lambda spec, manager: RawSecret('value')
+ options = PipelineOptions([
+ '--aws_credentials_secret_manager=GoogleCloudSecretManager',
+ '--aws_access_key_id=projects/p/secrets/key-id/versions/1',
+ '--aws_secret_access_key=projects/p/secrets/secret-key/versions/1',
+ ])
+
+ boto3_client.Client(options)
+ boto3_client.Client(options)
+
+ self.assertEqual(mock_from_json.call_count, 2)
+
+ def test_connection_setting_overrides(self):
+ kwargs = client_options.get_client_kwargs(
+ PipelineOptions([
+ '--aws_access_key_id=AKIDEXAMPLE',
+ '--aws_region_name=us-east-1',
+ '--s3_region_name=us-east-2',
+ ]),
+ overrides={
+ 'region_name': 'us-west-2',
+ 'endpoint_url': 'http://localhost:4566',
+ 'disable_ssl': 'true',
+ 'verify': None,
+ })
+ self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+ self.assertEqual(kwargs['region_name'], 'us-west-2')
+ self.assertEqual(kwargs['endpoint_url'], 'http://localhost:4566')
+ self.assertFalse(kwargs['use_ssl'])
+
+ for bad in (
+ {'access_key_id': 'x'},
+ {'unknown_setting': 'x'},
+ {'disable_ssl': 'maybe'},
+ ):
+ with self.assertRaises(ValueError):
+ client_options.parse_connection_settings(bad)
+
if __name__ == '__main__':
unittest.main()
diff --git a/sdks/python/apache_beam/options/pipeline_options.py
b/sdks/python/apache_beam/options/pipeline_options.py
index 24abcae4d3e..84c0743ceaa 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -2426,15 +2426,36 @@ class AwsOptions(PipelineOptions):
parser.add_argument(
'--aws_access_key_id',
default=None,
- help='The access key ID to use when creating AWS clients.')
+ help=(
+ 'The access key ID to use when creating AWS clients. If '
+ '--aws_credentials_secret_manager is set, this is a secret spec '
+ 'parsed by that secret manager.'))
parser.add_argument(
'--aws_secret_access_key',
default=None,
- help='The secret access key to use when creating AWS clients.')
+ help=(
+ 'The secret access key to use when creating AWS clients. If '
+ '--aws_credentials_secret_manager is set, this is a secret spec '
+ 'parsed by that secret manager.'))
parser.add_argument(
'--aws_session_token',
default=None,
- help='The session token to use when creating AWS clients.')
+ help=(
+ 'The session token to use when creating AWS clients. If '
+ '--aws_credentials_secret_manager is set, this is a secret spec '
+ 'parsed by that secret manager.'))
+ parser.add_argument(
+ '--aws_credentials_secret_manager',
+ default=None,
+ help=(
+ 'Secret manager used to parse --aws_access_key_id, '
+ '--aws_secret_access_key and --aws_session_token (and their '
+ 'service-specific overrides, e.g. --s3_access_key_id) as secret '
+ 'specs (a JSON string or plain text), e.g. '
+ 'GoogleCloudSecretManager. For example: '
+ '--aws_credentials_secret_manager=GoogleCloudSecretManager '
+ '--aws_secret_access_key=\'{"name": "my-key", "project": "p"}\'. '
+ 'If not set, these options are used as raw credentials.'))
parser.add_argument(
'--aws_endpoint_url',
default=None,