Skip to content

Commit f4ae29d

Browse files
authored
fix(masking): Various performance, functional, and readability fixes for JSON-based masking
1 parent 1c7e887 commit f4ae29d

File tree

10 files changed

+447
-346
lines changed

10 files changed

+447
-346
lines changed

docs/docs/configuration/akhq.md

Lines changed: 100 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ akhq:
121121
- dateOfBirth
122122
- address.firstLine
123123
- address.town
124+
- metadata.notes
124125
```
125126

126127
Given a record on `users` that looks like:
@@ -131,11 +132,18 @@ Given a record on `users` that looks like:
131132
"status": "ACTIVE",
132133
"name": "John Smith",
133134
"dateOfBirth": "01-01-1991",
134-
"address": {
135-
"firstLine": "123 Example Avenue",
136-
"town": "Faketown",
137-
"country": "United Kingdom"
138-
},
135+
"address": [
136+
{
137+
"firstLine": "123 Example Avenue",
138+
"town": "Faketown",
139+
"country": "United Kingdom"
140+
},
141+
{
142+
"firstLine": "123 Previous Avenue",
143+
"town": "Previoustown",
144+
"country": "United Kingdom"
145+
}
146+
],
139147
"metadata": {
140148
"trusted": true,
141149
"rating": "10",
@@ -152,19 +160,51 @@ With the above configuration, it will appear as:
152160
"status": "ACTIVE",
153161
"name": "xxxx",
154162
"dateOfBirth": "xxxx",
155-
"address": {
156-
"firstLine": "xxxx",
157-
"town": "xxxx",
158-
"country": "United Kingdom"
159-
},
163+
"address": [
164+
{
165+
"firstLine": "xxxx",
166+
"town": "xxxx",
167+
"country": "United Kingdom"
168+
},
169+
{
170+
"firstLine": "xxxx",
171+
"town": "xxxx",
172+
"country": "United Kingdom"
173+
}
174+
],
160175
"metadata": {
161176
"trusted": true,
162177
"rating": "10",
163-
"notes": "All in good order"
178+
"notes": "xxxx"
179+
}
180+
}
181+
```
182+
183+
Note how arrays are automatically understood where relevant.
184+
In other words, `address.firstLine` will apply to both of the following:
185+
```json
186+
{
187+
"address": {
188+
"firstLine": "This field!"
164189
}
165190
}
166191
```
167192

193+
and
194+
195+
```json
196+
{
197+
"address": [
198+
{
199+
"firstLine": "This field!"
200+
},
201+
{
202+
"firstLine": "And this one!"
203+
}
204+
]
205+
}
206+
```
207+
168208
### Mask by default config
169209
This means, by default, everything is masked.
170210
This is useful in production scenarios where data must be carefully selected and made available to
@@ -207,11 +247,18 @@ Given a record on `users` that looks like:
207247
"status": "ACTIVE",
208248
"name": "John Smith",
209249
"dateOfBirth": "01-01-1991",
210-
"address": {
211-
"firstLine": "123 Example Avenue",
212-
"town": "Faketown",
213-
"country": "United Kingdom"
214-
},
250+
"address": [
251+
{
252+
"firstLine": "123 Example Avenue",
253+
"town": "Faketown",
254+
"country": "United Kingdom"
255+
},
256+
{
257+
"firstLine": "123 Previous Avenue",
258+
"town": "Previoustown",
259+
"country": "United Kingdom"
260+
}
261+
],
215262
"metadata": {
216263
"trusted": true,
217264
"rating": "10",
@@ -228,11 +275,18 @@ With the above configuration, it will appear as:
228275
"status": "ACTIVE",
229276
"name": "xxxx",
230277
"dateOfBirth": "xxxx",
231-
"address": {
232-
"firstLine": "xxxx",
233-
"town": "xxxx",
234-
"country": "United Kingdom"
235-
},
278+
"address": [
279+
{
280+
"firstLine": "xxxx",
281+
"town": "xxxx",
282+
"country": "United Kingdom"
283+
},
284+
{
285+
"firstLine": "xxxx",
286+
"town": "xxxx",
287+
"country": "United Kingdom"
288+
}
289+
],
236290
"metadata": {
237291
"trusted": true,
238292
"rating": "10",
@@ -241,6 +295,31 @@ With the above configuration, it will appear as:
241295
}
242296
```
243297

298+
Note how arrays are automatically understood where relevant.
299+
In other words, `address.firstLine` will apply to both of the following:
300+
```json
301+
{
302+
"address": {
303+
"firstLine": "This field!"
304+
}
305+
}
306+
```
307+
308+
and
309+
310+
```json
311+
{
312+
"address": [
313+
{
314+
"firstLine": "This field!"
315+
},
316+
{
317+
"firstLine": "And this one!"
318+
}
319+
]
320+
}
321+
```
322+
244323
### No masking required
245324
You can set `akhq.security.data-masking.mode` to `none` to disable masking altogether.
246325

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,94 @@
11
package org.akhq.utils;
22

3+
import com.google.gson.JsonArray;
34
import com.google.gson.JsonElement;
45
import com.google.gson.JsonObject;
56
import com.google.gson.JsonParser;
7+
import com.google.gson.JsonPrimitive;
68
import io.micronaut.context.annotation.Requires;
79
import jakarta.inject.Singleton;
810
import lombok.SneakyThrows;
911
import org.akhq.configs.DataMasking;
10-
import org.akhq.configs.JsonMaskingFilter;
1112
import org.akhq.models.Record;
1213

1314
import java.util.List;
1415
import java.util.Map;
1516

1617
@Singleton
1718
@Requires(property = "akhq.security.data-masking.mode", value = "json_mask_by_default")
18-
public class JsonMaskByDefaultMasker implements Masker {
19+
public class JsonMaskByDefaultMasker extends JsonMasker {
1920

20-
private final List<JsonMaskingFilter> jsonMaskingFilters;
21-
private final String jsonMaskReplacement;
21+
private static final String NON_JSON_MESSAGE = "This record is unable to be masked as it is not a structured object. This record is unavailable to view due to safety measures from json_mask_by_default to not leak sensitive data.";
22+
private static final String ERROR_MESSAGE = "An exception occurred during an attempt to mask this record. This record is unavailable to view due to safety measures from json_mask_by_default to not leak sensitive data.";
2223

2324
public JsonMaskByDefaultMasker(DataMasking dataMasking) {
24-
this.jsonMaskingFilters = dataMasking.getJsonFilters();
25-
this.jsonMaskReplacement = dataMasking.getJsonMaskReplacement();
25+
super(dataMasking);
2626
}
2727

2828
public Record maskRecord(Record record) {
29+
if (!isJson(record)) {
30+
record.setValue(NON_JSON_MESSAGE);
31+
return record;
32+
}
33+
2934
try {
30-
if(isJson(record)) {
31-
return jsonMaskingFilters
32-
.stream()
33-
.filter(jsonMaskingFilter -> record.getTopic().getName().equalsIgnoreCase(jsonMaskingFilter.getTopic()))
34-
.findFirst()
35-
.map(filter -> applyMasking(record, filter.getKeys()))
36-
.orElseGet(() -> applyMasking(record, List.of()));
37-
} else {
38-
record.setValue("This record is unable to be masked as it is not a structured object. This record is unavailable to view due to safety measures from json_mask_by_default to not leak sensitive data. Please contact akhq administrator.");
39-
}
35+
List<String> keysToUnmask = getKeysForTopic(record.getTopic().getName());
36+
return applyMasking(record, keysToUnmask);
4037
} catch (Exception e) {
41-
LOG.error("Error masking record at topic {}, partition {}, offset {} due to {}", record.getTopic(), record.getPartition(), record.getOffset(), e.getMessage());
42-
record.setValue("An exception occurred during an attempt to mask this record. This record is unavailable to view due to safety measures from json_mask_by_default to not leak sensitive data. Please contact akhq administrator.");
38+
LOG.error("Error masking record at topic {}, partition {}, offset {} due to {}",
39+
record.getTopic(), record.getPartition(), record.getOffset(), e.getMessage());
40+
record.setValue(ERROR_MESSAGE);
41+
return record;
4342
}
44-
return record;
4543
}
4644

4745
@SneakyThrows
48-
private Record applyMasking(Record record, List<String> keys) {
49-
JsonObject jsonElement = JsonParser.parseString(record.getValue()).getAsJsonObject();
50-
maskAllExcept(jsonElement, keys);
51-
record.setValue(jsonElement.toString());
46+
private Record applyMasking(Record record, List<String> keysToUnmask) {
47+
JsonElement root = JsonParser.parseString(record.getValue());
48+
maskJson(root, "", keysToUnmask);
49+
record.setValue(root.toString());
5250
return record;
5351
}
5452

55-
private void maskAllExcept(JsonObject jsonElement, List<String> keys) {
56-
maskAllExcept("", jsonElement, keys);
53+
private void maskJson(JsonElement element, String path, List<String> keysToUnmask) {
54+
if (element.isJsonObject()) {
55+
maskJsonObject(element.getAsJsonObject(), path, keysToUnmask);
56+
} else if (element.isJsonArray()) {
57+
maskJsonArray(element.getAsJsonArray(), path, keysToUnmask);
58+
}
5759
}
5860

59-
private void maskAllExcept(String currentKey, JsonObject node, List<String> keys) {
60-
if (node.isJsonObject()) {
61-
JsonObject objectNode = node.getAsJsonObject();
62-
for(Map.Entry<String, JsonElement> entry : objectNode.entrySet()) {
63-
if(entry.getValue().isJsonObject()) {
64-
maskAllExcept(currentKey + entry.getKey() + ".", entry.getValue().getAsJsonObject(), keys);
65-
} else {
66-
if(!keys.contains(currentKey + entry.getKey())) {
67-
objectNode.addProperty(entry.getKey(), jsonMaskReplacement);
68-
}
69-
}
61+
private void maskJsonObject(JsonObject obj, String path, List<String> keysToUnmask) {
62+
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
63+
String newPath = path + entry.getKey();
64+
JsonElement value = entry.getValue();
65+
66+
if (shouldMaskPrimitive(value, newPath, keysToUnmask)) {
67+
entry.setValue(new JsonPrimitive(jsonMaskReplacement));
68+
} else if (isNestedStructure(value)) {
69+
maskJson(value, newPath + ".", keysToUnmask);
7070
}
7171
}
7272
}
73+
74+
private void maskJsonArray(JsonArray array, String path, List<String> keysToUnmask) {
75+
boolean shouldMask = !keysToUnmask.contains(path.substring(0, path.length() - 1));
76+
77+
for (int i = 0; i < array.size(); i++) {
78+
JsonElement arrayElement = array.get(i);
79+
if (arrayElement.isJsonPrimitive() && shouldMask) {
80+
array.set(i, new JsonPrimitive(jsonMaskReplacement));
81+
} else if (isNestedStructure(arrayElement)) {
82+
maskJson(arrayElement, path, keysToUnmask);
83+
}
84+
}
85+
}
86+
87+
private boolean shouldMaskPrimitive(JsonElement value, String path, List<String> keysToUnmask) {
88+
return value.isJsonPrimitive() && !keysToUnmask.contains(path);
89+
}
90+
91+
private boolean isNestedStructure(JsonElement value) {
92+
return value.isJsonObject() || value.isJsonArray();
93+
}
7394
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package org.akhq.utils;
2+
3+
import org.akhq.configs.DataMasking;
4+
import org.akhq.configs.JsonMaskingFilter;
5+
6+
import java.util.Collections;
7+
import java.util.HashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.stream.Collectors;
11+
12+
public abstract class JsonMasker implements Masker {
13+
private final Map<String, List<String>> topicToKeysMap;
14+
protected final String jsonMaskReplacement;
15+
16+
public JsonMasker(DataMasking dataMasking) {
17+
this.jsonMaskReplacement = dataMasking.getJsonMaskReplacement();
18+
this.topicToKeysMap = buildTopicKeysMap(dataMasking);
19+
}
20+
21+
private Map<String, List<String>> buildTopicKeysMap(DataMasking dataMasking) {
22+
return dataMasking.getJsonFilters().stream()
23+
.collect(Collectors.toMap(
24+
JsonMaskingFilter::getTopic,
25+
JsonMaskingFilter::getKeys,
26+
(a, b) -> a,
27+
HashMap::new
28+
));
29+
}
30+
31+
protected List<String> getKeysForTopic(String topic) {
32+
return topicToKeysMap.getOrDefault(topic.toLowerCase(), Collections.emptyList());
33+
}
34+
}

0 commit comments

Comments
 (0)