diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java index e0b20d6c93f..899cd419aae 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java @@ -126,7 +126,6 @@ public static class KeystoreConfig { String keystoreLocation; String keystorePassword; } - @Data public static class Masking { Type type; @@ -136,6 +135,7 @@ public static class Masking { String replacement; //used when type=REPLACE String topicKeysPattern; String topicValuesPattern; + Boolean enableNestedPaths = false; // New field to enable nested path support public enum Type { REMOVE, MASK, REPLACE diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java index 99563943984..cc14556943b 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java @@ -2,6 +2,7 @@ import com.provectus.kafka.ui.config.ClustersProperties; import com.provectus.kafka.ui.exception.ValidationException; +import java.util.List; import java.util.regex.Pattern; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -12,17 +13,62 @@ static FieldsSelector create(ClustersProperties.Masking property) { if (StringUtils.hasText(property.getFieldsNamePattern()) && !CollectionUtils.isEmpty(property.getFields())) { throw new ValidationException("You can't provide both fieldNames & fieldsNamePattern for masking"); } + + boolean nestedPathsEnabled = Boolean.TRUE.equals(property.getEnableNestedPaths()); + if (StringUtils.hasText(property.getFieldsNamePattern())) { Pattern pattern = Pattern.compile(property.getFieldsNamePattern()); - return f -> pattern.matcher(f).matches(); + return new FieldsSelector() { + @Override + public boolean shouldBeMasked(String fieldName) { + return pattern.matcher(fieldName).matches(); + } + + @Override + public boolean shouldBeMasked(List fieldPath) { + if (!nestedPathsEnabled) { + return shouldBeMasked(fieldPath.get(fieldPath.size() - 1)); + } + String path = String.join(".", fieldPath); + return pattern.matcher(path).matches(); + } + }; } + if (!CollectionUtils.isEmpty(property.getFields())) { - return f -> property.getFields().contains(f); + return new FieldsSelector() { + @Override + public boolean shouldBeMasked(String fieldName) { + return property.getFields().contains(fieldName); + } + + @Override + public boolean shouldBeMasked(List fieldPath) { + if (!nestedPathsEnabled) { + return shouldBeMasked(fieldPath.get(fieldPath.size() - 1)); + } + String path = String.join(".", fieldPath); + return property.getFields().contains(path); + } + }; } + //no pattern, no field names - mean all fields should be masked - return fieldName -> true; + return new FieldsSelector() { + @Override + public boolean shouldBeMasked(String fieldName) { + return true; + } + + @Override + public boolean shouldBeMasked(List fieldPath) { + return true; + } + }; } boolean shouldBeMasked(String fieldName); + + boolean shouldBeMasked(List fieldPath); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java index e6a469f2c03..8442db16b99 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java @@ -50,23 +50,35 @@ private static UnaryOperator createMasker(List maskingChars) { return sb.toString(); }; } - private JsonNode maskWithFieldsCheck(JsonNode node) { + return maskWithFieldsCheck(node, new java.util.ArrayList<>()); + } + + private JsonNode maskWithFieldsCheck(JsonNode node, java.util.List path) { if (node.isObject()) { ObjectNode obj = ((ObjectNode) node).objectNode(); node.fields().forEachRemaining(f -> { String fieldName = f.getKey(); JsonNode fieldVal = f.getValue(); - if (fieldShouldBeMasked(fieldName)) { + + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(fieldName); + + if (fieldShouldBeMasked(fieldName) || fieldShouldBeMasked(currentPath)) { obj.set(fieldName, maskNodeRecursively(fieldVal)); } else { - obj.set(fieldName, maskWithFieldsCheck(fieldVal)); + obj.set(fieldName, maskWithFieldsCheck(fieldVal, currentPath)); } }); return obj; } else if (node.isArray()) { ArrayNode arr = ((ArrayNode) node).arrayNode(node.size()); - node.elements().forEachRemaining(e -> arr.add(maskWithFieldsCheck(e))); + int index = 0; + for (JsonNode element : node) { + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(String.valueOf(index++)); + arr.add(maskWithFieldsCheck(element, currentPath)); + } return arr; } return node; diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java index 9b80da0cb18..a5d98687c47 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.node.ContainerNode; import com.provectus.kafka.ui.config.ClustersProperties; +import java.util.List; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -34,6 +35,10 @@ protected boolean fieldShouldBeMasked(String fieldName) { return fieldsSelector.shouldBeMasked(fieldName); } + protected boolean fieldShouldBeMasked(List fieldPath) { + return fieldsSelector.shouldBeMasked(fieldPath); + } + public abstract ContainerNode applyToJsonContainer(ContainerNode node); public abstract String applyToString(String str); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java index cc5cdd14159..c9cb469a219 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java @@ -21,21 +21,33 @@ public String applyToString(String str) { public ContainerNode applyToJsonContainer(ContainerNode node) { return (ContainerNode) removeFields(node); } - private JsonNode removeFields(JsonNode node) { + return removeFields(node, new java.util.ArrayList<>()); + } + + private JsonNode removeFields(JsonNode node, java.util.List path) { if (node.isObject()) { ObjectNode obj = ((ObjectNode) node).objectNode(); node.fields().forEachRemaining(f -> { String fieldName = f.getKey(); JsonNode fieldVal = f.getValue(); - if (!fieldShouldBeMasked(fieldName)) { - obj.set(fieldName, removeFields(fieldVal)); + + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(fieldName); + + if (!fieldShouldBeMasked(fieldName) && !fieldShouldBeMasked(currentPath)) { + obj.set(fieldName, removeFields(fieldVal, currentPath)); } }); return obj; } else if (node.isArray()) { var arr = ((ArrayNode) node).arrayNode(node.size()); - node.elements().forEachRemaining(e -> arr.add(removeFields(e))); + int index = 0; + for (JsonNode element : node) { + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(String.valueOf(index++)); + arr.add(removeFields(element, currentPath)); + } return arr; } return node; diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java index 1cf91793d22..4d92c57b969 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java @@ -27,23 +27,35 @@ public String applyToString(String str) { public ContainerNode applyToJsonContainer(ContainerNode node) { return (ContainerNode) replaceWithFieldsCheck(node); } - private JsonNode replaceWithFieldsCheck(JsonNode node) { + return replaceWithFieldsCheck(node, new java.util.ArrayList<>()); + } + + private JsonNode replaceWithFieldsCheck(JsonNode node, java.util.List path) { if (node.isObject()) { ObjectNode obj = ((ObjectNode) node).objectNode(); node.fields().forEachRemaining(f -> { String fieldName = f.getKey(); JsonNode fieldVal = f.getValue(); - if (fieldShouldBeMasked(fieldName)) { + + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(fieldName); + + if (fieldShouldBeMasked(fieldName) || fieldShouldBeMasked(currentPath)) { obj.set(fieldName, replaceRecursive(fieldVal)); } else { - obj.set(fieldName, replaceWithFieldsCheck(fieldVal)); + obj.set(fieldName, replaceWithFieldsCheck(fieldVal, currentPath)); } }); return obj; } else if (node.isArray()) { ArrayNode arr = ((ArrayNode) node).arrayNode(node.size()); - node.elements().forEachRemaining(e -> arr.add(replaceWithFieldsCheck(e))); + int index = 0; + for (JsonNode element : node) { + java.util.List currentPath = new java.util.ArrayList<>(path); + currentPath.add(String.valueOf(index++)); + arr.add(replaceWithFieldsCheck(element, currentPath)); + } return arr; } // if it is not an object or array - we have nothing to replace here diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java index 497a9365d75..1f342adca01 100644 --- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java @@ -50,4 +50,65 @@ void throwsExceptionIfBothFieldListAndPatternProvided() { .isInstanceOf(ValidationException.class); } + @Test + void selectsNestedFieldsWhenEnabledWithFieldNames() { + var properties = new ClustersProperties.Masking(); + properties.setFields(List.of("user.email", "user.address.street")); + properties.setEnableNestedPaths(true); + + var selector = FieldsSelector.create(properties); + + // Test single field names (backward compatibility) + assertThat(selector.shouldBeMasked("email")).isFalse(); + assertThat(selector.shouldBeMasked("street")).isFalse(); + + // Test nested paths + assertThat(selector.shouldBeMasked(List.of("user", "email"))).isTrue(); + assertThat(selector.shouldBeMasked(List.of("user", "address", "street"))).isTrue(); + assertThat(selector.shouldBeMasked(List.of("user", "name"))).isFalse(); + assertThat(selector.shouldBeMasked(List.of("other", "email"))).isFalse(); + } + + @Test + void selectsNestedFieldsWhenEnabledWithPattern() { + var properties = new ClustersProperties.Masking(); + properties.setFieldsNamePattern("user\\..*|.*\\.secret"); + properties.setEnableNestedPaths(true); + + var selector = FieldsSelector.create(properties); + + // Test nested paths with pattern + assertThat(selector.shouldBeMasked(List.of("user", "email"))).isTrue(); + assertThat(selector.shouldBeMasked(List.of("user", "address", "street"))).isTrue(); + assertThat(selector.shouldBeMasked(List.of("config", "secret"))).isTrue(); + assertThat(selector.shouldBeMasked(List.of("other", "public"))).isFalse(); + } + + @Test + void fallsBackToFieldNameWhenNestedPathsDisabled() { + var properties = new ClustersProperties.Masking(); + properties.setFields(List.of("user.email", "street")); + properties.setEnableNestedPaths(false); + + var selector = FieldsSelector.create(properties); + + // Should only match the last part of the path when nested paths are disabled + assertThat(selector.shouldBeMasked(List.of("user", "email"))).isFalse(); // Only matches "email", not "user.email" + assertThat(selector.shouldBeMasked(List.of("address", "street"))).isTrue(); // Matches "street" + assertThat(selector.shouldBeMasked("street")).isTrue(); // Direct field name matching still works + } + + @Test + void defaultNestedPathsBehaviorIsFalse() { + var properties = new ClustersProperties.Masking(); + properties.setFields(List.of("user.email")); + // enableNestedPaths not set, should default to false + + var selector = FieldsSelector.create(properties); + + // Should behave as if nested paths are disabled + assertThat(selector.shouldBeMasked(List.of("user", "email"))).isFalse(); + assertThat(selector.shouldBeMasked("email")).isFalse(); + } + } diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/NestedFieldsMaskingTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/NestedFieldsMaskingTest.java new file mode 100644 index 00000000000..d71df091897 --- /dev/null +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/NestedFieldsMaskingTest.java @@ -0,0 +1,234 @@ +package com.provectus.kafka.ui.service.masking.policies; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.ContainerNode; +import com.provectus.kafka.ui.config.ClustersProperties; +import java.util.List; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +class NestedFieldsMaskingTest { + + @Test + @SneakyThrows + void testNestedFieldMaskingWithReplace() { + var properties = new ClustersProperties.Masking(); + properties.setType(ClustersProperties.Masking.Type.REPLACE); + properties.setReplacement("***MASKED***"); + properties.setFields(List.of("user.address.street", "user.email")); + properties.setEnableNestedPaths(true); + + var policy = MaskingPolicy.create(properties); + + String inputJson = """ + { + "user": { + "name": "John Doe", + "email": "john@example.com", + "address": { + "street": "123 Main St", + "city": "Anytown", + "zipcode": "12345" + } + }, + "order": { + "id": "ORD-001", + "total": 99.99 + } + } + """; + + String expectedJson = """ + { + "user": { + "name": "John Doe", + "email": "***MASKED***", + "address": { + "street": "***MASKED***", + "city": "Anytown", + "zipcode": "12345" + } + }, + "order": { + "id": "ORD-001", + "total": 99.99 + } + } + """; + + JsonMapper mapper = new JsonMapper(); + ContainerNode input = (ContainerNode) mapper.readTree(inputJson); + ContainerNode expected = (ContainerNode) mapper.readTree(expectedJson); + + ContainerNode result = policy.applyToJsonContainer(input); + assertThat(result).isEqualTo(expected); + } + + @Test + @SneakyThrows + void testNestedFieldMaskingWithRemove() { + var properties = new ClustersProperties.Masking(); + properties.setType(ClustersProperties.Masking.Type.REMOVE); + properties.setFields(List.of("user.sensitiveData", "metadata.internal")); + properties.setEnableNestedPaths(true); + + var policy = MaskingPolicy.create(properties); + + String inputJson = """ + { + "user": { + "name": "John Doe", + "sensitiveData": { + "ssn": "123-45-6789", + "creditCard": "4111-1111-1111-1111" + }, + "publicInfo": "Available" + }, + "metadata": { + "timestamp": "2023-01-01", + "internal": { + "secret": "top-secret", + "debug": "trace-info" + } + } + } + """; + + String expectedJson = """ + { + "user": { + "name": "John Doe", + "publicInfo": "Available" + }, + "metadata": { + "timestamp": "2023-01-01" + } + } + """; + + JsonMapper mapper = new JsonMapper(); + ContainerNode input = (ContainerNode) mapper.readTree(inputJson); + ContainerNode expected = (ContainerNode) mapper.readTree(expectedJson); + + ContainerNode result = policy.applyToJsonContainer(input); + assertThat(result).isEqualTo(expected); + } + + @Test + @SneakyThrows + void testNestedFieldMaskingWithPattern() { + var properties = new ClustersProperties.Masking(); + properties.setType(ClustersProperties.Masking.Type.MASK); + properties.setMaskingCharsReplacement(List.of("X", "x", "n", "-")); + properties.setFieldsNamePattern(".*\\.secret.*"); + properties.setEnableNestedPaths(true); + + var policy = MaskingPolicy.create(properties); + + String inputJson = """ + { + "config": { + "publicSetting": "visible", + "secretKey": "my-secret-key", + "database": { + "host": "localhost", + "secretPassword": "very-secret-password" + } + } + } + """; + + JsonMapper mapper = new JsonMapper(); + ContainerNode input = (ContainerNode) mapper.readTree(inputJson); + + ContainerNode result = policy.applyToJsonContainer(input); + + // Verify that nested secret fields are masked + JsonNode configNode = result.get("config"); + assertThat(configNode.get("publicSetting").asText()).isEqualTo("visible"); + assertThat(configNode.get("secretKey").asText()).isEqualTo("xx-xxxxxx-xxx"); // masked + + JsonNode databaseNode = configNode.get("database"); + assertThat(databaseNode.get("host").asText()).isEqualTo("localhost"); + assertThat(databaseNode.get("secretPassword").asText()).isEqualTo("xxxx-xxxxxx-xxxxxxxx"); // masked + } + + @Test + @SneakyThrows + void testBackwardCompatibilityWithoutNestedPaths() { + var properties = new ClustersProperties.Masking(); + properties.setType(ClustersProperties.Masking.Type.REPLACE); + properties.setReplacement("***MASKED***"); + properties.setFields(List.of("email")); // Only top-level field names + properties.setEnableNestedPaths(false); // Explicitly disabled + + var policy = MaskingPolicy.create(properties); + + String inputJson = """ + { + "email": "john@example.com", + "user": { + "email": "nested@example.com" + } + } + """; + + String expectedJson = """ + { + "email": "***MASKED***", + "user": { + "email": "***MASKED***" + } + } + """; + + JsonMapper mapper = new JsonMapper(); + ContainerNode input = (ContainerNode) mapper.readTree(inputJson); + ContainerNode expected = (ContainerNode) mapper.readTree(expectedJson); + + ContainerNode result = policy.applyToJsonContainer(input); + assertThat(result).isEqualTo(expected); + } + + @Test + @SneakyThrows + void testArrayElementsWithNestedPaths() { + var properties = new ClustersProperties.Masking(); + properties.setType(ClustersProperties.Masking.Type.REPLACE); + properties.setReplacement("***MASKED***"); + properties.setFields(List.of("users.email")); + properties.setEnableNestedPaths(true); + + var policy = MaskingPolicy.create(properties); + + String inputJson = """ + { + "users": [ + { + "name": "John", + "email": "john@example.com" + }, + { + "name": "Jane", + "email": "jane@example.com" + } + ] + } + """; + + JsonMapper mapper = new JsonMapper(); + ContainerNode input = (ContainerNode) mapper.readTree(inputJson); + + ContainerNode result = policy.applyToJsonContainer(input); + + // Since we're using array indices in the path, this should only mask exact path matches + // In this case, users.email doesn't match users.0.email or users.1.email + // So emails should remain unmasked unless we specify the full path with indices + JsonNode usersNode = result.get("users"); + assertThat(usersNode.get(0).get("email").asText()).isEqualTo("john@example.com"); + assertThat(usersNode.get(1).get("email").asText()).isEqualTo("jane@example.com"); + } +}