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 61ed38fd5fd Support Secret Manager in JdbcIO for Java, Python and YAML 
(#39834)
61ed38fd5fd is described below

commit 61ed38fd5fd1c9174e83d30d83a321e5da8c9863
Author: Shunping Huang <[email protected]>
AuthorDate: Fri Aug 21 16:09:22 2026 -0400

    Support Secret Manager in JdbcIO for Java, Python and YAML (#39834)
    
    * Support secret manager for jdbcio in python, java and yaml
    
    * Add java unit test and xlang and yaml integration tests.
    
    * Reformat
    
    * Add more descriptions and examples for Java, Python and YAML doc.
---
 .../beam_PostCommit_Python_Xlang_Gcp_Direct.json   |   2 +-
 .../beam_PostCommit_Yaml_Xlang_Direct.json         |   2 +-
 .../schemaio-expansion-service/build.gradle        |   6 ++
 .../java/org/apache/beam/sdk/io/jdbc/JdbcIO.java   |  70 +++++++++++++
 .../io/jdbc/JdbcReadSchemaTransformProvider.java   |  34 ++++++-
 .../beam/sdk/io/jdbc/JdbcSchemaIOProvider.java     |   6 ++
 .../io/jdbc/JdbcWriteSchemaTransformProvider.java  |  34 ++++++-
 .../org/apache/beam/sdk/io/jdbc/JdbcIOTest.java    |  45 ++++++++
 .../io/external/xlang_jdbcio_it_test.py            | 113 +++++++++++++++++++++
 sdks/python/apache_beam/io/jdbc.py                 |  48 ++++++++-
 .../databases/jdbc_secret_manager.yaml             |  59 +++++++++++
 sdks/python/apache_beam/yaml/integration_tests.py  | 113 +++++++++++++++++++++
 sdks/python/apache_beam/yaml/standard_io.yaml      |   2 +
 13 files changed, 524 insertions(+), 10 deletions(-)

diff --git a/.github/trigger_files/beam_PostCommit_Python_Xlang_Gcp_Direct.json 
b/.github/trigger_files/beam_PostCommit_Python_Xlang_Gcp_Direct.json
index e3d6056a5de..b2683333323 100644
--- a/.github/trigger_files/beam_PostCommit_Python_Xlang_Gcp_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Python_Xlang_Gcp_Direct.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "modification": 1
+  "modification": 2
 }
diff --git a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json 
b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
index a975cd1cd10..541dc4ea8e8 100644
--- a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "revision": 1
+  "revision": 2
 }
diff --git a/sdks/java/extensions/schemaio-expansion-service/build.gradle 
b/sdks/java/extensions/schemaio-expansion-service/build.gradle
index e33d6b96b63..5d0ff7e26b0 100644
--- a/sdks/java/extensions/schemaio-expansion-service/build.gradle
+++ b/sdks/java/extensions/schemaio-expansion-service/build.gradle
@@ -66,6 +66,12 @@ dependencies {
     permitUnusedDeclared 'com.google.cloud.sql:postgres-socket-factory:1.25.0'
     implementation 
'com.google.cloud.sql:mysql-socket-factory-connector-j-8:1.25.0'
     permitUnusedDeclared 
'com.google.cloud.sql:mysql-socket-factory-connector-j-8:1.25.0'
+    implementation 
enforcedPlatform(library.java.google_cloud_platform_libraries_bom)
+    permitUnusedDeclared 
enforcedPlatform(library.java.google_cloud_platform_libraries_bom)
+    implementation library.java.google_cloud_secret_manager
+    permitUnusedDeclared library.java.google_cloud_secret_manager
+    implementation library.java.proto_google_cloud_secret_manager_v1
+    permitUnusedDeclared library.java.proto_google_cloud_secret_manager_v1
     testImplementation library.java.junit
     testImplementation library.java.mockito_core
     runtimeOnly ("org.xerial:sqlite-jdbc:3.49.1.0")
diff --git 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcIO.java 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcIO.java
index b53dbfd4fa5..811032170e6 100644
--- a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcIO.java
+++ b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcIO.java
@@ -85,6 +85,7 @@ import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
 import org.apache.beam.sdk.util.BackOff;
 import org.apache.beam.sdk.util.BackOffUtils;
 import org.apache.beam.sdk.util.FluentBackoff;
+import org.apache.beam.sdk.util.Secret;
 import org.apache.beam.sdk.util.Sleeper;
 import org.apache.beam.sdk.values.KV;
 import org.apache.beam.sdk.values.PBegin;
@@ -320,6 +321,27 @@ import org.slf4j.LoggerFactory;
  * since that risks duplicating records in the database, or failing due to 
primary key conflicts.
  * Consider using <a href="https://en.wikipedia.org/wiki/Merge_(SQL)">MERGE 
("upsert")
  * statements</a> supported by your database instead.
+ *
+ * <h3>Using Secret Manager</h3>
+ *
+ * <p>Secret Manager is supported in both read and write operations to avoid 
storing sensitive
+ * credentials such as database passwords in plain text. You can configure 
Secret Manager on {@link
+ * DataSourceConfiguration} by specifying the secret manager provider using 
{@link
+ * DataSourceConfiguration#withSecretManager(String)} (e.g. {@code 
"GoogleCloudSecretManager"}) and
+ * providing the secret specification string in JSON format to {@link
+ * DataSourceConfiguration#withPassword(String)}.
+ *
+ * <p>For example for Google Cloud Secret Manager:
+ *
+ * <pre>{@code
+ * pipeline.apply(JdbcIO.<...>read()
+ *   .withDataSourceConfiguration(JdbcIO.DataSourceConfiguration.create(...)
+ *       ...
+ *       .withPassword("{\"name\": \"my-db-secret\", \"project\": 
\"my-project\"}")
+ *       .withSecretManager("GoogleCloudSecretManager"))
+ *   ...
+ * );
+ * }</pre>
  */
 @SuppressWarnings({
   "rawtypes" // TODO(https://github.com/apache/beam/issues/20447)
@@ -503,6 +525,9 @@ public class JdbcIO {
     @Pure
     abstract @Nullable ValueProvider<String> getDriverJars();
 
+    @Pure
+    abstract @Nullable ValueProvider<@Nullable String> getSecretManager();
+
     @Pure
     abstract @Nullable DataSource getDataSource();
 
@@ -532,6 +557,8 @@ public class JdbcIO {
 
       abstract Builder setDriverJars(ValueProvider<String> driverJars);
 
+      abstract Builder setSecretManager(ValueProvider<@Nullable String> 
secretManager);
+
       abstract Builder setDataSource(@Nullable DataSource dataSource);
 
       abstract DataSourceConfiguration build();
@@ -571,10 +598,19 @@ public class JdbcIO {
       return builder().setUsername(username).build();
     }
 
+    /**
+     * Sets the database password.
+     *
+     * <p>You can specify a plain password string. Alternatively, if a secret 
manager is configured
+     * via {@link #withSecretManager(String)}, you can set this to a secret 
specification in JSON
+     * format (e.g. {@code "{\"name\": \"my-db-secret\", \"project\": 
\"my-project\"}"} for Google
+     * Cloud Secret Manager) that the secret manager uses to retrieve the 
password.
+     */
     public DataSourceConfiguration withPassword(@Nullable String password) {
       return withPassword(ValueProvider.StaticValueProvider.of(password));
     }
 
+    /** Same as {@link #withPassword(String)} but accepting a ValueProvider. */
     public DataSourceConfiguration withPassword(ValueProvider<@Nullable 
String> password) {
       return builder().setPassword(password).build();
     }
@@ -668,6 +704,28 @@ public class JdbcIO {
       return builder().setDriverJars(driverJars).build();
     }
 
+    /**
+     * Sets the secret manager provider.
+     *
+     * <p>Currently supported options are:
+     *
+     * <ul>
+     *   <li>{@code "GoogleCloudSecretManager"}
+     *   <li>{@code "GoogleCloudHsmGeneratedSecretManager"}
+     * </ul>
+     *
+     * <p>If not set, no secret manager is used and the password is treated as 
a plain password.
+     */
+    public DataSourceConfiguration withSecretManager(@Nullable String 
secretManager) {
+      return 
withSecretManager(ValueProvider.StaticValueProvider.of(secretManager));
+    }
+
+    /** Same as {@link #withSecretManager(String)} but accepting a 
ValueProvider. */
+    public DataSourceConfiguration withSecretManager(
+        ValueProvider<@Nullable String> secretManager) {
+      return builder().setSecretManager(secretManager).build();
+    }
+
     void populateDisplayData(DisplayData.Builder builder) {
       if (getDataSource() != null) {
         builder.addIfNotNull(DisplayData.item("dataSource", 
getDataSource().getClass().getName()));
@@ -677,6 +735,7 @@ public class JdbcIO {
         builder.addIfNotNull(DisplayData.item("username", getUsername()));
         builder.addIfNotNull(DisplayData.item("driverJars", getDriverJars()));
         builder.addIfNotNull(DisplayData.item("queryTimeout", 
getQueryTimeout()));
+        builder.addIfNotNull(DisplayData.item("secretManager", 
getSecretManager()));
       }
     }
 
@@ -689,6 +748,7 @@ public class JdbcIO {
         if (getUrl() != null) {
           basicDataSource.setUrl(getUrl().get());
         }
+        ValueProvider<@Nullable String> secretManagerProvider = 
getSecretManager();
         if (getUsername() != null) {
           @SuppressWarnings(
               "nullness") // this is actually nullable, but apache commons 
dbcp2 not annotated
@@ -701,6 +761,16 @@ public class JdbcIO {
               "nullness") // this is actually nullable, but apache commons 
dbcp2 not annotated
           @NonNull
           String password = getPassword().get();
+          if (password != null) {
+            String secretManager = null;
+            if (secretManagerProvider != null) {
+              secretManager = secretManagerProvider.get();
+            }
+            String fetched = Secret.fromJson(password, 
secretManager).getString(false);
+            if (fetched != null) {
+              password = fetched;
+            }
+          }
           basicDataSource.setPassword(password);
         }
         if (getConnectionProperties() != null) {
diff --git 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcReadSchemaTransformProvider.java
 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcReadSchemaTransformProvider.java
index 6069924711c..9b29d76673d 100644
--- 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcReadSchemaTransformProvider.java
+++ 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcReadSchemaTransformProvider.java
@@ -111,7 +111,24 @@ public class JdbcReadSchemaTransformProvider
         + "      config:\n"
         + "        connectionProperties: \"characterEncoding=UTF-8;\"\n"
         + "        ...\n"
-        + "All properties should be semi-colon-delimited (e.g. 
\"key1=value1;key2=value2;\")\n";
+        + "All properties should be semi-colon-delimited (e.g. 
\"key1=value1;key2=value2;\")\n"
+        + "\n"
+        + "#### Using Secret Manager\n"
+        + "\n"
+        + "Secret Manager is supported to avoid storing sensitive credentials 
such as database passwords "
+        + "in plain text. You can configure `secret_manager` (e.g. 
`GoogleCloudSecretManager`) and provide the "
+        + "secret specification string in JSON format to `password`.\n"
+        + "\n"
+        + "For example, for Google Cloud Secret Manager: ::\n"
+        + "\n"
+        + "    - type: ReadFromJdbc\n"
+        + "      config:\n"
+        + "        jdbc_type: mysql\n"
+        + "        url: \"jdbc:mysql://my-host:3306/database\"\n"
+        + "        username: \"my-username\"\n"
+        + "        password: \"{\\\"name\\\": \\\"my-db-secret\\\", 
\\\"project\\\": \\\"my-project\\\"}\"\n"
+        + "        secret_manager: \"GoogleCloudSecretManager\"\n"
+        + "        query: \"SELECT * FROM table\"\n";
   }
 
   protected String inheritedDescription(
@@ -200,6 +217,11 @@ public class JdbcReadSchemaTransformProvider
         dsConfig = dsConfig.withConnectionProperties(connectionProperties);
       }
 
+      String secretManager = config.getSecretManager();
+      if (secretManager != null) {
+        dsConfig = dsConfig.withSecretManager(secretManager);
+      }
+
       List<@org.checkerframework.checker.nullness.qual.Nullable String> 
initialSql =
           config.getConnectionInitSql();
       if (initialSql != null && initialSql.size() > 0) {
@@ -355,7 +377,8 @@ public class JdbcReadSchemaTransformProvider
     @Nullable
     public abstract Boolean getOutputParallelization();
 
-    @SchemaFieldDescription("Password for the JDBC source.")
+    @SchemaFieldDescription(
+        "Password for the JDBC source. Can be specified as a plain password, 
or as a secret specification in JSON format if used with a secret manager.")
     @Nullable
     public abstract String getPassword();
 
@@ -363,6 +386,11 @@ public class JdbcReadSchemaTransformProvider
     @Nullable
     public abstract String getReadQuery();
 
+    @SchemaFieldDescription(
+        "Secret Manager to use for fetching secret values. Available options: 
'GoogleCloudSecretManager', 'GoogleCloudHsmGeneratedSecretManager'. If not set, 
no secret manager is used and the password is treated as a plain password.")
+    @Nullable
+    public abstract String getSecretManager();
+
     @SchemaFieldDescription("Username for the JDBC source.")
     @Nullable
     public abstract String getUsername();
@@ -451,6 +479,8 @@ public class JdbcReadSchemaTransformProvider
 
       public abstract Builder setDriverJars(String value);
 
+      public abstract Builder setSecretManager(String value);
+
       public abstract JdbcReadSchemaTransformConfiguration build();
     }
   }
diff --git 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcSchemaIOProvider.java
 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcSchemaIOProvider.java
index b9c8f2fad15..c63531072c7 100644
--- 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcSchemaIOProvider.java
+++ 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcSchemaIOProvider.java
@@ -60,6 +60,7 @@ public class JdbcSchemaIOProvider implements SchemaIOProvider 
{
         .addStringField("jdbcUrl")
         .addStringField("username")
         .addStringField("password")
+        .addNullableField("secretManager", FieldType.STRING)
         .addNullableField("connectionProperties", FieldType.STRING)
         .addNullableField("connectionInitSqls", 
FieldType.iterable(FieldType.STRING))
         .addNullableField("readQuery", FieldType.STRING)
@@ -221,6 +222,11 @@ public class JdbcSchemaIOProvider implements 
SchemaIOProvider {
               .withUsername(config.getString("username"))
               .withPassword(config.getString("password"));
 
+      @Nullable String secretManager = config.getString("secretManager");
+      if (secretManager != null) {
+        dataSourceConfiguration = 
dataSourceConfiguration.withSecretManager(secretManager);
+      }
+
       @Nullable String connectionProperties = 
config.getString("connectionProperties");
       if (connectionProperties != null) {
         dataSourceConfiguration =
diff --git 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcWriteSchemaTransformProvider.java
 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcWriteSchemaTransformProvider.java
index 47742da3548..3379fad639e 100644
--- 
a/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcWriteSchemaTransformProvider.java
+++ 
b/sdks/java/io/jdbc/src/main/java/org/apache/beam/sdk/io/jdbc/JdbcWriteSchemaTransformProvider.java
@@ -115,7 +115,24 @@ public class JdbcWriteSchemaTransformProvider
         + "      config:\n"
         + "        connectionProperties: \"characterEncoding=UTF-8;\"\n"
         + "        ...\n"
-        + "All properties should be semi-colon-delimited (e.g. 
\"key1=value1;key2=value2;\")\n";
+        + "All properties should be semi-colon-delimited (e.g. 
\"key1=value1;key2=value2;\")\n"
+        + "\n"
+        + "#### Using Secret Manager\n"
+        + "\n"
+        + "Secret Manager is supported to avoid storing sensitive credentials 
such as database passwords "
+        + "in plain text. You can configure `secret_manager` (e.g. 
`GoogleCloudSecretManager`) and provide the "
+        + "secret specification string in JSON format to `password`.\n"
+        + "\n"
+        + "For example, for Google Cloud Secret Manager: ::\n"
+        + "\n"
+        + "    - type: WriteToJdbc\n"
+        + "      config:\n"
+        + "        jdbc_type: mysql\n"
+        + "        url: \"jdbc:mysql://my-host:3306/database\"\n"
+        + "        username: \"my-username\"\n"
+        + "        password: \"{\\\"name\\\": \\\"my-db-secret\\\", 
\\\"project\\\": \\\"my-project\\\"}\"\n"
+        + "        secret_manager: \"GoogleCloudSecretManager\"\n"
+        + "        query: \"INSERT INTO table VALUES(?, ?)\"\n";
   }
 
   protected String inheritedDescription(
@@ -204,6 +221,11 @@ public class JdbcWriteSchemaTransformProvider
         dsConfig = dsConfig.withConnectionProperties(connectionProperties);
       }
 
+      String secretManager = config.getSecretManager();
+      if (secretManager != null) {
+        dsConfig = dsConfig.withSecretManager(secretManager);
+      }
+
       List<@org.checkerframework.checker.nullness.qual.Nullable String> 
initialSql =
           config.getConnectionInitSql();
       if (initialSql != null && initialSql.size() > 0) {
@@ -340,10 +362,16 @@ public class JdbcWriteSchemaTransformProvider
     @Nullable
     public abstract String getLocation();
 
-    @SchemaFieldDescription("Password for the JDBC source.")
+    @SchemaFieldDescription(
+        "Password for the JDBC source. Can be specified as a plain password, 
or as a secret specification in JSON format if used with a secret manager.")
     @Nullable
     public abstract String getPassword();
 
+    @SchemaFieldDescription(
+        "Secret Manager to use for fetching secret values. Available options: 
'GoogleCloudSecretManager', 'GoogleCloudHsmGeneratedSecretManager'. If not set, 
no secret manager is used and the password is treated as a plain password.")
+    @Nullable
+    public abstract String getSecretManager();
+
     @SchemaFieldDescription("Username for the JDBC source.")
     @Nullable
     public abstract String getUsername();
@@ -427,6 +455,8 @@ public class JdbcWriteSchemaTransformProvider
 
       public abstract Builder setBatchSize(Long value);
 
+      public abstract Builder setSecretManager(String value);
+
       public abstract JdbcWriteSchemaTransformConfiguration build();
     }
   }
diff --git 
a/sdks/java/io/jdbc/src/test/java/org/apache/beam/sdk/io/jdbc/JdbcIOTest.java 
b/sdks/java/io/jdbc/src/test/java/org/apache/beam/sdk/io/jdbc/JdbcIOTest.java
index 9099583fb3d..0f94963474e 100644
--- 
a/sdks/java/io/jdbc/src/test/java/org/apache/beam/sdk/io/jdbc/JdbcIOTest.java
+++ 
b/sdks/java/io/jdbc/src/test/java/org/apache/beam/sdk/io/jdbc/JdbcIOTest.java
@@ -32,6 +32,7 @@ import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -86,6 +87,7 @@ import org.apache.beam.sdk.transforms.Create;
 import org.apache.beam.sdk.transforms.ParDo;
 import org.apache.beam.sdk.transforms.SerializableFunction;
 import org.apache.beam.sdk.transforms.Wait;
+import org.apache.beam.sdk.util.Secret;
 import org.apache.beam.sdk.util.SerializableUtils;
 import org.apache.beam.sdk.values.KV;
 import org.apache.beam.sdk.values.PCollection;
@@ -93,6 +95,7 @@ import org.apache.beam.sdk.values.Row;
 import org.apache.beam.sdk.values.TypeDescriptor;
 import org.apache.beam.sdk.values.TypeDescriptors;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.commons.dbcp2.PoolingDataSource;
 import org.apache.commons.lang3.StringUtils;
 import org.hamcrest.Description;
@@ -108,6 +111,7 @@ import org.junit.Test;
 import org.junit.rules.ExpectedException;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.MockedStatic;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -195,6 +199,47 @@ public class JdbcIOTest implements Serializable {
     }
   }
 
+  @Test
+  public void testDataSourceConfigurationPlainPasswordAndNullSecretManager() 
throws Exception {
+    String username = "sa";
+    String password = "my_plain_password";
+    JdbcIO.DataSourceConfiguration config =
+        DATA_SOURCE_CONFIGURATION
+            .withUsername(username)
+            .withPassword(password)
+            .withSecretManager((String) null);
+    DataSource dataSource = config.buildDatasource();
+    assertTrue(dataSource instanceof BasicDataSource);
+    assertEquals(password, ((BasicDataSource) dataSource).getPassword());
+    try (Connection conn = dataSource.getConnection()) {
+      assertTrue(conn.isValid(0));
+    }
+  }
+
+  @Test
+  public void testDataSourceConfigurationWithMockedSecretManager() {
+    String secretSpec = "{'name': 'my-db-secret'}";
+    String resolvedPassword = "my_fetched_secret_password";
+    String secretManager = "GoogleCloudSecretManager";
+
+    Secret mockSecret = mock(Secret.class);
+    when(mockSecret.getString(false)).thenReturn(resolvedPassword);
+
+    try (MockedStatic<Secret> mockedSecret = mockStatic(Secret.class)) {
+      mockedSecret.when(() -> Secret.fromJson(secretSpec, 
secretManager)).thenReturn(mockSecret);
+
+      JdbcIO.DataSourceConfiguration config =
+          DATA_SOURCE_CONFIGURATION
+              .withUsername("sa")
+              .withPassword(secretSpec)
+              .withSecretManager(secretManager);
+
+      DataSource dataSource = config.buildDatasource();
+      assertTrue(dataSource instanceof BasicDataSource);
+      assertEquals(resolvedPassword, ((BasicDataSource) 
dataSource).getPassword());
+    }
+  }
+
   @Test
   public void testSetConnectoinInitSqlFailWithDerbyDB() {
     String username = "sa";
diff --git a/sdks/python/apache_beam/io/external/xlang_jdbcio_it_test.py 
b/sdks/python/apache_beam/io/external/xlang_jdbcio_it_test.py
index 848fc043a4d..96b9dea65f2 100644
--- a/sdks/python/apache_beam/io/external/xlang_jdbcio_it_test.py
+++ b/sdks/python/apache_beam/io/external/xlang_jdbcio_it_test.py
@@ -18,8 +18,10 @@
 # pytype: skip-file
 
 import datetime
+import json
 import logging
 import os
+import sys
 import time
 import typing
 import unittest
@@ -46,6 +48,13 @@ except ImportError:
   sqlalchemy = None
 # pylint: enable=wrong-import-order, wrong-import-position, ungrouped-imports
 
+# pylint: disable=wrong-import-order, wrong-import-position, ungrouped-imports
+try:
+  from google.cloud import secretmanager
+except ImportError:
+  secretmanager = None  # type: ignore[assignment]
+# pylint: enable=wrong-import-order, wrong-import-position, ungrouped-imports
+
 # pylint: disable=wrong-import-order, wrong-import-position, ungrouped-imports
 try:
   from testcontainers.mysql import MySqlContainer
@@ -138,6 +147,42 @@ class CrossLanguageJdbcIOTest(unittest.TestCase):
     cls.engines = {}
     cls.jdbc_configs = {}
 
+    cls.secret_manager_available = False
+    if secretmanager is not None:
+      try:
+        cls.project_id = os.environ.get(
+            'GOOGLE_CLOUD_PROJECT', 'apache-beam-testing')
+        cls.secret_client = secretmanager.SecretManagerServiceClient()
+        py_version = f'_py{sys.version_info.major}{sys.version_info.minor}'
+        secret_postfix = (
+            datetime.datetime.now().strftime('%m%d_%H%M%S') + py_version)
+        cls.secret_id = 'xlang_jdbc_test_secret_' + secret_postfix
+        cls.project_path = f'projects/{cls.project_id}'
+        cls.secret_path = f'{cls.project_path}/secrets/{cls.secret_id}'
+        try:
+          cls.secret_client.get_secret(request={'name': cls.secret_path})
+        except Exception:
+          cls.secret_client.create_secret(
+              request={
+                  'parent': cls.project_path,
+                  'secret_id': cls.secret_id,
+                  'secret': {
+                      'replication': {
+                          'automatic': {}
+                      }
+                  }
+              })
+        cls.secret_client.add_secret_version(
+            request={
+                'parent': cls.secret_path, 'payload': {
+                    'data': b'test'
+                }
+            })
+        cls.secret_manager_available = True
+      except Exception as e:
+        logging.warning("Could not set up GCP Secret Manager: %s", e)
+        cls.secret_manager_available = False
+
     for db_type, db_data in cls.DB_CONTAINER_CLASSPATH_STRING.items():
       container = cls.start_container(db_data.container_fn)
       cls.containers[db_type] = container
@@ -162,6 +207,13 @@ class CrossLanguageJdbcIOTest(unittest.TestCase):
 
   @classmethod
   def tearDownClass(cls):
+    if getattr(cls, 'secret_manager_available',
+               False) and secretmanager is not None:
+      try:
+        cls.secret_client.delete_secret(request={'name': cls.secret_path})
+      except Exception:  # pylint: disable=broad-except
+        logging.warning("Could not delete GCP secret: %s", cls.secret_path)
+
     for db_type, container in cls.containers.items():
       if container:
         # Sometimes stopping the container raises ReadTimeout. We can ignore it
@@ -454,6 +506,67 @@ class CrossLanguageJdbcIOTest(unittest.TestCase):
 
       assert_that(result, equal_to(expected_filtered_rows))
 
+  @parameterized.expand(['postgres', 'mysql'])
+  def test_xlang_jdbc_with_secret_manager(self, database):
+    if not getattr(self, 'secret_manager_available', False):
+      self.skipTest(
+          "GCP Secret Manager is not available or credentials not configured.")
+
+    if self.containers[database] is None:
+      self.skipTest(f"{database} container could not be initialized")
+
+    table_name = f"jdbc_secret_manager_test_{database}"
+
+    with self.engines[database].begin() as connection:
+      connection.execute(
+          sqlalchemy.text(
+              f"CREATE TABLE IF NOT EXISTS {table_name}" +
+              "(id INTEGER, name VARCHAR(50), value DOUBLE PRECISION)"))
+
+    test_rows = [
+        SimpleRow(1, "Item1", 10.5),
+        SimpleRow(2, "Item2", 20.75),
+        SimpleRow(3, "Item3", 30.25),
+        SimpleRow(4, "Item4", 40.0),
+        SimpleRow(-5, "Item5", 50.5),
+    ]
+
+    config = self.jdbc_configs[database]
+    secret_password_spec = json.dumps({
+        'name': self.secret_id, 'project': self.project_id
+    })
+
+    with TestPipeline() as p:
+      p.not_use_test_runner_api = True
+      _ = (
+          p
+          | beam.Create(test_rows).with_output_types(SimpleRow)
+          | 'Write to jdbc with secret manager' >> WriteToJdbc(
+              table_name=table_name,
+              driver_class_name=config['driver_class_name'],
+              jdbc_url=config['jdbc_url'],
+              username=config['username'],
+              password=secret_password_spec,
+              secret_manager='googlecloudsecretmanager',
+              classpath=config['classpath'],
+          ))
+
+    with TestPipeline() as p:
+      p.not_use_test_runner_api = True
+      result = (
+          p
+          | 'Read from jdbc with secret manager' >> ReadFromJdbc(
+              table_name=table_name,
+              driver_class_name=config['driver_class_name'],
+              jdbc_url=config['jdbc_url'],
+              username=config['username'],
+              password=secret_password_spec,
+              secret_manager='googlecloudsecretmanager',
+              classpath=config['classpath'],
+              schema=SimpleRow))
+
+      assert_that(result, equal_to(test_rows))
+
 
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
diff --git a/sdks/python/apache_beam/io/jdbc.py 
b/sdks/python/apache_beam/io/jdbc.py
index 20792fe858e..d3b8761c782 100644
--- a/sdks/python/apache_beam/io/jdbc.py
+++ b/sdks/python/apache_beam/io/jdbc.py
@@ -122,7 +122,8 @@ JdbcConfigSchema = typing.NamedTuple(
 Config = typing.NamedTuple(
     'Config',
     [('driver_class_name', str), ('jdbc_url', str), ('username', str),
-     ('password', str), ('connection_properties', typing.Optional[str]),
+     ('password', str), ('secret_manager', typing.Optional[str]),
+     ('connection_properties', typing.Optional[str]),
      ('connection_init_sqls', typing.Optional[list[str]]),
      ('read_query', typing.Optional[str]),
      ('write_statement', typing.Optional[str]),
@@ -169,6 +170,16 @@ class WriteToJdbc(ExternalTransform):
   The generated write_statement can be overridden by passing in a
   write_statment.
 
+  Secret Manager is supported to avoid storing sensitive credentials such as
+  database passwords in plain text. You can configure ``secret_manager`` (e.g.
+  ``'GoogleCloudSecretManager'``) and provide the secret specification string
+  in JSON format to ``password``, e.g.::
+
+    WriteToJdbc(
+        ...
+        password='{"name": "my-db-secret", "project": "my-project"}',
+        secret_manager='GoogleCloudSecretManager',
+    )
 
   Experimental; no backwards compatibility guarantees.
   """
@@ -191,6 +202,7 @@ class WriteToJdbc(ExternalTransform):
       expansion_service=None,
       classpath=None,
       write_batch_size=None,
+      secret_manager=None,
   ):
     """
     Initializes a write operation to Jdbc.
@@ -198,7 +210,9 @@ class WriteToJdbc(ExternalTransform):
     :param driver_class_name: name of the jdbc driver class
     :param jdbc_url: full jdbc url to the database.
     :param username: database username
-    :param password: database password
+    :param password: database password. Can be specified as a plain password,
+                     or as a secret specification in JSON format if used with
+                     a secret manager.
     :param statement: sql statement to be executed
     :param connection_properties: properties of the jdbc connection
                                   passed as string with format
@@ -225,6 +239,11 @@ class WriteToJdbc(ExternalTransform):
     :param write_batch_size: sets the maximum size in number of SQL statement
                              for the batch.
                              default is {@link JdbcIO.DEFAULT_BATCH_SIZE}
+    :param secret_manager: The secret manager to use for retrieving secrets.
+                           Available options: 'GoogleCloudSecretManager',
+                           'GoogleCloudHsmGeneratedSecretManager'. If not set,
+                           no secret manager is used and the password is
+                           treated as a plain password.
     """
     classpath = classpath or DEFAULT_JDBC_CLASSPATH
     super().__init__(
@@ -239,6 +258,7 @@ class WriteToJdbc(ExternalTransform):
                             jdbc_url=jdbc_url,
                             username=username,
                             password=password,
+                            secret_manager=secret_manager,
                             connection_properties=connection_properties,
                             connection_init_sqls=connection_init_sqls,
                             write_statement=statement,
@@ -296,6 +316,17 @@ class ReadFromJdbc(ExternalTransform):
 
   The generated read_query can be overridden by passing in a read_query.
 
+  Secret Manager is supported to avoid storing sensitive credentials such as
+  database passwords in plain text. You can configure ``secret_manager`` (e.g.
+  ``'GoogleCloudSecretManager'``) and provide the secret specification string
+  in JSON format to ``password``, e.g.::
+
+    ReadFromJdbc(
+        ...
+        password='{"name": "my-db-secret", "project": "my-project"}',
+        secret_manager='GoogleCloudSecretManager',
+    )
+
   Experimental; no backwards compatibility guarantees.
   """
 
@@ -320,14 +351,17 @@ class ReadFromJdbc(ExternalTransform):
       driver_jars=None,
       expansion_service=None,
       classpath=None,
-      schema=None):
+      schema=None,
+      secret_manager=None):
     """
     Initializes a read operation from Jdbc.
 
     :param driver_class_name: name of the jdbc driver class
     :param jdbc_url: full jdbc url to the database.
     :param username: database username
-    :param password: database password
+    :param password: database password. Can be specified as a plain password,
+                     or as a secret specification in JSON format if used with
+                     a secret manager.
     :param query: sql query to be executed
     :param disable_autocommit: disable autocommit on read
     :param output_parallelization: is output parallelization on
@@ -360,6 +394,11 @@ class ReadFromJdbc(ExternalTransform):
                    this should be a NamedTuple type that defines the structure
                    of the output PCollection elements. This bypasses automatic
                    schema inference during pipeline construction.
+    :param secret_manager: The secret manager to use for retrieving secrets.
+                           Available options: 'GoogleCloudSecretManager',
+                           'GoogleCloudHsmGeneratedSecretManager'. If not set,
+                           no secret manager is used and the password is
+                           treated as a plain password.
     """
     # override new portable Date type with the current Jdbc type
     # TODO(https://github.com/apache/beam/issues/28359):
@@ -388,6 +427,7 @@ class ReadFromJdbc(ExternalTransform):
                             jdbc_url=jdbc_url,
                             username=username,
                             password=password,
+                            secret_manager=secret_manager,
                             connection_properties=connection_properties,
                             connection_init_sqls=connection_init_sqls,
                             write_statement=None,
diff --git 
a/sdks/python/apache_beam/yaml/extended_tests/databases/jdbc_secret_manager.yaml
 
b/sdks/python/apache_beam/yaml/extended_tests/databases/jdbc_secret_manager.yaml
new file mode 100644
index 00000000000..e457dcdff8b
--- /dev/null
+++ 
b/sdks/python/apache_beam/yaml/extended_tests/databases/jdbc_secret_manager.yaml
@@ -0,0 +1,59 @@
+#
+# 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.
+#
+
+fixtures:
+  - name: TEMP_DB
+    type: 
"apache_beam.yaml.integration_tests.temp_postgres_database_with_secret_manager"
+
+pipelines:
+  # Jdbc write pipeline with secret manager
+  - pipeline:
+      type: chain
+      transforms:
+        - type: Create
+          config:
+            elements:
+              - {value: 123, rank: 0}
+              - {value: 456, rank: 1}
+              - {value: 789, rank: 2}
+        - type: WriteToJdbc
+          config:
+            url: "{TEMP_DB[URL]}"
+            username: "{TEMP_DB[USERNAME]}"
+            password: "{TEMP_DB[PASSWORD_SPEC]}"
+            secret_manager: "{TEMP_DB[SECRET_MANAGER]}"
+            driver_class_name: "org.postgresql.Driver"
+            query: "INSERT INTO tmp_table (value, rank) VALUES(?,?)"
+
+  # Jdbc read pipeline with secret manager
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromJdbc
+          config:
+            url: "{TEMP_DB[URL]}"
+            username: "{TEMP_DB[USERNAME]}"
+            password: "{TEMP_DB[PASSWORD_SPEC]}"
+            secret_manager: "{TEMP_DB[SECRET_MANAGER]}"
+            driver_class_name: "org.postgresql.Driver"
+            query: "SELECT * FROM tmp_table"
+        - type: AssertEqual
+          config:
+            elements:
+              - {value: 123, rank: 0}
+              - {value: 456, rank: 1}
+              - {value: 789, rank: 2}
diff --git a/sdks/python/apache_beam/yaml/integration_tests.py 
b/sdks/python/apache_beam/yaml/integration_tests.py
index f4329205855..cd632ae9738 100644
--- a/sdks/python/apache_beam/yaml/integration_tests.py
+++ b/sdks/python/apache_beam/yaml/integration_tests.py
@@ -21,6 +21,7 @@ import contextlib
 import copy
 import glob
 import itertools
+import json
 import logging
 import os
 import random
@@ -79,6 +80,11 @@ from apitools.base.py.exceptions import HttpError
 from google.cloud import pubsub_v1
 from google.cloud.bigtable import client
 from google.cloud.bigtable_admin_v2.types import instance
+
+try:
+  from google.cloud import secretmanager
+except ImportError:
+  secretmanager = None
 from testcontainers.core.container import DockerContainer
 from testcontainers.core.waiting_utils import wait_for_logs
 from testcontainers.google import PubSubContainer
@@ -508,6 +514,113 @@ def temp_postgres_database():
       raise err
 
 
[email protected]
+def temp_postgres_database_with_secret_manager(
+    project=None, prefix='yaml_jdbc_sm_it_'):
+  """Context manager to provide a temporary PostgreSQL database authenticated
+  via GCP Secret Manager for testing.
+
+  This function utilizes the 'testcontainers' library to spin up a
+  PostgreSQL instance within a Docker container, creates a predefined 
'tmp_table',
+  registers the database container password in GCP Secret Manager, and yields
+  a dictionary containing the JDBC connection URL, username, secret 
specification,
+  and secret manager identifier.
+
+  The Docker container, database instance, and GCP secret are automatically
+  managed and torn down when the context manager exits.
+
+  Args:
+      project (str): Google Cloud Project ID. If not provided, reads from
+        the GOOGLE_CLOUD_PROJECT environment variable or defaults to
+        'apache-beam-testing'.
+      prefix (str): Prefix to use for the temporary GCP secret name.
+
+  Yields:
+      dict: A dictionary containing connection and secret details:
+            {
+                'URL': 'jdbc:postgresql://<host>:<port>/<dbname>',
+                'USERNAME': '<username>',
+                'PASSWORD_SPEC': '{"name": "<secret_id>", "project": 
"<project_id>"}',
+                'SECRET_MANAGER': 'googlecloudsecretmanager',
+            }
+  """
+  if secretmanager is None:
+    raise RuntimeError("google-cloud-secret-manager is not installed.")
+
+  project_id = project or os.environ.get(
+      'GOOGLE_CLOUD_PROJECT', 'apache-beam-testing')
+  secret_client = secretmanager.SecretManagerServiceClient()
+
+  default_port = 5432
+  with PostgresContainer(port=default_port) as postgres_container:
+    secret_postfix = (
+        datetime.now(timezone.utc).strftime('%m%d_%H%M%S') + '_' +
+        uuid.uuid4().hex[:6])
+    secret_id = f'{prefix}{secret_postfix}'
+    project_path = f'projects/{project_id}'
+    secret_path = f'{project_path}/secrets/{secret_id}'
+
+    _LOGGER.info("Creating GCP secret %s in project %s", secret_id, project_id)
+    try:
+      secret_client.get_secret(request={'name': secret_path})
+    except Exception:
+      secret_client.create_secret(
+          request={
+              'parent': project_path,
+              'secret_id': secret_id,
+              'secret': {
+                  'replication': {
+                      'automatic': {}
+                  }
+              }
+          })
+
+    secret_client.add_secret_version(
+        request={
+            'parent': secret_path,
+            'payload': {
+                'data': postgres_container.password.encode('utf-8')
+            }
+        })
+
+    try:
+      # Make connection to temp database and create tmp table
+      engine = 
sqlalchemy.create_engine(postgres_container.get_connection_url())
+      with engine.begin() as connection:
+        connection.execute(
+            sqlalchemy.text(
+                "CREATE TABLE tmp_table (value INTEGER, rank INTEGER);"))
+
+      # Construct the JDBC url for connections
+      jdbc_url = (
+          f"jdbc:postgresql://{postgres_container.get_container_host_ip()}:"
+          f"{postgres_container.get_exposed_port(default_port)}/"
+          f"{postgres_container.dbname}")
+
+      secret_password_spec = json.dumps({
+          'name': secret_id,
+          'project': project_id,
+      })
+
+      yield {
+          'URL': jdbc_url,
+          'USERNAME': postgres_container.username,
+          'PASSWORD_SPEC': secret_password_spec,
+          'SECRET_MANAGER': 'googlecloudsecretmanager',
+      }
+    except (psycopg2.Error, Exception) as err:
+      logging.error(
+          "Error interacting with temporary Postgres DB with secret manager: 
%s",
+          err)
+      raise err
+    finally:
+      try:
+        _LOGGER.info("Deleting GCP secret: %s", secret_path)
+        secret_client.delete_secret(request={'name': secret_path})
+      except Exception as err:
+        _LOGGER.warning("Could not delete GCP secret %s: %s", secret_path, err)
+
+
 @contextlib.contextmanager
 def temp_sqlserver_database():
   """Context manager to provide a temporary SQL Server database for testing.
diff --git a/sdks/python/apache_beam/yaml/standard_io.yaml 
b/sdks/python/apache_beam/yaml/standard_io.yaml
index 81fd62b0501..f1ba727cc60 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -337,6 +337,7 @@
         table: 'location'
         partition_column : 'partition_column'
         num_partitions: 'num_partitions'
+        secret_manager: 'secret_manager'
         type: 'jdbc_type'
         username: 'username'
       'WriteToJdbc':
@@ -349,6 +350,7 @@
         password: 'password'
         table: 'location'
         batch_size: 'batch_size'
+        secret_manager: 'secret_manager'
         type: 'jdbc_type'
         username: 'username'
         query: 'write_statement'

Reply via email to