Skip to content

Commit 3840ef2

Browse files
committed
BE: Feature to mask nested object fields. Resolution for issue 4361.
1 parent 83b5a60 commit 3840ef2

File tree

8 files changed

+398
-16
lines changed

8 files changed

+398
-16
lines changed

kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ public static class KeystoreConfig {
126126
String keystoreLocation;
127127
String keystorePassword;
128128
}
129-
130129
@Data
131130
public static class Masking {
132131
Type type;
@@ -136,6 +135,7 @@ public static class Masking {
136135
String replacement; //used when type=REPLACE
137136
String topicKeysPattern;
138137
String topicValuesPattern;
138+
Boolean enableNestedPaths = false; // New field to enable nested path support
139139

140140
public enum Type {
141141
REMOVE, MASK, REPLACE

kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.provectus.kafka.ui.config.ClustersProperties;
44
import com.provectus.kafka.ui.exception.ValidationException;
5+
import java.util.List;
56
import java.util.regex.Pattern;
67
import org.springframework.util.CollectionUtils;
78
import org.springframework.util.StringUtils;
@@ -12,17 +13,62 @@ static FieldsSelector create(ClustersProperties.Masking property) {
1213
if (StringUtils.hasText(property.getFieldsNamePattern()) && !CollectionUtils.isEmpty(property.getFields())) {
1314
throw new ValidationException("You can't provide both fieldNames & fieldsNamePattern for masking");
1415
}
16+
17+
boolean nestedPathsEnabled = Boolean.TRUE.equals(property.getEnableNestedPaths());
18+
1519
if (StringUtils.hasText(property.getFieldsNamePattern())) {
1620
Pattern pattern = Pattern.compile(property.getFieldsNamePattern());
17-
return f -> pattern.matcher(f).matches();
21+
return new FieldsSelector() {
22+
@Override
23+
public boolean shouldBeMasked(String fieldName) {
24+
return pattern.matcher(fieldName).matches();
25+
}
26+
27+
@Override
28+
public boolean shouldBeMasked(List<String> fieldPath) {
29+
if (!nestedPathsEnabled) {
30+
return shouldBeMasked(fieldPath.get(fieldPath.size() - 1));
31+
}
32+
String path = String.join(".", fieldPath);
33+
return pattern.matcher(path).matches();
34+
}
35+
};
1836
}
37+
1938
if (!CollectionUtils.isEmpty(property.getFields())) {
20-
return f -> property.getFields().contains(f);
39+
return new FieldsSelector() {
40+
@Override
41+
public boolean shouldBeMasked(String fieldName) {
42+
return property.getFields().contains(fieldName);
43+
}
44+
45+
@Override
46+
public boolean shouldBeMasked(List<String> fieldPath) {
47+
if (!nestedPathsEnabled) {
48+
return shouldBeMasked(fieldPath.get(fieldPath.size() - 1));
49+
}
50+
String path = String.join(".", fieldPath);
51+
return property.getFields().contains(path);
52+
}
53+
};
2154
}
55+
2256
//no pattern, no field names - mean all fields should be masked
23-
return fieldName -> true;
57+
return new FieldsSelector() {
58+
@Override
59+
public boolean shouldBeMasked(String fieldName) {
60+
return true;
61+
}
62+
63+
@Override
64+
public boolean shouldBeMasked(List<String> fieldPath) {
65+
return true;
66+
}
67+
};
2468
}
2569

2670
boolean shouldBeMasked(String fieldName);
71+
72+
boolean shouldBeMasked(List<String> fieldPath);
2773

2874
}

kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,35 @@ private static UnaryOperator<String> createMasker(List<String> maskingChars) {
5050
return sb.toString();
5151
};
5252
}
53-
5453
private JsonNode maskWithFieldsCheck(JsonNode node) {
54+
return maskWithFieldsCheck(node, new java.util.ArrayList<>());
55+
}
56+
57+
private JsonNode maskWithFieldsCheck(JsonNode node, java.util.List<String> path) {
5558
if (node.isObject()) {
5659
ObjectNode obj = ((ObjectNode) node).objectNode();
5760
node.fields().forEachRemaining(f -> {
5861
String fieldName = f.getKey();
5962
JsonNode fieldVal = f.getValue();
60-
if (fieldShouldBeMasked(fieldName)) {
63+
64+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
65+
currentPath.add(fieldName);
66+
67+
if (fieldShouldBeMasked(fieldName) || fieldShouldBeMasked(currentPath)) {
6168
obj.set(fieldName, maskNodeRecursively(fieldVal));
6269
} else {
63-
obj.set(fieldName, maskWithFieldsCheck(fieldVal));
70+
obj.set(fieldName, maskWithFieldsCheck(fieldVal, currentPath));
6471
}
6572
});
6673
return obj;
6774
} else if (node.isArray()) {
6875
ArrayNode arr = ((ArrayNode) node).arrayNode(node.size());
69-
node.elements().forEachRemaining(e -> arr.add(maskWithFieldsCheck(e)));
76+
int index = 0;
77+
for (JsonNode element : node) {
78+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
79+
currentPath.add(String.valueOf(index++));
80+
arr.add(maskWithFieldsCheck(element, currentPath));
81+
}
7082
return arr;
7183
}
7284
return node;

kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.fasterxml.jackson.databind.node.ContainerNode;
44
import com.provectus.kafka.ui.config.ClustersProperties;
5+
import java.util.List;
56
import lombok.RequiredArgsConstructor;
67

78
@RequiredArgsConstructor
@@ -34,6 +35,10 @@ protected boolean fieldShouldBeMasked(String fieldName) {
3435
return fieldsSelector.shouldBeMasked(fieldName);
3536
}
3637

38+
protected boolean fieldShouldBeMasked(List<String> fieldPath) {
39+
return fieldsSelector.shouldBeMasked(fieldPath);
40+
}
41+
3742
public abstract ContainerNode<?> applyToJsonContainer(ContainerNode<?> node);
3843

3944
public abstract String applyToString(String str);

kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,33 @@ public String applyToString(String str) {
2121
public ContainerNode<?> applyToJsonContainer(ContainerNode<?> node) {
2222
return (ContainerNode<?>) removeFields(node);
2323
}
24-
2524
private JsonNode removeFields(JsonNode node) {
25+
return removeFields(node, new java.util.ArrayList<>());
26+
}
27+
28+
private JsonNode removeFields(JsonNode node, java.util.List<String> path) {
2629
if (node.isObject()) {
2730
ObjectNode obj = ((ObjectNode) node).objectNode();
2831
node.fields().forEachRemaining(f -> {
2932
String fieldName = f.getKey();
3033
JsonNode fieldVal = f.getValue();
31-
if (!fieldShouldBeMasked(fieldName)) {
32-
obj.set(fieldName, removeFields(fieldVal));
34+
35+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
36+
currentPath.add(fieldName);
37+
38+
if (!fieldShouldBeMasked(fieldName) && !fieldShouldBeMasked(currentPath)) {
39+
obj.set(fieldName, removeFields(fieldVal, currentPath));
3340
}
3441
});
3542
return obj;
3643
} else if (node.isArray()) {
3744
var arr = ((ArrayNode) node).arrayNode(node.size());
38-
node.elements().forEachRemaining(e -> arr.add(removeFields(e)));
45+
int index = 0;
46+
for (JsonNode element : node) {
47+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
48+
currentPath.add(String.valueOf(index++));
49+
arr.add(removeFields(element, currentPath));
50+
}
3951
return arr;
4052
}
4153
return node;

kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,35 @@ public String applyToString(String str) {
2727
public ContainerNode<?> applyToJsonContainer(ContainerNode<?> node) {
2828
return (ContainerNode<?>) replaceWithFieldsCheck(node);
2929
}
30-
3130
private JsonNode replaceWithFieldsCheck(JsonNode node) {
31+
return replaceWithFieldsCheck(node, new java.util.ArrayList<>());
32+
}
33+
34+
private JsonNode replaceWithFieldsCheck(JsonNode node, java.util.List<String> path) {
3235
if (node.isObject()) {
3336
ObjectNode obj = ((ObjectNode) node).objectNode();
3437
node.fields().forEachRemaining(f -> {
3538
String fieldName = f.getKey();
3639
JsonNode fieldVal = f.getValue();
37-
if (fieldShouldBeMasked(fieldName)) {
40+
41+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
42+
currentPath.add(fieldName);
43+
44+
if (fieldShouldBeMasked(fieldName) || fieldShouldBeMasked(currentPath)) {
3845
obj.set(fieldName, replaceRecursive(fieldVal));
3946
} else {
40-
obj.set(fieldName, replaceWithFieldsCheck(fieldVal));
47+
obj.set(fieldName, replaceWithFieldsCheck(fieldVal, currentPath));
4148
}
4249
});
4350
return obj;
4451
} else if (node.isArray()) {
4552
ArrayNode arr = ((ArrayNode) node).arrayNode(node.size());
46-
node.elements().forEachRemaining(e -> arr.add(replaceWithFieldsCheck(e)));
53+
int index = 0;
54+
for (JsonNode element : node) {
55+
java.util.List<String> currentPath = new java.util.ArrayList<>(path);
56+
currentPath.add(String.valueOf(index++));
57+
arr.add(replaceWithFieldsCheck(element, currentPath));
58+
}
4759
return arr;
4860
}
4961
// if it is not an object or array - we have nothing to replace here

kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,65 @@ void throwsExceptionIfBothFieldListAndPatternProvided() {
5050
.isInstanceOf(ValidationException.class);
5151
}
5252

53+
@Test
54+
void selectsNestedFieldsWhenEnabledWithFieldNames() {
55+
var properties = new ClustersProperties.Masking();
56+
properties.setFields(List.of("user.email", "user.address.street"));
57+
properties.setEnableNestedPaths(true);
58+
59+
var selector = FieldsSelector.create(properties);
60+
61+
// Test single field names (backward compatibility)
62+
assertThat(selector.shouldBeMasked("email")).isFalse();
63+
assertThat(selector.shouldBeMasked("street")).isFalse();
64+
65+
// Test nested paths
66+
assertThat(selector.shouldBeMasked(List.of("user", "email"))).isTrue();
67+
assertThat(selector.shouldBeMasked(List.of("user", "address", "street"))).isTrue();
68+
assertThat(selector.shouldBeMasked(List.of("user", "name"))).isFalse();
69+
assertThat(selector.shouldBeMasked(List.of("other", "email"))).isFalse();
70+
}
71+
72+
@Test
73+
void selectsNestedFieldsWhenEnabledWithPattern() {
74+
var properties = new ClustersProperties.Masking();
75+
properties.setFieldsNamePattern("user\\..*|.*\\.secret");
76+
properties.setEnableNestedPaths(true);
77+
78+
var selector = FieldsSelector.create(properties);
79+
80+
// Test nested paths with pattern
81+
assertThat(selector.shouldBeMasked(List.of("user", "email"))).isTrue();
82+
assertThat(selector.shouldBeMasked(List.of("user", "address", "street"))).isTrue();
83+
assertThat(selector.shouldBeMasked(List.of("config", "secret"))).isTrue();
84+
assertThat(selector.shouldBeMasked(List.of("other", "public"))).isFalse();
85+
}
86+
87+
@Test
88+
void fallsBackToFieldNameWhenNestedPathsDisabled() {
89+
var properties = new ClustersProperties.Masking();
90+
properties.setFields(List.of("user.email", "street"));
91+
properties.setEnableNestedPaths(false);
92+
93+
var selector = FieldsSelector.create(properties);
94+
95+
// Should only match the last part of the path when nested paths are disabled
96+
assertThat(selector.shouldBeMasked(List.of("user", "email"))).isFalse(); // Only matches "email", not "user.email"
97+
assertThat(selector.shouldBeMasked(List.of("address", "street"))).isTrue(); // Matches "street"
98+
assertThat(selector.shouldBeMasked("street")).isTrue(); // Direct field name matching still works
99+
}
100+
101+
@Test
102+
void defaultNestedPathsBehaviorIsFalse() {
103+
var properties = new ClustersProperties.Masking();
104+
properties.setFields(List.of("user.email"));
105+
// enableNestedPaths not set, should default to false
106+
107+
var selector = FieldsSelector.create(properties);
108+
109+
// Should behave as if nested paths are disabled
110+
assertThat(selector.shouldBeMasked(List.of("user", "email"))).isFalse();
111+
assertThat(selector.shouldBeMasked("email")).isFalse();
112+
}
113+
53114
}

0 commit comments

Comments
 (0)