This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 760f9d44ac2 NIFI-16260 Add a Database Password Provider for GCP 
CloudSQL (#11604)
760f9d44ac2 is described below

commit 760f9d44ac2726cfdc51f687c355b03b346c7a7e
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Sep 7 17:05:51 2026 +0200

    NIFI-16260 Add a Database Password Provider for GCP CloudSQL (#11604)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../nifi-gcp-bundle/nifi-gcp-processors/pom.xml    |   5 +
 .../GcpCloudSqlIamDatabasePasswordProvider.java    | 234 +++++++
 .../factory/CredentialPropertyDescriptors.java     |   7 +
 ...kloadIdentityFederationCredentialsStrategy.java |  17 +-
 .../service/GCPCredentialsControllerService.java   |  19 +-
 .../org.apache.nifi.controller.ControllerService   |   1 +
 .../additionalDetails.md                           |  91 +++
 .../additionalDetails.md                           |  38 +-
 ...GcpCloudSqlIamDatabasePasswordProviderTest.java | 669 +++++++++++++++++++++
 ...dIdentityFederationCredentialsStrategyTest.java | 131 ++++
 .../service/GCPCredentialsServiceTest.java         | 133 +++-
 11 files changed, 1324 insertions(+), 21 deletions(-)

diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml
index 5d6ebd6bc80..e47e00fc485 100644
--- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml
+++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml
@@ -72,6 +72,11 @@
             <version>2.12.0-SNAPSHOT</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-dbcp-service-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.nifi</groupId>
             <artifactId>nifi-resource-transfer</artifactId>
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java
new file mode 100644
index 00000000000..fb886529f9d
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java
@@ -0,0 +1,234 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.processors.gcp.cloudsql;
+
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.ImpersonatedCredentials;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.ConfigVerificationResult.Outcome;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.dbcp.api.DatabasePasswordProvider;
+import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext;
+import org.apache.nifi.gcp.credentials.service.GCPCredentialsService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.reporting.InitializationException;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Tags({"gcp", "cloud sql", "postgresql", "mysql", "iam", "jdbc", "password"})
+@CapabilityDescription("""
+        Generates Google Cloud SQL IAM authentication tokens for Cloud SQL 
database connections.
+        PostgreSQL and MySQL are supported.
+        The generated access token replaces the database user password so that 
NiFi does not need to store long-lived credentials inside DBCP services.
+        """)
+public class GcpCloudSqlIamDatabasePasswordProvider extends 
AbstractControllerService implements DatabasePasswordProvider, 
VerifiableControllerService {
+
+    static final String SQLSERVICE_LOGIN_SCOPE = 
"https://www.googleapis.com/auth/sqlservice.login";;
+    static final String FAILED_PASSWORD_MESSAGE = "Failed to generate Cloud 
SQL IAM database password";
+    static final String VERIFY_SCOPE_STEP = "Resolve GCP credentials";
+    static final String VERIFY_TOKEN_STEP = "Acquire Cloud SQL IAM access 
token";
+    static final String VERIFY_CREDENTIALS_UNAVAILABLE = "Configured GCP 
Credentials Provider Service did not return Google credentials.";
+    static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to 
apply the Cloud SQL login scope to the configured Google credentials.";
+    static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a 
Cloud SQL IAM access token.";
+
+    static final PropertyDescriptor GCP_CREDENTIALS_PROVIDER_SERVICE = new 
PropertyDescriptor.Builder()
+            .name("GCP Credentials Provider Service")
+            .description("Controller Service that provides the Google 
credentials used to request Cloud SQL IAM authentication tokens.")
+            .identifiesControllerService(GCPCredentialsService.class)
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = 
List.of(
+            GCP_CREDENTIALS_PROVIDER_SERVICE
+    );
+
+    private volatile GoogleCredentials scopedCredentials;
+
+    @Override
+    protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(final ConfigurationContext context) throws 
InitializationException {
+        scopedCredentials = requireScopedCredentials(context);
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        scopedCredentials = null;
+    }
+
+    @Override
+    public char[] getPassword(final DatabasePasswordRequestContext 
requestContext) {
+        Objects.requireNonNull(requestContext, "Database Password Request 
Context required");
+
+        final GoogleCredentials credentials = scopedCredentials;
+        if (credentials == null) {
+            throw new ProcessException(FAILED_PASSWORD_MESSAGE);
+        }
+
+        final AccessToken accessToken = refreshAccessToken(credentials);
+        if (!hasTokenValue(accessToken)) {
+            throw new ProcessException(FAILED_PASSWORD_MESSAGE);
+        }
+
+        return accessToken.getTokenValue().toCharArray();
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(final ConfigurationContext 
context, final ComponentLog verificationLogger,
+                                                 final Map<String, String> 
attributes) {
+        final List<ConfigVerificationResult> results = new ArrayList<>(2);
+        final GoogleCredentials scopedVerificationCredentials = 
resolveVerificationCredentials(context, verificationLogger);
+        if (scopedVerificationCredentials == null) {
+            results.add(buildVerificationResult(VERIFY_SCOPE_STEP, 
Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE));
+        } else {
+            final ConfigVerificationResult scopedCredentialResult = 
describeScopedCredential(scopedVerificationCredentials);
+            results.add(scopedCredentialResult);
+            if (scopedCredentialResult.getOutcome() == Outcome.SUCCESSFUL) {
+                results.add(verifyAccessToken(scopedVerificationCredentials, 
verificationLogger));
+            }
+        }
+        return results;
+    }
+
+    private GoogleCredentials resolveVerificationCredentials(final 
ConfigurationContext context, final ComponentLog verificationLogger) {
+        try {
+            return 
createSqlLoginScopedCredentials(resolveGoogleCredentials(context));
+        } catch (final RuntimeException e) {
+            verificationLogger.error("Failed to resolve scoped Google 
credentials", e);
+            return null;
+        }
+    }
+
+    private ConfigVerificationResult verifyAccessToken(final GoogleCredentials 
credentials, final ComponentLog verificationLogger) {
+        try {
+            final AccessToken accessToken = credentials.refreshAccessToken();
+            return hasTokenValue(accessToken)
+                    ? buildTokenVerificationResult()
+                    : buildVerificationResult(VERIFY_TOKEN_STEP, 
Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED);
+        } catch (final IOException | RuntimeException e) {
+            verificationLogger.error("Failed to acquire Cloud SQL IAM access 
token", e);
+            return buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, 
VERIFY_TOKEN_ACQUISITION_FAILED);
+        }
+    }
+
+    private GoogleCredentials requireScopedCredentials(final 
ConfigurationContext context) throws InitializationException {
+        final GoogleCredentials googleCredentials;
+        try {
+            googleCredentials = resolveGoogleCredentials(context);
+        } catch (final RuntimeException e) {
+            throw new InitializationException(VERIFY_CREDENTIALS_UNAVAILABLE, 
e);
+        }
+
+        if (googleCredentials == null) {
+            throw new InitializationException(VERIFY_CREDENTIALS_UNAVAILABLE);
+        }
+
+        final GoogleCredentials credentials;
+        try {
+            credentials = createSqlLoginScopedCredentials(googleCredentials);
+        } catch (final RuntimeException e) {
+            throw new 
InitializationException(VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE, e);
+        }
+
+        if (credentials == null) {
+            throw new 
InitializationException(VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE);
+        }
+
+        return credentials;
+    }
+
+    private AccessToken refreshAccessToken(final GoogleCredentials 
credentials) {
+        try {
+            credentials.refreshIfExpired();
+        } catch (final IOException | RuntimeException e) {
+            throw new ProcessException(FAILED_PASSWORD_MESSAGE);
+        }
+
+        return credentials.getAccessToken();
+    }
+
+    private GoogleCredentials resolveGoogleCredentials(final 
ConfigurationContext context) {
+        final GCPCredentialsService credentialsService = 
context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)
+                .asControllerService(GCPCredentialsService.class);
+        if (credentialsService == null) {
+            return null;
+        }
+
+        return credentialsService.getGoogleCredentials();
+    }
+
+    private GoogleCredentials createSqlLoginScopedCredentials(final 
GoogleCredentials googleCredentials) {
+        if (googleCredentials == null) {
+            return null;
+        }
+
+        return googleCredentials.createScoped(List.of(SQLSERVICE_LOGIN_SCOPE));
+    }
+
+    private ConfigVerificationResult describeScopedCredential(final 
GoogleCredentials scopedVerificationCredentials) {
+        if (scopedVerificationCredentials instanceof ImpersonatedCredentials) {
+            return buildVerificationResult(
+                    VERIFY_SCOPE_STEP,
+                    Outcome.SUCCESSFUL,
+                    "Resolved GCP credentials and Cloud SQL login scope. 
Target service account impersonation is active."
+            );
+        }
+
+        return buildVerificationResult(
+                VERIFY_SCOPE_STEP,
+                Outcome.SUCCESSFUL,
+                "Resolved GCP credentials and Cloud SQL login scope."
+        );
+    }
+
+    private ConfigVerificationResult buildTokenVerificationResult() {
+        return buildVerificationResult(
+                VERIFY_TOKEN_STEP,
+                Outcome.SUCCESSFUL,
+                "Acquired a Cloud SQL IAM access token. Use DBCP Verify to 
validate the database connection."
+        );
+    }
+
+    private boolean hasTokenValue(final AccessToken accessToken) {
+        return accessToken != null && 
StringUtils.isNotBlank(accessToken.getTokenValue());
+    }
+
+    private ConfigVerificationResult buildVerificationResult(final String 
stepName, final Outcome outcome, final String explanation) {
+        return new ConfigVerificationResult.Builder()
+                .verificationStepName(stepName)
+                .outcome(outcome)
+                .explanation(explanation)
+                .build();
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java
index c0c0f801491..24a81b118d9 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java
@@ -132,6 +132,13 @@ public final class CredentialPropertyDescriptors {
             .dependsOn(AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue())
             .build();
 
+    public static final PropertyDescriptor TARGET_SERVICE_ACCOUNT = new 
PropertyDescriptor.Builder()
+            .name("Target Service Account")
+            .description("Target Google service account email to impersonate 
after Workload Identity Federation token exchange.")
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .dependsOn(AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue())
+            .build();
+
     public static final PropertyDescriptor DELEGATION_STRATEGY = new 
PropertyDescriptor.Builder()
             .name("Delegation Strategy")
             .required(true)
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java
index dfe261e895a..b25e790517f 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java
@@ -20,6 +20,7 @@ import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.auth.oauth2.IdentityPoolCredentials;
 import com.google.auth.oauth2.IdentityPoolSubjectTokenSupplier;
+import com.google.auth.oauth2.ImpersonatedCredentials;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.controller.ConfigurationContext;
@@ -60,6 +61,7 @@ public class WorkloadIdentityFederationCredentialsStrategy 
extends AbstractCrede
         final String scopeValue = 
context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE).getValue();
         final String tokenEndpoint = 
context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT).getValue();
         final String subjectTokenType = 
context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE).getValue();
+        final String targetServiceAccount = 
context.getProperty(CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT).getValue();
 
         final List<String> scopes = parseScopes(scopeValue);
         final IdentityPoolSubjectTokenSupplier tokenSupplier = 
createSubjectTokenSupplier(subjectTokenProvider);
@@ -77,7 +79,20 @@ public class WorkloadIdentityFederationCredentialsStrategy 
extends AbstractCrede
             builder.setHttpTransportFactory(transportFactory);
         }
 
-        return builder.build();
+        final IdentityPoolCredentials sourceCredentials = builder.build();
+        if (StringUtils.isBlank(targetServiceAccount)) {
+            return sourceCredentials;
+        }
+
+        final ImpersonatedCredentials.Builder impersonatedCredentialsBuilder = 
ImpersonatedCredentials.newBuilder()
+                .setSourceCredentials(sourceCredentials)
+                .setTargetPrincipal(targetServiceAccount)
+                .setScopes(Collections.emptyList());
+        if (transportFactory != null) {
+            
impersonatedCredentialsBuilder.setHttpTransportFactory(transportFactory);
+        }
+
+        return impersonatedCredentialsBuilder.build();
     }
 
     private IdentityPoolSubjectTokenSupplier createSubjectTokenSupplier(final 
OAuth2AccessTokenProvider tokenProvider) {
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java
index 25cda97bc5d..c90388ef8f9 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java
@@ -18,6 +18,7 @@ package org.apache.nifi.processors.gcp.credentials.service;
 
 import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.oauth2.GoogleCredentials;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.annotation.documentation.CapabilityDescription;
 import org.apache.nifi.annotation.documentation.Tags;
 import org.apache.nifi.annotation.lifecycle.OnEnabled;
@@ -55,6 +56,7 @@ import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPrope
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.LEGACY_USE_COMPUTE_ENGINE_CREDENTIALS;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON_FILE;
+import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER;
@@ -84,6 +86,7 @@ public class GCPCredentialsControllerService extends 
AbstractControllerService i
             WORKLOAD_IDENTITY_TOKEN_ENDPOINT,
             WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER,
             WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE,
+            TARGET_SERVICE_ACCOUNT,
             
ProxyConfiguration.createProxyConfigPropertyDescriptor(ProxyAwareTransportFactory.PROXY_SPECS),
             DELEGATION_STRATEGY,
             DELEGATION_USER
@@ -106,6 +109,20 @@ public class GCPCredentialsControllerService extends 
AbstractControllerService i
     protected Collection<ValidationResult> customValidate(final 
ValidationContext validationContext) {
         final List<ValidationResult> results = new ArrayList<>();
         ProxyConfiguration.validateProxySpec(validationContext, results, 
ProxyAwareTransportFactory.PROXY_SPECS);
+
+        final String targetServiceAccount = 
validationContext.getProperty(TARGET_SERVICE_ACCOUNT).getValue();
+        final AuthenticationStrategy authenticationStrategy = 
validationContext.getProperty(AUTHENTICATION_STRATEGY)
+                .asAllowableValue(AuthenticationStrategy.class);
+        if (StringUtils.isNotBlank(targetServiceAccount)
+                && authenticationStrategy != 
AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION) {
+            results.add(new ValidationResult.Builder()
+                    .subject(TARGET_SERVICE_ACCOUNT.getDisplayName())
+                    .input(targetServiceAccount)
+                    .valid(false)
+                    .explanation("Target Service Account requires Workload 
Identity Federation")
+                    .build());
+        }
+
         return results;
     }
 
@@ -117,7 +134,7 @@ public class GCPCredentialsControllerService extends 
AbstractControllerService i
             result = new ConfigVerificationResult.Builder()
                     .verificationStepName("Provide Google Credentials")
                     .outcome(Outcome.SUCCESSFUL)
-                    .explanation(String.format("Successfully provided [%s] as 
Google Credentials", credentials.getClass().getSimpleName()))
+                    .explanation("Successfully provided [%s] as Google 
Credentials".formatted(credentials.getClass().getSimpleName()))
                     .build();
         } catch (final IOException e) {
             result = new ConfigVerificationResult.Builder()
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
index f4fa86f59e3..29d279ca060 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
@@ -12,5 +12,6 @@
 # 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.
+org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider
 
org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService
 org.apache.nifi.processors.gcp.storage.GCSFileResourceService
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md
new file mode 100644
index 00000000000..6898fb5d2d9
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md
@@ -0,0 +1,91 @@
+<!--
+  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.
+-->
+
+## Summary
+
+`GcpCloudSqlIamDatabasePasswordProvider` generates a short-lived Cloud SQL IAM 
login token and supplies it as the
+database password for a DBCP service. Use it when you want NiFi to connect to 
Cloud SQL without storing a long-lived
+database password in NiFi.
+
+The provider works with Cloud SQL for PostgreSQL and Cloud SQL for MySQL. When 
a DBCP service references this provider,
+the static DBCP password property is ignored.
+
+## Usage
+
+1. Configure `GCPCredentialsControllerService` so NiFi can obtain Google 
credentials.
+2. Create and enable `GcpCloudSqlIamDatabasePasswordProvider`.
+3. Set **GCP Credentials Provider Service** to the credentials service.
+4. Configure the DBCP service with the JDBC URL, driver, database user, and 
**Database Password Provider** set to
+   `GcpCloudSqlIamDatabasePasswordProvider`.
+5. Run **Verify** on the provider, then run **Verify** on the DBCP service.
+
+Create the Cloud SQL IAM database user separately and grant the database 
privileges required by your application.
+
+## Workload Identity Federation
+
+For the Snowflake Workload Identity Federation configuration tested with this 
provider, configure
+`GCPCredentialsControllerService` with **Target Service Account**. The 
workload identity principal must also have
+`roles/iam.workloadIdentityUser` on that target service account. Other Google 
credential configurations can be used
+when they can acquire a token accepted by Cloud SQL for the configured 
database user.
+
+## PostgreSQL Configuration
+
+Cloud SQL for PostgreSQL expects the DBCP **Database User** to match the IAM 
identity used for login. Configure the
+DBCP service with a PostgreSQL JDBC driver and PostgreSQL JDBC URL. Configure 
TLS in the DBCP service as required for
+your environment.
+
+| Setting | Value |
+|---|---|
+| Driver Class Name | `org.postgresql.Driver` |
+| JDBC URL | `jdbc:postgresql://<HOST>:5432/<DATABASE>?sslmode=require` |
+| Database User for Google user | full email address |
+| Database User for service account | service-account email without 
`.gserviceaccount.com` |
+
+Example service-account mapping:
+
+- Target service account: `[email protected]`
+- DBCP **Database User**: `[email protected]`
+
+## MySQL Configuration
+
+Cloud SQL for MySQL uses the full service-account email when the IAM database 
user is created, but the JDBC login name
+must be only the portion before `@`. Configure the DBCP service with a 
compatible MySQL Connector/J driver and MySQL
+JDBC URL. Configure TLS in the DBCP service as required for your environment.
+
+| Setting | Value |
+|---|---|
+| Driver Class Name | `com.mysql.cj.jdbc.Driver` |
+| Driver Location(s) | compatible MySQL Connector/J driver jar provided to the 
DBCP service |
+| JDBC URL | `jdbc:mysql://<HOST>:3306/<DATABASE>?sslMode=REQUIRED` |
+| Database User | service-account identifier before `@` |
+
+Example service-account mapping:
+
+- IAM database user created in Cloud SQL: 
`[email protected]`
+- DBCP **Database User**: `nifi-sa`
+
+## Verify and Troubleshooting
+
+`GcpCloudSqlIamDatabasePasswordProvider` **Verify** checks that NiFi can 
obtain a token. DBCP **Verify** checks the
+actual database connection using the configured URL, driver, TLS settings, 
database user, and password provider.
+
+If provider **Verify** fails:
+
+- Confirm the referenced `GCPCredentialsControllerService` is enabled.
+- For Workload Identity Federation using service-account impersonation, 
confirm **Target Service Account** is set and
+  the workload identity principal has `roles/iam.workloadIdentityUser` on that 
service account.
+
+If provider **Verify** succeeds but DBCP **Verify** fails, token acquisition 
is working and the problem is in the JDBC
+connection configuration, network path, TLS settings, driver setup, database 
user, or database privileges.
\ No newline at end of file
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md
index fc8d0b6acf5..25fdbd89923 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md
@@ -108,8 +108,8 @@ Record the audience string printed by the command; it must 
be copied into NiFi
 
 ### 2. Authorize the workload identity principal for Google Cloud resources
 
-The STS-issued access token represents the workload identity principal itself. 
Grant IAM roles to that identity on
-projects or specific resources:
+When **Target Service Account** is left blank, the STS-issued access token 
represents the workload identity principal
+itself. Grant IAM roles to that identity on projects or specific resources:
 
 ```bash
 # Project scoped
@@ -123,8 +123,17 @@ gcloud storage buckets add-iam-policy-binding 
gs://MY_BUCKET \
   --role="roles/storage.objectViewer"
 ```
 
-`IDENTITY_SUBJECT` must match the claim you mapped in the provider (for 
example `assertion.sub`). Service-account
-impersonation is not yet supported, so grant roles directly to the workload 
identity principal.
+`IDENTITY_SUBJECT` must match the claim you mapped in the provider (for 
example `assertion.sub`).
+
+When you set **Target Service Account**, also grant 
`roles/iam.workloadIdentityUser` on that service account to the
+same workload identity subject so Google can perform service-account 
impersonation:
+
+```bash
+gcloud iam service-accounts add-iam-policy-binding \
+  "<TARGET_SERVICE_ACCOUNT_EMAIL>" \
+  --role="roles/iam.workloadIdentityUser" \
+  
--member="principal://iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/nifi-pool/subject/IDENTITY_SUBJECT"
+```
 
 ### 3. Configure NiFi properties (Workload Identity strategy selected)
 
@@ -135,26 +144,29 @@ impersonation is not yet supported, so grant roles 
directly to the workload iden
 | **STS Token Endpoint** | Optional override for the Google STS endpoint; 
leave blank to use `https://sts.googleapis.com/v1/token`. |
 | **Subject Token Provider** | Controller Service that retrieves the upstream 
workload identity token (JWT or access token). The token must contain the 
claims referenced by your attribute mapping. |
 | **Subject Token Type** | Defaults to `urn:ietf:params:oauth:token-type:jwt`. 
Choose the alternate access-token type only when the upstream provider issues 
OAuth access tokens instead of JWTs. |
+| **Target Service Account** | Optional. Set this only when downstream 
components must impersonate a Google service account. When configured, the 
controller service returns generic impersonated credentials and dependent 
components apply any service-specific scopes when they first use the 
credential. |
 | **Proxy Configuration Service** | Optional controller service allowing NiFi 
to reach STS through HTTP/SOCKS proxies. |
 
-Once these properties are set, enable GCPCredentialsControllerService. 
Processors referencing it immediately obtain
-`IdentityPoolCredentials`, and Google’s libraries refresh access tokens 
automatically using the configured subject
--token provider.
+Once these properties are set, enable GCPCredentialsControllerService. When 
**Target Service Account** is blank,
+processors and controller services reference direct `IdentityPoolCredentials`. 
When **Target Service Account** is set,
+they reference impersonated credentials layered over the workload identity 
source credential.
 
 ### Verification workflow
 
 1. Enable or refresh the Subject Token Provider controller service.
-2. Use the **Verify** action on GCPCredentialsControllerService. Successful 
verification confirms that NiFi can
-   exchange the subject token with Google STS using the configured proxy, 
audience, and scopes.
-3. Enable dependent processors. No additional controller services are required.
+2. Use the **Verify** action on GCPCredentialsControllerService. Successful 
verification confirms only that NiFi can
+   construct the configured credential object from the current properties.
+3. Verify does not perform STS exchange, service-account impersonation, or 
network reachability checks. The first token
+   refresh happens when a dependent processor or controller service actually 
uses the credential.
+4. Enable the dependent components that reference this controller service. 
Product-specific validation happens there.
 
 ### Troubleshooting
 
 | Symptom | Guidance |
 | --- | --- |
-| `403 Caller does not have storage.objects.list` | Confirm the workload 
identity principal has the required IAM role: `gcloud projects get-iam-policy` 
/ `gcloud storage buckets get-iam-policy`. Ensure the attribute mapping emits 
the same subject referenced in IAM. |
-| STS errors during verification | Double-check the **Audience** string and 
**STS Token Endpoint**. Use DEBUG logs or the Verify dialog output to inspect 
the STS response. Ensure the subject token includes the mapped claims. |
-| Access token rejected by Google APIs | Call the API directly with the 
federated token (for example, `curl -H "Authorization: Bearer TOKEN" 
https://storage.googleapis.com/...`). If it still fails, revisit IAM bindings 
or scope selection. |
+| `403 Caller does not have storage.objects.list` | Confirm the active 
identity has the required IAM role. Without **Target Service Account**, grant 
roles directly to the workload identity principal. With **Target Service 
Account**, confirm both the impersonated service account permissions and the 
`roles/iam.workloadIdentityUser` binding for the workload identity subject. |
+| Verify succeeds but the dependent component fails on first use | Verify only 
constructs credentials. Recheck the **Audience**, **STS Token Endpoint**, 
upstream subject token, proxy reachability, and any optional **Target Service 
Account** setting at the dependent component that triggers the first refresh. |
+| Access token rejected by Google APIs | Revisit IAM bindings and scope 
selection for the dependent component. If the component requires a 
service-specific scope or impersonated identity, confirm that **Target Service 
Account** is set and that the downstream component supports the needed scope. |
 | Need to rotate upstream tokens | The controller service requests a fresh 
subject token 60 seconds before expiry. Trigger **Refresh** on the Subject 
Token Provider to invalidate cached tokens immediately. |
 
 ---
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java
new file mode 100644
index 00000000000..024e4b2a6eb
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java
@@ -0,0 +1,669 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.processors.gcp.cloudsql;
+
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.IdentityPoolCredentials;
+import com.google.auth.oauth2.ImpersonatedCredentials;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.dbcp.api.DatabasePasswordProvider;
+import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext;
+import org.apache.nifi.gcp.credentials.service.GCPCredentialsService;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.util.LogMessage;
+import org.apache.nifi.util.MockComponentLog;
+import org.apache.nifi.util.NoOpProcessor;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.slf4j.helpers.MessageFormatter;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static 
org.apache.nifi.components.ConfigVerificationResult.Outcome.FAILED;
+import static 
org.apache.nifi.components.ConfigVerificationResult.Outcome.SUCCESSFUL;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.GCP_CREDENTIALS_PROVIDER_SERVICE;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.SQLSERVICE_LOGIN_SCOPE;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPE_STEP;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED;
+import static 
org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_STEP;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class GcpCloudSqlIamDatabasePasswordProviderTest {
+
+    private static final String CREDENTIALS_SERVICE_ID = "gcpCredentials";
+    private static final String PASSWORD_PROVIDER_ID = "cloudSqlIamProvider";
+    private static final String DRIVER_CLASS = "org.postgresql.Driver";
+    private static final String DATABASE_USER = 
"[email protected]";
+    private static final String JDBC_URL = 
"jdbc:postgresql://example:5432/database";
+    private static final String TOKEN_VALUE = "cloud-sql-token";
+    private static final String REFRESHED_TOKEN_VALUE = 
"refreshed-cloud-sql-token";
+    private static final String LEAK_SENTINEL = "sentinel-token-value";
+    private static final String CLOUD_SQL_IAM = "Cloud SQL IAM";
+
+    private ExecutorService executorService;
+
+    @AfterEach
+    void tearDown() {
+        if (executorService != null) {
+            executorService.shutdownNow();
+        }
+    }
+
+    @Test
+    void testSupportedPropertyDescriptorsContainOnlyCredentialsService() 
throws Exception {
+        final List<PropertyDescriptor> descriptors = 
getSupportedPropertyDescriptors(new GcpCloudSqlIamDatabasePasswordProvider());
+
+        assertEquals(1, descriptors.size());
+        assertEquals(GCP_CREDENTIALS_PROVIDER_SERVICE, descriptors.get(0));
+        assertTrue(descriptors.get(0).isRequired());
+    }
+
+    @Test
+    void testOnEnabledCachesScopedCredentialAndReusesIt() throws Exception {
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15));
+        final RootGoogleCredentials rootCredentials = new 
RootGoogleCredentials(scopedCredentials);
+        final TestRunner runner = configureRunner(rootCredentials);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        assertEquals(1, rootCredentials.getCreateScopedCount());
+        assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), 
rootCredentials.getLastRequestedScopes());
+        assertSame(scopedCredentials, getScopedCredentials(provider));
+        assertEquals(TOKEN_VALUE, new 
String(provider.getPassword(requestContext())));
+        assertEquals(TOKEN_VALUE, new 
String(provider.getPassword(requestContext())));
+        assertEquals(0, scopedCredentials.getRefreshAccessTokenCount());
+    }
+
+    @Test
+    void testOnDisabledClearsCachedCredential() throws Exception {
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 
15))), true);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        runner.disableControllerService(provider);
+
+        assertNull(getScopedCredentials(provider));
+
+        final ProcessException exception = 
assertThrows(ProcessException.class, () -> 
provider.getPassword(requestContext()));
+
+        assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM));
+        assertNull(exception.getCause());
+    }
+
+    @Test
+    void testVerifyImpersonatedCredentialsAcquireLiveToken() throws Exception {
+        final ImpersonatedCredentials scopedCredentials = 
impersonatedCredentials(accessToken(TOKEN_VALUE, 15));
+        final RootGoogleCredentials rootCredentials = new 
RootGoogleCredentials(scopedCredentials);
+        final TestRunner runner = configureRunner(rootCredentials);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(2, results.size());
+        assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, 
SUCCESSFUL, "impersonation");
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, 
SUCCESSFUL, "DBCP Verify");
+        assertEquals(2, rootCredentials.getCreateScopedCount());
+        Mockito.verify(scopedCredentials).refreshAccessToken();
+    }
+
+    @Test
+    void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() 
throws Exception {
+        final TestScopedGoogleCredentials enabledScopedCredentials = new 
TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15));
+        final TestScopedGoogleCredentials verificationScopedCredentials = new 
TestScopedGoogleCredentials(accessToken(REFRESHED_TOKEN_VALUE, -15));
+        
verificationScopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE,
 15));
+        final RootGoogleCredentials rootCredentials = new 
RootGoogleCredentials(enabledScopedCredentials, verificationScopedCredentials);
+        final TestRunner runner = configureRunner(rootCredentials);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+        final char[] password = provider.getPassword(requestContext());
+
+        assertEquals(2, rootCredentials.getCreateScopedCount());
+        assertEquals(0, enabledScopedCredentials.getRefreshAccessTokenCount());
+        assertEquals(1, 
verificationScopedCredentials.getRefreshAccessTokenCount());
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, 
SUCCESSFUL, CLOUD_SQL_IAM);
+        assertArrayEquals(TOKEN_VALUE.toCharArray(), password);
+    }
+
+    @Test
+    void testVerifyIdentityPoolCredentialsAcquireToken() throws Exception {
+        final IdentityPoolCredentials scopedCredentials = 
identityPoolCredentials(accessToken(TOKEN_VALUE, 15));
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(scopedCredentials), false);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(2, results.size());
+        assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, 
SUCCESSFUL, "Cloud SQL login scope");
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, 
SUCCESSFUL, CLOUD_SQL_IAM);
+        Mockito.verify(scopedCredentials).refreshAccessToken();
+    }
+
+    @Test
+    void testVerifyNullCredentialsFails() throws Exception {
+        final TestRunner runner = configureRunner(null, false);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(1, results.size());
+        assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, 
VERIFY_CREDENTIALS_UNAVAILABLE);
+    }
+
+    @Test
+    void testOnEnabledNullCredentialsFails() throws Exception {
+        final GcpCloudSqlIamDatabasePasswordProvider provider = new 
GcpCloudSqlIamDatabasePasswordProvider();
+        final ConfigurationContext context = mock(ConfigurationContext.class);
+        final PropertyValue credentialsPropertyValue = 
mock(PropertyValue.class);
+
+        
when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue);
+        
when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(null);
+
+        final InitializationException exception = 
assertThrows(InitializationException.class, () -> provider.onEnabled(context));
+
+        assertTrue(exception.getMessage().contains("credentials"));
+        assertNull(getScopedCredentials(provider));
+    }
+
+    @Test
+    void testVerifyScopedCredentialCreationReturningNullFails() throws 
Exception {
+        final RootGoogleCredentials rootCredentials = new 
RootGoogleCredentials((GoogleCredentials) null);
+        final TestRunner runner = configureRunner(rootCredentials, false);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(1, results.size());
+        assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, 
VERIFY_CREDENTIALS_UNAVAILABLE);
+        assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), 
rootCredentials.getLastRequestedScopes());
+    }
+
+    @Test
+    void testVerifyScopedCredentialCreationFailureIsReported() throws 
Exception {
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(new IllegalStateException(LEAK_SENTINEL)), false);
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(1, results.size());
+        assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, 
VERIFY_CREDENTIALS_UNAVAILABLE);
+        assertFalse(results.get(0).getExplanation().contains(LEAK_SENTINEL));
+        assertVerificationExceptionLogged(
+                runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
+                "Failed to resolve scoped Google credentials",
+                LEAK_SENTINEL
+        );
+    }
+
+    @Test
+    void testOnEnabledScopedCredentialCreationReturningNullFails() throws 
Exception {
+        final GcpCloudSqlIamDatabasePasswordProvider provider = new 
GcpCloudSqlIamDatabasePasswordProvider();
+        final ConfigurationContext context = mock(ConfigurationContext.class);
+        final PropertyValue credentialsPropertyValue = 
mock(PropertyValue.class);
+        final GCPCredentialsService credentialsService = 
mock(GCPCredentialsService.class);
+
+        
when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue);
+        
when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService);
+        when(credentialsService.getGoogleCredentials()).thenReturn(new 
RootGoogleCredentials((GoogleCredentials) null));
+
+        final InitializationException exception = 
assertThrows(InitializationException.class, () -> provider.onEnabled(context));
+
+        assertTrue(exception.getMessage().contains("scope"));
+        assertNull(getScopedCredentials(provider));
+    }
+
+    @Test
+    void testVerifyRefreshFailureIsSanitized() throws Exception {
+        final ImpersonatedCredentials scopedCredentials = 
impersonatedCredentials(ioException(LEAK_SENTINEL));
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(scopedCredentials));
+        final GcpCloudSqlIamDatabasePasswordProvider provider = 
getProviderImplementation(runner);
+
+        final List<ConfigVerificationResult> results = runner.verify(provider, 
Map.of());
+
+        assertEquals(2, results.size());
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, 
VERIFY_TOKEN_ACQUISITION_FAILED);
+        assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL));
+        assertVerificationExceptionLogged(
+                runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
+                "Failed to acquire Cloud SQL IAM access token",
+                LEAK_SENTINEL
+        );
+    }
+
+    @Test
+    void testVerifyNullAccessTokenFails() throws Exception {
+        final ImpersonatedCredentials scopedCredentials = 
impersonatedCredentials((AccessToken) null);
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(scopedCredentials));
+
+        final List<ConfigVerificationResult> results = 
runner.verify(getProviderImplementation(runner), Map.of());
+
+        assertEquals(2, results.size());
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, 
VERIFY_TOKEN_ACQUISITION_FAILED);
+    }
+
+    @Test
+    void testVerifyBlankAccessTokenFails() throws Exception {
+        final ImpersonatedCredentials scopedCredentials = 
impersonatedCredentials(accessToken(" ", 15));
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(scopedCredentials));
+
+        final List<ConfigVerificationResult> results = 
runner.verify(getProviderImplementation(runner), Map.of());
+
+        assertEquals(2, results.size());
+        assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, 
VERIFY_TOKEN_ACQUISITION_FAILED);
+    }
+
+    @Test
+    void testFreshTokenDoesNotRefresh() throws Exception {
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15));
+        final DatabasePasswordProvider provider = 
getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials)));
+
+        final char[] password = provider.getPassword(requestContext());
+
+        assertArrayEquals(TOKEN_VALUE.toCharArray(), password);
+        assertEquals(0, scopedCredentials.getRefreshAccessTokenCount());
+    }
+
+    @Test
+    void testExpiredTokenRefreshes() throws Exception {
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15));
+        
scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 
15));
+        final DatabasePasswordProvider provider = 
getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials)));
+
+        final char[] password = provider.getPassword(requestContext());
+
+        assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), password);
+        assertEquals(1, scopedCredentials.getRefreshAccessTokenCount());
+    }
+
+    @Test
+    void testConcurrentGetPasswordPerformsSingleRefresh() throws Exception {
+        final BlockingScopedGoogleCredentials scopedCredentials = new 
BlockingScopedGoogleCredentials();
+        
scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 
15));
+        final DatabasePasswordProvider provider = 
getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials)));
+
+        executorService = Executors.newFixedThreadPool(2);
+        final CountDownLatch startLatch = new CountDownLatch(1);
+        final Future<char[]> first = executorService.submit(() -> 
getPasswordAfterStart(provider, startLatch));
+        final Future<char[]> second = executorService.submit(() -> 
getPasswordAfterStart(provider, startLatch));
+
+        startLatch.countDown();
+        assertTrue(scopedCredentials.awaitRefreshEntry());
+        scopedCredentials.releaseRefresh();
+
+        assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), first.get(5, 
TimeUnit.SECONDS));
+        assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), second.get(5, 
TimeUnit.SECONDS));
+        assertEquals(1, scopedCredentials.getRefreshAccessTokenCount());
+    }
+
+    @Test
+    void testNullAccessTokenRejectedForPasswordGeneration() throws Exception {
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(null);
+        scopedCredentials.setRefreshedAccessToken(null);
+        final DatabasePasswordProvider provider = 
getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials)));
+
+        final ProcessException exception = 
assertThrows(ProcessException.class, () -> 
provider.getPassword(requestContext()));
+
+        assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM));
+    }
+
+    @Test
+    void testBlankAccessTokenRejectedForPasswordGeneration() throws Exception {
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(null);
+        scopedCredentials.setRefreshedAccessToken(accessToken(" ", 15));
+        final DatabasePasswordProvider provider = 
getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials)));
+
+        final ProcessException exception = 
assertThrows(ProcessException.class, () -> 
provider.getPassword(requestContext()));
+
+        assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM));
+    }
+
+    @Test
+    void testRefreshFailureIsSanitizedForPasswordGeneration() throws Exception 
{
+        final TestScopedGoogleCredentials scopedCredentials = new 
TestScopedGoogleCredentials(null);
+        scopedCredentials.setRefreshException(ioException(LEAK_SENTINEL));
+        final TestRunner runner = configureRunner(new 
RootGoogleCredentials(scopedCredentials));
+        final DatabasePasswordProvider provider = getProvider(runner);
+
+        final ProcessException exception = 
assertThrows(ProcessException.class, () -> 
provider.getPassword(requestContext()));
+
+        assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM));
+        assertNull(exception.getCause());
+        
assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
 LEAK_SENTINEL);
+    }
+
+    @Test
+    void testGetPasswordReturnsFreshCharacterArrayEachCall() throws Exception {
+        final DatabasePasswordProvider provider = getProvider(configureRunner(
+                new RootGoogleCredentials(new 
TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)))));
+
+        final char[] firstPassword = provider.getPassword(requestContext());
+        firstPassword[0] = 'X';
+        final char[] secondPassword = provider.getPassword(requestContext());
+
+        assertNotSame(firstPassword, secondPassword);
+        assertArrayEquals(TOKEN_VALUE.toCharArray(), secondPassword);
+    }
+
+    @Test
+    void testControllerServiceRegistrationContainsProvider() throws 
IOException {
+        final String resourcePath = 
"META-INF/services/org.apache.nifi.controller.ControllerService";
+        try (InputStream inputStream = 
GcpCloudSqlIamDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath))
 {
+            assertNotNull(inputStream);
+            final String registeredServices = new 
String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+            
assertTrue(registeredServices.contains(GcpCloudSqlIamDatabasePasswordProvider.class.getName()));
+        }
+    }
+
+    private TestRunner configureRunner(final GoogleCredentials 
rootCredentials) throws Exception {
+        return configureRunner(rootCredentials, true);
+    }
+
+    private TestRunner configureRunner(final GoogleCredentials 
rootCredentials, final boolean enableProvider) throws Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(NoOpProcessor.class);
+
+        final TestGCPCredentialsService credentialsService = new 
TestGCPCredentialsService(rootCredentials);
+        runner.addControllerService(CREDENTIALS_SERVICE_ID, 
credentialsService);
+        runner.enableControllerService(credentialsService);
+
+        final GcpCloudSqlIamDatabasePasswordProvider provider = new 
GcpCloudSqlIamDatabasePasswordProvider();
+        runner.addControllerService(PASSWORD_PROVIDER_ID, provider);
+        runner.setProperty(provider, GCP_CREDENTIALS_PROVIDER_SERVICE, 
CREDENTIALS_SERVICE_ID);
+        if (enableProvider) {
+            runner.enableControllerService(provider);
+            runner.assertValid(provider);
+        }
+
+        return runner;
+    }
+
+    private DatabasePasswordProvider getProvider(final TestRunner runner) {
+        return (DatabasePasswordProvider) runner.getProcessContext()
+                .getControllerServiceLookup()
+                .getControllerService(PASSWORD_PROVIDER_ID);
+    }
+
+    private GcpCloudSqlIamDatabasePasswordProvider 
getProviderImplementation(final TestRunner runner) {
+        return (GcpCloudSqlIamDatabasePasswordProvider) getProvider(runner);
+    }
+
+    private DatabasePasswordRequestContext requestContext() {
+        return DatabasePasswordRequestContext.builder()
+                .jdbcUrl(JDBC_URL)
+                .databaseUser(DATABASE_USER)
+                .driverClassName(DRIVER_CLASS)
+                .connectionProperties(Map.of())
+                .build();
+    }
+
+    private char[] getPasswordAfterStart(final DatabasePasswordProvider 
provider, final CountDownLatch startLatch) throws InterruptedException {
+        startLatch.await(5, TimeUnit.SECONDS);
+        return provider.getPassword(requestContext());
+    }
+
+    private static AccessToken accessToken(final String tokenValue, final long 
offsetMinutes) {
+        return tokenValue == null ? null : new AccessToken(tokenValue, 
Date.from(Instant.now().plusSeconds(offsetMinutes * 60)));
+    }
+
+    private static IOException ioException(final String message) {
+        return new IOException(message);
+    }
+
+    private static void assertVerificationResult(final 
ConfigVerificationResult result, final String stepName,
+                                                 final 
ConfigVerificationResult.Outcome outcome, final String explanationFragment) {
+        assertEquals(stepName, result.getVerificationStepName());
+        assertEquals(outcome, result.getOutcome());
+        assertTrue(result.getExplanation().contains(explanationFragment), 
result::getExplanation);
+    }
+
+    private static void assertNoLogMessagesContain(final MockComponentLog 
logger, final String value) {
+        final List<LogMessage> logMessages = new ArrayList<>();
+        logMessages.addAll(logger.getInfoMessages());
+        logMessages.addAll(logger.getWarnMessages());
+        logMessages.addAll(logger.getErrorMessages());
+
+        for (final LogMessage logMessage : logMessages) {
+            final String rawMessage = logMessage.getMsg();
+            assertFalse(rawMessage != null && rawMessage.contains(value));
+            final Object[] args = logMessage.getArgs();
+            final String formattedMessage = 
MessageFormatter.arrayFormat(rawMessage, args == null ? new Object[0] : 
args).getMessage();
+            assertFalse(formattedMessage != null && 
formattedMessage.contains(value));
+            if (args != null) {
+                for (final Object arg : args) {
+                    final String argValue = arg == null ? null : 
arg.toString();
+                    assertFalse(argValue != null && argValue.contains(value));
+                }
+            }
+            assertThrowableChainDoesNotContain(logMessage.getThrowable(), 
value, Collections.newSetFromMap(new IdentityHashMap<>()));
+        }
+    }
+
+    private static void assertVerificationExceptionLogged(final 
MockComponentLog logger, final String message, final String exceptionMessage) {
+        assertTrue(logger.getErrorMessages().stream().anyMatch(logMessage -> {
+            final Object[] arguments = logMessage.getArgs();
+            final Throwable throwable = logMessage.getThrowable();
+            final boolean throwableMatched = throwable != null && 
exceptionMessage.equals(throwable.getMessage());
+            final boolean argumentMatched = arguments != null
+                    && List.of(arguments).stream()
+                    .filter(Throwable.class::isInstance)
+                    .map(Throwable.class::cast)
+                    .anyMatch(argument -> 
exceptionMessage.equals(argument.getMessage()));
+            return logMessage.getMsg().contains(message) && (throwableMatched 
|| argumentMatched);
+        }));
+    }
+
+    private static void assertThrowableChainDoesNotContain(final Throwable 
throwable, final String value, final Set<Throwable> visited) {
+        if (throwable == null || !visited.add(throwable)) {
+            return;
+        }
+
+        final String message = throwable.getMessage();
+        assertFalse(message != null && message.contains(value));
+
+        for (final Throwable suppressed : throwable.getSuppressed()) {
+            assertThrowableChainDoesNotContain(suppressed, value, visited);
+        }
+
+        assertThrowableChainDoesNotContain(throwable.getCause(), value, 
visited);
+    }
+
+    private static GoogleCredentials getScopedCredentials(final 
GcpCloudSqlIamDatabasePasswordProvider provider) throws 
ReflectiveOperationException {
+        final Field field = 
GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("scopedCredentials");
+        field.setAccessible(true);
+        return (GoogleCredentials) field.get(provider);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<PropertyDescriptor> 
getSupportedPropertyDescriptors(final GcpCloudSqlIamDatabasePasswordProvider 
provider)
+            throws ReflectiveOperationException {
+        final Method method = 
GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredMethod("getSupportedPropertyDescriptors");
+        method.setAccessible(true);
+        return (List<PropertyDescriptor>) method.invoke(provider);
+    }
+
+    private static final class TestGCPCredentialsService extends 
AbstractControllerService implements GCPCredentialsService {
+        private final GoogleCredentials googleCredentials;
+
+        private TestGCPCredentialsService(final GoogleCredentials 
googleCredentials) {
+            this.googleCredentials = googleCredentials;
+        }
+
+        @Override
+        public GoogleCredentials getGoogleCredentials() {
+            return googleCredentials;
+        }
+    }
+
+    private static final class RootGoogleCredentials extends GoogleCredentials 
{
+        private final AtomicInteger createScopedCount = new AtomicInteger();
+        private final GoogleCredentials[] scopedCredentials;
+        private final RuntimeException createScopedException;
+        private final AtomicInteger scopedCredentialIndex = new 
AtomicInteger();
+        private volatile List<String> lastRequestedScopes = List.of();
+
+        private RootGoogleCredentials(final GoogleCredentials 
scopedCredentials) {
+            this.scopedCredentials = new 
GoogleCredentials[]{scopedCredentials};
+            this.createScopedException = null;
+        }
+
+        private RootGoogleCredentials(final RuntimeException 
createScopedException) {
+            this.scopedCredentials = new GoogleCredentials[0];
+            this.createScopedException = createScopedException;
+        }
+
+        private RootGoogleCredentials(final GoogleCredentials 
firstScopedCredentials, final GoogleCredentials secondScopedCredentials) {
+            this.scopedCredentials = new 
GoogleCredentials[]{firstScopedCredentials, secondScopedCredentials};
+            this.createScopedException = null;
+        }
+
+        @Override
+        public GoogleCredentials createScoped(final Collection<String> scopes) 
{
+            createScopedCount.incrementAndGet();
+            lastRequestedScopes = List.copyOf(scopes);
+            if (createScopedException != null) {
+                throw createScopedException;
+            }
+
+            final int index = 
Math.min(scopedCredentialIndex.getAndIncrement(), scopedCredentials.length - 1);
+            return scopedCredentials.length == 0 ? null : 
scopedCredentials[index];
+        }
+
+        private int getCreateScopedCount() {
+            return createScopedCount.get();
+        }
+
+        private List<String> getLastRequestedScopes() {
+            return lastRequestedScopes;
+        }
+    }
+
+    private static class TestScopedGoogleCredentials extends GoogleCredentials 
{
+        private final AtomicInteger refreshAccessTokenCount = new 
AtomicInteger();
+        private volatile AccessToken refreshedAccessToken;
+        private volatile IOException refreshException;
+
+        private TestScopedGoogleCredentials(final AccessToken 
initialAccessToken) {
+            super(initialAccessToken);
+        }
+
+        @Override
+        public AccessToken refreshAccessToken() throws IOException {
+            refreshAccessTokenCount.incrementAndGet();
+            if (refreshException != null) {
+                throw refreshException;
+            }
+            return refreshedAccessToken;
+        }
+
+        protected void setRefreshedAccessToken(final AccessToken 
refreshedAccessToken) {
+            this.refreshedAccessToken = refreshedAccessToken;
+        }
+
+        protected void setRefreshException(final IOException refreshException) 
{
+            this.refreshException = refreshException;
+        }
+
+        protected int getRefreshAccessTokenCount() {
+            return refreshAccessTokenCount.get();
+        }
+    }
+
+    private static ImpersonatedCredentials impersonatedCredentials(final 
AccessToken accessToken) throws IOException {
+        final ImpersonatedCredentials credentials = 
mock(ImpersonatedCredentials.class);
+        when(credentials.refreshAccessToken()).thenReturn(accessToken);
+        return credentials;
+    }
+
+    private static ImpersonatedCredentials impersonatedCredentials(final 
IOException exception) throws IOException {
+        final ImpersonatedCredentials credentials = 
mock(ImpersonatedCredentials.class);
+        when(credentials.refreshAccessToken()).thenThrow(exception);
+        return credentials;
+    }
+
+    private static IdentityPoolCredentials identityPoolCredentials(final 
AccessToken accessToken) throws IOException {
+        final IdentityPoolCredentials credentials = 
mock(IdentityPoolCredentials.class);
+        when(credentials.refreshAccessToken()).thenReturn(accessToken);
+        return credentials;
+    }
+
+    private static final class BlockingScopedGoogleCredentials extends 
TestScopedGoogleCredentials {
+        private final CountDownLatch refreshEnteredLatch = new 
CountDownLatch(1);
+        private final CountDownLatch releaseRefreshLatch = new 
CountDownLatch(1);
+
+        private BlockingScopedGoogleCredentials() {
+            super(null);
+        }
+
+        @Override
+        public AccessToken refreshAccessToken() throws IOException {
+            refreshEnteredLatch.countDown();
+            try {
+                if (!releaseRefreshLatch.await(5, TimeUnit.SECONDS)) {
+                    throw new IOException("Timed out waiting for refresh 
release");
+                }
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IOException("Interrupted while waiting for refresh 
release", e);
+            }
+            return super.refreshAccessToken();
+        }
+
+        private boolean awaitRefreshEntry() throws InterruptedException {
+            return refreshEnteredLatch.await(5, TimeUnit.SECONDS);
+        }
+
+        private void releaseRefresh() {
+            releaseRefreshLatch.countDown();
+        }
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java
new file mode 100644
index 00000000000..be4d13ff010
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.processors.gcp.credentials.factory.strategies;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.IdentityPoolCredentials;
+import com.google.auth.oauth2.ImpersonatedCredentials;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.oauth2.AccessToken;
+import org.apache.nifi.oauth2.OAuth2AccessTokenProvider;
+import 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class WorkloadIdentityFederationCredentialsStrategyTest {
+    private static final String AUDIENCE = 
"projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider";
+    private static final String SCOPE = 
"https://www.googleapis.com/auth/cloud-platform";;
+    private static final String TOKEN_ENDPOINT = 
"https://sts.googleapis.com/v1/token";;
+    private static final String SUBJECT_TOKEN_TYPE = 
"urn:ietf:params:oauth:token-type:jwt";
+    private static final String TARGET_SERVICE_ACCOUNT = 
"[email protected]";
+    private static final String SQLSERVICE_LOGIN_SCOPE = 
"https://www.googleapis.com/auth/sqlservice.login";;
+
+    private final WorkloadIdentityFederationCredentialsStrategy strategy = new 
WorkloadIdentityFederationCredentialsStrategy();
+
+    @Test
+    void 
testReturnsIdentityPoolCredentialsWhenTargetServiceAccountNotConfigured() 
throws IOException {
+        final ConfigurationContext context = mockConfigurationContext(null);
+
+        final GoogleCredentials credentials = 
strategy.getGoogleCredentials(context, transportFactory());
+
+        assertInstanceOf(IdentityPoolCredentials.class, credentials);
+    }
+
+    @Test
+    void 
testReturnsImpersonatedCredentialsWithEmptyScopesAndPreservedTransportFactory() 
throws IOException {
+        final HttpTransportFactory transportFactory = transportFactory();
+        final ConfigurationContext context = 
mockConfigurationContext(TARGET_SERVICE_ACCOUNT);
+
+        final GoogleCredentials credentials = 
strategy.getGoogleCredentials(context, transportFactory);
+
+        final ImpersonatedCredentials impersonatedCredentials = 
assertInstanceOf(ImpersonatedCredentials.class, credentials);
+        assertEquals(TARGET_SERVICE_ACCOUNT, 
impersonatedCredentials.getAccount());
+        assertInstanceOf(IdentityPoolCredentials.class, 
impersonatedCredentials.getSourceCredentials());
+        assertTrue(impersonatedCredentials.toBuilder().getScopes().isEmpty());
+        assertTrue(impersonatedCredentials.createScopedRequired());
+        assertSame(transportFactory, 
impersonatedCredentials.toBuilder().getHttpTransportFactory());
+
+        final GoogleCredentials scopedCredentials = 
credentials.createScoped(List.of(SQLSERVICE_LOGIN_SCOPE));
+
+        final ImpersonatedCredentials scopedImpersonatedCredentials = 
assertInstanceOf(ImpersonatedCredentials.class, scopedCredentials);
+        assertEquals(TARGET_SERVICE_ACCOUNT, 
scopedImpersonatedCredentials.getAccount());
+        assertSame(impersonatedCredentials.getSourceCredentials(), 
scopedImpersonatedCredentials.getSourceCredentials());
+        assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), 
scopedImpersonatedCredentials.toBuilder().getScopes());
+        assertFalse(scopedImpersonatedCredentials.createScopedRequired());
+        assertSame(transportFactory, 
scopedImpersonatedCredentials.toBuilder().getHttpTransportFactory());
+    }
+
+    private ConfigurationContext mockConfigurationContext(final String 
targetServiceAccount) {
+        final ConfigurationContext context = mock(ConfigurationContext.class);
+        final PropertyValue audiencePropertyValue = 
stringPropertyValue(AUDIENCE);
+        final PropertyValue scopePropertyValue = stringPropertyValue(SCOPE);
+        final PropertyValue tokenEndpointPropertyValue = 
stringPropertyValue(TOKEN_ENDPOINT);
+        final PropertyValue subjectTokenTypePropertyValue = 
stringPropertyValue(SUBJECT_TOKEN_TYPE);
+        final PropertyValue targetServiceAccountPropertyValue = 
stringPropertyValue(targetServiceAccount);
+
+        
when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE)).thenReturn(audiencePropertyValue);
+        
when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE)).thenReturn(scopePropertyValue);
+        
when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT)).thenReturn(tokenEndpointPropertyValue);
+        
when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE)).thenReturn(subjectTokenTypePropertyValue);
+        
when(context.getProperty(CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT)).thenReturn(targetServiceAccountPropertyValue);
+
+        final PropertyValue subjectTokenProviderProperty = 
mock(PropertyValue.class);
+        
when(subjectTokenProviderProperty.asControllerService(OAuth2AccessTokenProvider.class)).thenReturn(new
 MockOAuth2AccessTokenProvider());
+        
when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER)).thenReturn(subjectTokenProviderProperty);
+        return context;
+    }
+
+    private PropertyValue stringPropertyValue(final String value) {
+        final PropertyValue propertyValue = mock(PropertyValue.class);
+        when(propertyValue.getValue()).thenReturn(value);
+        return propertyValue;
+    }
+
+    private HttpTransportFactory transportFactory() {
+        final HttpTransport transport = new NetHttpTransport();
+        return () -> transport;
+    }
+
+    private static final class MockOAuth2AccessTokenProvider extends 
AbstractControllerService implements OAuth2AccessTokenProvider {
+        @Override
+        public AccessToken getAccessDetails() {
+            final AccessToken accessToken = new AccessToken();
+            accessToken.setAccessToken("subject-token");
+            accessToken.setExpiresIn(3600L);
+            return accessToken;
+        }
+
+        @Override
+        public void refreshAccessDetails() {
+        }
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java
index 538374668ad..890d41757d6 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java
@@ -16,9 +16,12 @@
  */
 package org.apache.nifi.processors.gcp.credentials.service;
 
+import com.google.auth.oauth2.ComputeEngineCredentials;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.auth.oauth2.IdentityPoolCredentials;
+import com.google.auth.oauth2.ImpersonatedCredentials;
 import com.google.auth.oauth2.ServiceAccountCredentials;
+import org.apache.nifi.components.ValidationResult;
 import org.apache.nifi.controller.AbstractControllerService;
 import org.apache.nifi.gcp.credentials.service.GCPCredentialsService;
 import org.apache.nifi.oauth2.AccessToken;
@@ -30,19 +33,32 @@ import org.junit.jupiter.api.Test;
 
 import java.nio.file.Files;
 import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
 
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.AUTHENTICATION_STRATEGY;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON_FILE;
+import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE;
 import static 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class GCPCredentialsServiceTest {
+    private static final String WORKLOAD_IDENTITY_AUDIENCE_VALUE = 
"projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider";
+    private static final String WORKLOAD_IDENTITY_SCOPE_VALUE = 
"https://www.googleapis.com/auth/cloud-platform";;
+    private static final String WORKLOAD_IDENTITY_TOKEN_ENDPOINT_VALUE = 
"https://sts.googleapis.com/v1/token";;
+    private static final String WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE_VALUE = 
"urn:ietf:params:oauth:token-type:jwt";
+    private static final String TARGET_SERVICE_ACCOUNT_VALUE = 
"[email protected]";
+
     @Test
     public void testToString() throws Exception {
         // toString method shouldn't cause an exception
@@ -121,12 +137,7 @@ public class GCPCredentialsServiceTest {
         runner.addControllerService("subjectTokenProvider", 
subjectTokenProvider);
         runner.enableControllerService(subjectTokenProvider);
 
-        runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue());
-        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_AUDIENCE, 
"projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider");
-        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SCOPE, 
"https://www.googleapis.com/auth/cloud-platform";);
-        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_TOKEN_ENDPOINT, 
"https://sts.googleapis.com/v1/token";);
-        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE, 
"urn:ietf:params:oauth:token-type:jwt");
-        runner.setProperty(serviceImpl, 
WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER, "subjectTokenProvider");
+        configureWorkloadIdentityFederation(runner, serviceImpl);
 
         runner.enableControllerService(serviceImpl);
         runner.assertValid(serviceImpl);
@@ -140,6 +151,44 @@ public class GCPCredentialsServiceTest {
         assertEquals(IdentityPoolCredentials.class, credentials.getClass());
     }
 
+    @Test
+    public void testWorkloadIdentityFederationImpersonationCredentials() 
throws Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsServiceProcessor.class);
+        final GCPCredentialsControllerService serviceImpl = new 
GCPCredentialsControllerService();
+        runner.addControllerService("gcpCredentialsProvider", serviceImpl);
+
+        final MockOAuth2AccessTokenProvider subjectTokenProvider = new 
MockOAuth2AccessTokenProvider();
+        runner.addControllerService("subjectTokenProvider", 
subjectTokenProvider);
+        runner.enableControllerService(subjectTokenProvider);
+
+        configureWorkloadIdentityFederation(runner, serviceImpl);
+        runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, 
TARGET_SERVICE_ACCOUNT_VALUE);
+
+        runner.enableControllerService(serviceImpl);
+        runner.assertValid(serviceImpl);
+
+        final GCPCredentialsService service = (GCPCredentialsService) 
runner.getProcessContext()
+                
.getControllerServiceLookup().getControllerService("gcpCredentialsProvider");
+
+        assertNotNull(service);
+        final GoogleCredentials credentials = service.getGoogleCredentials();
+        assertNotNull(credentials);
+        final ImpersonatedCredentials impersonatedCredentials = 
assertInstanceOf(ImpersonatedCredentials.class, credentials);
+        assertEquals(TARGET_SERVICE_ACCOUNT_VALUE, 
impersonatedCredentials.getAccount());
+        assertInstanceOf(IdentityPoolCredentials.class, 
impersonatedCredentials.getSourceCredentials());
+        assertTrue(impersonatedCredentials.createScopedRequired());
+
+        final GoogleCredentials scopedCredentials = 
credentials.createScoped(List.of("https://www.googleapis.com/auth/sqlservice.login";));
+
+        final ImpersonatedCredentials scopedImpersonatedCredentials = 
assertInstanceOf(ImpersonatedCredentials.class, scopedCredentials);
+        assertEquals(TARGET_SERVICE_ACCOUNT_VALUE, 
scopedImpersonatedCredentials.getAccount());
+        assertEquals(impersonatedCredentials.getSourceCredentials(), 
scopedImpersonatedCredentials.getSourceCredentials());
+        
assertEquals(List.of("https://www.googleapis.com/auth/sqlservice.login";), 
scopedImpersonatedCredentials.toBuilder().getScopes());
+        assertFalse(scopedImpersonatedCredentials.createScopedRequired());
+        
assertNotNull(impersonatedCredentials.toBuilder().getHttpTransportFactory());
+        
assertEquals(impersonatedCredentials.toBuilder().getHttpTransportFactory(), 
scopedImpersonatedCredentials.toBuilder().getHttpTransportFactory());
+    }
+
     @Test
     public void testBadFileCredentials() throws Exception {
         final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsServiceProcessor.class);
@@ -166,6 +215,56 @@ public class GCPCredentialsServiceTest {
         runner.assertValid(serviceImpl);
     }
 
+    @Test
+    public void testComputeEngineCredentials() throws Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsServiceProcessor.class);
+        final GCPCredentialsControllerService serviceImpl = new 
GCPCredentialsControllerService();
+        runner.addControllerService("gcpCredentialsProvider", serviceImpl);
+
+        runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.COMPUTE_ENGINE.getValue());
+        runner.enableControllerService(serviceImpl);
+
+        runner.assertValid(serviceImpl);
+
+        final GCPCredentialsService service = (GCPCredentialsService) 
runner.getProcessContext()
+                
.getControllerServiceLookup().getControllerService("gcpCredentialsProvider");
+
+        assertNotNull(service);
+        final GoogleCredentials credentials = service.getGoogleCredentials();
+        assertNotNull(credentials);
+
+        assertEquals(ComputeEngineCredentials.class, credentials.getClass(),
+                "Credentials class should be equal");
+    }
+
+    @Test
+    public void testTargetServiceAccountBlankRejected() throws Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsServiceProcessor.class);
+        final GCPCredentialsControllerService serviceImpl = new 
GCPCredentialsControllerService();
+        runner.addControllerService("gcpCredentialsProvider", serviceImpl);
+
+        final MockOAuth2AccessTokenProvider subjectTokenProvider = new 
MockOAuth2AccessTokenProvider();
+        runner.addControllerService("subjectTokenProvider", 
subjectTokenProvider);
+        runner.enableControllerService(subjectTokenProvider);
+
+        configureWorkloadIdentityFederation(runner, serviceImpl);
+        runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, " ");
+
+        assertHasInvalidResult(runner.validate(serviceImpl), "must contain at 
least one character that is not white space");
+    }
+
+    @Test
+    public void testTargetServiceAccountRequiresWorkloadIdentityFederation() 
throws Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsServiceProcessor.class);
+        final GCPCredentialsControllerService serviceImpl = new 
GCPCredentialsControllerService();
+        runner.addControllerService("gcpCredentialsProvider", serviceImpl);
+
+        runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.APPLICATION_DEFAULT.getValue());
+        runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, 
TARGET_SERVICE_ACCOUNT_VALUE);
+
+        assertHasInvalidResult(runner.validate(serviceImpl), "Target Service 
Account requires Workload Identity Federation");
+    }
+
     @Test
     public void testRawJsonCredentials() throws Exception {
         final String jsonRead = new String(
@@ -194,6 +293,28 @@ public class GCPCredentialsServiceTest {
                 "Credentials class should be equal");
     }
 
+    private static void configureWorkloadIdentityFederation(final TestRunner 
runner, final GCPCredentialsControllerService serviceImpl) {
+        runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue());
+        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_AUDIENCE, 
WORKLOAD_IDENTITY_AUDIENCE_VALUE);
+        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SCOPE, 
WORKLOAD_IDENTITY_SCOPE_VALUE);
+        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_TOKEN_ENDPOINT, 
WORKLOAD_IDENTITY_TOKEN_ENDPOINT_VALUE);
+        runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE, 
WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE_VALUE);
+        runner.setProperty(serviceImpl, 
WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER, "subjectTokenProvider");
+    }
+
+    private static void assertHasInvalidResult(final 
Collection<ValidationResult> validationResults, final String 
explanationFragment) {
+        final List<String> explanations = new 
ArrayList<>(validationResults.size());
+        for (final ValidationResult validationResult : validationResults) {
+            if (!validationResult.isValid()) {
+                explanations.add(validationResult.getExplanation());
+            }
+        }
+
+        final boolean explanationFound = explanations.stream()
+                .anyMatch(explanation -> explanation != null && 
explanation.contains(explanationFragment));
+        assertTrue(explanationFound, () -> "Expected invalid result containing 
[%s] but found %s".formatted(explanationFragment, explanations));
+    }
+
     private static final class MockOAuth2AccessTokenProvider extends 
AbstractControllerService implements OAuth2AccessTokenProvider {
         private static final String ACCESS_TOKEN_VALUE = 
"federated-access-token";
         private static final long EXPIRES_IN_SECONDS = 3600;

Reply via email to