|
1 | 1 | package org.akhq.utils; |
2 | 2 |
|
3 | | -import com.google.gson.JsonElement; |
4 | | -import com.google.gson.JsonObject; |
5 | | -import com.google.gson.JsonParser; |
| 3 | +import com.google.gson.*; |
6 | 4 | import io.micronaut.context.annotation.Requires; |
7 | 5 | import jakarta.inject.Singleton; |
8 | 6 | import lombok.SneakyThrows; |
9 | 7 | import org.akhq.configs.DataMasking; |
10 | 8 | import org.akhq.configs.JsonMaskingFilter; |
11 | 9 | import org.akhq.models.Record; |
12 | 10 |
|
| 11 | +import java.util.Collections; |
| 12 | +import java.util.HashMap; |
13 | 13 | import java.util.List; |
14 | 14 | import java.util.Map; |
| 15 | +import java.util.stream.Collectors; |
15 | 16 |
|
16 | 17 | @Singleton |
17 | 18 | @Requires(property = "akhq.security.data-masking.mode", value = "json_mask_by_default") |
18 | 19 | public class JsonMaskByDefaultMasker implements Masker { |
19 | 20 |
|
20 | | - private final List<JsonMaskingFilter> jsonMaskingFilters; |
| 21 | + private final Map<String, List<String>> topicToKeysMap; |
21 | 22 | private final String jsonMaskReplacement; |
| 23 | + 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."; |
| 24 | + 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. Please contact akhq administrator."; |
22 | 25 |
|
23 | 26 | public JsonMaskByDefaultMasker(DataMasking dataMasking) { |
24 | | - this.jsonMaskingFilters = dataMasking.getJsonFilters(); |
25 | 27 | this.jsonMaskReplacement = dataMasking.getJsonMaskReplacement(); |
| 28 | + this.topicToKeysMap = buildTopicKeysMap(dataMasking); |
| 29 | + } |
| 30 | + |
| 31 | + private Map<String, List<String>> buildTopicKeysMap(DataMasking dataMasking) { |
| 32 | + return dataMasking.getJsonFilters().stream() |
| 33 | + .collect(Collectors.toMap( |
| 34 | + JsonMaskingFilter::getTopic, |
| 35 | + JsonMaskingFilter::getKeys, |
| 36 | + (a, b) -> a, |
| 37 | + HashMap::new |
| 38 | + )); |
26 | 39 | } |
27 | 40 |
|
28 | 41 | public Record maskRecord(Record record) { |
| 42 | + if (!isJson(record)) { |
| 43 | + return createNonJsonRecord(record); |
| 44 | + } |
| 45 | + |
29 | 46 | 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 | | - } |
| 47 | + List<String> unmaskedKeys = getUnmaskedKeysForTopic(record.getTopic().getName()); |
| 48 | + return applyMasking(record, unmaskedKeys); |
40 | 49 | } 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."); |
| 50 | + logMaskingError(record, e); |
| 51 | + return createErrorRecord(record); |
43 | 52 | } |
| 53 | + } |
| 54 | + |
| 55 | + private List<String> getUnmaskedKeysForTopic(String topic) { |
| 56 | + return topicToKeysMap.getOrDefault(topic.toLowerCase(), Collections.emptyList()); |
| 57 | + } |
| 58 | + |
| 59 | + private Record createNonJsonRecord(Record record) { |
| 60 | + record.setValue(NON_JSON_MESSAGE); |
| 61 | + return record; |
| 62 | + } |
| 63 | + |
| 64 | + private Record createErrorRecord(Record record) { |
| 65 | + record.setValue(ERROR_MESSAGE); |
44 | 66 | return record; |
45 | 67 | } |
46 | 68 |
|
| 69 | + private void logMaskingError(Record record, Exception e) { |
| 70 | + LOG.error("Error masking record at topic {}, partition {}, offset {} due to {}", |
| 71 | + record.getTopic(), record.getPartition(), record.getOffset(), e.getMessage()); |
| 72 | + } |
| 73 | + |
47 | 74 | @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()); |
| 75 | + private Record applyMasking(Record record, List<String> unmaskedKeys) { |
| 76 | + JsonObject root = JsonParser.parseString(record.getValue()).getAsJsonObject(); |
| 77 | + maskJson(root, "", unmaskedKeys); |
| 78 | + record.setValue(root.toString()); |
52 | 79 | return record; |
53 | 80 | } |
54 | 81 |
|
55 | | - private void maskAllExcept(JsonObject jsonElement, List<String> keys) { |
56 | | - maskAllExcept("", jsonElement, keys); |
| 82 | + private void maskJson(JsonElement element, String path, List<String> unmaskedKeys) { |
| 83 | + if (element.isJsonObject()) { |
| 84 | + maskJsonObject(element.getAsJsonObject(), path, unmaskedKeys); |
| 85 | + } else if (element.isJsonArray()) { |
| 86 | + maskJsonArray(element.getAsJsonArray(), path, unmaskedKeys); |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + private void maskJsonObject(JsonObject obj, String path, List<String> unmaskedKeys) { |
| 91 | + for (Map.Entry<String, JsonElement> entry : obj.entrySet()) { |
| 92 | + String newPath = path + entry.getKey(); |
| 93 | + JsonElement value = entry.getValue(); |
| 94 | + |
| 95 | + if (shouldMaskPrimitive(value, newPath, unmaskedKeys)) { |
| 96 | + entry.setValue(new JsonPrimitive(jsonMaskReplacement)); |
| 97 | + } else if (isNestedStructure(value)) { |
| 98 | + maskJson(value, newPath + ".", unmaskedKeys); |
| 99 | + } |
| 100 | + } |
57 | 101 | } |
58 | 102 |
|
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 | | - } |
| 103 | + private void maskJsonArray(JsonArray array, String path, List<String> unmaskedKeys) { |
| 104 | + boolean shouldMask = !unmaskedKeys.contains(path.substring(0, path.length() - 1)); |
| 105 | + |
| 106 | + for (int i = 0; i < array.size(); i++) { |
| 107 | + JsonElement arrayElement = array.get(i); |
| 108 | + if (arrayElement.isJsonPrimitive() && shouldMask) { |
| 109 | + array.set(i, new JsonPrimitive(jsonMaskReplacement)); |
| 110 | + } else if (isNestedStructure(arrayElement)) { |
| 111 | + maskJson(arrayElement, path, unmaskedKeys); |
70 | 112 | } |
71 | 113 | } |
72 | 114 | } |
| 115 | + |
| 116 | + private boolean shouldMaskPrimitive(JsonElement value, String path, List<String> unmaskedKeys) { |
| 117 | + return value.isJsonPrimitive() && !unmaskedKeys.contains(path); |
| 118 | + } |
| 119 | + |
| 120 | + private boolean isNestedStructure(JsonElement value) { |
| 121 | + return value.isJsonObject() || value.isJsonArray(); |
| 122 | + } |
73 | 123 | } |
0 commit comments