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

turcsanyip 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 17ddb8740e3 NIFI-16010 - Enforce service-account credential type in 
GCP credential strategies (#11323)
17ddb8740e3 is described below

commit 17ddb8740e3fd487b6086547add04c7f83a12053
Author: Pierre Villard <[email protected]>
AuthorDate: Fri Jun 12 00:55:27 2026 -0700

    NIFI-16010 - Enforce service-account credential type in GCP credential 
strategies (#11323)
    
    Signed-off-by: Peter Turcsanyi <[email protected]>
---
 .../nifi-gcp-bundle/nifi-gcp-processors/pom.xml    |  1 +
 .../factory/CredentialPropertyDescriptors.java     |  3 +-
 .../factory/ServiceAccountJsonValidator.java       | 78 ++++++++++++++++++++++
 .../AbstractServiceAccountCredentialsStrategy.java | 24 +++++--
 .../factory/CredentialsFactoryTest.java            | 27 ++++++++
 .../resources/external-account-credentials.json    |  9 +++
 6 files changed, 136 insertions(+), 6 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 c7fb9acadaf..e21dfe67dac 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
@@ -179,6 +179,7 @@
                         
<exclude>src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker</exclude>
                         
<exclude>src/test/resources/mock-gcp-service-account.json</exclude>
                         
<exclude>src/test/resources/mock-gcp-application-default-credentials.json</exclude>
+                        
<exclude>src/test/resources/external-account-credentials.json</exclude>
                         
<exclude>src/test/resources/bigquery/avrodecimal.avsc</exclude>
                         
<exclude>src/test/resources/bigquery/avrodecimal.avro</exclude>
                         
<exclude>src/test/resources/bigquery/avrofloat.avsc</exclude>
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 a0ef1988fa9..c0c0f801491 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
@@ -21,7 +21,6 @@ import 
org.apache.nifi.components.resource.ResourceCardinality;
 import org.apache.nifi.components.resource.ResourceType;
 import org.apache.nifi.expression.ExpressionLanguageScope;
 import org.apache.nifi.oauth2.OAuth2AccessTokenProvider;
-import org.apache.nifi.processor.util.JsonValidator;
 import org.apache.nifi.processor.util.StandardValidators;
 
 /**
@@ -74,7 +73,7 @@ public final class CredentialPropertyDescriptors {
             .name("Service Account JSON")
             .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
             .required(true)
-            .addValidator(JsonValidator.INSTANCE)
+            .addValidator(ServiceAccountJsonValidator.INSTANCE)
             .dependsOn(AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.SERVICE_ACCOUNT_JSON.getValue())
             .description("The raw JSON containing a Service Account keyfile.")
             .sensitive(true)
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/ServiceAccountJsonValidator.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/ServiceAccountJsonValidator.java
new file mode 100644
index 00000000000..120e241966d
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/ServiceAccountJsonValidator.java
@@ -0,0 +1,78 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.processor.util.JsonValidator;
+
+/**
+ * Validates that a property value is a JSON object describing a Google 
Service Account key, that is a JSON object
+ * whose {@code type} field is {@code service_account}. Other Google 
credential types, such as {@code external_account},
+ * are rejected so that external identity credentials are configured through 
the Workload Identity Federation strategy.
+ */
+public class ServiceAccountJsonValidator implements Validator {
+    public static final ServiceAccountJsonValidator INSTANCE = new 
ServiceAccountJsonValidator();
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private static final String TYPE_FIELD = "type";
+    private static final String SERVICE_ACCOUNT_TYPE = "service_account";
+    private static final String EXTERNAL_ACCOUNT_TYPE = "external_account";
+
+    @Override
+    public ValidationResult validate(final String subject, final String input, 
final ValidationContext context) {
+        final ValidationResult jsonResult = 
JsonValidator.INSTANCE.validate(subject, input, context);
+        if (!jsonResult.isValid()) {
+            return jsonResult;
+        }
+
+        if (context.isExpressionLanguageSupported(subject) && 
context.isExpressionLanguagePresent(input)) {
+            return jsonResult;
+        }
+
+        final ValidationResult.Builder builder = new ValidationResult.Builder()
+                .subject(subject)
+                .input(input);
+
+        try {
+            final JsonNode rootNode = OBJECT_MAPPER.readTree(input);
+            final JsonNode typeNode = rootNode.get(TYPE_FIELD);
+            if (typeNode != null && 
SERVICE_ACCOUNT_TYPE.equals(typeNode.asText())) {
+                builder.valid(true);
+                builder.explanation("Service Account JSON found");
+            } else {
+                final String foundType = typeNode == null ? "none" : 
typeNode.asText();
+                final StringBuilder explanation = new StringBuilder(
+                        "Expected a Service Account key with \"type\": 
\"service_account\" but found \"type\": \"%s\".".formatted(foundType));
+                if (EXTERNAL_ACCOUNT_TYPE.equals(foundType)) {
+                    explanation.append(" Use the Workload Identity Federation 
strategy for external account credentials.");
+                }
+                builder.valid(false);
+                builder.explanation(explanation.toString());
+            }
+        } catch (final Exception e) {
+            builder.valid(false);
+            builder.explanation("Service Account JSON validation failed: 
%s".formatted(e.getMessage()));
+        }
+
+        return builder.build();
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/AbstractServiceAccountCredentialsStrategy.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/AbstractServiceAccountCredentialsStrategy.java
index f8b6a9b8cce..12b4279e6d9 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/AbstractServiceAccountCredentialsStrategy.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/AbstractServiceAccountCredentialsStrategy.java
@@ -17,7 +17,9 @@
 package org.apache.nifi.processors.gcp.credentials.factory.strategies;
 
 import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.ExternalAccountCredentials;
 import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.ServiceAccountCredentials;
 import org.apache.nifi.components.PropertyDescriptor;
 import 
org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors;
 import org.apache.nifi.processors.gcp.credentials.factory.DelegationStrategy;
@@ -38,14 +40,28 @@ public abstract class 
AbstractServiceAccountCredentialsStrategy extends Abstract
     protected abstract InputStream 
getServiceAccountJson(Map<PropertyDescriptor, String> properties) throws 
IOException;
 
     @Override
-    public GoogleCredentials getGoogleCredentials(Map<PropertyDescriptor, 
String> properties, HttpTransportFactory transportFactory) throws IOException {
+    public GoogleCredentials getGoogleCredentials(final 
Map<PropertyDescriptor, String> properties, final HttpTransportFactory 
transportFactory) throws IOException {
+        final GoogleCredentials credentials;
+        try (final InputStream serviceAccountJson = 
getServiceAccountJson(properties)) {
+            credentials = GoogleCredentials.fromStream(serviceAccountJson, 
transportFactory);
+        }
+
+        if (!(credentials instanceof ServiceAccountCredentials)) {
+            final StringBuilder message = new StringBuilder(
+                    "Configured credentials must be a Google Service Account 
key (\"type\": \"service_account\") but resolved to 
%s.".formatted(credentials.getClass().getSimpleName()));
+            if (credentials instanceof ExternalAccountCredentials) {
+                message.append(" Use the Workload Identity Federation strategy 
for external account credentials.");
+            }
+            throw new IOException(message.toString());
+        }
+
         final String delegationStrategy = 
properties.get(CredentialPropertyDescriptors.DELEGATION_STRATEGY);
         if (delegationStrategy != null && 
delegationStrategy.equals(DelegationStrategy.DELEGATED_ACCOUNT.getValue())) {
             final String delegationUser = 
properties.get(CredentialPropertyDescriptors.DELEGATION_USER);
-            return 
GoogleCredentials.fromStream(getServiceAccountJson(properties), 
transportFactory).createDelegated(delegationUser);
-        } else {
-            return 
GoogleCredentials.fromStream(getServiceAccountJson(properties), 
transportFactory);
+            return credentials.createDelegated(delegationUser);
         }
+
+        return credentials;
     }
 
 }
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialsFactoryTest.java
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialsFactoryTest.java
index 952db5f75ef..52982d0b6fc 100644
--- 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialsFactoryTest.java
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialsFactoryTest.java
@@ -27,6 +27,7 @@ import org.apache.nifi.util.TestRunner;
 import org.apache.nifi.util.TestRunners;
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Modifier;
 import java.nio.file.Files;
@@ -35,6 +36,7 @@ import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -139,6 +141,31 @@ public class CredentialsFactoryTest {
                 "credentials class should be equal");
     }
 
+    @Test
+    public void testJsonFileCredentialsRejectsExternalAccount() throws 
Exception {
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsFactoryProcessor.class);
+        
runner.setProperty(CredentialPropertyDescriptors.AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.SERVICE_ACCOUNT_JSON_FILE.getValue());
+        
runner.setProperty(CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON_FILE,
+                "src/test/resources/external-account-credentials.json");
+        runner.assertValid();
+
+        final Map<PropertyDescriptor, String> properties = 
runner.getProcessContext().getProperties();
+        final CredentialsFactory factory = new CredentialsFactory();
+        final IOException exception = assertThrows(IOException.class, () -> 
factory.getGoogleCredentials(properties, TRANSPORT_FACTORY));
+        assertTrue(exception.getMessage().contains("service_account"));
+    }
+
+    @Test
+    public void testJsonStringCredentialsRejectsExternalAccount() throws 
Exception {
+        final String externalAccountJson = new String(
+                
Files.readAllBytes(Paths.get("src/test/resources/external-account-credentials.json"))
+        );
+        final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsFactoryProcessor.class);
+        
runner.setProperty(CredentialPropertyDescriptors.AUTHENTICATION_STRATEGY, 
AuthenticationStrategy.SERVICE_ACCOUNT_JSON.getValue());
+        runner.setProperty(CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON, 
externalAccountJson);
+        runner.assertNotValid();
+    }
+
     @Test
     public void testComputeEngineCredentials() throws Exception {
         final TestRunner runner = 
TestRunners.newTestRunner(MockCredentialsFactoryProcessor.class);
diff --git 
a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/resources/external-account-credentials.json
 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/resources/external-account-credentials.json
new file mode 100644
index 00000000000..7deca356b84
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/resources/external-account-credentials.json
@@ -0,0 +1,9 @@
+{
+  "type": "external_account",
+  "audience": 
"//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider",
+  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
+  "token_url": "https://sts.googleapis.com/v1/token";,
+  "credential_source": {
+    "file": "/tmp/external-account-subject-token"
+  }
+}

Reply via email to