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"
+ }
+}