From 89e16a37ee65fd00f7190391746dac16fd99afd2 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Mon, 13 Jul 2026 19:54:25 +0200 Subject: [PATCH] NIFI-16100 Allow non-sensitive connector properties to reference Secrets Manager values --- .../org/apache/nifi/util/NiFiProperties.java | 1 + .../apache/nifi/web/api/dto/SecretDTO.java | 10 ++ .../ParameterProviderSecretProvider.java | 21 +++ .../ParameterProviderSecretsManager.java | 5 +- .../connector/secrets/StandardSecret.java | 14 ++ .../TestParameterProviderSecretProvider.java | 110 ++++++++++++ .../connector/StandardConnectorNode.java | 49 ++++- .../nifi/controller/FlowController.java | 4 + .../connector/TestStandardConnectorNode.java | 167 ++++++++++++++++++ .../apache/nifi/web/api/dto/DtoFactory.java | 5 + .../nifi/web/api/dto/DtoFactoryTest.java | 40 +++++ 11 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretProvider.java diff --git a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java index 2c42ca01859c..4606e4d57909 100644 --- a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java +++ b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java @@ -152,6 +152,7 @@ public class NiFiProperties extends ApplicationProperties { // Secrets Manager properties public static final String SECRETS_MANAGER_IMPLEMENTATION = "nifi.secrets.manager.implementation"; public static final String SECRETS_MANAGER_CACHE_DURATION = "nifi.secrets.manager.cache.duration"; + public static final String SECRETS_MANAGER_UNRESTRICTED_TAG_NAME = "nifi.secrets.manager.unrestricted.tag.name"; // security properties public static final String SECURITY_KEYSTORE = "nifi.security.keystore"; diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/SecretDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/SecretDTO.java index dbbf0f36a0bd..a2bcdcddbd7c 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/SecretDTO.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/SecretDTO.java @@ -31,6 +31,7 @@ public class SecretDTO { private String name; private String fullyQualifiedName; private String description; + private String propertyProtectionType; @Schema(description = "The identifier of the secret provider that manages this secret.") public String getProviderId() { @@ -85,5 +86,14 @@ public String getDescription() { public void setDescription(final String description) { this.description = description; } + + @Schema(description = "Whether the secret may be referenced only by sensitive properties (RESTRICTED) or by any property (UNRESTRICTED).") + public String getPropertyProtectionType() { + return propertyProtectionType; + } + + public void setPropertyProtectionType(final String propertyProtectionType) { + this.propertyProtectionType = propertyProtectionType; + } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretProvider.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretProvider.java index c44d5f10fd22..03a392433268 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretProvider.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretProvider.java @@ -17,6 +17,7 @@ package org.apache.nifi.components.connector.secrets; +import org.apache.nifi.components.connector.PropertyProtectionType; import org.apache.nifi.components.connector.Secret; import org.apache.nifi.controller.ParameterProviderNode; import org.apache.nifi.parameter.Parameter; @@ -28,9 +29,15 @@ public class ParameterProviderSecretProvider implements SecretProvider { private final ParameterProviderNode parameterProvider; + private final String unrestrictedTagName; public ParameterProviderSecretProvider(final ParameterProviderNode parameterProvider) { + this(parameterProvider, null); + } + + public ParameterProviderSecretProvider(final ParameterProviderNode parameterProvider, final String unrestrictedTagName) { this.parameterProvider = parameterProvider; + this.unrestrictedTagName = unrestrictedTagName; } @Override @@ -61,6 +68,19 @@ public List getAllSecrets() { private Secret createSecret(final String groupName, final Parameter parameter) { final ParameterDescriptor descriptor = parameter.getDescriptor(); + // A Secret is UNRESTRICTED (usable by non-sensitive properties) only when the deployment has + // configured an unrestricted tag name and the backing Parameter carries that tag with a value + // of "true" (case-insensitive). Otherwise the Secret remains RESTRICTED. The value is read via + // the constant-first idiom so a null tag value does not throw. + PropertyProtectionType protectionType = PropertyProtectionType.RESTRICTED; + if (unrestrictedTagName != null && !unrestrictedTagName.isBlank()) { + final boolean unrestricted = parameter.getTags().stream() + .anyMatch(tag -> unrestrictedTagName.equals(tag.getKey()) && "true".equalsIgnoreCase(tag.getValue())); + if (unrestricted) { + protectionType = PropertyProtectionType.UNRESTRICTED; + } + } + return new StandardSecret.Builder() .providerId(getProviderId()) .providerName(getProviderName()) @@ -70,6 +90,7 @@ private Secret createSecret(final String groupName, final Parameter parameter) { .description(descriptor.getDescription()) .value(parameter.getValue()) .authorizable(parameterProvider) + .propertyProtectionType(protectionType) .build(); } diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java index 6285ce9e16f2..7e72fd591a32 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java @@ -49,6 +49,7 @@ public class ParameterProviderSecretsManager implements SecretsManager { private FlowManager flowManager; private Duration cacheDuration; + private String unrestrictedTagName; private final Map secretCache = new ConcurrentHashMap<>(); // Per-ParameterProvider-id deduplication for the WARN log emitted whenever a SecretReference @@ -69,6 +70,8 @@ public void initialize(final SecretsManagerInitializationContext initializationC final String cacheDurationValue = initializationContext.getApplicationProperty(NiFiProperties.SECRETS_MANAGER_CACHE_DURATION); final String effectiveDuration = cacheDurationValue == null ? DEFAULT_CACHE_DURATION : cacheDurationValue; this.cacheDuration = Duration.ofNanos(FormatUtils.getTimeDuration(effectiveDuration.trim(), TimeUnit.NANOSECONDS)); + + this.unrestrictedTagName = initializationContext.getApplicationProperty(NiFiProperties.SECRETS_MANAGER_UNRESTRICTED_TAG_NAME); } @Override @@ -122,7 +125,7 @@ public Set getSecretProviders() { logger.info("{} returned to VALID after being logged as [{}] now resolving Secret References", parameterProvider, priorWarnedStatus); } } - providers.add(new ParameterProviderSecretProvider(parameterProviderNode)); + providers.add(new ParameterProviderSecretProvider(parameterProviderNode, unrestrictedTagName)); } return providers; diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/StandardSecret.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/StandardSecret.java index 0a81955fe5bc..d780890ff6da 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/StandardSecret.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/StandardSecret.java @@ -19,6 +19,7 @@ import org.apache.nifi.authorization.Resource; import org.apache.nifi.authorization.resource.Authorizable; +import org.apache.nifi.components.connector.PropertyProtectionType; import java.util.Objects; @@ -31,6 +32,7 @@ public class StandardSecret implements AuthorizableSecret { private final String description; private final String value; private final Authorizable authorizable; + private final PropertyProtectionType propertyProtectionType; private StandardSecret(final Builder builder) { this.providerId = builder.providerId; @@ -40,6 +42,7 @@ private StandardSecret(final Builder builder) { this.description = builder.description; this.value = builder.value; this.authorizable = builder.authorizable; + this.propertyProtectionType = builder.propertyProtectionType; this.fullyQualifiedName = builder.fullyQualifiedName == null ? groupName + "." + name : builder.fullyQualifiedName; } @@ -78,6 +81,11 @@ public String getFullyQualifiedName() { return fullyQualifiedName; } + @Override + public PropertyProtectionType getPropertyProtectionType() { + return propertyProtectionType; + } + @Override public String toString() { return "StandardSecret[providerName=%s, groupName=%s, name=%s]".formatted(providerName, groupName, name); @@ -121,6 +129,7 @@ public static class Builder { private String description; private String value; private Authorizable authorizable; + private PropertyProtectionType propertyProtectionType = PropertyProtectionType.RESTRICTED; public Builder providerId(final String providerId) { this.providerId = providerId; @@ -162,6 +171,11 @@ public Builder fullyQualifiedName(final String fullyQualifiedName) { return this; } + public Builder propertyProtectionType(final PropertyProtectionType propertyProtectionType) { + this.propertyProtectionType = propertyProtectionType == null ? PropertyProtectionType.RESTRICTED : propertyProtectionType; + return this; + } + public StandardSecret build() { if (groupName == null) { throw new IllegalStateException("Group name is required"); diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretProvider.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretProvider.java new file mode 100644 index 000000000000..4b708d306942 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretProvider.java @@ -0,0 +1,110 @@ +/* + * 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.components.connector.secrets; + +import org.apache.nifi.components.connector.PropertyProtectionType; +import org.apache.nifi.components.connector.Secret; +import org.apache.nifi.controller.ParameterProviderNode; +import org.apache.nifi.parameter.Parameter; +import org.apache.nifi.parameter.ParameterDescriptor; +import org.apache.nifi.parameter.ParameterGroup; +import org.apache.nifi.parameter.ParameterTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestParameterProviderSecretProvider { + + private static final String TAG_NAME = "nifi.unrestricted"; + + @Test + public void testTagValueTrueLowercaseIsUnrestricted() { + assertEquals(PropertyProtectionType.UNRESTRICTED, classify(TAG_NAME, List.of(new ParameterTag(TAG_NAME, "true")))); + } + + @Test + public void testTagValueTrueTitlecaseIsUnrestricted() { + assertEquals(PropertyProtectionType.UNRESTRICTED, classify(TAG_NAME, List.of(new ParameterTag(TAG_NAME, "True")))); + } + + @Test + public void testTagValueTrueUppercaseIsUnrestricted() { + assertEquals(PropertyProtectionType.UNRESTRICTED, classify(TAG_NAME, List.of(new ParameterTag(TAG_NAME, "TRUE")))); + } + + @Test + public void testTagValueFalseIsRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(TAG_NAME, List.of(new ParameterTag(TAG_NAME, "false")))); + } + + @Test + public void testTagValueNullIsRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(TAG_NAME, List.of(new ParameterTag(TAG_NAME, null)))); + } + + @Test + public void testTagKeyMismatchIsRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(TAG_NAME, List.of(new ParameterTag("other-tag", "true")))); + } + + @Test + public void testNoTagsIsRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(TAG_NAME, List.of())); + } + + @Test + public void testConfiguredNameNullIsAlwaysRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(null, List.of(new ParameterTag(TAG_NAME, "true")))); + } + + @Test + public void testConfiguredNameBlankIsAlwaysRestricted() { + assertEquals(PropertyProtectionType.RESTRICTED, classify(" ", List.of(new ParameterTag(TAG_NAME, "true")))); + } + + @Test + public void testMultipleTagsOneMatchIsUnrestricted() { + assertEquals(PropertyProtectionType.UNRESTRICTED, classify(TAG_NAME, + List.of(new ParameterTag("other-tag", "false"), new ParameterTag(TAG_NAME, "true")))); + } + + private PropertyProtectionType classify(final String configuredTagName, final List tags) { + final ParameterProviderNode node = mock(ParameterProviderNode.class); + when(node.getIdentifier()).thenReturn("provider-id"); + when(node.getName()).thenReturn("Provider"); + + final ParameterDescriptor descriptor = new ParameterDescriptor.Builder() + .name("secret") + .description("description") + .build(); + final Parameter parameter = new Parameter.Builder() + .descriptor(descriptor) + .value("secret-value") + .tags(tags) + .build(); + when(node.fetchParameterValues()).thenReturn(List.of(new ParameterGroup("Group", List.of(parameter)))); + + final ParameterProviderSecretProvider provider = new ParameterProviderSecretProvider(node, configuredTagName); + final List secrets = provider.getAllSecrets(); + assertEquals(1, secrets.size()); + return secrets.get(0).getPropertyProtectionType(); + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java index c9f8c9d3769f..8cd8723e373d 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java @@ -1235,9 +1235,11 @@ public List verifyConfigurationStep(final String stepN final ConfigurationStep configurationStep = optionalStep.get(); final List invalidSecretRefs = new ArrayList<>(); final List invalidAssetRefs = new ArrayList<>(); + final List unauthorizedSecretRefs = new ArrayList<>(); // Bypass the Secret value cache during verification so the user sees results based on the current // Secret values rather than potentially stale cached values awaiting TTL expiration. - final Map resolvedPropertyOverrides = resolvePropertyReferences(configurationStep, configurationOverrides, invalidSecretRefs, invalidAssetRefs, false); + final Map resolvedPropertyOverrides = resolvePropertyReferences(configurationStep, configurationOverrides, + invalidSecretRefs, invalidAssetRefs, unauthorizedSecretRefs, false); final DescribedValueProvider allowableValueProvider = (step, propertyName) -> fetchAllowableValues(step, propertyName, workingFlowContext); @@ -1250,7 +1252,7 @@ public List verifyConfigurationStep(final String stepN validatePropertyReferences(configurationStep, configurationOverrides, validationResults); // If there are any invalid secrets or assets referenced, add Validation Results for them. - addInvalidReferenceResults(validationResults, invalidSecretRefs, invalidAssetRefs); + addInvalidReferenceResults(validationResults, invalidSecretRefs, invalidAssetRefs, unauthorizedSecretRefs); // If there are any framework-level validation failures, we do not run the Connector-specific validation because // doing so would mean that we must provide weak guarantees about the state of the configuration when the Connector's @@ -1292,7 +1294,8 @@ private ConfigVerificationResult createConfigVerificationResult(final Validation } private Map resolvePropertyReferences(final ConfigurationStep configurationStep, final StepConfiguration configurationOverrides, - final List invalidSecretRefs, final List invalidAssetRefs, final boolean useCache) { + final List invalidSecretRefs, final List invalidAssetRefs, + final List unauthorizedSecretRefs, final boolean useCache) { final Map resolvedProperties = new HashMap<>(); final Map descriptorLookup = buildPropertyDescriptorLookup(configurationStep); @@ -1348,8 +1351,22 @@ private Map resolvePropertyReferences(final ConfigurationStep co continue; } final Secret secret = secretsByReference.get(secretReference); - final String resolvedValue = (secret == null) ? null : secret.getValue(); - resolvedProperties.put(propertyName, resolvedValue); + + // A Secret referenced by a non-sensitive property is only permitted when its owner has + // authorized it (UNRESTRICTED). A found-but-RESTRICTED Secret on a non-SECRET property (including + // a property with no descriptor in this step, which is treated as non-sensitive) is not permitted: + // its value is withheld from the resolved configuration and it is recorded separately so it surfaces + // a distinct "not authorized" message rather than the "could not be found" message used for + // unresolved references. + final boolean secretProperty = descriptor != null && descriptor.getType() == PropertyType.SECRET; + final boolean unauthorized = secret != null && !secretProperty + && secret.getPropertyProtectionType() != PropertyProtectionType.UNRESTRICTED; + if (unauthorized) { + resolvedProperties.put(propertyName, null); + unauthorizedSecretRefs.add(secretReference); + } else { + resolvedProperties.put(propertyName, (secret == null) ? null : secret.getValue()); + } continue; } @@ -2002,14 +2019,16 @@ private void validatePropertyReferences(final List allResults) // Check for invalid Secret and Asset references final List invalidSecrets = new ArrayList<>(); final List invalidAssets = new ArrayList<>(); + final List unauthorizedSecrets = new ArrayList<>(); // Regular validation may run frequently, so cached Secret values are used here to avoid // repeatedly fetching from the underlying Secret Providers on every validation cycle. - resolvePropertyReferences(step, stepConfiguration, invalidSecrets, invalidAssets, true); - addInvalidReferenceResults(allResults, invalidSecrets, invalidAssets); + resolvePropertyReferences(step, stepConfiguration, invalidSecrets, invalidAssets, unauthorizedSecrets, true); + addInvalidReferenceResults(allResults, invalidSecrets, invalidAssets, unauthorizedSecrets); } } - private void addInvalidReferenceResults(final List results, final List invalidSecretRefs, final List invalidAssetRefs) { + private void addInvalidReferenceResults(final List results, final List invalidSecretRefs, final List invalidAssetRefs, + final List unauthorizedSecretRefs) { for (final SecretReference invalidSecretRef : invalidSecretRefs) { final String secretName = invalidSecretRef.getFullyQualifiedName() != null ? invalidSecretRef.getFullyQualifiedName() : invalidSecretRef.getSecretName(); results.add(new ValidationResult.Builder() @@ -2019,6 +2038,15 @@ private void addInvalidReferenceResults(final List results, fi .build()); } + for (final SecretReference unauthorizedSecretRef : unauthorizedSecretRefs) { + final String secretName = unauthorizedSecretRef.getFullyQualifiedName() != null ? unauthorizedSecretRef.getFullyQualifiedName() : unauthorizedSecretRef.getSecretName(); + results.add(new ValidationResult.Builder() + .subject("Secret Reference") + .valid(false) + .explanation("The referenced secret [" + secretName + "] is not authorized for use by non-sensitive properties") + .build()); + } + for (final AssetReference invalidAssetRef : invalidAssetRefs) { results.add(new ValidationResult.Builder() .subject("Asset Reference") @@ -2092,7 +2120,10 @@ private boolean isReferenceAllowed(final ConnectorValueReference reference, fina return reference.getValueType() == ConnectorValueType.ASSET_REFERENCE; } - return reference.getValueType() != ConnectorValueType.SECRET_REFERENCE && reference.getValueType() != ConnectorValueType.ASSET_REFERENCE; + // A non-sensitive property may structurally carry a Secret reference; whether that Secret is actually + // permitted (UNRESTRICTED) is decided during resolution in resolvePropertyReferences. Asset references + // remain disallowed on non-asset properties. + return reference.getValueType() != ConnectorValueType.ASSET_REFERENCE; } private ConnectorValidationContext createValidationContext(final FrameworkFlowContext context) { diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java index 353a81f63ab0..6fa3d29d473d 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java @@ -982,6 +982,10 @@ private static SecretsManager createSecretsManager(final NiFiProperties properti if (cacheDuration != null) { secretsManagerProperties.put(NiFiProperties.SECRETS_MANAGER_CACHE_DURATION, cacheDuration); } + final String unrestrictedTagName = properties.getProperty(NiFiProperties.SECRETS_MANAGER_UNRESTRICTED_TAG_NAME); + if (unrestrictedTagName != null) { + secretsManagerProperties.put(NiFiProperties.SECRETS_MANAGER_UNRESTRICTED_TAG_NAME, unrestrictedTagName); + } final SecretsManagerInitializationContext initializationContext = new StandardSecretsManagerInitializationContext(flowManager, secretsManagerProperties); synchronized (created) { diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java index bbd0497a257f..7555351b95eb 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java @@ -681,6 +681,106 @@ public void testPerformValidationSurfacesRequiredErrorForEmptySecretReferenceInA }), () -> "Expected required-property failure in active validation, got: " + errors); } + @Test + public void testUnrestrictedSecretAllowedOnNonSecretProperty() throws FlowUpdateException { + final Secret secret = mockSecretWithProtection(PropertyProtectionType.UNRESTRICTED); + final StandardConnectorNode connectorNode = createConnectorNode(new StringAndSecretPropertyConnector(), secretsManagerReturning(secret)); + + connectorNode.transitionStateForUpdating(); + connectorNode.prepareForUpdate(); + final Map propertyValues = new HashMap<>(); + propertyValues.put("StringProp", new SecretReference("pid", "My Provider", "my-secret", "My Provider.my-secret")); + connectorNode.setConfiguration("propStep", new StepConfiguration(propertyValues)); + connectorNode.applyUpdate(); + + final ValidationState state = connectorNode.performValidation(); + assertEquals(ValidationStatus.VALID, state.getStatus(), () -> "Expected VALID validation state, got: " + state.getValidationErrors()); + final Collection errors = state.getValidationErrors(); + assertTrue(errors.stream().noneMatch(result -> result.getExplanation() != null && result.getExplanation().contains("not authorized")), + () -> "UNRESTRICTED secret should be allowed on a non-sensitive property, got: " + errors); + assertTrue(errors.stream().noneMatch(result -> result.getExplanation() != null && result.getExplanation().contains("must be configured with")), + () -> "UNRESTRICTED secret should not trigger a structural reference error, got: " + errors); + } + + @Test + public void testRestrictedSecretRejectedOnNonSecretProperty() throws FlowUpdateException { + final Secret secret = mockSecretWithProtection(PropertyProtectionType.RESTRICTED); + final StandardConnectorNode connectorNode = createConnectorNode(new StringAndSecretPropertyConnector(), secretsManagerReturning(secret)); + + connectorNode.transitionStateForUpdating(); + connectorNode.prepareForUpdate(); + final Map propertyValues = new HashMap<>(); + propertyValues.put("StringProp", new SecretReference("pid", "My Provider", "my-secret", "My Provider.my-secret")); + connectorNode.setConfiguration("propStep", new StepConfiguration(propertyValues)); + connectorNode.applyUpdate(); + + final ValidationState state = connectorNode.performValidation(); + assertEquals(ValidationStatus.INVALID, state.getStatus(), () -> "Expected INVALID validation state, got: " + state); + final Collection errors = state.getValidationErrors(); + assertTrue(errors.stream().anyMatch(result -> result.getExplanation() != null + && result.getExplanation().contains("is not authorized for use by non-sensitive properties")), + () -> "RESTRICTED secret on a non-sensitive property should be rejected with the distinct message, got: " + errors); + } + + @Test + public void testRestrictedSecretAllowedOnSecretProperty() throws FlowUpdateException { + final Secret secret = mockSecretWithProtection(PropertyProtectionType.RESTRICTED); + final StandardConnectorNode connectorNode = createConnectorNode(new StringAndSecretPropertyConnector(), secretsManagerReturning(secret)); + + connectorNode.transitionStateForUpdating(); + connectorNode.prepareForUpdate(); + final Map propertyValues = new HashMap<>(); + propertyValues.put("SecretProp", new SecretReference("pid", "My Provider", "my-secret", "My Provider.my-secret")); + connectorNode.setConfiguration("propStep", new StepConfiguration(propertyValues)); + connectorNode.applyUpdate(); + + final ValidationState state = connectorNode.performValidation(); + assertEquals(ValidationStatus.VALID, state.getStatus(), () -> "Expected VALID validation state, got: " + state.getValidationErrors()); + final Collection errors = state.getValidationErrors(); + assertTrue(errors.stream().noneMatch(result -> result.getExplanation() != null && result.getExplanation().contains("not authorized")), + () -> "A RESTRICTED Secret reference on a SECRET property must always be allowed, got: " + errors); + } + + @Test + public void testUnrestrictedSecretAllowedOnSecretProperty() throws FlowUpdateException { + final Secret secret = mockSecretWithProtection(PropertyProtectionType.UNRESTRICTED); + final StandardConnectorNode connectorNode = createConnectorNode(new StringAndSecretPropertyConnector(), secretsManagerReturning(secret)); + + connectorNode.transitionStateForUpdating(); + connectorNode.prepareForUpdate(); + final Map propertyValues = new HashMap<>(); + propertyValues.put("SecretProp", new SecretReference("pid", "My Provider", "my-secret", "My Provider.my-secret")); + connectorNode.setConfiguration("propStep", new StepConfiguration(propertyValues)); + connectorNode.applyUpdate(); + + final ValidationState state = connectorNode.performValidation(); + assertEquals(ValidationStatus.VALID, state.getStatus(), () -> "Expected VALID validation state, got: " + state.getValidationErrors()); + final Collection errors = state.getValidationErrors(); + assertTrue(errors.stream().noneMatch(result -> result.getExplanation() != null && result.getExplanation().contains("not authorized")), + () -> "An UNRESTRICTED Secret reference on a SECRET property must be allowed, got: " + errors); + } + + private Secret mockSecretWithProtection(final PropertyProtectionType protectionType) { + final Secret secret = mock(Secret.class); + when(secret.getPropertyProtectionType()).thenReturn(protectionType); + when(secret.getValue()).thenReturn("resolved-value"); + return secret; + } + + private SecretsManager secretsManagerReturning(final Secret secret) { + final SecretsManager secretsManager = mock(SecretsManager.class); + when(secretsManager.getAllSecrets()).thenReturn(List.of()); + when(secretsManager.getSecrets(anySet(), anyBoolean())).thenAnswer(invocation -> { + final Set references = invocation.getArgument(0); + final Map resolved = new HashMap<>(); + for (final SecretReference reference : references) { + resolved.put(reference, secret); + } + return resolved; + }); + return secretsManager; + } + @Test public void testVerifyConfigurationStepResolvesSecretsBypassingCache() throws FlowUpdateException { final SecretsManager recordingSecretsManager = mock(SecretsManager.class); @@ -1258,6 +1358,73 @@ public List verifyConfigurationStep(final String stepN } } + /** + * Connector exposing one non-sensitive (STRING) property and one SECRET property, used to exercise the + * protection-type gate: an UNRESTRICTED secret may be referenced by the non-sensitive property, a RESTRICTED + * secret may not, and both are always allowed on the SECRET property. + */ + private static class StringAndSecretPropertyConnector extends AbstractConnector { + @Override + public VersionedExternalFlow getInitialFlow() { + return null; + } + + @Override + public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) { + return null; + } + + @Override + public void prepareForUpdate(final FlowContext workingContext, final FlowContext activeContext) { + } + + @Override + public List getConfigurationSteps() { + final ConnectorPropertyDescriptor stringProperty = new ConnectorPropertyDescriptor.Builder() + .name("StringProp") + .description("A non-sensitive string property") + .build(); + + final ConnectorPropertyDescriptor secretProperty = new ConnectorPropertyDescriptor.Builder() + .name("SecretProp") + .description("A sensitive property") + .type(PropertyType.SECRET) + .build(); + + final ConnectorPropertyGroup propertyGroup = ConnectorPropertyGroup.builder() + .name("g") + .description("g") + .properties(List.of(stringProperty, secretProperty)) + .build(); + + final ConfigurationStep step = new ConfigurationStep.Builder() + .name("propStep") + .propertyGroups(List.of(propertyGroup)) + .build(); + + return List.of(step); + } + + @Override + public List validateConfigurationStep(final ConfigurationStep configurationStep, final ConnectorConfigurationContext connectorConfigurationContext, + final ConnectorValidationContext connectorValidationContext) { + return List.of(); + } + + @Override + public void applyUpdate(final FlowContext workingContext, final FlowContext activeContext) { + } + + @Override + protected void onStepConfigured(final String stepName, final FlowContext workingContext) { + } + + @Override + public List verifyConfigurationStep(final String stepName, final Map overrides, final FlowContext flowContext) { + return List.of(); + } + } + /** * Test connector that allows control over when drainFlowFiles completes via a CompletableFuture */ diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java index b6ca4cda226b..256889f237bd 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java @@ -77,6 +77,7 @@ import org.apache.nifi.components.connector.ConnectorValueReference; import org.apache.nifi.components.connector.FrameworkFlowContext; import org.apache.nifi.components.connector.NamedStepConfiguration; +import org.apache.nifi.components.connector.PropertyProtectionType; import org.apache.nifi.components.connector.Secret; import org.apache.nifi.components.connector.SecretReference; import org.apache.nifi.components.connector.StepConfiguration; @@ -5592,6 +5593,10 @@ public SecretDTO createSecretDto(final Secret secret) { dto.setName(secret.getName()); dto.setFullyQualifiedName(secret.getFullyQualifiedName()); dto.setDescription(secret.getDescription()); + final PropertyProtectionType propertyProtectionType = secret.getPropertyProtectionType(); + if (propertyProtectionType != null) { + dto.setPropertyProtectionType(propertyProtectionType.name()); + } return dto; } diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java index 4adb7afb8ac3..31683f6d3bb6 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java @@ -22,6 +22,8 @@ import org.apache.nifi.bundle.BundleDetails; import org.apache.nifi.components.AllowableValue; import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.connector.PropertyProtectionType; +import org.apache.nifi.components.connector.Secret; import org.apache.nifi.components.validation.ValidationStatus; import org.apache.nifi.connectable.Connectable; import org.apache.nifi.connectable.ConnectableType; @@ -1001,4 +1003,42 @@ private static void configureBaseParameterContext(final ParameterContext context when(context.getName()).thenReturn(name); when(context.getParameterReferenceManager()).thenReturn(ParameterReferenceManager.EMPTY); } + + @Test + public void testCreateSecretDtoReturnsNullForNullSecret() { + final DtoFactory dtoFactory = new DtoFactory(); + assertNull(dtoFactory.createSecretDto(null)); + } + + @Test + public void testCreateSecretDtoMapsRestrictedProtectionType() { + final DtoFactory dtoFactory = new DtoFactory(); + final Secret secret = mock(Secret.class); + when(secret.getPropertyProtectionType()).thenReturn(PropertyProtectionType.RESTRICTED); + + final SecretDTO dto = dtoFactory.createSecretDto(secret); + assertNotNull(dto); + assertEquals("RESTRICTED", dto.getPropertyProtectionType()); + } + + @Test + public void testCreateSecretDtoMapsUnrestrictedProtectionType() { + final DtoFactory dtoFactory = new DtoFactory(); + final Secret secret = mock(Secret.class); + when(secret.getPropertyProtectionType()).thenReturn(PropertyProtectionType.UNRESTRICTED); + + final SecretDTO dto = dtoFactory.createSecretDto(secret); + assertEquals("UNRESTRICTED", dto.getPropertyProtectionType()); + } + + @Test + public void testCreateSecretDtoLeavesProtectionTypeNullWhenSecretReturnsNull() { + final DtoFactory dtoFactory = new DtoFactory(); + final Secret secret = mock(Secret.class); + when(secret.getPropertyProtectionType()).thenReturn(null); + + final SecretDTO dto = dtoFactory.createSecretDto(secret); + assertNotNull(dto); + assertNull(dto.getPropertyProtectionType()); + } }